mirror of
				https://github.com/theoludwig/markdownlint-rule-relative-links.git
				synced 2025-09-09 19:39:29 +02:00 
			
		
		
		
	feat: html anchor support
This commit is contained in:
		| @@ -19,6 +19,8 @@ | ||||
|     "noUncheckedIndexedAccess": true, | ||||
|     "noUnusedLocals": true, | ||||
|     "noUnusedParameters": true, | ||||
|     "forceConsistentCasingInFileNames": true | ||||
|   } | ||||
|     "forceConsistentCasingInFileNames": true, | ||||
|     "maxNodeModuleJsDepth": 0 | ||||
|   }, | ||||
|   "exclude": ["node_modules"] | ||||
| } | ||||
|   | ||||
							
								
								
									
										28
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										28
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
| @@ -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<string, number>} */ | ||||
|               const fragments = new Map() | ||||
| @@ -77,6 +80,8 @@ const customRule = { | ||||
|                 return fragment | ||||
|               }) | ||||
|  | ||||
|               headingsHTMLFragments.push(...anchorHTMLFragments) | ||||
|  | ||||
|               if (!headingsHTMLFragments.includes(url.hash)) { | ||||
|                 onError({ | ||||
|                   lineNumber, | ||||
|   | ||||
							
								
								
									
										49
									
								
								src/utils.js
									
									
									
									
									
								
							
							
						
						
									
										49
									
								
								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, | ||||
| } | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| # Valid | ||||
|  | ||||
| <a name="existing-heading-anchor" ></a> | ||||
|  | ||||
| ## Existing Heading | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| # Invalid | ||||
|  | ||||
| [Link fragment](./awesome.md#non-existing-heading) | ||||
|  | ||||
| [Link fragment](./awesome.md#non-existing-heading-anchor) | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| # Valid | ||||
|  | ||||
| <a name="existing-heading-anchor" ></a> | ||||
|  | ||||
| ## Existing Heading | ||||
|  | ||||
| ### Repeated Heading | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 () => { | ||||
|   | ||||
| @@ -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('<a name="anchorName" id="anchorId"></a>'), | ||||
|       ["#anchorId"], | ||||
|     ) | ||||
|     assert.deepStrictEqual( | ||||
|       getMarkdownAnchorHTMLFragments('<a name="anchorName"></a>'), | ||||
|       ["#anchorName"], | ||||
|     ) | ||||
|     assert.deepStrictEqual(getMarkdownAnchorHTMLFragments("<a></a>"), []) | ||||
|     assert.deepStrictEqual(getMarkdownAnchorHTMLFragments("<a>"), []) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user