diff --git a/jsconfig.json b/jsconfig.json index e997767..c3e1fb3 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -19,6 +19,8 @@ "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, - "forceConsistentCasingInFileNames": true - } + "forceConsistentCasingInFileNames": true, + "maxNodeModuleJsDepth": 0 + }, + "exclude": ["node_modules"] } diff --git a/package-lock.json b/package-lock.json index 2971363..84a457f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "markdown-it": "14.0.0" + "markdown-it": "14.0.0", + "markdownlint-rule-helpers": "0.24.0" }, "devDependencies": { "@commitlint/cli": "18.4.4", @@ -29,6 +30,7 @@ "lint-staged": "15.2.0", "markdownlint": "0.33.0", "markdownlint-cli2": "0.11.0", + "markdownlint-rule-helpers": "0.24.0", "pinst": "3.0.0", "prettier": "3.1.1", "semantic-release": "22.0.12", @@ -5174,6 +5176,30 @@ "url": "https://github.com/sponsors/DavidAnson" } }, + "node_modules/markdownlint-rule-helpers": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/markdownlint-rule-helpers/-/markdownlint-rule-helpers-0.24.0.tgz", + "integrity": "sha512-Q7l/VO0ro3+oK7ZzB0aODVFwD0/X+S3JXM9ItdTTgo23yqsv7XzGHx+cGp/zlqD1W8+uiSoXflMVXzh5KSKskA==", + "dev": true, + "dependencies": { + "markdownlint-micromark": "0.1.2" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/DavidAnson" + } + }, + "node_modules/markdownlint-rule-helpers/node_modules/markdownlint-micromark": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/markdownlint-micromark/-/markdownlint-micromark-0.1.2.tgz", + "integrity": "sha512-jRxlQg8KpOfM2IbCL9RXM8ZiYWz2rv6DlZAnGv8ASJQpUh6byTBnEsbuMZ6T2/uIgntyf7SKg/mEaEBo1164fQ==", + "dev": true, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/marked": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", diff --git a/package.json b/package.json index d993b4f..d7b2391 100644 --- a/package.json +++ b/package.json @@ -37,14 +37,15 @@ "lint:prettier": "prettier . --check --ignore-path .gitignore", "lint:javascript": "tsc --project jsconfig.json --noEmit", "lint:staged": "lint-staged", - "test": "node --test --experimental-test-coverage ./test", + "test": "node --test --experimental-test-coverage", "release": "semantic-release", "postinstall": "husky install", "prepublishOnly": "pinst --disable", "postpublish": "pinst --enable" }, "dependencies": { - "markdown-it": "14.0.0" + "markdown-it": "14.0.0", + "markdownlint-rule-helpers": "0.24.0" }, "devDependencies": { "@commitlint/cli": "18.4.4", @@ -63,6 +64,7 @@ "lint-staged": "15.2.0", "markdownlint": "0.33.0", "markdownlint-cli2": "0.11.0", + "markdownlint-rule-helpers": "0.24.0", "pinst": "3.0.0", "prettier": "3.1.1", "semantic-release": "22.0.12", diff --git a/src/index.js b/src/index.js index 1ba3513..4026ce1 100644 --- a/src/index.js +++ b/src/index.js @@ -7,6 +7,7 @@ const { filterTokens, convertHeadingToHTMLFragment, getMarkdownHeadings, + getMarkdownAnchorHTMLFragments, } = require("./utils.js") /** @typedef {import('markdownlint').Rule} MarkdownLintRule */ @@ -63,6 +64,8 @@ const customRule = { if (type === "link_open" && url.hash !== "") { const fileContent = fs.readFileSync(url, { encoding: "utf8" }) const headings = getMarkdownHeadings(fileContent) + const anchorHTMLFragments = + getMarkdownAnchorHTMLFragments(fileContent) /** @type {Map} */ const fragments = new Map() @@ -77,6 +80,8 @@ const customRule = { return fragment }) + headingsHTMLFragments.push(...anchorHTMLFragments) + if (!headingsHTMLFragments.includes(url.hash)) { onError({ lineNumber, diff --git a/src/utils.js b/src/utils.js index 49baaec..ad4933f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,4 +1,6 @@ const MarkdownIt = require("markdown-it") +// @ts-ignore +const { getHtmlAttributeRe } = require("markdownlint-rule-helpers") /** @typedef {import('markdownlint').RuleParams} MarkdownLintRuleParams */ /** @typedef {import('markdownlint').MarkdownItToken} MarkdownItToken */ @@ -94,8 +96,55 @@ const getMarkdownHeadings = (content) => { return headings } +const anchorNameRe = getHtmlAttributeRe("name") +const anchorIdRe = getHtmlAttributeRe("id") + +/** + * Gets the anchor HTML fragments from a Markdown string. + * @param {string} content + * @returns {string[]} + */ +const getMarkdownAnchorHTMLFragments = (content) => { + const markdownIt = new MarkdownIt({ html: true }) + const tokens = markdownIt.parse(content, {}) + + /** @type {string[]} */ + const result = [] + + for (const token of tokens) { + if (token.type === "inline") { + if (!token.children) { + continue + } + + for (const child of token.children) { + if (child.type === "html_inline") { + const anchorMatch = + anchorIdRe.exec(token.content) || anchorNameRe.exec(token.content) + if (!anchorMatch || anchorMatch.length === 0) { + continue + } + + const anchorIdOrName = anchorMatch[1] + if (anchorMatch[1] === undefined) { + continue + } + + const anchorHTMLFragment = "#" + anchorIdOrName + if (!result.includes(anchorHTMLFragment)) { + result.push(anchorHTMLFragment) + } + } + } + } + } + + return result +} + module.exports = { filterTokens, convertHeadingToHTMLFragment, getMarkdownHeadings, + getMarkdownAnchorHTMLFragments, } diff --git a/test/fixtures/invalid/non-existing-heading-fragment/awesome.md b/test/fixtures/invalid/non-existing-heading-fragment/awesome.md index 6b3a7d5..a2eeff7 100644 --- a/test/fixtures/invalid/non-existing-heading-fragment/awesome.md +++ b/test/fixtures/invalid/non-existing-heading-fragment/awesome.md @@ -1,3 +1,5 @@ # Valid + + ## Existing Heading diff --git a/test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md b/test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md index cd01aef..90c0589 100644 --- a/test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md +++ b/test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md @@ -1,3 +1,5 @@ # Invalid [Link fragment](./awesome.md#non-existing-heading) + +[Link fragment](./awesome.md#non-existing-heading-anchor) diff --git a/test/fixtures/valid/existing-heading-fragment/awesome.md b/test/fixtures/valid/existing-heading-fragment/awesome.md index 0bc1d5c..374381f 100644 --- a/test/fixtures/valid/existing-heading-fragment/awesome.md +++ b/test/fixtures/valid/existing-heading-fragment/awesome.md @@ -1,5 +1,7 @@ # Valid + + ## Existing Heading ### Repeated Heading diff --git a/test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md b/test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md index 15dcd71..e16e91a 100644 --- a/test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md +++ b/test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md @@ -2,6 +2,8 @@ [Link fragment](./awesome.md#existing-heading) +[Link fragment](./awesome.md#existing-heading-anchor) + [Link fragment Repeated 0](./awesome.md#repeated-heading) [Link fragment Repeated 1](./awesome.md#repeated-heading-1) diff --git a/test/index.test.js b/test/index.test.js index 3993fec..7c15d71 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -65,16 +65,22 @@ test("ensure the rule validates correctly", async (t) => { const lintResults = await validateMarkdownLint( "test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md", ) - assert.equal(lintResults?.length, 1) - assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names) - assert.equal( - lintResults?.[0]?.ruleDescription, - relativeLinksRule.description, - ) + assert.equal(lintResults?.length, 2) + for (let i = 0; i < 2; i++) { + assert.deepEqual(lintResults?.[i]?.ruleNames, relativeLinksRule.names) + assert.equal( + lintResults?.[i]?.ruleDescription, + relativeLinksRule.description, + ) + } assert.equal( lintResults?.[0]?.errorDetail, '"./awesome.md#non-existing-heading" should have a valid fragment identifier', ) + assert.equal( + lintResults?.[1]?.errorDetail, + '"./awesome.md#non-existing-heading-anchor" should have a valid fragment identifier', + ) }) await t.test("with a non-existing file", async () => { diff --git a/test/utils.test.js b/test/utils.test.js index c87caa3..4af8fc7 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -4,6 +4,7 @@ const assert = require("node:assert/strict") const { convertHeadingToHTMLFragment, getMarkdownHeadings, + getMarkdownAnchorHTMLFragments, } = require("../src/utils.js") test("utils", async (t) => { @@ -34,4 +35,17 @@ test("utils", async (t) => { ["Hello", "World", "Hello, world!"], ) }) + + await t.test("getMarkdownAnchorHTMLFragments", async () => { + assert.deepStrictEqual( + getMarkdownAnchorHTMLFragments(''), + ["#anchorId"], + ) + assert.deepStrictEqual( + getMarkdownAnchorHTMLFragments(''), + ["#anchorName"], + ) + assert.deepStrictEqual(getMarkdownAnchorHTMLFragments(""), []) + assert.deepStrictEqual(getMarkdownAnchorHTMLFragments(""), []) + }) })