From 6c4e8cec9cfd4364bd078a3d6982435952499f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LUDWIG?= Date: Sat, 24 Jun 2023 11:42:09 +0200 Subject: [PATCH] feat: validate relative links fragments Similar to https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md Fixes #2 BREAKING CHANGE: Validate links fragments in relative links --- package-lock.json | 15 ++--- package.json | 3 + src/index.js | 75 ++++++++++++------------ src/utils.js | 120 +++++++++++++++++++++++++++++++++++++++ test/basic.test.js | 18 ++++-- test/fixtures/Invalid.md | 14 +++++ test/fixtures/Valid.md | 8 +++ test/utils.test.js | 37 ++++++++++++ 8 files changed, 237 insertions(+), 53 deletions(-) create mode 100644 src/utils.js create mode 100644 test/utils.test.js diff --git a/package-lock.json b/package-lock.json index f882080..20f0cd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0-development", "hasInstallScript": true, "license": "MIT", + "dependencies": { + "markdown-it": "13.0.1" + }, "devDependencies": { "@commitlint/cli": "17.6.5", "@commitlint/config-conventional": "17.6.5", @@ -1619,8 +1622,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/argv-formatter": { "version": "1.0.0", @@ -2447,7 +2449,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", - "dev": true, "engines": { "node": ">=0.12" }, @@ -4372,7 +4373,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz", "integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==", - "dev": true, "dependencies": { "uc.micro": "^1.0.1" } @@ -4872,7 +4872,6 @@ "version": "13.0.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz", "integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==", - "dev": true, "dependencies": { "argparse": "^2.0.1", "entities": "~3.0.1", @@ -5023,8 +5022,7 @@ "node_modules/mdurl": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", - "dev": true + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" }, "node_modules/meow": { "version": "8.1.2", @@ -10471,8 +10469,7 @@ "node_modules/uc.micro": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", - "dev": true + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" }, "node_modules/uglify-js": { "version": "3.17.4", diff --git a/package.json b/package.json index ea9085b..d6fce3f 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,9 @@ "prepublishOnly": "pinst --disable", "postpublish": "pinst --enable" }, + "dependencies": { + "markdown-it": "13.0.1" + }, "devDependencies": { "@commitlint/cli": "17.6.5", "@commitlint/config-conventional": "17.6.5", diff --git a/src/index.js b/src/index.js index 4de3307..800b65d 100644 --- a/src/index.js +++ b/src/index.js @@ -3,42 +3,12 @@ const { pathToFileURL } = require('node:url') const fs = require('node:fs') -/** - * Calls the provided function for each matching token. - * - * @param {Object} params RuleParams instance. - * @param {string} type Token type identifier. - * @param {Function} handler Callback function. - * @returns {void} - */ -const filterTokens = (params, type, handler) => { - for (const token of params.tokens) { - if (token.type === type) { - handler(token) - } - } -} - -/** - * Adds a generic error object via the onError callback. - * - * @param {Object} onError RuleOnError instance. - * @param {number} lineNumber Line number. - * @param {string} [detail] Error details. - * @param {string} [context] Error context. - * @param {number[]} [range] Column and length of error. - * @param {Object} [fixInfo] RuleOnErrorFixInfo instance. - * @returns {void} - */ -const addError = (onError, lineNumber, detail, context, range, fixInfo) => { - onError({ - lineNumber, - detail, - context, - range, - fixInfo - }) -} +const { + filterTokens, + addError, + convertHeadingToHTMLFragment, + getMarkdownHeadings +} = require('./utils.js') const customRule = { names: ['relative-links'], @@ -70,12 +40,37 @@ const customRule = { if (hrefSrc != null) { const url = new URL(hrefSrc, pathToFileURL(params.name)) - url.hash = '' const isRelative = url.protocol === 'file:' && !hrefSrc.startsWith('/') - if (isRelative && !fs.existsSync(url)) { - const detail = `Link "${hrefSrc}" is dead` - addError(onError, lineNumber, detail) + if (isRelative) { + const detail = `Link "${hrefSrc}" is not valid` + + if (!fs.existsSync(url)) { + addError(onError, lineNumber, detail) + return + } + + if (type === 'link_open' && url.hash !== '') { + const fileContent = fs.readFileSync(url, { encoding: 'utf8' }) + const headings = getMarkdownHeadings(fileContent) + + /** @type {Map} */ + const fragments = new Map() + + const headingsHTMLFragments = headings.map((heading) => { + const fragment = convertHeadingToHTMLFragment(heading) + const count = fragments.get(fragment) ?? 0 + fragments.set(fragment, count + 1) + if (count !== 0) { + return `${fragment}-${count}` + } + return fragment + }) + + if (!headingsHTMLFragments.includes(url.hash)) { + addError(onError, lineNumber, detail) + } + } } } }) diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..f2ebe29 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,120 @@ +const MarkdownIt = require('markdown-it') + +/** + * Calls the provided function for each matching token. + * + * @param {Object} params RuleParams instance. + * @param {string} type Token type identifier. + * @param {Function} handler Callback function. + * @returns {void} + */ +const filterTokens = (params, type, handler) => { + for (const token of params.tokens) { + if (token.type === type) { + handler(token) + } + } +} + +/** + * Adds a generic error object via the onError callback. + * + * @param {Object} onError RuleOnError instance. + * @param {number} lineNumber Line number. + * @param {string} [detail] Error details. + * @param {string} [context] Error context. + * @param {number[]} [range] Column and length of error. + * @param {Object} [fixInfo] RuleOnErrorFixInfo instance. + * @returns {void} + */ +const addError = (onError, lineNumber, detail, context, range, fixInfo) => { + onError({ + lineNumber, + detail, + context, + range, + fixInfo + }) +} + +/** + * Converts a Markdown heading into an HTML fragment according to the rules + * used by GitHub. + * + * Taken from + * + * @param {string} inlineText Inline token for heading. + * @returns {string} Fragment string for heading. + */ +const convertHeadingToHTMLFragment = (inlineText) => { + return ( + '#' + + encodeURIComponent( + inlineText + .toLowerCase() + // RegExp source with Ruby's \p{Word} expanded into its General Categories + // eslint-disable-next-line max-len + // https://github.com/gjtorikian/html-pipeline/blob/main/lib/html/pipeline/toc_filter.rb + // https://ruby-doc.org/core-3.0.2/Regexp.html + .replace( + /[^\p{Letter}\p{Mark}\p{Number}\p{Connector_Punctuation}\- ]/gu, + '' + ) + .replace(/ /gu, '-') + ) + ) +} + +const headingTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) +const ignoredTokens = new Set(['heading_open', 'heading_close']) + +/** + * Gets the headings from a Markdown string. + * @param {string} content + * @returns {string[]} + */ +const getMarkdownHeadings = (content) => { + const markdownIt = new MarkdownIt({ html: true }) + const tokens = markdownIt.parse(content, {}) + + /** @type {string[]} */ + const headings = [] + + /** @type {string | null} */ + let headingToken = null + + for (const token of tokens) { + if (headingTags.has(token.tag)) { + if (token.type === 'heading_open') { + headingToken = token.markup + } else if (token.type === 'heading_close') { + headingToken = null + } + } + + if (ignoredTokens.has(token.type)) { + continue + } + + if (headingToken === null) { + continue + } + + headings.push( + `${token.children + .map((token) => { + return token.content + }) + .join('')}` + ) + } + + return headings +} + +module.exports = { + filterTokens, + addError, + convertHeadingToHTMLFragment, + getMarkdownHeadings +} diff --git a/test/basic.test.js b/test/basic.test.js index efbc167..ce0ded7 100644 --- a/test/basic.test.js +++ b/test/basic.test.js @@ -5,16 +5,17 @@ const { markdownlint } = require('markdownlint').promises const relativeLinks = require('../src/index.js') -test('ensure we validate correctly', async () => { +test('ensure the rule validate correctly', async () => { const lintResults = await markdownlint({ files: ['test/fixtures/Valid.md', 'test/fixtures/Invalid.md'], config: { + default: false, 'relative-links': true }, customRules: [relativeLinks] }) assert.equal(lintResults['test/fixtures/Valid.md'].length, 0) - assert.equal(lintResults['test/fixtures/Invalid.md'].length, 2) + assert.equal(lintResults['test/fixtures/Invalid.md'].length, 3) assert.equal( lintResults['test/fixtures/Invalid.md'][0]?.ruleDescription, @@ -22,7 +23,7 @@ test('ensure we validate correctly', async () => { ) assert.equal( lintResults['test/fixtures/Invalid.md'][0]?.errorDetail, - 'Link "./basic.test.js" is dead' + 'Link "./basic.test.js" is not valid' ) assert.equal( @@ -31,6 +32,15 @@ test('ensure we validate correctly', async () => { ) assert.equal( lintResults['test/fixtures/Invalid.md'][1]?.errorDetail, - 'Link "../image.png" is dead' + 'Link "../image.png" is not valid' + ) + + assert.equal( + lintResults['test/fixtures/Invalid.md'][2]?.ruleDescription, + 'Relative links should be valid' + ) + assert.equal( + lintResults['test/fixtures/Invalid.md'][2]?.errorDetail, + 'Link "./Valid.md#not-existing-heading" is not valid' ) }) diff --git a/test/fixtures/Invalid.md b/test/fixtures/Invalid.md index 08abe73..8d02f99 100644 --- a/test/fixtures/Invalid.md +++ b/test/fixtures/Invalid.md @@ -3,3 +3,17 @@ [basic.js](./basic.test.js) ![Image](../image.png) + +[Link fragment](./Valid.md#not-existing-heading) + +## Existing Heading + +### Repeated Heading + +Text + +### Repeated Heading + +Text + +### Repeated Heading diff --git a/test/fixtures/Valid.md b/test/fixtures/Valid.md index 327d56a..3daf9e8 100644 --- a/test/fixtures/Valid.md +++ b/test/fixtures/Valid.md @@ -11,3 +11,11 @@ [External https link 2](https:./external.https) [External ftp link](ftp:./external.ftp) + +[Link fragment](./Invalid.md#existing-heading) + +[Link fragment Repeated 0](./Invalid.md#repeated-heading) + +[Link fragment Repeated 1](./Invalid.md#repeated-heading-1) + +[Link fragment Repeated 2](./Invalid.md#repeated-heading-2) diff --git a/test/utils.test.js b/test/utils.test.js new file mode 100644 index 0000000..92f781f --- /dev/null +++ b/test/utils.test.js @@ -0,0 +1,37 @@ +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + convertHeadingToHTMLFragment, + getMarkdownHeadings +} = require('../src/utils.js') + +test('utils', async (t) => { + await t.test('convertHeadingToHTMLFragment', async () => { + assert.strictEqual( + convertHeadingToHTMLFragment('Valid Fragments'), + '#valid-fragments' + ) + assert.strictEqual( + convertHeadingToHTMLFragment('Valid Heading With Underscores _'), + '#valid-heading-with-underscores-_' + ) + assert.strictEqual( + convertHeadingToHTMLFragment( + `Valid Heading With Quotes ' And Double Quotes "` + ), + '#valid-heading-with-quotes--and-double-quotes-' + ) + assert.strictEqual( + convertHeadingToHTMLFragment('🚀 Valid Heading With Emoji'), + '#-valid-heading-with-emoji' + ) + }) + + await t.test('getMarkdownHeadings', async () => { + assert.deepStrictEqual( + getMarkdownHeadings('# Hello\n\n## World\n\n## Hello, world!\n'), + ['Hello', 'World', 'Hello, world!'] + ) + }) +})