mirror of
https://github.com/theoludwig/markdownlint-rule-relative-links.git
synced 2024-12-08 00:45:32 +01: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:
parent
9e28311791
commit
6c4e8cec9c
15
package-lock.json
generated
15
package-lock.json
generated
@ -9,6 +9,9 @@
|
|||||||
"version": "0.0.0-development",
|
"version": "0.0.0-development",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"markdown-it": "13.0.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "17.6.5",
|
"@commitlint/cli": "17.6.5",
|
||||||
"@commitlint/config-conventional": "17.6.5",
|
"@commitlint/config-conventional": "17.6.5",
|
||||||
@ -1619,8 +1622,7 @@
|
|||||||
"node_modules/argparse": {
|
"node_modules/argparse": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/argv-formatter": {
|
"node_modules/argv-formatter": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@ -2447,7 +2449,6 @@
|
|||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
|
||||||
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
|
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12"
|
"node": ">=0.12"
|
||||||
},
|
},
|
||||||
@ -4372,7 +4373,6 @@
|
|||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
|
||||||
"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
|
"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"uc.micro": "^1.0.1"
|
"uc.micro": "^1.0.1"
|
||||||
}
|
}
|
||||||
@ -4872,7 +4872,6 @@
|
|||||||
"version": "13.0.1",
|
"version": "13.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.1.tgz",
|
||||||
"integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==",
|
"integrity": "sha512-lTlxriVoy2criHP0JKRhO2VDG9c2ypWCsT237eDiLqi09rmbKoUetyGHq2uOIRoRS//kfoJckS0eUzzkDR+k2Q==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
"entities": "~3.0.1",
|
"entities": "~3.0.1",
|
||||||
@ -5023,8 +5022,7 @@
|
|||||||
"node_modules/mdurl": {
|
"node_modules/mdurl": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
|
||||||
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
|
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/meow": {
|
"node_modules/meow": {
|
||||||
"version": "8.1.2",
|
"version": "8.1.2",
|
||||||
@ -10471,8 +10469,7 @@
|
|||||||
"node_modules/uc.micro": {
|
"node_modules/uc.micro": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
|
||||||
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
|
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/uglify-js": {
|
"node_modules/uglify-js": {
|
||||||
"version": "3.17.4",
|
"version": "3.17.4",
|
||||||
|
@ -42,6 +42,9 @@
|
|||||||
"prepublishOnly": "pinst --disable",
|
"prepublishOnly": "pinst --disable",
|
||||||
"postpublish": "pinst --enable"
|
"postpublish": "pinst --enable"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"markdown-it": "13.0.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "17.6.5",
|
"@commitlint/cli": "17.6.5",
|
||||||
"@commitlint/config-conventional": "17.6.5",
|
"@commitlint/config-conventional": "17.6.5",
|
||||||
|
73
src/index.js
73
src/index.js
@ -3,42 +3,12 @@
|
|||||||
const { pathToFileURL } = require('node:url')
|
const { pathToFileURL } = require('node:url')
|
||||||
const fs = require('node:fs')
|
const fs = require('node:fs')
|
||||||
|
|
||||||
/**
|
const {
|
||||||
* Calls the provided function for each matching token.
|
filterTokens,
|
||||||
*
|
addError,
|
||||||
* @param {Object} params RuleParams instance.
|
convertHeadingToHTMLFragment,
|
||||||
* @param {string} type Token type identifier.
|
getMarkdownHeadings
|
||||||
* @param {Function} handler Callback function.
|
} = require('./utils.js')
|
||||||
* @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 customRule = {
|
const customRule = {
|
||||||
names: ['relative-links'],
|
names: ['relative-links'],
|
||||||
@ -70,12 +40,37 @@ const customRule = {
|
|||||||
|
|
||||||
if (hrefSrc != null) {
|
if (hrefSrc != null) {
|
||||||
const url = new URL(hrefSrc, pathToFileURL(params.name))
|
const url = new URL(hrefSrc, pathToFileURL(params.name))
|
||||||
url.hash = ''
|
|
||||||
const isRelative =
|
const isRelative =
|
||||||
url.protocol === 'file:' && !hrefSrc.startsWith('/')
|
url.protocol === 'file:' && !hrefSrc.startsWith('/')
|
||||||
if (isRelative && !fs.existsSync(url)) {
|
if (isRelative) {
|
||||||
const detail = `Link "${hrefSrc}" is dead`
|
const detail = `Link "${hrefSrc}" is not valid`
|
||||||
|
|
||||||
|
if (!fs.existsSync(url)) {
|
||||||
addError(onError, lineNumber, detail)
|
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')
|
const relativeLinks = require('../src/index.js')
|
||||||
|
|
||||||
test('ensure we validate correctly', async () => {
|
test('ensure the rule validate correctly', async () => {
|
||||||
const lintResults = await markdownlint({
|
const lintResults = await markdownlint({
|
||||||
files: ['test/fixtures/Valid.md', 'test/fixtures/Invalid.md'],
|
files: ['test/fixtures/Valid.md', 'test/fixtures/Invalid.md'],
|
||||||
config: {
|
config: {
|
||||||
|
default: false,
|
||||||
'relative-links': true
|
'relative-links': true
|
||||||
},
|
},
|
||||||
customRules: [relativeLinks]
|
customRules: [relativeLinks]
|
||||||
})
|
})
|
||||||
assert.equal(lintResults['test/fixtures/Valid.md'].length, 0)
|
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(
|
assert.equal(
|
||||||
lintResults['test/fixtures/Invalid.md'][0]?.ruleDescription,
|
lintResults['test/fixtures/Invalid.md'][0]?.ruleDescription,
|
||||||
@ -22,7 +23,7 @@ test('ensure we validate correctly', async () => {
|
|||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
lintResults['test/fixtures/Invalid.md'][0]?.errorDetail,
|
lintResults['test/fixtures/Invalid.md'][0]?.errorDetail,
|
||||||
'Link "./basic.test.js" is dead'
|
'Link "./basic.test.js" is not valid'
|
||||||
)
|
)
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@ -31,6 +32,15 @@ test('ensure we validate correctly', async () => {
|
|||||||
)
|
)
|
||||||
assert.equal(
|
assert.equal(
|
||||||
lintResults['test/fixtures/Invalid.md'][1]?.errorDetail,
|
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)
|
[basic.js](./basic.test.js)
|
||||||
|
|
||||||
![Image](../image.png)
|
![Image](../image.png)
|
||||||
|
|
||||||
|
[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 https link 2](https:./external.https)
|
||||||
|
|
||||||
[External ftp link](ftp:./external.ftp)
|
[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!']
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user