mirror of
				https://github.com/theoludwig/markdownlint-rule-relative-links.git
				synced 2025-09-09 19:39:29 +02:00 
			
		
		
		
	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
This commit is contained in:
		
							
								
								
									
										15
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										15
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -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", | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
							
								
								
									
										75
									
								
								src/index.js
									
									
									
									
									
								
							
							
						
						
									
										75
									
								
								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<string, number>} */ | ||||
|               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) | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       }) | ||||
|   | ||||
							
								
								
									
										120
									
								
								src/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								src/utils.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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 <https://github.com/DavidAnson/markdownlint/blob/d01180ec5a014083ee9d574b693a8d7fbc1e566d/lib/md051.js#L19> | ||||
|  * | ||||
|  * @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 | ||||
| } | ||||
| @@ -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' | ||||
|   ) | ||||
| }) | ||||
|   | ||||
							
								
								
									
										14
									
								
								test/fixtures/Invalid.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								test/fixtures/Invalid.md
									
									
									
									
										vendored
									
									
								
							| @@ -3,3 +3,17 @@ | ||||
| [basic.js](./basic.test.js) | ||||
|  | ||||
|  | ||||
|  | ||||
| [Link fragment](./Valid.md#not-existing-heading) | ||||
|  | ||||
| ## Existing Heading | ||||
|  | ||||
| ### Repeated Heading | ||||
|  | ||||
| Text | ||||
|  | ||||
| ### Repeated Heading | ||||
|  | ||||
| Text | ||||
|  | ||||
| ### Repeated Heading | ||||
|   | ||||
							
								
								
									
										8
									
								
								test/fixtures/Valid.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								test/fixtures/Valid.md
									
									
									
									
										vendored
									
									
								
							| @@ -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) | ||||
|   | ||||
							
								
								
									
										37
									
								
								test/utils.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								test/utils.test.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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!'] | ||||
|     ) | ||||
|   }) | ||||
| }) | ||||
		Reference in New Issue
	
	Block a user