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",
|
||||
"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",
|
||||
|
73
src/index.js
73
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`
|
||||
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)
|
||||
|
||||
![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 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