19 Commits

Author SHA1 Message Date
f332c833ca feat: support line number checking in link fragment (e.g: '#L50')
Fixes #6
2024-01-31 01:14:27 +01:00
e20ee54b05 fix: should only check valid fragments in markdown (.md) files 2024-01-31 00:10:41 +01:00
5c39afbe20 refactor: simplify logic understanding 2024-01-30 23:57:38 +01:00
cc9a1cf6a2 chore: cleaner configs 2024-01-29 21:24:22 +01:00
1095647d41 docs(license): add email address 2024-01-29 21:09:23 +01:00
146f904866 chore: small tweaks 2024-01-12 01:30:52 +01:00
64396954e4 chore: dependency vendoring of markdownlint-rule-helpers 2024-01-12 01:30:52 +01:00
92f35daeaf docs: explain limitations/features 2024-01-12 01:30:52 +01:00
7ef7cc3bb3 refactor: early conditions first 2024-01-12 01:30:52 +01:00
0479652ffe test: add cases for fragments checking 2024-01-12 01:30:52 +01:00
68f35ddc0b fix: empty id fragment should be invalid 2024-01-12 01:30:52 +01:00
747203c23b fix: fragments checking should work in other elements than only anchor (e.g: <div>) 2024-01-12 01:30:52 +01:00
a5deae599a fix: ignore checking fragment in own file
We don't need to check if fragments are valid in own file.
It's already checked by the rule MD051 of markdownlint (else we have the error duplicated).
Ref: https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md
2024-01-12 01:30:52 +01:00
24a0788d32 feat: html anchor support 2024-01-12 01:30:52 +01:00
9d2cc818d5 docs: fix broken GitHub Action link 2024-01-10 21:09:11 +01:00
7465ffd8bc test: separate cases, so it's easier to know what fails 2024-01-10 00:01:13 +01:00
1ddcdc7b18 fix: cleaner code + better error messages 2024-01-09 23:20:17 +01:00
fcd0340e57 docs: describe the Additional features and the Limitations 2024-01-09 21:58:00 +01:00
7bf3b93822 build(deps): update latest 2024-01-09 21:33:27 +01:00
59 changed files with 1105 additions and 832 deletions

View File

@ -1,7 +1,11 @@
{ {
"extends": ["conventions", "prettier"], "extends": ["conventions", "prettier"],
"plugins": ["prettier"], "plugins": ["prettier", "import", "unicorn"],
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": { "rules": {
"prettier/prettier": "error" "prettier/prettier": "error",
"import/extensions": ["error", "always"]
} }
} }

View File

@ -26,3 +26,4 @@ jobs:
- run: "npm run lint:markdown" - run: "npm run lint:markdown"
- run: "npm run lint:eslint" - run: "npm run lint:eslint"
- run: "npm run lint:prettier" - run: "npm run lint:prettier"
- run: "npm run lint:javascript"

View File

@ -1,4 +1,3 @@
#!/bin/sh #!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:commit -- --edit npm run lint:commit -- --edit

View File

@ -1,5 +1,5 @@
#!/bin/sh #!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npm run lint:staged npm run lint:staged
npm run lint:javascript
npm run test npm run test

View File

@ -1,6 +1,7 @@
{ {
"*": ["editorconfig-checker"], "**/*": ["editorconfig-checker", "prettier --write --ignore-unknown"],
"*.{js,jsx,ts,tsx}": ["prettier --write", "eslint --fix"], "**/*.md": ["markdownlint-cli2 --fix --no-globs"],
"*.{json,jsonc,yml,yaml}": ["prettier --write"], "**/*.{js,jsx,ts,tsx}": [
"*.{md,mdx}": ["prettier --write", "markdownlint-cli2 --fix"] "eslint --fix --max-warnings 0 --report-unused-disable-directives"
]
} }

View File

@ -1,11 +1,11 @@
{ {
"config": { "config": {
"extends": "markdownlint/style/prettier", "extends": "markdownlint/style/prettier",
"relative-links": true,
"default": true, "default": true,
"MD033": false "relative-links": true,
"no-inline-html": false,
}, },
"globs": ["**/*.{md,mdx}"], "globs": ["**/*.md"],
"ignores": ["**/node_modules", "**/test/fixtures"], "ignores": ["**/node_modules", "**/test/fixtures/**"],
"customRules": ["./src/index.js"] "customRules": ["./src/index.js"],
} }

View File

@ -1,6 +1,6 @@
# MIT License # MIT License
Copyright (c) Théo LUDWIG Copyright (c) Théo LUDWIG <contact@theoludwig.fr>
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

@ -10,7 +10,7 @@
<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/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" alt="Lint" /></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" alt="Lint" /></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" alt="Test" /></a> <a href="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/test.yml"><img src="https://github.com/theoludwig/markdownlint-rule-relative-links/actions/workflows/test.yml/badge.svg?branch=develop" alt="Test" /></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>
@ -43,9 +43,21 @@ With `awesome.md` content:
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 "./invalid.txt" should exist in the file system] awesome.md:3 relative-links Relative links should be valid ["./invalid.txt" should exist in the file system]
``` ```
### Additional features
- Support images (e.g: `![Image](./image.png)`).
- Support links fragments similar to the [built-in `markdownlint` rule - MD051](https://github.com/DavidAnson/markdownlint/blob/main/doc/md051.md) (e.g: `[Link](./awesome.md#heading)`).
- Ignore external links and absolute paths as it only checks relative links (e.g: `https://example.com/` or `/absolute/path.png`).
### Limitations
- Only images and links defined using markdown syntax are validated, html syntax is ignored (e.g: `<a href="./link.txt" />` or `<img src="./image.png" />`).
Contributions are welcome to improve the rule, and to alleviate these limitations. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more information.
### Related links ### Related links
- [DavidAnson/markdownlint#253](https://github.com/DavidAnson/markdownlint/issues/253) - [DavidAnson/markdownlint#253](https://github.com/DavidAnson/markdownlint/issues/253)
@ -54,7 +66,7 @@ awesome.md:3 relative-links Relative links should be valid [Link "./invalid.txt"
## Prerequisites ## Prerequisites
- [Node.js](https://nodejs.org/) >= 16.0.0 [Node.js](https://nodejs.org/) >= 16.0.0
## Installation ## Installation
@ -76,7 +88,7 @@ We recommend configuring [markdownlint-cli2](https://github.com/DavidAnson/markd
"default": true, "default": true,
"relative-links": true "relative-links": true
}, },
"globs": ["**/*.{md,mdx}"], "globs": ["**/*.md"],
"ignores": ["**/node_modules"], "ignores": ["**/node_modules"],
"customRules": ["markdownlint-rule-relative-links"] "customRules": ["markdownlint-rule-relative-links"]
} }

25
jsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"lib": ["ESNext"],
"target": "ESNext",
"module": "CommonJS",
"moduleResolution": "Node",
"checkJs": true,
"allowJs": true,
"noEmit": true,
"rootDir": ".",
"baseUrl": ".",
"skipLibCheck": true,
"strict": true,
"allowUnusedLabels": false,
"allowUnreachableCode": false,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
},
}

1052
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,10 +35,11 @@
"lint:markdown": "markdownlint-cli2", "lint:markdown": "markdownlint-cli2",
"lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore", "lint:eslint": "eslint . --max-warnings 0 --report-unused-disable-directives --ignore-path .gitignore",
"lint:prettier": "prettier . --check --ignore-path .gitignore", "lint:prettier": "prettier . --check --ignore-path .gitignore",
"lint:javascript": "tsc --project jsconfig.json --noEmit",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test": "node --test ./test", "test": "node --test",
"release": "semantic-release", "release": "semantic-release",
"postinstall": "husky install", "postinstall": "husky",
"prepublishOnly": "pinst --disable", "prepublishOnly": "pinst --disable",
"postpublish": "pinst --enable" "postpublish": "pinst --enable"
}, },
@ -46,23 +47,25 @@
"markdown-it": "14.0.0" "markdown-it": "14.0.0"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "18.4.3", "@commitlint/cli": "18.6.0",
"@commitlint/config-conventional": "18.4.3", "@commitlint/config-conventional": "18.6.0",
"@types/node": "20.10.5", "@types/markdown-it": "13.0.7",
"@types/node": "20.11.10",
"editorconfig-checker": "5.1.2", "editorconfig-checker": "5.1.2",
"eslint": "8.56.0", "eslint": "8.56.0",
"eslint-config-conventions": "13.1.0", "eslint-config-conventions": "13.1.0",
"eslint-config-prettier": "9.1.0", "eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1", "eslint-plugin-import": "2.29.1",
"eslint-plugin-prettier": "5.1.2", "eslint-plugin-prettier": "5.1.3",
"eslint-plugin-promise": "6.1.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-unicorn": "50.0.1", "eslint-plugin-unicorn": "50.0.1",
"husky": "8.0.3", "husky": "9.0.7",
"lint-staged": "15.2.0", "lint-staged": "15.2.0",
"markdownlint": "0.32.1", "markdownlint": "0.33.0",
"markdownlint-cli2": "0.11.0", "markdownlint-cli2": "0.12.1",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "3.1.1", "prettier": "3.2.4",
"semantic-release": "22.0.12" "semantic-release": "23.0.0",
"typescript": "5.3.3"
} }
} }

View File

@ -3,24 +3,33 @@
const { pathToFileURL } = require("node:url") const { pathToFileURL } = require("node:url")
const fs = require("node:fs") const fs = require("node:fs")
const { filterTokens } = require("./markdownlint-rule-helpers/helpers.js")
const { const {
filterTokens,
addError,
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} = require("./utils.js") } = require("./utils.js")
/** @typedef {import('markdownlint').Rule} MarkdownLintRule */
/**
* @type {MarkdownLintRule}
*/
const customRule = { const customRule = {
names: ["relative-links"], names: ["relative-links"],
description: "Relative links should be valid", description: "Relative links should be valid",
tags: ["links"], tags: ["links"],
function: (params, onError) => { function: (params, onError) => {
filterTokens(params, "inline", (token) => { filterTokens(params, "inline", (token) => {
for (const child of token.children) { const children = token.children ?? []
const { lineNumber, type, attrs } = child for (const child of children) {
const { type, attrs, lineNumber } = child
/** @type {string | null} */ /** @type {string | undefined} */
let hrefSrc = null let hrefSrc
if (type === "link_open") { if (type === "link_open") {
for (const attr of attrs) { for (const attr of attrs) {
@ -40,30 +49,70 @@ const customRule = {
} }
} }
if (hrefSrc != null) { if (hrefSrc == null) {
const url = new URL(hrefSrc, pathToFileURL(params.name)) continue
const isRelative = }
url.protocol === "file:" && !hrefSrc.startsWith("/")
if (isRelative) { const url = new URL(hrefSrc, pathToFileURL(params.name))
const detail = `Link "${hrefSrc}"` const isRelative =
url.protocol === "file:" &&
if (!fs.existsSync(url)) { !hrefSrc.startsWith("/") &&
addError( !hrefSrc.startsWith("#")
onError,
lineNumber, if (!isRelative) {
`${detail} should exist in the file system`, continue
) }
const detail = `"${hrefSrc}"`
if (!fs.existsSync(url)) {
onError({
lineNumber,
detail: `${detail} should exist in the file system`,
})
continue
}
if (url.hash.length <= 0) {
if (hrefSrc.includes("#")) {
if (type === "image") {
onError({
lineNumber,
detail: `${detail} should not have a fragment identifier as it is an image`,
})
continue
}
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
continue
}
continue
}
if (type === "image") {
onError({
lineNumber,
detail: `${detail} should not have a fragment identifier as it is an image`,
})
continue
}
if (!url.pathname.endsWith(".md")) {
continue continue
} }
if (type === "link_open" && url.hash !== "") {
const fileContent = fs.readFileSync(url, { encoding: "utf8" }) const fileContent = fs.readFileSync(url, { encoding: "utf8" })
const headings = getMarkdownHeadings(fileContent) const headings = getMarkdownHeadings(fileContent)
const idOrAnchorNameHTMLFragments =
getMarkdownIdOrAnchorNameFragments(fileContent)
/** @type {Map<string, number>} */ /** @type {Map<string, number>} */
const fragments = new Map() const fragments = new Map()
const headingsHTMLFragments = headings.map((heading) => { const fragmentsHTML = headings.map((heading) => {
const fragment = convertHeadingToHTMLFragment(heading) const fragment = convertHeadingToHTMLFragment(heading)
const count = fragments.get(fragment) ?? 0 const count = fragments.get(fragment) ?? 0
fragments.set(fragment, count + 1) fragments.set(fragment, count + 1)
@ -73,15 +122,42 @@ const customRule = {
return fragment return fragment
}) })
if (!headingsHTMLFragments.includes(url.hash)) { fragmentsHTML.push(...idOrAnchorNameHTMLFragments)
addError(
onError, if (!fragmentsHTML.includes(url.hash.toLowerCase())) {
lineNumber, if (url.hash.startsWith("#L")) {
`${detail} should have a valid fragment`, const lineNumberFragmentString = getLineNumberStringFromFragment(
url.hash,
) )
const hasOnlyDigits = isValidIntegerString(lineNumberFragmentString)
if (!hasOnlyDigits) {
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
continue
}
const lineNumberFragment = Number.parseInt(
lineNumberFragmentString,
10,
)
const numberOfLines = getNumberOfLines(fileContent)
if (lineNumberFragment > numberOfLines) {
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier, ${detail} should have at least ${lineNumberFragment} lines to be valid`,
})
continue
} }
} }
}
onError({
lineNumber,
detail: `${detail} should have a valid fragment identifier`,
})
continue
} }
} }
}) })

View File

@ -0,0 +1,38 @@
/**
* Dependency Vendoring of `markdownlint-rule-helpers`
* @see https://www.npmjs.com/package/markdownlint-rule-helpers
*/
/** @typedef {import('markdownlint').RuleParams} MarkdownLintRuleParams */
/** @typedef {import('markdownlint').MarkdownItToken} MarkdownItToken */
/**
* Calls the provided function for each matching token.
*
* @param {MarkdownLintRuleParams} params RuleParams instance.
* @param {string} type Token type identifier.
* @param {(token: MarkdownItToken) => void} handler Callback function.
* @returns {void}
*/
const filterTokens = (params, type, handler) => {
for (const token of params.tokens) {
if (token.type === type) {
handler(token)
}
}
}
/**
* Gets a Regular Expression for matching the specified HTML attribute.
*
* @param {string} name HTML attribute name.
* @returns {RegExp} Regular Expression for matching.
*/
const getHtmlAttributeRe = (name) => {
return new RegExp(`\\s${name}\\s*=\\s*['"]?([^'"\\s>]*)`, "iu")
}
module.exports = {
filterTokens,
getHtmlAttributeRe,
}

View File

@ -1,41 +1,8 @@
const MarkdownIt = require("markdown-it") const MarkdownIt = require("markdown-it")
/** const { getHtmlAttributeRe } = require("./markdownlint-rule-helpers/helpers.js")
* 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)
}
}
}
/** const markdownIt = new MarkdownIt({ html: true })
* 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 * Converts a Markdown heading into an HTML fragment according to the rules
@ -72,7 +39,6 @@ const ignoredTokens = new Set(["heading_open", "heading_close"])
* @returns {string[]} * @returns {string[]}
*/ */
const getMarkdownHeadings = (content) => { const getMarkdownHeadings = (content) => {
const markdownIt = new MarkdownIt({ html: true })
const tokens = markdownIt.parse(content, {}) const tokens = markdownIt.parse(content, {})
/** @type {string[]} */ /** @type {string[]} */
@ -98,8 +64,10 @@ const getMarkdownHeadings = (content) => {
continue continue
} }
const children = token.children ?? []
headings.push( headings.push(
`${token.children `${children
.map((token) => { .map((token) => {
return token.content return token.content
}) })
@ -110,9 +78,83 @@ const getMarkdownHeadings = (content) => {
return headings return headings
} }
const nameHTMLAttributeRegex = getHtmlAttributeRe("name")
const idHTMLAttributeRegex = getHtmlAttributeRe("id")
/**
* Gets the id or anchor name fragments from a Markdown string.
* @param {string} content
* @returns {string[]}
*/
const getMarkdownIdOrAnchorNameFragments = (content) => {
const tokens = markdownIt.parse(content, {})
/** @type {string[]} */
const result = []
for (const token of tokens) {
const regexMatch =
idHTMLAttributeRegex.exec(token.content) ||
nameHTMLAttributeRegex.exec(token.content)
if (regexMatch == null) {
continue
}
const idOrName = regexMatch[1]
if (idOrName == null || idOrName.length <= 0) {
continue
}
const htmlFragment = "#" + idOrName
if (!result.includes(htmlFragment)) {
result.push(htmlFragment)
}
}
return result
}
/**
* Checks if a string is a valid integer.
*
* Using `Number.parseInt` combined with `Number.isNaN` will not be sufficient enough because `Number.parseInt("1abc", 10)` will return `1` (a valid number) instead of `NaN`.
*
* @param {string} value
* @returns {boolean}
* @example isValidIntegerString("1") // true
* @example isValidIntegerString("45") // true
* @example isValidIntegerString("1abc") // false
* @example isValidIntegerString("1.0") // false
*/
const isValidIntegerString = (value) => {
const regex = /^\d+$/
return regex.test(value)
}
/**
* Gets the number of lines in a string, based on the number of `\n` characters.
* @param {string} content
* @returns {number}
*/
const getNumberOfLines = (content) => {
return content.split("\n").length
}
/**
* Gets the line number string from a fragment.
* @param {string} fragment
* @returns {string}
* @example getLineNumberStringFromFragment("#L50") // 50
*/
const getLineNumberStringFromFragment = (fragment) => {
return fragment.slice(2)
}
module.exports = { module.exports = {
filterTokens,
addError,
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} }

View File

@ -1,46 +0,0 @@
const test = require("node:test")
const assert = require("node:assert/strict")
const { markdownlint } = require("markdownlint").promises
const relativeLinks = require("../src/index.js")
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, 3)
assert.equal(
lintResults["test/fixtures/Invalid.md"][0]?.ruleDescription,
"Relative links should be valid",
)
assert.equal(
lintResults["test/fixtures/Invalid.md"][0]?.errorDetail,
'Link "./basic.test.js" should exist in the file system',
)
assert.equal(
lintResults["test/fixtures/Invalid.md"][1]?.ruleDescription,
"Relative links should be valid",
)
assert.equal(
lintResults["test/fixtures/Invalid.md"][1]?.errorDetail,
'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

@ -1,19 +0,0 @@
# Invalid
[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

View File

@ -1,21 +0,0 @@
# Valid
[basic.js](../basic.test.js)
![Image](./image.png)
![Absolute Path](/absolute/path.png)
[External https link](https://example.com/)
[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)

View File

@ -0,0 +1,3 @@
# Awesome
<div id>Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#)

View File

@ -0,0 +1,3 @@
# Invalid
![Image](../image.png#)

View File

@ -0,0 +1,3 @@
# Invalid
![Image](../image.png#non-existing-fragment)

View File

@ -0,0 +1,3 @@
# Awesome
<input name="name-should-be-ignored" id="id-after-name" />

View File

@ -0,0 +1,3 @@
# Invalid
[Invalid](./awesome.md#name-should-be-ignored)

View File

@ -0,0 +1,3 @@
# Awesome
<div data-id="not-an-id-should-be-ignored">Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Invalid](./awesome.md#not-an-id-should-be-ignored)

View File

@ -0,0 +1 @@
# Awesome

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment line number 7](./awesome.md#L7abc)

View File

@ -0,0 +1 @@
# Awesome

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment line number 7](./awesome.md#L7)

View File

@ -0,0 +1,3 @@
# Awesome
<a name="existing-heading-anchor">Link</a>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#non-existing-anchor-name-fragment)

View File

@ -0,0 +1,3 @@
# Awesome
<div id="existing-element-id-fragment">Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#non-existing-element-id-fragment)

View File

@ -0,0 +1,3 @@
# Invalid
[File](./index.test.js)

View File

@ -0,0 +1,3 @@
# Awesome
## Existing Heading

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#non-existing-heading)

View File

@ -0,0 +1,3 @@
# Invalid
![Image](./image.png)

View File

@ -0,0 +1,3 @@
# Awesome
<a name="existing-heading-anchor">Link</a>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#existing-heading-anchor)

View File

@ -0,0 +1,3 @@
# Awesome
<div id="existing-element-id-fragment">Content</div>

View File

@ -0,0 +1,3 @@
# Invalid
[Link fragment](./awesome.md#existing-element-id-fragment)

3
test/fixtures/valid/existing-file.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Valid
[File](../../index.test.js)

View File

@ -0,0 +1,3 @@
# Awesome
## Existing Heading

View File

@ -0,0 +1,3 @@
# Valid
[Link fragment](./awesome.md#ExistIng-Heading)

View File

@ -0,0 +1,13 @@
# Awesome
## Existing Heading
### Repeated Heading
Text
### Repeated Heading
Text
### Repeated Heading

View File

@ -0,0 +1,9 @@
# Valid
[Link fragment](./awesome.md#existing-heading)
[Link fragment Repeated 0](./awesome.md#repeated-heading)
[Link fragment Repeated 1](./awesome.md#repeated-heading-1)
[Link fragment Repeated 2](./awesome.md#repeated-heading-2)

3
test/fixtures/valid/existing-image.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Valid
![Image](../image.png)

View File

@ -0,0 +1,3 @@
# Valid
![Absolute Path](/absolute/path.png)

View File

@ -0,0 +1,7 @@
# Valid
[External https link](https://example.com/)
[External https link 2](https:./external.https)
[External ftp link](ftp:./external.ftp)

View File

@ -0,0 +1,5 @@
# Valid
<div id="existing-element-id-fragment">Content</div>
[Link fragment](#non-existing-element-id-fragment)

View File

@ -0,0 +1,9 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Awesome</title>
</head>
<body></body>
</html>

View File

@ -0,0 +1,7 @@
# Valid
[Link fragment HTML](./awesome.html#existing-heading)
[Link fragment TXT](./abc.txt#existing-heading)
[Link fragment Image](../../image.png#existing-heading)

View File

@ -0,0 +1,3 @@
# Awesome
## L7

View File

@ -0,0 +1,3 @@
# Valid
[Link fragment](./awesome.md#L7)

View File

@ -0,0 +1,9 @@
# Awesome
ABC
Line 5
Line 7 Text
## L7

View File

@ -0,0 +1,3 @@
# Valid
[Link fragment line number 7](./awesome.md#L7)

189
test/index.test.js Normal file
View File

@ -0,0 +1,189 @@
const { test } = require("node:test")
const assert = require("node:assert/strict")
const { markdownlint } = require("markdownlint").promises
const relativeLinksRule = require("../src/index.js")
/**
*
* @param {string} fixtureFile
* @returns
*/
const validateMarkdownLint = async (fixtureFile) => {
const lintResults = await markdownlint({
files: [fixtureFile],
config: {
default: false,
"relative-links": true,
},
customRules: [relativeLinksRule],
})
return lintResults[fixtureFile]
}
test("ensure the rule validates correctly", async (t) => {
await t.test("should be invalid", async (t) => {
const testCases = [
{
name: "with an empty id fragment",
fixturePath:
"test/fixtures/invalid/empty-id-fragment/empty-id-fragment.md",
error: '"./awesome.md#" should have a valid fragment identifier',
},
{
name: "with a name fragment other than for an anchor",
fixturePath:
"test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/ignore-name-fragment-if-not-an-anchor.md",
error:
'"./awesome.md#name-should-be-ignored" should have a valid fragment identifier',
},
{
name: "with a non-existing id fragment (data-id !== id)",
fixturePath:
"test/fixtures/invalid/ignore-not-an-id-fragment/ignore-not-an-id-fragment.md",
error:
'"./awesome.md#not-an-id-should-be-ignored" should have a valid fragment identifier',
},
{
name: "with invalid heading with #L fragment",
fixturePath:
"test/fixtures/invalid/invalid-heading-with-L-fragment/invalid-heading-with-L-fragment.md",
error: '"./awesome.md#L7abc" should have a valid fragment identifier',
},
{
name: "with a invalid line number fragment",
fixturePath:
"test/fixtures/invalid/invalid-line-number-fragment/invalid-line-number-fragment.md",
error:
'"./awesome.md#L7" should have a valid fragment identifier, "./awesome.md#L7" should have at least 7 lines to be valid',
},
{
name: "with a non-existing anchor name fragment",
fixturePath:
"test/fixtures/invalid/non-existing-anchor-name-fragment/non-existing-anchor-name-fragment.md",
error:
'"./awesome.md#non-existing-anchor-name-fragment" should have a valid fragment identifier',
},
{
name: "with a non-existing element id fragment",
fixturePath:
"test/fixtures/invalid/non-existing-element-id-fragment/non-existing-element-id-fragment.md",
error:
'"./awesome.md#non-existing-element-id-fragment" should have a valid fragment identifier',
},
{
name: "with a non-existing heading fragment",
fixturePath:
"test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md",
error:
'"./awesome.md#non-existing-heading" should have a valid fragment identifier',
},
{
name: "with a link to an image with a empty fragment",
fixturePath:
"test/fixtures/invalid/ignore-empty-fragment-checking-for-image.md",
error:
'"../image.png#" should not have a fragment identifier as it is an image',
},
{
name: "with a link to an image with a fragment",
fixturePath:
"test/fixtures/invalid/ignore-fragment-checking-for-image.md",
error:
'"../image.png#non-existing-fragment" should not have a fragment identifier as it is an image',
},
{
name: "with a non-existing file",
fixturePath: "test/fixtures/invalid/non-existing-file.md",
error: '"./index.test.js" should exist in the file system',
},
{
name: "with a non-existing image",
fixturePath: "test/fixtures/invalid/non-existing-image.md",
error: '"./image.png" should exist in the file system',
},
]
for (const { name, fixturePath, error } of testCases) {
await t.test(name, async () => {
const lintResults = await validateMarkdownLint(fixturePath)
assert.equal(lintResults?.length, 1)
assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names)
assert.equal(
lintResults?.[0]?.ruleDescription,
relativeLinksRule.description,
)
assert.equal(lintResults?.[0]?.errorDetail, error)
})
}
})
await t.test("should be valid", async (t) => {
const testCases = [
{
name: "with an existing anchor name fragment",
fixturePath:
"test/fixtures/valid/existing-anchor-name-fragment/existing-anchor-name-fragment.md",
},
{
name: "with an existing element id fragment",
fixturePath:
"test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md",
},
{
name: "with an existing heading fragment (case insensitive)",
fixturePath:
"test/fixtures/valid/existing-heading-case-insensitive/existing-heading-case-insensitive.md",
},
{
name: "with an existing heading fragment",
fixturePath:
"test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md",
},
{
name: "should only parse markdown files for fragments checking",
fixturePath:
"test/fixtures/valid/only-parse-markdown-files-for-fragments/only-parse-markdown-files-for-fragments.md",
},
{
name: 'with valid heading "like" line number fragment',
fixturePath:
"test/fixtures/valid/valid-heading-like-number-fragment/valid-heading-like-number-fragment.md",
},
{
name: "with valid line number fragment",
fixturePath:
"test/fixtures/valid/valid-line-number-fragment/valid-line-number-fragment.md",
},
{
name: "with an existing file",
fixturePath: "test/fixtures/valid/existing-file.md",
},
{
name: "with an existing image",
fixturePath: "test/fixtures/valid/existing-image.md",
},
{
name: "should ignore absolute paths",
fixturePath: "test/fixtures/valid/ignore-absolute-paths.md",
},
{
name: "should ignore external links",
fixturePath: "test/fixtures/valid/ignore-external-links.md",
},
{
name: "should ignore checking fragment in own file",
fixturePath:
"test/fixtures/valid/ignore-fragment-checking-in-own-file.md",
},
]
for (const { name, fixturePath } of testCases) {
await t.test(name, async () => {
const lintResults = await validateMarkdownLint(fixturePath)
assert.equal(lintResults?.length, 0)
})
}
})
})

View File

@ -1,9 +1,13 @@
const test = require("node:test") const { test } = require("node:test")
const assert = require("node:assert/strict") const assert = require("node:assert/strict")
const { const {
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} = require("../src/utils.js") } = require("../src/utils.js")
test("utils", async (t) => { test("utils", async (t) => {
@ -34,4 +38,42 @@ test("utils", async (t) => {
["Hello", "World", "Hello, world!"], ["Hello", "World", "Hello, world!"],
) )
}) })
await t.test("getMarkdownIdOrAnchorNameFragments", async () => {
assert.deepStrictEqual(
getMarkdownIdOrAnchorNameFragments(
'<a name="anchorName" id="anchorId">Link</a>',
),
["#anchorId"],
)
assert.deepStrictEqual(
getMarkdownIdOrAnchorNameFragments('<a name="anchorName">Link</a>'),
["#anchorName"],
)
assert.deepStrictEqual(
getMarkdownIdOrAnchorNameFragments("<a>Link</a>"),
[],
)
assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments("<a>"), [])
assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments("<a id=>"), [])
})
await t.test("isValidIntegerString", async () => {
assert.strictEqual(isValidIntegerString("1"), true)
assert.strictEqual(isValidIntegerString("45"), true)
assert.strictEqual(isValidIntegerString("1abc"), false)
assert.strictEqual(isValidIntegerString("1.0"), false)
})
await t.test("getNumberOfLines", async () => {
assert.strictEqual(getNumberOfLines(""), 1)
assert.strictEqual(getNumberOfLines("Hello"), 1)
assert.strictEqual(getNumberOfLines("Hello\nWorld"), 2)
assert.strictEqual(getNumberOfLines("Hello\nWorld\n"), 3)
assert.strictEqual(getNumberOfLines("Hello\nWorld\n\n"), 4)
})
await t.test("getLineNumberStringFromFragment", async () => {
assert.strictEqual(getLineNumberStringFromFragment("#L50"), "50")
})
}) })