diff --git a/.eslintrc.json b/.eslintrc.json index 54dbab3..0059662 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,11 @@ { "extends": ["conventions", "prettier"], - "plugins": ["prettier"], + "plugins": ["prettier", "import", "unicorn"], + "parserOptions": { + "project": "./tsconfig.json" + }, "rules": { - "prettier/prettier": "error" + "prettier/prettier": "error", + "import/extensions": ["error", "always"] } } diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 3d46c8b..0b4e176 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,9 +1,9 @@ { "config": { "extends": "markdownlint/style/prettier", - "relative-links": true, "default": true, - "MD033": false + "relative-links": true, + "no-inline-html": false }, "globs": ["**/*.{md,mdx}"], "ignores": ["**/node_modules", "**/test/fixtures/**"], diff --git a/README.md b/README.md index 0b76f4a..a0181d7 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,12 @@ awesome.md:3 relative-links Relative links should be valid ["./invalid.txt" shou ### Additional features - Support images (e.g: `![Image](./image.png)`). -- Support anchors (heading fragment links) (e.g: `[Link](./awesome.md#existing-heading)`). +- 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 not supported (e.g: `` or ``). -- Anchors checking is limited to headings, other elements are not supported (e.g: with a "id", `
`). +- Only images and links defined using markdown syntax are validated, html syntax is ignored (e.g: `` or ``). Contributions are welcome to improve the rule, and to alleviate these limitations. See [CONTRIBUTING.md](./CONTRIBUTING.md) for more information. diff --git a/jsconfig.json b/jsconfig.json index e997767..c82d356 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -9,6 +9,7 @@ "noEmit": true, "rootDir": ".", "baseUrl": ".", + "skipLibCheck": true, "strict": true, "allowUnusedLabels": false, "allowUnreachableCode": false, diff --git a/package-lock.json b/package-lock.json index 2971363..5d152cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,13 @@ "@commitlint/cli": "18.4.4", "@commitlint/config-conventional": "18.4.4", "@types/markdown-it": "13.0.7", - "@types/node": "20.10.8", + "@types/node": "20.11.0", "editorconfig-checker": "5.1.2", "eslint": "8.56.0", "eslint-config-conventions": "13.1.0", "eslint-config-prettier": "9.1.0", "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-unicorn": "50.0.1", "husky": "8.0.3", @@ -557,13 +557,13 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.13", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", - "integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -584,9 +584,9 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz", - "integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", + "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "dev": true }, "node_modules/@nodelib/fs.scandir": { @@ -1325,9 +1325,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.10.8", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.8.tgz", - "integrity": "sha512-f8nQs3cLxbAFc00vEU59yf9UyGUftkPaLGfvbVOIDdx2i1b8epBqj2aNGyP19fiyXWvlmZ7qC1XLjAzw/OKIeA==", + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.0.tgz", + "integrity": "sha512-o9bjXmDNcF7GbM4CNQpmi+TutCgap/K3w1JyKgxAjqx41zp9qlIAVFi0IhCNsJcXolEqLWhbFbEeL0PvYm4pcQ==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -2422,9 +2422,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.625", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.625.tgz", - "integrity": "sha512-DENMhh3MFgaPDoXWrVIqSPInQoLImywfCwrSmVl3cf9QHzoZSiutHwGaB/Ql3VkqcQV30rzgdM+BjKqBAJxo5Q==", + "version": "1.4.628", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.628.tgz", + "integrity": "sha512-2k7t5PHvLsufpP6Zwk0nof62yLOsCf032wZx7/q0mv8gwlXjhcxI3lz6f0jBr0GrnWKcm3burXzI3t5IrcdUxw==", "dev": true }, "node_modules/emoji-regex": { @@ -2911,9 +2911,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.2.tgz", - "integrity": "sha512-dhlpWc9vOwohcWmClFcA+HjlvUpuyynYs0Rf+L/P6/0iQE6vlHW9l5bkfzN62/Stm9fbq8ku46qzde76T1xlSg==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", "dev": true, "dependencies": { "prettier-linter-helpers": "^1.0.0", @@ -5410,9 +5410,9 @@ } }, "node_modules/npm": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/npm/-/npm-10.2.5.tgz", - "integrity": "sha512-lXdZ7titEN8CH5YJk9C/aYRU9JeDxQ4d8rwIIDsvH3SMjLjHTukB2CFstMiB30zXs4vCrPN2WH6cDq1yHBeJAw==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-10.3.0.tgz", + "integrity": "sha512-9u5GFc1UqI2DLlGI7QdjkpIaBs3UhTtY8KoCqYJK24gV/j/tByaI4BA4R7RkOc+ASqZMzFPKt4Pj2Z8JcGo//A==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -5494,12 +5494,12 @@ "@npmcli/fs": "^3.1.0", "@npmcli/map-workspaces": "^3.0.4", "@npmcli/package-json": "^5.0.0", - "@npmcli/promise-spawn": "^7.0.0", - "@npmcli/run-script": "^7.0.2", + "@npmcli/promise-spawn": "^7.0.1", + "@npmcli/run-script": "^7.0.3", "@sigstore/tuf": "^2.2.0", "abbrev": "^2.0.0", "archy": "~1.0.0", - "cacache": "^18.0.1", + "cacache": "^18.0.2", "chalk": "^5.3.0", "ci-info": "^4.0.0", "cli-columns": "^4.0.0", @@ -5653,7 +5653,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "7.2.2", + "version": "7.3.0", "dev": true, "inBundle": true, "license": "ISC", @@ -5700,7 +5700,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "8.0.3", + "version": "8.1.0", "dev": true, "inBundle": true, "license": "ISC", @@ -5758,7 +5758,7 @@ } }, "node_modules/npm/node_modules/@npmcli/git": { - "version": "5.0.3", + "version": "5.0.4", "dev": true, "inBundle": true, "license": "ISC", @@ -5859,7 +5859,7 @@ } }, "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "7.0.0", + "version": "7.0.1", "dev": true, "inBundle": true, "license": "ISC", @@ -5883,7 +5883,7 @@ } }, "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "7.0.2", + "version": "7.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -5987,18 +5987,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/abort-controller": { - "version": "3.0.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, "node_modules/npm/node_modules/agent-base": { "version": "7.1.0", "dev": true, @@ -6061,14 +6049,10 @@ "license": "MIT" }, "node_modules/npm/node_modules/are-we-there-yet": { - "version": "4.0.1", + "version": "4.0.2", "dev": true, "inBundle": true, "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^4.1.0" - }, "engines": { "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } @@ -6079,26 +6063,6 @@ "inBundle": true, "license": "MIT" }, - "node_modules/npm/node_modules/base64-js": { - "version": "1.5.1", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/bin-links": { "version": "4.0.3", "dev": true, @@ -6132,30 +6096,6 @@ "balanced-match": "^1.0.0" } }, - "node_modules/npm/node_modules/buffer": { - "version": "6.0.3", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "inBundle": true, - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/npm/node_modules/builtins": { "version": "5.0.1", "dev": true, @@ -6166,7 +6106,7 @@ } }, "node_modules/npm/node_modules/cacache": { - "version": "18.0.1", + "version": "18.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -6461,12 +6401,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npm/node_modules/delegates": { - "version": "1.0.0", - "dev": true, - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/diff": { "version": "5.1.0", "dev": true, @@ -6513,24 +6447,6 @@ "inBundle": true, "license": "MIT" }, - "node_modules/npm/node_modules/event-target-shim": { - "version": "5.0.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/events": { - "version": "3.3.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, "node_modules/npm/node_modules/exponential-backoff": { "version": "3.1.1", "dev": true, @@ -6726,26 +6642,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm/node_modules/ieee754": { - "version": "1.2.1", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "inBundle": true, - "license": "BSD-3-Clause" - }, "node_modules/npm/node_modules/ignore-walk": { "version": "6.0.4", "dev": true, @@ -6937,7 +6833,7 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "6.0.4", + "version": "6.0.5", "dev": true, "inBundle": true, "license": "ISC", @@ -6957,7 +6853,7 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "7.0.5", + "version": "7.0.6", "dev": true, "inBundle": true, "license": "ISC", @@ -6979,7 +6875,7 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "5.0.2", + "version": "5.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -7017,7 +6913,7 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "6.0.4", + "version": "6.0.5", "dev": true, "inBundle": true, "license": "ISC", @@ -7442,7 +7338,7 @@ } }, "node_modules/npm/node_modules/npm-packlist": { - "version": "8.0.1", + "version": "8.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -7610,7 +7506,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "6.0.13", + "version": "6.0.15", "dev": true, "inBundle": true, "license": "MIT", @@ -7631,15 +7527,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/process": { - "version": "0.11.10", - "dev": true, - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/npm/node_modules/promise-all-reject-late": { "version": "1.0.1", "dev": true, @@ -7746,22 +7633,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/readable-stream": { - "version": "4.4.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "abort-controller": "^3.0.0", - "buffer": "^6.0.3", - "events": "^3.3.0", - "process": "^0.11.10", - "string_decoder": "^1.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/npm/node_modules/retry": { "version": "0.12.0", "dev": true, @@ -7771,26 +7642,6 @@ "node": ">= 4" } }, - "node_modules/npm/node_modules/safe-buffer": { - "version": "5.2.1", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "inBundle": true, - "license": "MIT" - }, "node_modules/npm/node_modules/safer-buffer": { "version": "2.1.2", "dev": true, @@ -7961,15 +7812,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/npm/node_modules/string_decoder": { - "version": "1.3.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/npm/node_modules/string-width": { "version": "4.2.3", "dev": true, @@ -9296,9 +9138,9 @@ "dev": true }, "node_modules/safe-regex-test": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.1.tgz", - "integrity": "sha512-Y5NejJTTliTyY4H7sipGqY+RX5P87i3F7c4Rcepy72nq+mNLhIsD0W4c7kEmduMDQCSqtPsXPlSTsFhh2LQv+g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.2.tgz", + "integrity": "sha512-83S9w6eFq12BBIJYvjMux6/dkirb8+4zJRA9cxNBVb7Wq5fJBW+Xze48WqR8pxua7bDuAaaAxtVVd4Idjp1dBQ==", "dev": true, "dependencies": { "call-bind": "^1.0.5", diff --git a/package.json b/package.json index d993b4f..4d11139 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "lint:prettier": "prettier . --check --ignore-path .gitignore", "lint:javascript": "tsc --project jsconfig.json --noEmit", "lint:staged": "lint-staged", - "test": "node --test --experimental-test-coverage ./test", + "test": "node --test --experimental-test-coverage", "release": "semantic-release", "postinstall": "husky install", "prepublishOnly": "pinst --disable", @@ -50,13 +50,13 @@ "@commitlint/cli": "18.4.4", "@commitlint/config-conventional": "18.4.4", "@types/markdown-it": "13.0.7", - "@types/node": "20.10.8", + "@types/node": "20.11.0", "editorconfig-checker": "5.1.2", "eslint": "8.56.0", "eslint-config-conventions": "13.1.0", "eslint-config-prettier": "9.1.0", "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-unicorn": "50.0.1", "husky": "8.0.3", diff --git a/src/index.js b/src/index.js index 1ba3513..0e7c9bd 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,11 @@ const { pathToFileURL } = require("node:url") const fs = require("node:fs") +const { filterTokens } = require("./markdownlint-rule-helpers/helpers.js") const { - filterTokens, convertHeadingToHTMLFragment, getMarkdownHeadings, + getMarkdownIdOrAnchorNameFragments, } = require("./utils.js") /** @typedef {import('markdownlint').Rule} MarkdownLintRule */ @@ -45,46 +46,83 @@ const customRule = { } } - if (hrefSrc != null) { - const url = new URL(hrefSrc, pathToFileURL(params.name)) - const isRelative = - url.protocol === "file:" && !hrefSrc.startsWith("/") - if (isRelative) { - const detail = `"${hrefSrc}"` + if (hrefSrc == null) { + continue + } + + const url = new URL(hrefSrc, pathToFileURL(params.name)) + const isRelative = + url.protocol === "file:" && + !hrefSrc.startsWith("/") && + !hrefSrc.startsWith("#") + + if (!isRelative) { + continue + } + + const detail = `"${hrefSrc}"` - if (!fs.existsSync(url)) { + 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 !== "link_open") { onError({ lineNumber, - detail: `${detail} should exist in the file system`, + detail: `${detail} should not have a fragment identifier as it is an image`, }) continue } - if (type === "link_open" && url.hash !== "") { - const fileContent = fs.readFileSync(url, { encoding: "utf8" }) - const headings = getMarkdownHeadings(fileContent) - - /** @type {Map} */ - 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 - }) + onError({ + lineNumber, + detail: `${detail} should have a valid fragment identifier`, + }) + continue + } + continue + } - if (!headingsHTMLFragments.includes(url.hash)) { - onError({ - lineNumber, - detail: `${detail} should have a valid fragment identifier`, - }) - } - } + if (type !== "link_open") { + onError({ + lineNumber, + detail: `${detail} should not have a fragment identifier as it is an image`, + }) + continue + } + + const fileContent = fs.readFileSync(url, { encoding: "utf8" }) + const headings = getMarkdownHeadings(fileContent) + const idOrAnchorNameHTMLFragments = + getMarkdownIdOrAnchorNameFragments(fileContent) + + /** @type {Map} */ + const fragments = new Map() + + const fragmentsHTML = 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 + }) + + fragmentsHTML.push(...idOrAnchorNameHTMLFragments) + + if (!fragmentsHTML.includes(url.hash)) { + onError({ + lineNumber, + detail: `${detail} should have a valid fragment identifier`, + }) + continue } } }) diff --git a/src/markdownlint-rule-helpers/helpers.js b/src/markdownlint-rule-helpers/helpers.js new file mode 100644 index 0000000..5949867 --- /dev/null +++ b/src/markdownlint-rule-helpers/helpers.js @@ -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, +} diff --git a/src/utils.js b/src/utils.js index 49baaec..a2345ee 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,23 +1,6 @@ const MarkdownIt = require("markdown-it") -/** @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) - } - } -} +const { getHtmlAttributeRe } = require("./markdownlint-rule-helpers/helpers.js") /** * Converts a Markdown heading into an HTML fragment according to the rules @@ -94,8 +77,45 @@ const getMarkdownHeadings = (content) => { 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 markdownIt = new MarkdownIt({ html: true }) + 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 +} + module.exports = { - filterTokens, convertHeadingToHTMLFragment, getMarkdownHeadings, + getMarkdownIdOrAnchorNameFragments, } diff --git a/test/fixtures/valid/image.png b/test/fixtures/image.png similarity index 100% rename from test/fixtures/valid/image.png rename to test/fixtures/image.png diff --git a/test/fixtures/invalid/empty-id-fragment/awesome.md b/test/fixtures/invalid/empty-id-fragment/awesome.md new file mode 100644 index 0000000..23f938d --- /dev/null +++ b/test/fixtures/invalid/empty-id-fragment/awesome.md @@ -0,0 +1,3 @@ +# Awesome + +
Content
diff --git a/test/fixtures/invalid/empty-id-fragment/empty-id-fragment.md b/test/fixtures/invalid/empty-id-fragment/empty-id-fragment.md new file mode 100644 index 0000000..9578eb5 --- /dev/null +++ b/test/fixtures/invalid/empty-id-fragment/empty-id-fragment.md @@ -0,0 +1,3 @@ +# Invalid + +[Link fragment](./awesome.md#) diff --git a/test/fixtures/invalid/ignore-empty-fragment-checking-for-image.md b/test/fixtures/invalid/ignore-empty-fragment-checking-for-image.md new file mode 100644 index 0000000..7406f43 --- /dev/null +++ b/test/fixtures/invalid/ignore-empty-fragment-checking-for-image.md @@ -0,0 +1,3 @@ +# Invalid + +![Image](../image.png#) diff --git a/test/fixtures/invalid/ignore-fragment-checking-for-image.md b/test/fixtures/invalid/ignore-fragment-checking-for-image.md new file mode 100644 index 0000000..f07a307 --- /dev/null +++ b/test/fixtures/invalid/ignore-fragment-checking-for-image.md @@ -0,0 +1,3 @@ +# Invalid + +![Image](../image.png#non-existing-fragment) diff --git a/test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/awesome.md b/test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/awesome.md new file mode 100644 index 0000000..9df5196 --- /dev/null +++ b/test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/awesome.md @@ -0,0 +1,3 @@ +# Awesome + + diff --git a/test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/ignore-name-fragment-if-not-an-anchor.md b/test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/ignore-name-fragment-if-not-an-anchor.md new file mode 100644 index 0000000..b629896 --- /dev/null +++ b/test/fixtures/invalid/ignore-name-fragment-if-not-an-anchor/ignore-name-fragment-if-not-an-anchor.md @@ -0,0 +1,3 @@ +# Invalid + +[Invalid](./awesome.md#name-should-be-ignored) diff --git a/test/fixtures/invalid/ignore-not-an-id-fragment/awesome.md b/test/fixtures/invalid/ignore-not-an-id-fragment/awesome.md new file mode 100644 index 0000000..c07397d --- /dev/null +++ b/test/fixtures/invalid/ignore-not-an-id-fragment/awesome.md @@ -0,0 +1,3 @@ +# Awesome + +
Content
diff --git a/test/fixtures/invalid/ignore-not-an-id-fragment/ignore-not-an-id-fragment.md b/test/fixtures/invalid/ignore-not-an-id-fragment/ignore-not-an-id-fragment.md new file mode 100644 index 0000000..a991643 --- /dev/null +++ b/test/fixtures/invalid/ignore-not-an-id-fragment/ignore-not-an-id-fragment.md @@ -0,0 +1,3 @@ +# Invalid + +[Invalid](./awesome.md#not-an-id-should-be-ignored) diff --git a/test/fixtures/invalid/non-existing-anchor-name-fragment/awesome.md b/test/fixtures/invalid/non-existing-anchor-name-fragment/awesome.md new file mode 100644 index 0000000..51200e5 --- /dev/null +++ b/test/fixtures/invalid/non-existing-anchor-name-fragment/awesome.md @@ -0,0 +1,3 @@ +# Awesome + +
Link diff --git a/test/fixtures/invalid/non-existing-anchor-name-fragment/non-existing-anchor-name-fragment.md b/test/fixtures/invalid/non-existing-anchor-name-fragment/non-existing-anchor-name-fragment.md new file mode 100644 index 0000000..9482958 --- /dev/null +++ b/test/fixtures/invalid/non-existing-anchor-name-fragment/non-existing-anchor-name-fragment.md @@ -0,0 +1,3 @@ +# Invalid + +[Link fragment](./awesome.md#non-existing-anchor-name-fragment) diff --git a/test/fixtures/invalid/non-existing-element-id-fragment/awesome.md b/test/fixtures/invalid/non-existing-element-id-fragment/awesome.md new file mode 100644 index 0000000..1341249 --- /dev/null +++ b/test/fixtures/invalid/non-existing-element-id-fragment/awesome.md @@ -0,0 +1,3 @@ +# Awesome + +
Content
diff --git a/test/fixtures/invalid/non-existing-element-id-fragment/non-existing-element-id-fragment.md b/test/fixtures/invalid/non-existing-element-id-fragment/non-existing-element-id-fragment.md new file mode 100644 index 0000000..914511e --- /dev/null +++ b/test/fixtures/invalid/non-existing-element-id-fragment/non-existing-element-id-fragment.md @@ -0,0 +1,3 @@ +# Invalid + +[Link fragment](./awesome.md#non-existing-element-id-fragment) diff --git a/test/fixtures/invalid/non-existing-heading-fragment/awesome.md b/test/fixtures/invalid/non-existing-heading-fragment/awesome.md index 6b3a7d5..11bda72 100644 --- a/test/fixtures/invalid/non-existing-heading-fragment/awesome.md +++ b/test/fixtures/invalid/non-existing-heading-fragment/awesome.md @@ -1,3 +1,3 @@ -# Valid +# Awesome ## Existing Heading diff --git a/test/fixtures/valid/existing-anchor-name-fragment/awesome.md b/test/fixtures/valid/existing-anchor-name-fragment/awesome.md new file mode 100644 index 0000000..51200e5 --- /dev/null +++ b/test/fixtures/valid/existing-anchor-name-fragment/awesome.md @@ -0,0 +1,3 @@ +# Awesome + +Link diff --git a/test/fixtures/valid/existing-anchor-name-fragment/existing-anchor-name-fragment.md b/test/fixtures/valid/existing-anchor-name-fragment/existing-anchor-name-fragment.md new file mode 100644 index 0000000..60de204 --- /dev/null +++ b/test/fixtures/valid/existing-anchor-name-fragment/existing-anchor-name-fragment.md @@ -0,0 +1,3 @@ +# Invalid + +[Link fragment](./awesome.md#existing-heading-anchor) diff --git a/test/fixtures/valid/existing-element-id-fragment/awesome.md b/test/fixtures/valid/existing-element-id-fragment/awesome.md new file mode 100644 index 0000000..1341249 --- /dev/null +++ b/test/fixtures/valid/existing-element-id-fragment/awesome.md @@ -0,0 +1,3 @@ +# Awesome + +
Content
diff --git a/test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md b/test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md new file mode 100644 index 0000000..7a4df08 --- /dev/null +++ b/test/fixtures/valid/existing-element-id-fragment/existing-element-id-fragment.md @@ -0,0 +1,3 @@ +# Invalid + +[Link fragment](./awesome.md#existing-element-id-fragment) diff --git a/test/fixtures/valid/existing-image.md b/test/fixtures/valid/existing-image.md index 7606df7..08c0c06 100644 --- a/test/fixtures/valid/existing-image.md +++ b/test/fixtures/valid/existing-image.md @@ -1,3 +1,3 @@ # Valid -![Image](./image.png) +![Image](../image.png) diff --git a/test/fixtures/valid/ignore-fragment-checking-in-own-file.md b/test/fixtures/valid/ignore-fragment-checking-in-own-file.md new file mode 100644 index 0000000..ef2e345 --- /dev/null +++ b/test/fixtures/valid/ignore-fragment-checking-in-own-file.md @@ -0,0 +1,5 @@ +# Valid + +
Content
+ +[Link fragment](#non-existing-element-id-fragment) diff --git a/test/index.test.js b/test/index.test.js index 3993fec..451d728 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -23,90 +23,134 @@ const validateMarkdownLint = async (fixtureFile) => { } test("ensure the rule validates correctly", async (t) => { - await t.test("should be valid", async (t) => { - await t.test("with an existing heading fragment", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/valid/existing-heading-fragment/existing-heading-fragment.md", - ) - assert.equal(lintResults?.length, 0) - }) - - await t.test("with an existing file", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/valid/existing-file.md", - ) - assert.equal(lintResults?.length, 0) - }) - - await t.test("with an existing image", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/valid/existing-image.md", - ) - assert.equal(lintResults?.length, 0) - }) - - await t.test("should ignore absolute paths", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/valid/ignore-absolute-paths.md", - ) - assert.equal(lintResults?.length, 0) - }) + 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 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', + }, + ] - await t.test("should ignore external links", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/valid/ignore-external-links.md", - ) - assert.equal(lintResults?.length, 0) - }) + 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 invalid", async (t) => { - await t.test("with a non-existing heading fragment", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/invalid/non-existing-heading-fragment/non-existing-heading-fragment.md", - ) - assert.equal(lintResults?.length, 1) - assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names) - assert.equal( - lintResults?.[0]?.ruleDescription, - relativeLinksRule.description, - ) - assert.equal( - lintResults?.[0]?.errorDetail, - '"./awesome.md#non-existing-heading" should have a valid fragment identifier', - ) - }) - - await t.test("with a non-existing file", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/invalid/non-existing-file.md", - ) - assert.equal(lintResults?.length, 1) - assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names) - assert.equal( - lintResults?.[0]?.ruleDescription, - relativeLinksRule.description, - ) - assert.equal( - lintResults?.[0]?.errorDetail, - '"./index.test.js" should exist in the file system', - ) - }) + 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", + fixturePath: + "test/fixtures/valid/existing-heading-fragment/existing-heading-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", + }, + ] - await t.test("with a non-existing image", async () => { - const lintResults = await validateMarkdownLint( - "test/fixtures/invalid/non-existing-image.md", - ) - assert.equal(lintResults?.length, 1) - assert.deepEqual(lintResults?.[0]?.ruleNames, relativeLinksRule.names) - assert.equal( - lintResults?.[0]?.ruleDescription, - relativeLinksRule.description, - ) - assert.equal( - lintResults?.[0]?.errorDetail, - '"./image.png" should exist in the file system', - ) - }) + for (const { name, fixturePath } of testCases) { + await t.test(name, async () => { + const lintResults = await validateMarkdownLint(fixturePath) + assert.equal(lintResults?.length, 0) + }) + } }) }) diff --git a/test/utils.test.js b/test/utils.test.js index c87caa3..d98ce1c 100644 --- a/test/utils.test.js +++ b/test/utils.test.js @@ -4,6 +4,7 @@ const assert = require("node:assert/strict") const { convertHeadingToHTMLFragment, getMarkdownHeadings, + getMarkdownIdOrAnchorNameFragments, } = require("../src/utils.js") test("utils", async (t) => { @@ -34,4 +35,23 @@ test("utils", async (t) => { ["Hello", "World", "Hello, world!"], ) }) + + await t.test("getMarkdownIdOrAnchorNameFragments", async () => { + assert.deepStrictEqual( + getMarkdownIdOrAnchorNameFragments( + 'Link', + ), + ["#anchorId"], + ) + assert.deepStrictEqual( + getMarkdownIdOrAnchorNameFragments('Link'), + ["#anchorName"], + ) + assert.deepStrictEqual( + getMarkdownIdOrAnchorNameFragments("Link"), + [], + ) + assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments(""), []) + assert.deepStrictEqual(getMarkdownIdOrAnchorNameFragments(""), []) + }) })