Compare commits

..

No commits in common. "f332c833ca6eb368a57bef3b6f0fa6542f2c1acb" and "146f904866e30a14faafdf77af1246e54b50792d" have entirely different histories.

27 changed files with 463 additions and 530 deletions

View File

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

View File

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

View File

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

View File

@ -3,9 +3,9 @@
"extends": "markdownlint/style/prettier", "extends": "markdownlint/style/prettier",
"default": true, "default": true,
"relative-links": true, "relative-links": true,
"no-inline-html": false, "no-inline-html": false
}, },
"globs": ["**/*.md"], "globs": ["**/*.{md,mdx}"],
"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 <contact@theoludwig.fr> 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

@ -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"], "globs": ["**/*.{md,mdx}"],
"ignores": ["**/node_modules"], "ignores": ["**/node_modules"],
"customRules": ["markdownlint-rule-relative-links"] "customRules": ["markdownlint-rule-relative-links"]
} }

View File

@ -20,6 +20,6 @@
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true
}, }
} }

755
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -37,9 +37,9 @@
"lint:prettier": "prettier . --check --ignore-path .gitignore", "lint:prettier": "prettier . --check --ignore-path .gitignore",
"lint:javascript": "tsc --project jsconfig.json --noEmit", "lint:javascript": "tsc --project jsconfig.json --noEmit",
"lint:staged": "lint-staged", "lint:staged": "lint-staged",
"test": "node --test", "test": "node --test --experimental-test-coverage",
"release": "semantic-release", "release": "semantic-release",
"postinstall": "husky", "postinstall": "husky install",
"prepublishOnly": "pinst --disable", "prepublishOnly": "pinst --disable",
"postpublish": "pinst --enable" "postpublish": "pinst --enable"
}, },
@ -47,10 +47,10 @@
"markdown-it": "14.0.0" "markdown-it": "14.0.0"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "18.6.0", "@commitlint/cli": "18.4.4",
"@commitlint/config-conventional": "18.6.0", "@commitlint/config-conventional": "18.4.4",
"@types/markdown-it": "13.0.7", "@types/markdown-it": "13.0.7",
"@types/node": "20.11.10", "@types/node": "20.11.0",
"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",
@ -59,13 +59,13 @@
"eslint-plugin-prettier": "5.1.3", "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": "9.0.7", "husky": "8.0.3",
"lint-staged": "15.2.0", "lint-staged": "15.2.0",
"markdownlint": "0.33.0", "markdownlint": "0.33.0",
"markdownlint-cli2": "0.12.1", "markdownlint-cli2": "0.11.0",
"pinst": "3.0.0", "pinst": "3.0.0",
"prettier": "3.2.4", "prettier": "3.1.1",
"semantic-release": "23.0.0", "semantic-release": "22.0.12",
"typescript": "5.3.3" "typescript": "5.3.3"
} }
} }

View File

@ -8,9 +8,6 @@ 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 */
@ -75,7 +72,7 @@ const customRule = {
if (url.hash.length <= 0) { if (url.hash.length <= 0) {
if (hrefSrc.includes("#")) { if (hrefSrc.includes("#")) {
if (type === "image") { if (type !== "link_open") {
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`,
@ -92,7 +89,7 @@ const customRule = {
continue continue
} }
if (type === "image") { if (type !== "link_open") {
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`,
@ -100,10 +97,6 @@ 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 =
@ -124,35 +117,7 @@ const customRule = {
fragmentsHTML.push(...idOrAnchorNameHTMLFragments) fragmentsHTML.push(...idOrAnchorNameHTMLFragments)
if (!fragmentsHTML.includes(url.hash.toLowerCase())) { if (!fragmentsHTML.includes(url.hash)) {
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,8 +2,6 @@ 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.
@ -39,6 +37,7 @@ 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[]} */
@ -87,6 +86,7 @@ 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,47 +114,8 @@ 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

@ -1 +0,0 @@
# Awesome

View File

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

View File

@ -1 +0,0 @@
# Awesome

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +0,0 @@
<!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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -45,19 +45,6 @@ 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:
@ -131,31 +118,11 @@ 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,9 +5,6 @@ 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) => {
@ -57,23 +54,4 @@ 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")
})
}) })