5 Commits

13 changed files with 899 additions and 4419 deletions

8
.taprc
View File

@ -1,8 +0,0 @@
ts: false
jsx: false
flow: false
check-coverage: false
coverage: false
files:
- 'test/**/*.test.js'

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
<contact@divlo.fr>. <contact@theoludwig.fr>.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the

View File

@ -19,7 +19,7 @@ All work on **markdownlint-rule-relative-links** happens directly on this reposi
## Pull Requests ## Pull Requests
- **Please first discuss** the change you wish to make via [issue](https://github.com/Divlo/markdownlint-rule-relative-links/issues) before making a change. It might avoid a waste of your time. - **Please first discuss** the change you wish to make via [issue](https://github.com/theoludwig/markdownlint-rule-relative-links/issues) before making a change. It might avoid a waste of your time.
- Ensure your code respect linting. - Ensure your code respect linting.

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) Divlo Copyright (c) Théo LUDWIG
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@ -9,8 +9,8 @@
<a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/licence-MIT-blue.svg" alt="Licence MIT"/></a>
<a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a> <a href="./CODE_OF_CONDUCT.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Contributor Covenant" /></a>
<br /> <br />
<a href="https://github.com/Divlo/markdownlint-rule-relative-links/actions/workflows/lint.yml"><img src="https://github.com/Divlo/markdownlint-rule-relative-links/actions/workflows/lint.yml/badge.svg?branch=develop" /></a> <a href="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/lint.yml"><img src="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/lint.yml/badge.svg?branch=develop" /></a>
<a href="https://github.com/Divlo/markdownlint-rule-relative-linksactions/workflows/test.yml"><img src="https://github.com/Divlo/markdownlint-rule-relative-links/actions/workflows/test.yml/badge.svg?branch=develop" /></a> <a href="https://github.com/theoludwig/markdownlint-rule-relative-linksactions/workflows/test.yml"><img src="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/test.yml/badge.svg?branch=develop" /></a>
<br /> <br />
<a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a> <a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg" alt="Conventional Commits" /></a>
<a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a> <a href="https://github.com/semantic-release/semantic-release"><img src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg" alt="semantic-release" /></a>
@ -21,7 +21,7 @@
**markdownlint-rule-relative-links** is a [markdownlint](https://github.com/DavidAnson/markdownlint) custom rule to validate relative links. **markdownlint-rule-relative-links** is a [markdownlint](https://github.com/DavidAnson/markdownlint) custom rule to validate relative links.
It ensures that relative links (using `file:` protocol) are working and not "dead" which means that it exists in the file system of the project that uses [markdownlint](https://github.com/DavidAnson/markdownlint). It ensures that relative links (using `file:` protocol) are working and exists in the file system of the project that uses [markdownlint](https://github.com/DavidAnson/markdownlint).
### Example ### Example
@ -37,13 +37,13 @@ With `awesome.md` content:
```md ```md
[abc](./abc.txt) [abc](./abc.txt)
[Dead link](./dead.txt) [Invalid link](./invalid.txt)
``` ```
Running [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) with `markdownlint-rule-relative-links` will output: Running [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) with `markdownlint-rule-relative-links` will output:
```sh ```sh
awesome.md:3 relative-links Relative links should be valid [Link "./dead.txt" is dead] awesome.md:3 relative-links Relative links should be valid [Link "./invalid.txt" should exist in the file system]
``` ```
### Related links ### Related links

4969
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,16 +3,16 @@
"version": "0.0.0-development", "version": "0.0.0-development",
"public": true, "public": true,
"description": "Custom rule for markdownlint to validate relative links.", "description": "Custom rule for markdownlint to validate relative links.",
"author": "Divlo <contact@divlo.fr>", "author": "Théo LUDWIG <contact@theoludwig.fr>",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Divlo/markdownlint-rule-relative-links.git" "url": "https://github.com/theoludwig/markdownlint-rule-relative-links.git"
}, },
"bugs": { "bugs": {
"url": "https://github.com/Divlo/markdownlint-rule-relative-links/issues" "url": "https://github.com/theoludwig/markdownlint-rule-relative-links/issues"
}, },
"homepage": "https://github.com/Divlo/markdownlint-rule-relative-links#readme", "homepage": "https://github.com/theoludwig/markdownlint-rule-relative-links#readme",
"keywords": [ "keywords": [
"markdownlint", "markdownlint",
"markdownlint-rule" "markdownlint-rule"
@ -33,21 +33,24 @@
"lint:commit": "commitlint", "lint:commit": "commitlint",
"lint:editorconfig": "editorconfig-checker", "lint:editorconfig": "editorconfig-checker",
"lint:markdown": "markdownlint-cli2", "lint:markdown": "markdownlint-cli2",
"lint:eslint": "eslint \".\" --ignore-path \".gitignore\"", "lint:eslint": "eslint . --ignore-path .gitignore",
"lint:prettier": "prettier \".\" --check --ignore-path \".gitignore\"", "lint:prettier": "prettier . --check --ignore-path .gitignore",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test": "tap", "test": "node --test ./test",
"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": {
"markdown-it": "13.0.1"
},
"devDependencies": { "devDependencies": {
"@commitlint/cli": "17.6.3", "@commitlint/cli": "17.6.5",
"@commitlint/config-conventional": "17.6.3", "@commitlint/config-conventional": "17.6.5",
"@types/tap": "15.0.8", "@types/node": "20.3.1",
"editorconfig-checker": "5.0.1", "editorconfig-checker": "5.0.1",
"eslint": "8.40.0", "eslint": "8.43.0",
"eslint-config-conventions": "9.0.0", "eslint-config-conventions": "9.0.0",
"eslint-config-prettier": "8.8.0", "eslint-config-prettier": "8.8.0",
"eslint-plugin-import": "2.27.5", "eslint-plugin-import": "2.27.5",
@ -56,11 +59,10 @@
"eslint-plugin-unicorn": "47.0.0", "eslint-plugin-unicorn": "47.0.0",
"husky": "8.0.3", "husky": "8.0.3",
"lint-staged": "13.2.2", "lint-staged": "13.2.2",
"markdownlint": "0.28.2", "markdownlint": "0.29.0",
"markdownlint-cli2": "0.7.1", "markdownlint-cli2": "0.8.1",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "2.8.8", "prettier": "2.8.8",
"semantic-release": "21.0.2", "semantic-release": "21.0.5"
"tap": "16.3.4"
} }
} }

View File

@ -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,45 @@ 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}"`
addError(onError, lineNumber, detail)
if (!fs.existsSync(url)) {
addError(
onError,
lineNumber,
`${detail} should exist in the file system`
)
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} should have a valid fragment`
)
}
}
} }
} }
}) })

119
src/utils.js Normal file
View File

@ -0,0 +1,119 @@
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.
*
* @see https://github.com/DavidAnson/markdownlint/blob/d01180ec5a014083ee9d574b693a8d7fbc1e566d/lib/md051.js#L1
* @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
}

View File

@ -1,34 +1,46 @@
const tap = require('tap') const test = require('node:test')
const assert = require('node:assert/strict')
const { markdownlint } = require('markdownlint').promises const { markdownlint } = require('markdownlint').promises
const relativeLinks = require('../src/index.js') const relativeLinks = require('../src/index.js')
tap.test('ensure we validate correctly', async (t) => { 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]
}) })
t.equal(lintResults['test/fixtures/Valid.md'].length, 0) assert.equal(lintResults['test/fixtures/Valid.md'].length, 0)
t.equal(lintResults['test/fixtures/Invalid.md'].length, 2) assert.equal(lintResults['test/fixtures/Invalid.md'].length, 3)
t.equal( assert.equal(
lintResults['test/fixtures/Invalid.md'][0]?.ruleDescription, lintResults['test/fixtures/Invalid.md'][0]?.ruleDescription,
'Relative links should be valid' 'Relative links should be valid'
) )
t.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" should exist in the file system'
) )
t.equal( assert.equal(
lintResults['test/fixtures/Invalid.md'][1]?.ruleDescription, lintResults['test/fixtures/Invalid.md'][1]?.ruleDescription,
'Relative links should be valid' 'Relative links should be valid'
) )
t.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" should exist in the file system'
)
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" should have a valid fragment'
) )
}) })

View File

@ -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

View File

@ -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
View 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!']
)
})
})