Compare commits

...

3 Commits

19 changed files with 185 additions and 8 deletions

View File

@ -66,7 +66,7 @@ Contributions are welcome to improve the rule, and to alleviate these limitation
## Prerequisites ## Prerequisites
- [Node.js](https://nodejs.org/) >= 16.0.0 [Node.js](https://nodejs.org/) >= 16.0.0
## Installation ## Installation
@ -88,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"]
} }

View File

@ -8,6 +8,9 @@ const {
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments, getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} = require("./utils.js") } = require("./utils.js")
/** @typedef {import('markdownlint').Rule} MarkdownLintRule */ /** @typedef {import('markdownlint').Rule} MarkdownLintRule */
@ -72,7 +75,7 @@ const customRule = {
if (url.hash.length <= 0) { if (url.hash.length <= 0) {
if (hrefSrc.includes("#")) { if (hrefSrc.includes("#")) {
if (type !== "link_open") { if (type === "image") {
onError({ onError({
lineNumber, lineNumber,
detail: `${detail} should not have a fragment identifier as it is an image`, detail: `${detail} should not have a fragment identifier as it is an image`,
@ -89,7 +92,7 @@ const customRule = {
continue continue
} }
if (type !== "link_open") { if (type === "image") {
onError({ onError({
lineNumber, lineNumber,
detail: `${detail} should not have a fragment identifier as it is an image`, detail: `${detail} should not have a fragment identifier as it is an image`,
@ -97,6 +100,10 @@ const customRule = {
continue continue
} }
if (!url.pathname.endsWith(".md")) {
continue
}
const fileContent = fs.readFileSync(url, { encoding: "utf8" }) const fileContent = fs.readFileSync(url, { encoding: "utf8" })
const headings = getMarkdownHeadings(fileContent) const headings = getMarkdownHeadings(fileContent)
const idOrAnchorNameHTMLFragments = const idOrAnchorNameHTMLFragments =
@ -117,7 +124,35 @@ const customRule = {
fragmentsHTML.push(...idOrAnchorNameHTMLFragments) fragmentsHTML.push(...idOrAnchorNameHTMLFragments)
if (!fragmentsHTML.includes(url.hash)) { if (!fragmentsHTML.includes(url.hash.toLowerCase())) {
if (url.hash.startsWith("#L")) {
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({ onError({
lineNumber, lineNumber,
detail: `${detail} should have a valid fragment identifier`, detail: `${detail} should have a valid fragment identifier`,

View File

@ -2,6 +2,8 @@ const MarkdownIt = require("markdown-it")
const { getHtmlAttributeRe } = require("./markdownlint-rule-helpers/helpers.js") const { getHtmlAttributeRe } = require("./markdownlint-rule-helpers/helpers.js")
const markdownIt = new MarkdownIt({ html: true })
/** /**
* Converts a Markdown heading into an HTML fragment according to the rules * Converts a Markdown heading into an HTML fragment according to the rules
* used by GitHub. * used by GitHub.
@ -37,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[]} */
@ -86,7 +87,6 @@ const idHTMLAttributeRegex = getHtmlAttributeRe("id")
* @returns {string[]} * @returns {string[]}
*/ */
const getMarkdownIdOrAnchorNameFragments = (content) => { const getMarkdownIdOrAnchorNameFragments = (content) => {
const markdownIt = new MarkdownIt({ html: true })
const tokens = markdownIt.parse(content, {}) const tokens = markdownIt.parse(content, {})
/** @type {string[]} */ /** @type {string[]} */
@ -114,8 +114,47 @@ const getMarkdownIdOrAnchorNameFragments = (content) => {
return result 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 = {
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments, getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} }

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
## Existing Heading

View File

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

View File

@ -1,4 +1,4 @@
# Valid # Awesome
## Existing Heading ## Existing Heading

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)

View File

@ -45,6 +45,19 @@ test("ensure the rule validates correctly", async (t) => {
error: error:
'"./awesome.md#not-an-id-should-be-ignored" should have a valid fragment identifier', '"./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", name: "with a non-existing anchor name fragment",
fixturePath: fixturePath:
@ -118,11 +131,31 @@ test("ensure the rule validates correctly", async (t) => {
fixturePath: fixturePath:
"test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md", "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", name: "with an existing heading fragment",
fixturePath: fixturePath:
"test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md", "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", name: "with an existing file",
fixturePath: "test/fixtures/valid/existing-file.md", fixturePath: "test/fixtures/valid/existing-file.md",

View File

@ -5,6 +5,9 @@ const {
convertHeadingToHTMLFragment, convertHeadingToHTMLFragment,
getMarkdownHeadings, getMarkdownHeadings,
getMarkdownIdOrAnchorNameFragments, getMarkdownIdOrAnchorNameFragments,
isValidIntegerString,
getNumberOfLines,
getLineNumberStringFromFragment,
} = require("../src/utils.js") } = require("../src/utils.js")
test("utils", async (t) => { test("utils", async (t) => {
@ -54,4 +57,23 @@ test("utils", async (t) => {
assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments("<a>"), []) assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments("<a>"), [])
assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments("<a id=>"), []) 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")
})
}) })