feat: html anchor support

This commit is contained in:
Igor Tsiglyar 2024-01-11 13:42:34 +00:00 committed by Théo LUDWIG
parent 9d2cc818d5
commit 24a0788d32
11 changed files with 123 additions and 11 deletions

View File

@ -19,6 +19,8 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"forceConsistentCasingInFileNames": true "forceConsistentCasingInFileNames": true,
} "maxNodeModuleJsDepth": 0
},
"exclude": ["node_modules"]
} }

28
package-lock.json generated
View File

@ -10,7 +10,8 @@
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"markdown-it": "14.0.0" "markdown-it": "14.0.0",
"markdownlint-rule-helpers": "0.24.0"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "18.4.4", "@commitlint/cli": "18.4.4",
@ -29,6 +30,7 @@
"lint-staged": "15.2.0", "lint-staged": "15.2.0",
"markdownlint": "0.33.0", "markdownlint": "0.33.0",
"markdownlint-cli2": "0.11.0", "markdownlint-cli2": "0.11.0",
"markdownlint-rule-helpers": "0.24.0",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "3.1.1", "prettier": "3.1.1",
"semantic-release": "22.0.12", "semantic-release": "22.0.12",
@ -5174,6 +5176,30 @@
"url": "https://github.com/sponsors/DavidAnson" "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": { "node_modules/marked": {
"version": "9.1.6", "version": "9.1.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz",

View File

@ -37,14 +37,15 @@
"lint:prettier": "prettier . --check --ignore-path .gitignore", "lint:prettier": "prettier . --check --ignore-path .gitignore",
"lint:javascript": "tsc --project jsconfig.json --noEmit", "lint:javascript": "tsc --project jsconfig.json --noEmit",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test": "node --test --experimental-test-coverage ./test", "test": "node --test --experimental-test-coverage",
"release": "semantic-release", "release": "semantic-release",
"postinstall": "husky install", "postinstall": "husky install",
"prepublishOnly": "pinst --disable", "prepublishOnly": "pinst --disable",
"postpublish": "pinst --enable" "postpublish": "pinst --enable"
}, },
"dependencies": { "dependencies": {
"markdown-it": "14.0.0" "markdown-it": "14.0.0",
"markdownlint-rule-helpers": "0.24.0"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "18.4.4", "@commitlint/cli": "18.4.4",
@ -63,6 +64,7 @@
"lint-staged": "15.2.0", "lint-staged": "15.2.0",
"markdownlint": "0.33.0", "markdownlint": "0.33.0",
"markdownlint-cli2": "0.11.0", "markdownlint-cli2": "0.11.0",
"markdownlint-rule-helpers": "0.24.0",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "3.1.1", "prettier": "3.1.1",
"semantic-release": "22.0.12", "semantic-release": "22.0.12",

View File

@ -7,6 +7,7 @@ const {
filterTokens, filterTokens,
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownAnchorHTMLFragments,
} = require("./utils.js") } = require("./utils.js")
/** @typedef {import('markdownlint').Rule} MarkdownLintRule */ /** @typedef {import('markdownlint').Rule} MarkdownLintRule */
@ -63,6 +64,8 @@ const customRule = {
if (type === "link_open" && url.hash !== "") { if (type === "link_open" && url.hash !== "") {
const fileContent = fs.readFileSync(url, { encoding: "utf8" }) const fileContent = fs.readFileSync(url, { encoding: "utf8" })
const headings = getMarkdownHeadings(fileContent) const headings = getMarkdownHeadings(fileContent)
const anchorHTMLFragments =
getMarkdownAnchorHTMLFragments(fileContent)
/** @type {Map<string, number>} */ /** @type {Map<string, number>} */
const fragments = new Map() const fragments = new Map()
@ -77,6 +80,8 @@ const customRule = {
return fragment return fragment
}) })
headingsHTMLFragments.push(...anchorHTMLFragments)
if (!headingsHTMLFragments.includes(url.hash)) { if (!headingsHTMLFragments.includes(url.hash)) {
onError({ onError({
lineNumber, lineNumber,

View File

@ -1,4 +1,6 @@
const MarkdownIt = require("markdown-it") const MarkdownIt = require("markdown-it")
// @ts-ignore
const { getHtmlAttributeRe } = require("markdownlint-rule-helpers")
/** @typedef {import('markdownlint').RuleParams} MarkdownLintRuleParams */ /** @typedef {import('markdownlint').RuleParams} MarkdownLintRuleParams */
/** @typedef {import('markdownlint').MarkdownItToken} MarkdownItToken */ /** @typedef {import('markdownlint').MarkdownItToken} MarkdownItToken */
@ -94,8 +96,55 @@ const getMarkdownHeadings = (content) => {
return headings 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 = { module.exports = {
filterTokens, filterTokens,
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownAnchorHTMLFragments,
} }

View File

@ -1,3 +1,5 @@
# Valid # Valid
<a name="existing-heading-anchor" ></a>
## Existing Heading ## Existing Heading

View File

@ -1,3 +1,5 @@
# Invalid # Invalid
[Link fragment](./awesome.md#non-existing-heading) [Link fragment](./awesome.md#non-existing-heading)
[Link fragment](./awesome.md#non-existing-heading-anchor)

View File

@ -1,5 +1,7 @@
# Valid # Valid
<a name="existing-heading-anchor" ></a>
## Existing Heading ## Existing Heading
### Repeated Heading ### Repeated Heading

View File

@ -2,6 +2,8 @@
[Link fragment](./awesome.md#existing-heading) [Link fragment](./awesome.md#existing-heading)
[Link fragment](./awesome.md#existing-heading-anchor)
[Link fragment Repeated 0](./awesome.md#repeated-heading) [Link fragment Repeated 0](./awesome.md#repeated-heading)
[Link fragment Repeated 1](./awesome.md#repeated-heading-1) [Link fragment Repeated 1](./awesome.md#repeated-heading-1)

View File

@ -65,16 +65,22 @@ test("ensure the rule validates correctly", async (t) => {
const lintResults = await validateMarkdownLint( const lintResults = await validateMarkdownLint(
"test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md", "test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md",
) )
assert.equal(lintResults?.length, 1) assert.equal(lintResults?.length, 2)
assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names) for (let i = 0; i < 2; i++) {
assert.deepEqual(lintResults?.[i]?.ruleNames, relativeLinksRule.names)
assert.equal( assert.equal(
lintResults?.[0]?.ruleDescription, lintResults?.[i]?.ruleDescription,
relativeLinksRule.description, relativeLinksRule.description,
) )
}
assert.equal( assert.equal(
lintResults?.[0]?.errorDetail, lintResults?.[0]?.errorDetail,
'"./awesome.md#non-existing-heading" should have a valid fragment identifier', '"./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 () => { await t.test("with a non-existing file", async () => {

View File

@ -4,6 +4,7 @@ const assert = require("node:assert/strict")
const { const {
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownAnchorHTMLFragments,
} = require("../src/utils.js") } = require("../src/utils.js")
test("utils", async (t) => { test("utils", async (t) => {
@ -34,4 +35,17 @@ test("utils", async (t) => {
["Hello", "World", "Hello, world!"], ["Hello", "World", "Hello, world!"],
) )
}) })
await t.test("getMarkdownAnchorHTMLFragments", async () => {
assert.deepStrictEqual(
getMarkdownAnchorHTMLFragments('<a name="anchorName" id="anchorId"></a>'),
["#anchorId"],
)
assert.deepStrictEqual(
getMarkdownAnchorHTMLFragments('<a name="anchorName"></a>'),
["#anchorName"],
)
assert.deepStrictEqual(getMarkdownAnchorHTMLFragments("<a></a>"), [])
assert.deepStrictEqual(getMarkdownAnchorHTMLFragments("<a>"), [])
})
}) })