From fd92f374b9dff79c136c5b9acc7c40ed5976c7cb Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 23 Jul 2024 16:06:35 -0400 Subject: [PATCH 01/20] chore: Add type checking --- package.json | 15 +++++++++++---- rollup.config.js | 10 ++++++++++ tools/dedupe-types.js | 43 +++++++++++++++++++++++++++++++++++++++++++ tsconfig.esm.json | 4 ++++ tsconfig.json | 13 +++++++++++++ 5 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 rollup.config.js create mode 100644 tools/dedupe-types.js create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json diff --git a/package.json b/package.json index e2479d74..14ccfebd 100644 --- a/package.json +++ b/package.json @@ -8,14 +8,15 @@ "url": "https://github.com/btmills" }, "type": "module", - "main": "src/index.js", + "main": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", "exports": { "import": { - "default": "./src/index.js" + "default": "./dist/esm/index.js" } }, "files": [ - "src" + "dist" ], "publishConfig": { "access": "public" @@ -34,18 +35,24 @@ ], "scripts": { "lint": "eslint .", + "build:dedupe-types": "node tools/dedupe-types.js dist/esm/index.js", + "build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json", "prepare": "node ./npm-prepare.cjs", "test": "c8 mocha \"tests/**/*.test.js\" --timeout 30000" }, "devDependencies": { "@eslint/core": "^0.2.0", "@eslint/js": "^9.4.0", + "@types/eslint": "^9.6.0", "c8": "^10.1.2", "chai": "^5.1.1", "eslint": "^9.4.0", "eslint-config-eslint": "^11.0.0", "globals": "^15.1.0", - "mocha": "^10.6.0" + "mocha": "^10.6.0", + "rollup": "^4.19.0", + "rollup-plugin-copy": "^3.5.0", + "typescript": "^5.5.4" }, "dependencies": { "mdast-util-from-markdown": "^2.0.1" diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 00000000..18e23f22 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,10 @@ +export default { + input: "src/index.js", + output: [ + { + file: "dist/esm/index.js", + format: "esm", + banner: '// @ts-self-types="./index.d.ts"' + } + ] +}; diff --git a/tools/dedupe-types.js b/tools/dedupe-types.js new file mode 100644 index 00000000..ce8b16d1 --- /dev/null +++ b/tools/dedupe-types.js @@ -0,0 +1,43 @@ +/** + * @fileoverview Strips typedef aliases from the rolled-up file. This + * is necessary because the TypeScript compiler throws an error when + * it encounters a duplicate typedef. + * + * Usage: + * node scripts/strip-typedefs.js filename1.js filename2.js ... + * + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import fs from "node:fs"; + +//----------------------------------------------------------------------------- +// Main +//----------------------------------------------------------------------------- + +// read files from the command line +const files = process.argv.slice(2); + +files.forEach(filePath => { + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/gu); + const typedefs = new Set(); + + const remainingLines = lines.filter(line => { + if (!line.startsWith("/** @typedef {import")) { + return true; + } + + if (typedefs.has(line)) { + return false; + } + + typedefs.add(line); + return true; + }); + + fs.writeFileSync(filePath, remainingLines.join("\n"), "utf8"); +}); diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 00000000..40ece132 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "files": ["dist/esm/index.js"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..3fa504c2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "files": ["src/index.js"], + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "allowJs": true, + "checkJs": true, + "outDir": "dist/esm", + "target": "ES2022", + "moduleResolution": "NodeNext", + "module": "NodeNext" + } +} From 5dd8e74d6c3eff6009b2d2bceb4ce3f635709b69 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 23 Jul 2024 16:56:16 -0400 Subject: [PATCH 02/20] feat: Add type checking --- .gitignore | 1 + rollup.config.js | 9 ++++++ src/index.js | 9 ++++++ src/processor.js | 76 ++++++++++++++++++++++++++++-------------------- src/types.ts | 19 ++++++++++++ tsconfig.json | 1 + 6 files changed, 84 insertions(+), 31 deletions(-) create mode 100644 src/types.ts diff --git a/.gitignore b/.gitignore index 05c7c9ea..9c5c5a93 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ npm-debug.log yarn.lock package-lock.json pnpm-lock.yaml +dist diff --git a/rollup.config.js b/rollup.config.js index 18e23f22..5dfdd45c 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,3 +1,5 @@ +import copy from "rollup-plugin-copy"; + export default { input: "src/index.js", output: [ @@ -6,5 +8,12 @@ export default { format: "esm", banner: '// @ts-self-types="./index.d.ts"' } + ], + plugins: [ + copy({ + targets: [ + { src: "src/types.ts", dest: "dist/esm" } + ] + }) ] }; diff --git a/src/index.js b/src/index.js index 7de920c2..9f334ab2 100644 --- a/src/index.js +++ b/src/index.js @@ -9,10 +9,18 @@ import { processor } from "./processor.js"; +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Linter.RulesRecord} RulesRecord*/ +/** @typedef {import("eslint").ESLint.Plugin} Plugin */ + //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- +/** @type {RulesRecord} */ const rulesConfig = { // The Markdown parser automatically trims trailing @@ -36,6 +44,7 @@ const rulesConfig = { "unicode-bom": "off" }; +/** @type {Plugin} */ const plugin = { meta: { name: "@eslint/markdown", diff --git a/src/processor.js b/src/processor.js index 7712d373..a90c3007 100644 --- a/src/processor.js +++ b/src/processor.js @@ -3,30 +3,30 @@ * @author Brandon Mills */ -/** - * @typedef {import('eslint/lib/shared/types').LintMessage} Message - * @typedef {Object} ASTNode - * @property {string} type The type of node. - * @property {string} [lang] The language that the node is in - * @typedef {Object} RangeMap - * @property {number} indent Number of code block indent characters trimmed from - * the beginning of the line during extraction. - * @property {number} js Offset from the start of the code block's range in the - * extracted JS. - * @property {number} md Offset from the start of the code block's range in the - * original Markdown. - * @typedef {Object} BlockBase - * @property {string} baseIndentText Leading whitespace text for the block. - * @property {string[]} comments Comments inside of the JavaScript code. - * @property {RangeMap[]} rangeMap A list of offset-based adjustments, where - * lookups are done based on the `js` key, which represents the range in the - * linted JS, and the `md` key is the offset delta that, when added to the JS - * range, returns the corresponding location in the original Markdown source. - * @typedef {ASTNode & BlockBase} Block - */ +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- import { fromMarkdown } from "mdast-util-from-markdown"; +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("./types.ts").Block} Block */ +/** @typedef {import("./types.ts").RangeMap} RangeMap */ +/** @typedef {import("mdast").Node} Node */ +/** @typedef {import("mdast").Parent} ParentNode */ +/** @typedef {import("mdast").Code} CodeNode */ +/** @typedef {import("mdast").Html} HtmlNode */ +/** @typedef {import("eslint").Linter.LintMessage} Message */ +/** @typedef {import("eslint").Rule.Fix} Fix */ +/** @typedef {import("eslint").AST.Range} Range */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + const UNSATISFIABLE_RULES = new Set([ "eol-last", // The Markdown parser strips trailing newlines in code fences "unicode-bom" // Code blocks will begin in the middle of Markdown files @@ -40,8 +40,8 @@ const blocksCache = new Map(); /** * Performs a depth-first traversal of the Markdown AST. - * @param {ASTNode} node A Markdown AST node. - * @param {{[key: string]: (node: ASTNode) => void}} callbacks A map of node types to callbacks. + * @param {Node} node A Markdown AST node. + * @param {{[key: string]: (node?: Node) => void}} callbacks A map of node types to callbacks. * @returns {void} */ function traverse(node, callbacks) { @@ -51,9 +51,11 @@ function traverse(node, callbacks) { callbacks["*"](); } - if (typeof node.children !== "undefined") { - for (let i = 0; i < node.children.length; i++) { - traverse(node.children[i], callbacks); + const parent = /** @type {ParentNode} */ (node); + + if (typeof parent.children !== "undefined") { + for (let i = 0; i < parent.children.length; i++) { + traverse(parent.children[i], callbacks); } } } @@ -92,7 +94,7 @@ const leadingWhitespaceRegex = /^[>\s]*/u; /** * Gets the offset for the first column of the node's first line in the * original source text. - * @param {ASTNode} node A Markdown code block AST node. + * @param {Node} node A Markdown code block AST node. * @returns {number} The offset for the first column of the node's first line. */ function getBeginningOfLineOffset(node) { @@ -103,7 +105,7 @@ function getBeginningOfLineOffset(node) { * Gets the leading text, typically whitespace with possible blockquote chars, * used to indent a code block. * @param {string} text The text of the file. - * @param {ASTNode} node A Markdown code block AST node. + * @param {Node} node A Markdown code block AST node. * @returns {string} The text from the start of the first line to the opening * fence of the code block. */ @@ -137,7 +139,7 @@ function getIndentText(text, node) { * differences within the line, so the mapping need only provide the offset * delta at the beginning of each line. * @param {string} text The text of the file. - * @param {ASTNode} node A Markdown code block AST node. + * @param {Node} node A Markdown code block AST node. * @param {string[]} comments List of configuration comment strings that will be * inserted at the beginning of the code block. * @returns {RangeMap[]} A list of offset-based adjustments, where lookups are @@ -265,6 +267,12 @@ function preprocess(text, filename) { "*"() { htmlComments = []; }, + + /** + * Visit a code node. + * @param {CodeNode} node The visited node. + * @returns {void} + */ code(node) { if (node.lang) { const comments = []; @@ -288,6 +296,12 @@ function preprocess(text, filename) { }); } }, + + /** + * Visit an HTML node. + * @param {HtmlNode} node The visited node. + * @returns {void} + */ html(node) { const comment = getComment(node.value); @@ -357,7 +371,7 @@ function adjustBlock(block) { if (message.fix) { adjustedFix.fix = { - range: message.fix.range.map(range => { + range: /** @type {Range} */ (message.fix.range.map(range => { // Advance through the block's range map to find the last // matching range by finding the first range too far and @@ -370,7 +384,7 @@ function adjustBlock(block) { // Apply the mapping delta for this range. return range + block.rangeMap[i - 1].md; - }), + })), text: message.fix.text.replace(/\n/gu, `\n${block.baseIndentText}`) }; } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..02638045 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,19 @@ +import type { Node } from "mdast"; +import type { Linter } from "eslint"; + + +export interface RangeMap { + indent: number; + js: number; + md: number; +} + +export interface BlockBase { + baseIndentText: string; + comments: string[]; + rangeMap: RangeMap[]; +} + +export interface Block extends Node, BlockBase {} + +export type Message = Linter.LintMessage; diff --git a/tsconfig.json b/tsconfig.json index 3fa504c2..04a726cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "declaration": true, "emitDeclarationOnly": true, + "allowImportingTsExtensions": true, "allowJs": true, "checkJs": true, "outDir": "dist/esm", From ce6279a047fb8e4ff3bc44d3da4f5ac1167663fb Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 15 Jul 2024 11:51:29 -0400 Subject: [PATCH 03/20] feat: Add language --- .github/ISSUE_TEMPLATE/bug-report.yml | 81 ++++++ .github/ISSUE_TEMPLATE/change.yml | 51 ++++ .github/ISSUE_TEMPLATE/config.yml | 11 + .github/ISSUE_TEMPLATE/docs.yml | 46 ++++ .github/ISSUE_TEMPLATE/new-rule.yml | 41 +++ .github/ISSUE_TEMPLATE/rule-change.yml | 61 +++++ .gitignore | 1 + README.md | 224 +++------------- docs/processors/markdown.md | 237 +++++++++++++++++ docs/rules/fenced-code-language.md | 48 ++++ docs/rules/heading-increment.md | 28 ++ docs/rules/no-duplicate-headings.md | 33 +++ docs/rules/no-empty-links.md | 23 ++ docs/rules/no-html.md | 45 ++++ docs/rules/no-missing-label-refs.md | 33 +++ eslint.config-content.js | 9 + eslint.config.js | 15 +- package.json | 15 +- src/index.js | 33 ++- src/language/markdown-language.js | 145 ++++++++++ src/language/markdown-source-code.js | 280 ++++++++++++++++++++ src/processor.js | 76 +++--- src/rules/fenced-code-language.js | 77 ++++++ src/rules/heading-increment.js | 45 ++++ src/rules/no-duplicate-headings.js | 63 +++++ src/rules/no-empty-links.js | 39 +++ src/rules/no-html.js | 71 +++++ src/rules/no-missing-label-refs.js | 172 ++++++++++++ tests/language/markdown-source-code.test.js | 113 ++++++++ tests/rules/fenced-code-language.test.js | 79 ++++++ tests/rules/heading-increment.test.js | 100 +++++++ tests/rules/no-duplicate-headings.test.js | 99 +++++++ tests/rules/no-empty-links.test.js | 69 +++++ tests/rules/no-html.test.js | 72 +++++ tests/rules/no-missing-label-refs.test.js | 135 ++++++++++ tools/build-rules.js | 53 ++++ tools/dedupe-types.js | 26 +- 37 files changed, 2493 insertions(+), 256 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/change.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/docs.yml create mode 100644 .github/ISSUE_TEMPLATE/new-rule.yml create mode 100644 .github/ISSUE_TEMPLATE/rule-change.yml create mode 100644 docs/processors/markdown.md create mode 100644 docs/rules/fenced-code-language.md create mode 100644 docs/rules/heading-increment.md create mode 100644 docs/rules/no-duplicate-headings.md create mode 100644 docs/rules/no-empty-links.md create mode 100644 docs/rules/no-html.md create mode 100644 docs/rules/no-missing-label-refs.md create mode 100644 eslint.config-content.js create mode 100644 src/language/markdown-language.js create mode 100644 src/language/markdown-source-code.js create mode 100644 src/rules/fenced-code-language.js create mode 100644 src/rules/heading-increment.js create mode 100644 src/rules/no-duplicate-headings.js create mode 100644 src/rules/no-empty-links.js create mode 100644 src/rules/no-html.js create mode 100644 src/rules/no-missing-label-refs.js create mode 100644 tests/language/markdown-source-code.test.js create mode 100644 tests/rules/fenced-code-language.test.js create mode 100644 tests/rules/heading-increment.test.js create mode 100644 tests/rules/no-duplicate-headings.test.js create mode 100644 tests/rules/no-empty-links.test.js create mode 100644 tests/rules/no-html.test.js create mode 100644 tests/rules/no-missing-label-refs.test.js create mode 100644 tools/build-rules.js diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..677f8849 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,81 @@ +name: "\U0001F41E Report a problem" +description: "Report something that isn't working the way you expected." +title: "Bug: (fill in)" +labels: + - bug + - "repro:needed" +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: textarea + attributes: + label: Environment + description: | + Please tell us about how you're running ESLint (Run `npx eslint --env-info`.) + value: | + ESLint version: + @eslint/markdown version: + Node version: + npm version: + Operating System: + validations: + required: true + - type: dropdown + attributes: + label: Which language are you using? + description: | + Just tell us which language mode you're using. + options: + - commonmark + - gfm + validations: + required: true + - type: textarea + attributes: + label: What did you do? + description: | + Please include a *minimal* reproduction case. + value: | +
+ Configuration + + ``` + + ``` +
+ + ```js + + ``` + validations: + required: true + - type: textarea + attributes: + label: What did you expect to happen? + validations: + required: true + - type: textarea + attributes: + label: What actually happened? + description: | + Please copy-paste the actual ESLint output. + validations: + required: true + - type: input + attributes: + label: Link to Minimal Reproducible Example + description: "Link to a [StackBlitz](https://stackblitz.com) or GitHub repo with a minimal reproduction of the problem. **A minimal reproduction is required** so that others can help debug your issue. If a report is vague (e.g. just a generic error message) and has no reproduction, it may be auto-closed." + placeholder: "https://stackblitz.com/abcd1234" + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request for this issue. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/change.yml b/.github/ISSUE_TEMPLATE/change.yml new file mode 100644 index 00000000..f9ed7521 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/change.yml @@ -0,0 +1,51 @@ +name: "\U0001F680 Request a change (not rule-related)" +description: "Request a change that is not a bug fix, rule change, or new rule" +title: "Change Request: (fill in)" +labels: + - enhancement + - core +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: textarea + attributes: + label: Environment + description: | + Please tell us about how you're running ESLint (Run `npx eslint --env-info`.) + value: | + ESLint version: + @eslint/markdown version: + Node version: + npm version: + Operating System: + validations: + required: true + - type: textarea + attributes: + label: What problem do you want to solve? + description: | + Please explain your use case in as much detail as possible. + placeholder: | + The Markdown plugin currently... + validations: + required: true + - type: textarea + attributes: + label: What do you think is the correct solution? + description: | + Please explain how you'd like to change the Markdown plugin to address the problem. + placeholder: | + I'd like the Markdown plugin to... + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request for this change. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..f58cbe2c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: false +contact_links: + - name: 🐛 Report a Parsing Error + url: https://github.com/syntax-tree/mdast-util-from-markdown/issues/new/choose + about: File an issue with the parser that this plugin uses + - name: 🗣 Ask a Question, Discuss + url: https://github.com/eslint/markdown/discussions + about: Get help using this plugin + - name: Discord Server + url: https://eslint.org/chat + about: Talk with the team diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 00000000..a8a3bace --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,46 @@ +name: "\U0001F4DD Docs" +description: "Request an improvement to documentation" +title: "Docs: (fill in)" +labels: + - documentation +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: textarea + attributes: + label: Docs page(s) + description: | + What page(s) are you suggesting be changed or created? + placeholder: | + e.g. https://eslint.org/docs/latest/use/getting-started + validations: + required: true + - type: textarea + attributes: + label: What documentation issue do you want to solve? + description: | + Please explain your issue in as much detail as possible. + placeholder: | + The docs currently... + validations: + required: true + - type: textarea + attributes: + label: What do you think is the correct solution? + description: | + Please explain how you'd like to change the docs to address the problem. + placeholder: | + I'd like the docs to... + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request for this change. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/new-rule.yml b/.github/ISSUE_TEMPLATE/new-rule.yml new file mode 100644 index 00000000..6b6d62f9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-rule.yml @@ -0,0 +1,41 @@ +name: "\U0001F680 Propose a new rule" +description: "Propose a new rule to be added to the plugin" +title: "New Rule: (fill in)" +labels: + - rule + - feature +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: input + attributes: + label: Rule details + description: What should the new rule do? + validations: + required: true + - type: dropdown + attributes: + label: What type of rule is this? + options: + - Warns about a potential problem + - Suggests an alternate way of doing something + validations: + required: true + - type: textarea + attributes: + label: Example code + description: Please provide some example code that this rule will warn about. + render: markdown + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request to implement this rule. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.github/ISSUE_TEMPLATE/rule-change.yml b/.github/ISSUE_TEMPLATE/rule-change.yml new file mode 100644 index 00000000..44c4713e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/rule-change.yml @@ -0,0 +1,61 @@ +name: "\U0001F4DD Request a rule change" +description: "Request a change to an existing rule" +title: "Rule Change: (fill in)" +labels: + - enhancement + - rule +body: + - type: markdown + attributes: + value: By opening an issue, you agree to abide by the [OpenJS Foundation Code of Conduct](https://eslint.org/conduct). + - type: input + attributes: + label: What rule do you want to change? + validations: + required: true + - type: dropdown + attributes: + label: What change do you want to make? + options: + - Generate more warnings + - Generate fewer warnings + - Implement autofix + - Implement suggestions + validations: + required: true + - type: dropdown + attributes: + label: How do you think the change should be implemented? + options: + - A new option + - A new default behavior + - Other + validations: + required: true + - type: textarea + attributes: + label: Example code + description: Please provide some example code that this change will affect. + render: markdown + validations: + required: true + - type: textarea + attributes: + label: What does the rule currently do for this code? + validations: + required: true + - type: textarea + attributes: + label: What will the rule do after it's changed? + validations: + required: true + - type: checkboxes + attributes: + label: Participation + options: + - label: I am willing to submit a pull request to implement this change. + required: false + - type: textarea + attributes: + label: Additional comments + description: Is there anything else that's important for the team to know? diff --git a/.gitignore b/.gitignore index 9c5c5a93..b683b5e7 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ yarn.lock package-lock.json pnpm-lock.yaml dist +src/build diff --git a/README.md b/README.md index 83049eb4..ab3c063f 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,12 @@ Install the plugin alongside ESLint v8 or greater: npm install --save-dev eslint @eslint/markdown ``` -### Configuring +### Configurations + +| **Configuration Name** | **Description** | +|---------------|-----------------| +| `recommended` | Lints all `.md` files with the recommended rules and assumes [CommonMark](https://commonmark.org/) format. | +| `processor` | Enables extracting code blocks from all `.md` files so code blocks can be individually linted. | In your `eslint.config.js` file, import `@eslint/markdown` and include the recommended config to enable the Markdown processor on all `.md` files: @@ -38,24 +43,20 @@ export default [ ]; ``` -#### Advanced Configuration - -You can manually include the Markdown processor by setting the `processor` option in your configuration file for all `.md` files. - -Each fenced code block inside a Markdown document has a virtual filename appended to the Markdown file's path. - -The virtual filename's extension will match the fenced code block's syntax tag, except for the following: - -* `javascript` and `ecmascript` are mapped to `js` -* `typescript` is mapped to `ts` -* `markdown` is mapped to `md` +### Rules -For example, ```` ```js ```` code blocks in `README.md` would match `README.md/*.js` and ```` ```typescript ```` in `CONTRIBUTING.md` would match `CONTRIBUTING.md/*.ts`. +| **Rule Name** | **Description** | +|---------------|-----------------| +| [`fenced-code-language`](./docs/rules/fenced-code-language.md) | Enforce fenced code blocks to specify a language. | +| [`heading-increment`](./docs/rules/heading-increment.md) | Enforce heading levels increment by one. | +| [`no-duplicate-headings`](./docs/rules/no-duplicate-headings.md) | Disallow duplicate headings in the same document. | +| [`no-empty-links`](./docs/rules/no-empty-links.md) | Disallow empty links. | +| [`no-html`](./docs/rules/no-html.md) | Enforce fenced code blocks to specify a language. | +| [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references. | -You can use glob patterns for these virtual filenames to customize configuration for code blocks without affecting regular code. -For more information on configuring processors, refer to the [ESLint documentation](https://eslint.org/docs/user-guide/configuring#specifying-processor). +**Note:** This plugin does not provide formatting rules. We recommend using a source code formatter such as [Prettier](https://prettier.io) for that purpose. -Here's an example: +In order to individually configure a rule in your `eslint.config.js` file, import `@eslint/markdown` and configure each rule with a prefix: ```js // eslint.config.js @@ -63,203 +64,50 @@ import markdown from "@eslint/markdown"; export default [ { - // 1. Add the plugin + files: ["**/*.md"], plugins: { markdown - } - }, - { - // 2. Enable the Markdown processor for all .md files. - files: ["**/*.md"], - processor: "markdown/markdown" - }, - { - // 3. Optionally, customize the configuration ESLint uses for ```js - // fenced code blocks inside .md files. - files: ["**/*.md/*.js"], - // ... + }, rules: { - // ... + "markdown/no-html": "error" } } - - // your other configs here ]; ``` -#### Frequently-Disabled Rules +### Languages -Some rules that catch mistakes in regular code are less helpful in documentation. -For example, `no-undef` would flag variables that are declared outside of a code snippet because they aren't relevant to the example. -The `markdown.configs.recommended` config disables these rules in Markdown files: +| **Language Name** | **Description** | +|---------------|-----------------| +| `commonmark` | Parse using [CommonMark](https://commonmark.org) Markdown format | +| `gfm` | Parse using [GitHub-Flavored Markdown](https://github.github.com/gfm/) format | -- [`no-undef`](https://eslint.org/docs/rules/no-undef) -- [`no-unused-expressions`](https://eslint.org/docs/rules/no-unused-expressions) -- [`no-unused-vars`](https://eslint.org/docs/rules/no-unused-vars) -- [`padded-blocks`](https://eslint.org/docs/rules/padded-blocks) -Use glob patterns to disable more rules just for Markdown code blocks: +In order to individually configure a language in your `eslint.config.js` file, import `@eslint/markdown` and configure a `language`: ```js -// / eslint.config.js +// eslint.config.js import markdown from "@eslint/markdown"; export default [ { + files: ["**/*.md"], plugins: { markdown - } - }, - { - files: ["**/*.md"], - processor: "markdown/markdown" - }, - { - // 1. Target ```js code blocks in .md files. - files: ["**/*.md/*.js"], + }, + language: "markdown/gfm", rules: { - // 2. Disable other rules. - "no-console": "off", - "import/no-unresolved": "off" + "markdown/no-html": "error" } } - - // your other configs here ]; ``` -#### Strict Mode - -`"use strict"` directives in every code block would be annoying. -The `markdown.configs.recommended` config enables the [`impliedStrict` parser option](https://eslint.org/docs/user-guide/configuring#specifying-parser-options) and disables the [`strict` rule](https://eslint.org/docs/rules/strict) in Markdown files. -This opts into strict mode parsing without repeated `"use strict"` directives. - -#### Unsatisfiable Rules - -Markdown code blocks are not real files, so ESLint's file-format rules do not apply. -The `markdown.configs.recommended` config disables these rules in Markdown files: - -- [`eol-last`](https://eslint.org/docs/rules/eol-last): The Markdown parser trims trailing newlines from code blocks. -- [`unicode-bom`](https://eslint.org/docs/rules/unicode-bom): Markdown code blocks do not have Unicode Byte Order Marks. - -### Running - -If you are using an `eslint.config.js` file, then you can run ESLint as usual and it will pick up file patterns in your config file. The `--ext` option is not available when using flat config. - - -### Autofixing - -With this plugin, [ESLint's `--fix` option](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some issues in your Markdown fenced code blocks. -To enable this, pass the `--fix` flag when you run ESLint: - -```bash -eslint --fix . -``` - -## What Gets Linted? - -With this plugin, ESLint will lint [fenced code blocks](https://help.github.com/articles/github-flavored-markdown/#fenced-code-blocks) in your Markdown documents: - -````markdown -```js -// This gets linted -var answer = 6 * 7; -console.log(answer); -``` - -Here is some regular Markdown text that will be ignored. - -```js -// This also gets linted - -/* eslint quotes: [2, "double"] */ - -function hello() { - console.log("Hello, world!"); -} -hello(); -``` - -```jsx -// This can be linted too if you add `.jsx` files to file patterns in the `eslint.config.js`. -// Or `overrides[].files` in `eslintrc.*`. -var div =
; -``` -```` - -Blocks that don't specify a syntax are ignored: - -````markdown -``` -This is plain text and doesn't get linted. -``` -```` - -Unless a fenced code block's syntax appears as a file extension in file patterns in your config file, it will be ignored. - -## Configuration Comments - -The processor will convert HTML comments immediately preceding a code block into JavaScript block comments and insert them at the beginning of the source code that it passes to ESLint. -This permits configuring ESLint via configuration comments while keeping the configuration comments themselves hidden when the markdown is rendered. -Comment bodies are passed through unmodified, so the plugin supports any [configuration comments](http://eslint.org/docs/user-guide/configuring) supported by ESLint itself. - -This example enables the `alert` global variable, disables the `no-alert` rule, and configures the `quotes` rule to prefer single quotes: - -````markdown - - - - -```js -alert('Hello, world!'); -``` -```` - -Each code block in a file is linted separately, so configuration comments apply only to the code block that immediately follows. +### Processors -````markdown -Assuming `no-alert` is enabled in `eslint.config.js`, the first code block will have no error from `no-alert`: - - - - -```js -alert("Hello, world!"); -``` - -But the next code block will have an error from `no-alert`: - - - -```js -alert("Hello, world!"); -``` -```` - -### Skipping Blocks - -Sometimes it can be useful to have code blocks marked with `js` even though they don't contain valid JavaScript syntax, such as commented JSON blobs that need `js` syntax highlighting. -Standard `eslint-disable` comments only silence rule reporting, but ESLint still reports any syntax errors it finds. -In cases where a code block should not even be parsed, insert a non-standard `` comment before the block, and this plugin will hide the following block from ESLint. -Neither rule nor syntax errors will be reported. - -````markdown -There are comments in this JSON, so we use `js` syntax for better -highlighting. Skip the block to prevent warnings about invalid syntax. - - - -```js -{ - // This code block is hidden from ESLint. - "hello": "world" -} -``` - -```js -console.log("This code block is linted normally."); -``` -```` +| **Processor Name** | **Description** | +|---------------|-----------------| +| [`markdown`](./docs/processors/markdown.md) | Extract fenced code blocks from the Markdown code so they can be linted separately. | ## Editor Integrations @@ -284,4 +132,4 @@ $ npm install $ npm test ``` -This project follows the [ESLint contribution guidelines](http://eslint.org/docs/developer-guide/contributing/). +This project follows the [ESLint contribution guidelines](https://eslint.org/docs/latest/contribute/). diff --git a/docs/processors/markdown.md b/docs/processors/markdown.md new file mode 100644 index 00000000..cdd50994 --- /dev/null +++ b/docs/processors/markdown.md @@ -0,0 +1,237 @@ +# Using the Markdown processor + +With this processor, ESLint will lint [fenced code blocks](https://help.github.com/articles/github-flavored-markdown/#fenced-code-blocks) in your Markdown documents. This processor uses [CommonMark](https://commonmark.org) format to evaluate the Markdown code, but this shouldn't matter because all Markdown dialects use the same format for code blocks. Here are some examples: + +````markdown +```js +// This gets linted +var answer = 6 * 7; +console.log(answer); +``` + +Here is some regular Markdown text that will be ignored. + +```js +// This also gets linted + +/* eslint quotes: [2, "double"] */ + +function hello() { + console.log("Hello, world!"); +} +hello(); +``` + +```jsx +// This can be linted too if you add `.jsx` files to file patterns in the `eslint.config.js`. +var div =
; +``` +```` + +Blocks that don't specify a syntax are ignored: + +````markdown +``` +This is plain text and doesn't get linted. +``` +```` + +Unless a fenced code block's syntax appears as a file extension in file patterns in your config file, it will be ignored. + +**Important:** You cannot combine this processor and Markdown-specific linting rules. You can either lint the code blocks or lint the Markdown, but not both. This is an ESLint limitation. + +## Basic Configuration + +To enable the Markdown processor, use the `processor` configuration, which contains all of the configuration for setting up the plugin and processor to work on `.md` files: + +```js +// eslint.config.js +import markdown from "@eslint/markdown"; + +export default [ + ...markdown.configs.processor + + // your other configs here +]; +``` + +## Advanced Configuration + +You can manually include the Markdown processor by setting the `processor` option in your configuration file for all `.md` files. + +Each fenced code block inside a Markdown document has a virtual filename appended to the Markdown file's path. + +The virtual filename's extension will match the fenced code block's syntax tag, except for the following: + +* `javascript` and `ecmascript` are mapped to `js` +* `typescript` is mapped to `ts` +* `markdown` is mapped to `md` + +For example, ```` ```js ```` code blocks in `README.md` would match `README.md/*.js` and ```` ```typescript ```` in `CONTRIBUTING.md` would match `CONTRIBUTING.md/*.ts`. + +You can use glob patterns for these virtual filenames to customize configuration for code blocks without affecting regular code. +For more information on configuring processors, refer to the [ESLint documentation](https://eslint.org/docs/user-guide/configuring#specifying-processor). + +Here's an example: + +```js +// eslint.config.js +import markdown from "@eslint/markdown"; + +export default [ + { + // 1. Add the plugin + plugins: { + markdown + } + }, + { + // 2. Enable the Markdown processor for all .md files. + files: ["**/*.md"], + processor: "markdown/markdown" + }, + { + // 3. Optionally, customize the configuration ESLint uses for ```js + // fenced code blocks inside .md files. + files: ["**/*.md/*.js"], + // ... + rules: { + // ... + } + } + + // your other configs here +]; +``` + +## Frequently-Disabled Rules + +Some rules that catch mistakes in regular code are less helpful in documentation. +For example, `no-undef` would flag variables that are declared outside of a code snippet because they aren't relevant to the example. +The `markdown.configs.processor` config disables these rules in Markdown files: + +- [`no-undef`](https://eslint.org/docs/rules/no-undef) +- [`no-unused-expressions`](https://eslint.org/docs/rules/no-unused-expressions) +- [`no-unused-vars`](https://eslint.org/docs/rules/no-unused-vars) +- [`padded-blocks`](https://eslint.org/docs/rules/padded-blocks) + +Use glob patterns to disable more rules just for Markdown code blocks: + +```js +// / eslint.config.js +import markdown from "@eslint/markdown"; + +export default [ + { + plugins: { + markdown + } + }, + { + files: ["**/*.md"], + processor: "markdown/markdown" + }, + { + // 1. Target ```js code blocks in .md files. + files: ["**/*.md/*.js"], + rules: { + // 2. Disable other rules. + "no-console": "off", + "import/no-unresolved": "off" + } + } + + // your other configs here +]; +``` + +## Additional Notes + +Here are some other things to keep in mind when linting code blocks. + +### Strict Mode + +`"use strict"` directives in every code block would be annoying. +The `markdown.configs.processor` config enables the [`impliedStrict` parser option](https://eslint.org/docs/user-guide/configuring#specifying-parser-options) and disables the [`strict` rule](https://eslint.org/docs/rules/strict) in Markdown files. +This opts into strict mode parsing without repeated `"use strict"` directives. + +### Unsatisfiable Rules + +Markdown code blocks are not real files, so ESLint's file-format rules do not apply. +The `markdown.configs.processor` config disables these rules in Markdown files: + +- [`eol-last`](https://eslint.org/docs/rules/eol-last): The Markdown parser trims trailing newlines from code blocks. +- [`unicode-bom`](https://eslint.org/docs/rules/unicode-bom): Markdown code blocks do not have Unicode Byte Order Marks. + +### Autofixing + +With this plugin, [ESLint's `--fix` option](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some issues in your Markdown fenced code blocks. +To enable this, pass the `--fix` flag when you run ESLint: + +```bash +eslint --fix . +``` + +## Configuration Comments + +The processor will convert HTML comments immediately preceding a code block into JavaScript block comments and insert them at the beginning of the source code that it passes to ESLint. +This permits configuring ESLint via configuration comments while keeping the configuration comments themselves hidden when the markdown is rendered. +Comment bodies are passed through unmodified, so the plugin supports any [configuration comments](http://eslint.org/docs/user-guide/configuring) supported by ESLint itself. + +This example enables the `alert` global variable, disables the `no-alert` rule, and configures the `quotes` rule to prefer single quotes: + +````markdown + + + + +```js +alert('Hello, world!'); +``` +```` + +Each code block in a file is linted separately, so configuration comments apply only to the code block that immediately follows. + +````markdown +Assuming `no-alert` is enabled in `eslint.config.js`, the first code block will have no error from `no-alert`: + + + + +```js +alert("Hello, world!"); +``` + +But the next code block will have an error from `no-alert`: + + + +```js +alert("Hello, world!"); +``` +```` + +### Skipping Blocks + +Sometimes it can be useful to have code blocks marked with `js` even though they don't contain valid JavaScript syntax, such as commented JSON blobs that need `js` syntax highlighting. +Standard `eslint-disable` comments only silence rule reporting, but ESLint still reports any syntax errors it finds. +In cases where a code block should not even be parsed, insert a non-standard `` comment before the block, and this plugin will hide the following block from ESLint. +Neither rule nor syntax errors will be reported. + +````markdown +There are comments in this JSON, so we use `js` syntax for better +highlighting. Skip the block to prevent warnings about invalid syntax. + + + +```js +{ + // This code block is hidden from ESLint. + "hello": "world" +} +``` + +```js +console.log("This code block is linted normally."); +``` +```` diff --git a/docs/rules/fenced-code-language.md b/docs/rules/fenced-code-language.md new file mode 100644 index 00000000..258f9919 --- /dev/null +++ b/docs/rules/fenced-code-language.md @@ -0,0 +1,48 @@ +# fenced-code-language + +Require languages for fenced code blocks. + +## Background + +One of the ways that Markdown allows you to embed syntax-highlighted blocks of other languages is using fenced code blocks, such as: + +````markdown +```js +const message = "Hello, world!"; +console.log(message); +``` +```` + +The language name is expected, but not required, after the initial three backticks. In general, it's a good idea to provide a language because that allows editors and converters to properly syntax highlight the embedded code. Even if you're just embedding plain text, it's preferable to use `text` as the language to indicate your intention. + +## Rule Details + +This rule warns when it finds code blocks without a language specified. + +Examples of incorrect code: + +````markdown +``` +const message = "Hello, world!"; +console.log(message); +``` +```` + +## Options + +The following options are available on this rule: + +* `required: Array` - when specified, fenced code blocks must use one of the languages specified in this array. + +Examples of incorrect code when configured as `"fenced-code-language: ["error", { required: ["js"]}]`: + +````markdown +```javascript +const message = "Hello, world!"; +console.log(message); +``` +```` + +## When Not to Use It + +If you don't mind omitting the language for fenced code blocks, you can safely disable this rule. diff --git a/docs/rules/heading-increment.md b/docs/rules/heading-increment.md new file mode 100644 index 00000000..1cc3eb42 --- /dev/null +++ b/docs/rules/heading-increment.md @@ -0,0 +1,28 @@ +# heading-increment + +Enforce heading levels increment by one. + +## Background + +It can be difficult to keep track of the correct heading levels in a long document. Most of the time, you want to increment heading levels by one, so inside of a heading level 1 you'll have one or more heading level 2s. If you've skipped from, for example, heading level 1 to heading level 3, that is most likely an error. + +## Rule Details + +This rule warns when it finds a heading that is more than on level higher than the preceding heading. + +Examples of incorrect code: + +```markdown +# Hello world! + +### Hello world! + +Goodbye World! +-------------- + +#EEE Goodbye World! +``` + +## When Not to Use It + +If you aren't concerned with enforcing heading levels increment by one, you can safely disable this rule. diff --git a/docs/rules/no-duplicate-headings.md b/docs/rules/no-duplicate-headings.md new file mode 100644 index 00000000..30a46da0 --- /dev/null +++ b/docs/rules/no-duplicate-headings.md @@ -0,0 +1,33 @@ +# no-duplicate-headings + +Disallow duplicate headings in the same document. + +## Background + +Headings in Markdown documents are often used in a variety ways: + +1. To generate in-document links +1. To generate a table of contents + +When generating in-document links, unique headings are necessary to ensure you can navigate to a specific heading. Generated tables of contents then use those links, and when there are duplicate headings, you can only link to the first instance. + +## Rule Details + +This rule warns when it finds more than one heading with the same text, even if the headings are of different levels. + +Examples of incorrect code: + +```markdown +# Hello world! + +## Hello world! + +Goodbye World! +-------------- + +# Goodbye World! +``` + +## When Not to Use It + +If you aren't concerned with autolinking heading or autogenerating a table of contents, you can safely disable this rule. diff --git a/docs/rules/no-empty-links.md b/docs/rules/no-empty-links.md new file mode 100644 index 00000000..cdf79790 --- /dev/null +++ b/docs/rules/no-empty-links.md @@ -0,0 +1,23 @@ +# no-empty-links + +Disallow empty links. + +## Background + +Markdown syntax can make it difficult to easily see that you've forgotten to give a link a destination. This is especially true when writing prose in Markdown, in which case you may intend to create a link but leave the destination for later...and then forget to go back and add it. + +## Rule Details + +This rule warns when it finds links that either don't have a URL specified or have only an empty fragment (`"#"`). + +Examples of incorrect code: + +```markdown +[ESLint]() + +[Skip to Content](#) +``` + +## When Not to Use It + +If you aren't concerned with empty links, you can safely disable this rule. diff --git a/docs/rules/no-html.md b/docs/rules/no-html.md new file mode 100644 index 00000000..e0c16423 --- /dev/null +++ b/docs/rules/no-html.md @@ -0,0 +1,45 @@ +# no-html + +Disallow HTML tags. + +## Background + +By default, Markdown allows you to use HTML tags mixed in with Markdown syntax. In some cases, you may want to restrict the use of HTML to ensure that the output is predictable when converting the Markdown to HTML. + +## Rule Details + +This rule warns when it finds HTML tags inside Markdown content. + +Examples of incorrect code: + +```markdown +# Heading 1 + +Hello world! +``` + +## Options + +The following options are available on this rule: + +* `allowed: Array` - when specified, HTML tags are allowed only if they match one of the tags in this array.. + +Examples of incorrect code when configured as `"no-html: ["error", { allowed: ["b"]}]`: + +```markdown +# Heading 1 + +Hello world! +``` + +Examples of correct code when configured as `"no-html: ["error", { allowed: ["b"]}]`: + +```markdown +# Heading 1 + +Hello world! +``` + +## When Not to Use It + +If you aren't concerned with empty links, you can safely disable this rule. diff --git a/docs/rules/no-missing-label-refs.md b/docs/rules/no-missing-label-refs.md new file mode 100644 index 00000000..bb3c60b9 --- /dev/null +++ b/docs/rules/no-missing-label-refs.md @@ -0,0 +1,33 @@ +# no-missing-label-refs + +Disallow missing label references. + +## Background + +Markdown allows you to specify a label as a placeholder for a URL in both links and images using square brackets, such as: + +```markdown +[ESLint][eslint] + +[eslint]: https://eslint.org +``` + +If the label is never defined, then Markdown doesn't render a link and instead renders plain text. + +## Rule Details + +This rule warns when it finds text that looks like it's a label but the label reference doesn't exist. + +Examples of incorrect code: + +```markdown +[ESLint][eslint] + +[eslint][] + +[eslint] +``` + +## When Not to Use It + +If you aren't concerned with missing label references, you can safely disable this rule. diff --git a/eslint.config-content.js b/eslint.config-content.js new file mode 100644 index 00000000..ed5d1420 --- /dev/null +++ b/eslint.config-content.js @@ -0,0 +1,9 @@ +import markdown from "./src/index.js"; + +export default [ + { + name: "markdown/content/ignores", + ignores: ["**/*.js", "**/.cjs", "**/.mjs"] + }, + ...markdown.configs.recommended +]; diff --git a/eslint.config.js b/eslint.config.js index fc7ea9ae..021e4ecd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,19 +7,30 @@ export default [ ...eslintConfigESLint, eslintConfigESLintFormatting, { + name: "markdown/plugins", plugins: { markdown } }, { + name: "markdown/ignores", ignores: [ "**/examples", "**/coverage", "**/tests/fixtures", - "dist" + "dist", + "src/build/" ] }, { + name: "markdown/tools", + files: ["tools/**/*.js"], + rules: { + "no-console": "off" + } + }, + { + name: "markdown/tests", files: ["tests/**/*.js"], languageOptions: { globals: { @@ -31,10 +42,12 @@ export default [ } }, { + name: "markdown/code-blocks", files: ["**/*.md"], processor: "markdown/markdown" }, { + name: "markdown/code-blocks/js", files: ["**/*.md/*.js"], languageOptions: { sourceType: "module", diff --git a/package.json b/package.json index 14ccfebd..5b11d2c7 100644 --- a/package.json +++ b/package.json @@ -34,19 +34,22 @@ "linter" ], "scripts": { - "lint": "eslint .", + "lint": "eslint . && eslint -c eslint.config-content.js .", + "lint:fix": "eslint --fix . && eslint --fix -c eslint.config-content.js .", "build:dedupe-types": "node tools/dedupe-types.js dist/esm/index.js", - "build": "rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json", + "build:rules": "node tools/build-rules.js", + "build": "build:rules && rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json", "prepare": "node ./npm-prepare.cjs", "test": "c8 mocha \"tests/**/*.test.js\" --timeout 30000" }, "devDependencies": { - "@eslint/core": "^0.2.0", + "@eslint/core": "^0.3.0", "@eslint/js": "^9.4.0", "@types/eslint": "^9.6.0", "c8": "^10.1.2", "chai": "^5.1.1", - "eslint": "^9.4.0", + "dedent": "^1.5.3", + "eslint": "github:eslint/eslint", "eslint-config-eslint": "^11.0.0", "globals": "^15.1.0", "mocha": "^10.6.0", @@ -55,7 +58,9 @@ "typescript": "^5.5.4" }, "dependencies": { - "mdast-util-from-markdown": "^2.0.1" + "mdast-util-from-markdown": "^2.0.1", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0" }, "peerDependencies": { "eslint": ">=9" diff --git a/src/index.js b/src/index.js index 9f334ab2..fd08fb9e 100644 --- a/src/index.js +++ b/src/index.js @@ -8,20 +8,26 @@ //----------------------------------------------------------------------------- import { processor } from "./processor.js"; +import { MarkdownLanguage } from "./language/markdown-language.js"; +import recommendedRules from "./build/recommended-config.js"; +import rules from "./build/rules.js"; //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- /** @typedef {import("eslint").Linter.RulesRecord} RulesRecord*/ +/** @typedef {import("eslint").Linter.Config} Config*/ /** @typedef {import("eslint").ESLint.Plugin} Plugin */ +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ +/** @typedef {import("@eslint/core").Language} Language */ //----------------------------------------------------------------------------- // Exports //----------------------------------------------------------------------------- /** @type {RulesRecord} */ -const rulesConfig = { +const processorRulesConfig = { // The Markdown parser automatically trims trailing // newlines from code blocks. @@ -44,7 +50,7 @@ const rulesConfig = { "unicode-bom": "off" }; -/** @type {Plugin} */ +/** @type {Plugin & { languages: Record}} */ const plugin = { meta: { name: "@eslint/markdown", @@ -53,6 +59,11 @@ const plugin = { processors: { markdown: processor }, + languages: { + commonmark: new MarkdownLanguage({ mode: "commonmark" }), + gfm: new MarkdownLanguage({ mode: "gfm" }) + }, + rules, configs: { "recommended-legacy": { plugins: ["markdown"], @@ -74,7 +85,7 @@ const plugin = { } }, rules: { - ...rulesConfig + ...processorRulesConfig } } ] @@ -83,6 +94,20 @@ const plugin = { }; plugin.configs.recommended = [ + + /** @type {Config & {language:string}} */ + ({ + name: "markdown/recommended", + files: ["**/*.md"], + language: "markdown/commonmark", + plugins: { + markdown: plugin + }, + rules: /** @type {RulesRecord} */ (recommendedRules) + }) +]; + +plugin.configs.processor = [ { name: "markdown/recommended/plugin", plugins: { @@ -110,7 +135,7 @@ plugin.configs.recommended = [ } }, rules: { - ...rulesConfig + ...processorRulesConfig } } ]; diff --git a/src/language/markdown-language.js b/src/language/markdown-language.js new file mode 100644 index 00000000..ad00f97b --- /dev/null +++ b/src/language/markdown-language.js @@ -0,0 +1,145 @@ +/** + * @fileoverview Functions to fix up rules to provide missing methods on the `context` object. + * @author Nicholas C. Zakas + */ + +/* eslint class-methods-use-this: 0 -- Required to complete interface. */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import { MarkdownSourceCode } from "./markdown-source-code.js"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { gfmFromMarkdown } from "mdast-util-gfm"; +import { gfm } from "micromark-extension-gfm"; + +//----------------------------------------------------------------------------- +// Types +//----------------------------------------------------------------------------- + +/** @typedef {import("mdast").Root} RootNode */ +/** @typedef {import("@eslint/core").Language} Language */ +/** @typedef {import("@eslint/core").File} File */ +/** @typedef {import("@eslint/core").ParseResult} ParseResult */ +/** @typedef {import("@eslint/core").OkParseResult} OkParseResult */ +/** @typedef {import("@eslint/core").SyntaxElement} SyntaxElement */ +/** @typedef {"commonmark"|"gfm"} ParserMode */ + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * Markdown Language Object + * @implements {Language} + */ +export class MarkdownLanguage { + + /** + * The type of file to read. + * @type {"text"} + */ + fileType = "text"; + + /** + * The line number at which the parser starts counting. + * @type {0|1} + */ + lineStart = 1; + + /** + * The column number at which the parser starts counting. + * @type {0|1} + */ + columnStart = 1; + + /** + * The name of the key that holds the type of the node. + * @type {string} + */ + nodeTypeKey = "type"; + + + /** + * The Markdown parser mode. + * @type {ParserMode} + */ + #mode = "commonmark"; + + /** + * Creates a new instance. + * @param {Object} options The options to use for this instance. + * @param {ParserMode} [options.mode] The Markdown parser mode to use. + */ + constructor({ mode } = {}) { + if (mode) { + this.#mode = mode; + } + } + + /* eslint-disable no-unused-vars -- Required to complete interface. */ + /** + * Validates the language options. + * @param {Object} languageOptions The language options to validate. + * @returns {void} + * @throws {Error} When the language options are invalid. + */ + validateLanguageOptions(languageOptions) { + + // no-op + } + /* eslint-enable no-unused-vars -- Required to complete interface. */ + + /** + * Parses the given file into an AST. + * @param {File} file The virtual file to parse. + * @returns {ParseResult} The result of parsing. + */ + parse(file) { + + // Note: BOM already removed + const text = /** @type {string} */ (file.body); + + /* + * Check for parsing errors first. If there's a parsing error, nothing + * else can happen. However, a parsing error does not throw an error + * from this method - it's just considered a fatal error message, a + * problem that ESLint identified just like any other. + */ + try { + const options = this.#mode === "gfm" ? { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()] + } : { extensions: [] }; + const root = fromMarkdown(text, options); + + return { + ok: true, + ast: root + }; + + } catch (ex) { + + return { + ok: false, + errors: [ + ex + ] + }; + } + } + + /** + * Creates a new `JSONSourceCode` object from the given information. + * @param {File} file The virtual file to create a `JSONSourceCode` object from. + * @param {OkParseResult} parseResult The result returned from `parse()`. + * @returns {MarkdownSourceCode} The new `JSONSourceCode` object. + */ + createSourceCode(file, parseResult) { + return new MarkdownSourceCode({ + text: /** @type {string} */ (file.body), + ast: parseResult.ast + }); + } +} diff --git a/src/language/markdown-source-code.js b/src/language/markdown-source-code.js new file mode 100644 index 00000000..17c80932 --- /dev/null +++ b/src/language/markdown-source-code.js @@ -0,0 +1,280 @@ +/** + * @fileoverview The MarkdownSourceCode class. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + + +//----------------------------------------------------------------------------- +// Types +//----------------------------------------------------------------------------- + +/** @typedef {import("mdast").Root} RootNode */ +/** @typedef {import("mdast").Node} MarkdownNode */ +/** @typedef {import("@eslint/core").Language} Language */ +/** @typedef {import("@eslint/core").File} File */ +/** @typedef {import("@eslint/core").TraversalStep} TraversalStep */ +/** @typedef {import("@eslint/core").VisitTraversalStep} VisitTraversalStep */ +/** @typedef {import("@eslint/core").TextSourceCode} TextSourceCode */ +/** @typedef {import("@eslint/core").ParseResult} ParseResult */ +/** @typedef {import("@eslint/core").SourceLocation} SourceLocation */ +/** @typedef {import("@eslint/core").SourceRange} SourceRange */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +/** + * A class to represent a step in the traversal process. + * @implements {VisitTraversalStep} + */ +class MarkdownTraversalStep { + + /** + * The type of the step. + * @type {"visit"} + * @readonly + */ + type = "visit"; + + /** + * The kind of the step. Represents the same data as the `type` property + * but it's a number for performance. + * @type {1} + * @readonly + */ + kind = 1; + + /** + * The target of the step. + * @type {MarkdownNode} + */ + target; + + /** + * The phase of the step. + * @type {1|2} + */ + phase; + + /** + * The arguments of the step. + * @type {Array} + */ + args; + + /** + * Creates a new instance. + * @param {Object} options The options for the step. + * @param {MarkdownNode} options.target The target of the step. + * @param {1|2} options.phase The phase of the step. + * @param {Array} options.args The arguments of the step. + */ + constructor({ target, phase, args }) { + this.target = target; + this.phase = phase; + this.args = args; + } +} + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * JSON Source Code Object + * @implements {TextSourceCode} + */ +export class MarkdownSourceCode { + + /** + * Cached traversal steps. + * @type {Array|undefined} + */ + #steps; + + /** + * Cache of parent nodes. + * @type {WeakMap} + */ + #parents = new WeakMap(); + + /** + * The lines of text in the source code. + * @type {Array} + */ + #lines; + + /** + * Cache of ranges. + * @type {WeakMap} + */ + #ranges = new WeakMap(); + + /** + * The AST of the source code. + * @type {RootNode} + */ + ast; + + /** + * The text of the source code. + * @type {string} + */ + text; + + /** + * Creates a new instance. + * @param {Object} options The options for the instance. + * @param {string} options.text The source code text. + * @param {RootNode} options.ast The root AST node. + */ + constructor({ text, ast }) { + this.ast = ast; + this.text = text; + } + + /* eslint-disable class-methods-use-this -- Required to complete interface. */ + /** + * Gets the location of the node. + * @param {MarkdownNode} node The node to get the location of. + * @returns {SourceLocation} The location of the node. + */ + getLoc(node) { + return node.position; + } + + /** + * Gets the range of the node. + * @param {MarkdownNode} node The node to get the range of. + * @returns {SourceRange} The range of the node. + */ + getRange(node) { + + if (!this.#ranges.has(node)) { + this.#ranges.set(node, [node.position.start.offset, node.position.end.offset]); + } + + return this.#ranges.get(node); + } + + /* eslint-enable class-methods-use-this -- Required to complete interface. */ + + /** + * Returns the parent of the given node. + * @param {MarkdownNode} node The node to get the parent of. + * @returns {MarkdownNode|undefined} The parent of the node. + */ + getParent(node) { + return this.#parents.get(node); + } + + /** + * Gets all the ancestors of a given node + * @param {MarkdownNode} node The node + * @returns {Array} All the ancestor nodes in the AST, not including the provided node, starting + * from the root node at index 0 and going inwards to the parent node. + * @throws {TypeError} When `node` is missing. + */ + getAncestors(node) { + if (!node) { + throw new TypeError("Missing required argument: node."); + } + + const ancestorsStartingAtParent = []; + + for ( + let ancestor = this.#parents.get(node); + ancestor; + ancestor = this.#parents.get(ancestor) + ) { + ancestorsStartingAtParent.push(ancestor); + } + + return ancestorsStartingAtParent.reverse(); + } + + /** + * Gets the source code for the given node. + * @param {MarkdownNode} [node] The AST node to get the text for. + * @param {number} [beforeCount] The number of characters before the node to retrieve. + * @param {number} [afterCount] The number of characters after the node to retrieve. + * @returns {string} The text representing the AST node. + * @public + */ + getText(node, beforeCount = 0, afterCount = 0) { + if (node) { + const range = this.getRange(node); + + return this.text.slice( + Math.max(range[0] - beforeCount, 0), + range[1] + afterCount + ); + } + return this.text; + } + + /** + * Gets the entire source text split into an array of lines. + * @returns {Array} The source text as an array of lines. + * @public + */ + get lines() { + if (!this.#lines) { + this.#lines = this.text.split(/\r?\n/gu); + } + return this.#lines; + } + + /** + * Traverse the source code and return the steps that were taken. + * @returns {Iterable} The steps that were taken while traversing the source code. + */ + traverse() { + + // Because the AST doesn't mutate, we can cache the steps + if (this.#steps) { + return this.#steps.values(); + } + + const steps = (this.#steps = []); + + const visit = (node, parent) => { + + // first set the parent + this.#parents.set(node, parent); + + // then add the step + steps.push( + new MarkdownTraversalStep({ + target: node, + phase: 1, + args: [node, parent] + }) + ); + + // then visit the children + if (node.children) { + node.children.forEach(child => { + visit(child, node); + }); + } + + // then add the exit step + steps.push( + new MarkdownTraversalStep({ + target: node, + phase: 2, + args: [node, parent] + }) + ); + }; + + visit(this.ast); + + return steps; + } +} diff --git a/src/processor.js b/src/processor.js index a90c3007..7712d373 100644 --- a/src/processor.js +++ b/src/processor.js @@ -3,30 +3,30 @@ * @author Brandon Mills */ -//----------------------------------------------------------------------------- -// Imports -//----------------------------------------------------------------------------- +/** + * @typedef {import('eslint/lib/shared/types').LintMessage} Message + * @typedef {Object} ASTNode + * @property {string} type The type of node. + * @property {string} [lang] The language that the node is in + * @typedef {Object} RangeMap + * @property {number} indent Number of code block indent characters trimmed from + * the beginning of the line during extraction. + * @property {number} js Offset from the start of the code block's range in the + * extracted JS. + * @property {number} md Offset from the start of the code block's range in the + * original Markdown. + * @typedef {Object} BlockBase + * @property {string} baseIndentText Leading whitespace text for the block. + * @property {string[]} comments Comments inside of the JavaScript code. + * @property {RangeMap[]} rangeMap A list of offset-based adjustments, where + * lookups are done based on the `js` key, which represents the range in the + * linted JS, and the `md` key is the offset delta that, when added to the JS + * range, returns the corresponding location in the original Markdown source. + * @typedef {ASTNode & BlockBase} Block + */ import { fromMarkdown } from "mdast-util-from-markdown"; -//----------------------------------------------------------------------------- -// Type Definitions -//----------------------------------------------------------------------------- - -/** @typedef {import("./types.ts").Block} Block */ -/** @typedef {import("./types.ts").RangeMap} RangeMap */ -/** @typedef {import("mdast").Node} Node */ -/** @typedef {import("mdast").Parent} ParentNode */ -/** @typedef {import("mdast").Code} CodeNode */ -/** @typedef {import("mdast").Html} HtmlNode */ -/** @typedef {import("eslint").Linter.LintMessage} Message */ -/** @typedef {import("eslint").Rule.Fix} Fix */ -/** @typedef {import("eslint").AST.Range} Range */ - -//----------------------------------------------------------------------------- -// Helpers -//----------------------------------------------------------------------------- - const UNSATISFIABLE_RULES = new Set([ "eol-last", // The Markdown parser strips trailing newlines in code fences "unicode-bom" // Code blocks will begin in the middle of Markdown files @@ -40,8 +40,8 @@ const blocksCache = new Map(); /** * Performs a depth-first traversal of the Markdown AST. - * @param {Node} node A Markdown AST node. - * @param {{[key: string]: (node?: Node) => void}} callbacks A map of node types to callbacks. + * @param {ASTNode} node A Markdown AST node. + * @param {{[key: string]: (node: ASTNode) => void}} callbacks A map of node types to callbacks. * @returns {void} */ function traverse(node, callbacks) { @@ -51,11 +51,9 @@ function traverse(node, callbacks) { callbacks["*"](); } - const parent = /** @type {ParentNode} */ (node); - - if (typeof parent.children !== "undefined") { - for (let i = 0; i < parent.children.length; i++) { - traverse(parent.children[i], callbacks); + if (typeof node.children !== "undefined") { + for (let i = 0; i < node.children.length; i++) { + traverse(node.children[i], callbacks); } } } @@ -94,7 +92,7 @@ const leadingWhitespaceRegex = /^[>\s]*/u; /** * Gets the offset for the first column of the node's first line in the * original source text. - * @param {Node} node A Markdown code block AST node. + * @param {ASTNode} node A Markdown code block AST node. * @returns {number} The offset for the first column of the node's first line. */ function getBeginningOfLineOffset(node) { @@ -105,7 +103,7 @@ function getBeginningOfLineOffset(node) { * Gets the leading text, typically whitespace with possible blockquote chars, * used to indent a code block. * @param {string} text The text of the file. - * @param {Node} node A Markdown code block AST node. + * @param {ASTNode} node A Markdown code block AST node. * @returns {string} The text from the start of the first line to the opening * fence of the code block. */ @@ -139,7 +137,7 @@ function getIndentText(text, node) { * differences within the line, so the mapping need only provide the offset * delta at the beginning of each line. * @param {string} text The text of the file. - * @param {Node} node A Markdown code block AST node. + * @param {ASTNode} node A Markdown code block AST node. * @param {string[]} comments List of configuration comment strings that will be * inserted at the beginning of the code block. * @returns {RangeMap[]} A list of offset-based adjustments, where lookups are @@ -267,12 +265,6 @@ function preprocess(text, filename) { "*"() { htmlComments = []; }, - - /** - * Visit a code node. - * @param {CodeNode} node The visited node. - * @returns {void} - */ code(node) { if (node.lang) { const comments = []; @@ -296,12 +288,6 @@ function preprocess(text, filename) { }); } }, - - /** - * Visit an HTML node. - * @param {HtmlNode} node The visited node. - * @returns {void} - */ html(node) { const comment = getComment(node.value); @@ -371,7 +357,7 @@ function adjustBlock(block) { if (message.fix) { adjustedFix.fix = { - range: /** @type {Range} */ (message.fix.range.map(range => { + range: message.fix.range.map(range => { // Advance through the block's range map to find the last // matching range by finding the first range too far and @@ -384,7 +370,7 @@ function adjustBlock(block) { // Apply the mapping delta for this range. return range + block.rangeMap[i - 1].md; - })), + }), text: message.fix.text.replace(/\n/gu, `\n${block.baseIndentText}`) }; } diff --git a/src/rules/fenced-code-language.js b/src/rules/fenced-code-language.js new file mode 100644 index 00000000..21383f13 --- /dev/null +++ b/src/rules/fenced-code-language.js @@ -0,0 +1,77 @@ +/** + * @fileoverview Rule to enforce languages for fenced code. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: "problem", + + docs: { + recommended: true, + description: "Require languages for fenced code blocks." + }, + + messages: { + missingLanguage: "Missing code block language.", + disallowedLanguage: 'Code block language "{{lang}}" is not allowed.' + }, + + schema: [ + { + type: "object", + properties: { + required: { + type: "array", + items: { + type: "string" + }, + uniqueItems: true + } + }, + additionalProperties: false + } + ] + }, + + create(context) { + + const required = new Set(context.options[0]?.required ?? []); + const { sourceCode } = context + + return { + code(node) { + + if (!node.lang) { + + // only check fenced code blocks + if (sourceCode.text[node.position.start.offset] !== "`") { + return; + } + + context.report({ + loc: node.position, + messageId: "missingLanguage" + }); + + return; + } + + if (required.size && !required.has(node.lang)) { + context.report({ + loc: node.position, + messageId: "disallowedLanguage", + data: { + lang: node.lang + } + }); + } + + } + }; + } +}; diff --git a/src/rules/heading-increment.js b/src/rules/heading-increment.js new file mode 100644 index 00000000..804905a5 --- /dev/null +++ b/src/rules/heading-increment.js @@ -0,0 +1,45 @@ +/** + * @fileoverview Rule to enforce heading levels increment by one. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: "problem", + + docs: { + recommended: true, + description: "Enforce heading levels increment by one." + }, + + messages: { + skippedHeading: "Heading level skipped from {{fromLevel}} to {{toLevel}}." + } + }, + + create(context) { + let lastHeadingDepth = 0; + + return { + heading(node) { + + if (lastHeadingDepth > 0 && node.depth > lastHeadingDepth + 1) { + context.report({ + loc: node.position, + messageId: "skippedHeading", + data: { + fromLevel: lastHeadingDepth, + toLevel: node.depth + } + }); + } + + lastHeadingDepth = node.depth; + } + }; + } +}; diff --git a/src/rules/no-duplicate-headings.js b/src/rules/no-duplicate-headings.js new file mode 100644 index 00000000..86381be4 --- /dev/null +++ b/src/rules/no-duplicate-headings.js @@ -0,0 +1,63 @@ +/** + * @fileoverview Rule to prevent duplicate headings in Markdown. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: "problem", + + docs: { + description: "Disallow duplicate headings in the same document." + }, + + messages: { + duplicateHeading: 'Duplicate heading "{{text}}" found.' + } + }, + + create(context) { + const headings = new Set(); + const { sourceCode } = context; + + return { + heading(node) { + + /* + * There are two types of headings in markdown: + * - ATX headings, which start with one or more # characters + * - Setext headings, which are underlined with = or - + * Setext headings are identified by being on two lines instead of one, + * with the second line containing only = or - characters. In order to + * get the correct heading text, we need to determine which type of + * heading we're dealing with. + */ + const isSetext = node.position.start.line !== node.position.end.line; + + const text = isSetext + + // get only the text from the first line + ? sourceCode.lines[node.position.start.line - 1].trim() + + // get the text without the leading # characters + : sourceCode.getText(node).slice(node.depth + 1).trim(); + + if (headings.has(text)) { + context.report({ + loc: node.position, + messageId: "duplicateHeading", + data: { + text + } + }); + } + + headings.add(text); + } + }; + } +}; diff --git a/src/rules/no-empty-links.js b/src/rules/no-empty-links.js new file mode 100644 index 00000000..949f09e9 --- /dev/null +++ b/src/rules/no-empty-links.js @@ -0,0 +1,39 @@ +/** + * @fileoverview Rule to prevent empty links in Markdown. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: "problem", + + docs: { + recommended: true, + description: "Disallow empty links." + }, + + messages: { + emptyLink: "Unexpected empty link found." + } + }, + + create(context) { + + return { + link(node) { + + if (!node.url || node.url === "#") { + context.report({ + loc: node.position, + messageId: "emptyLink" + }); + } + + } + }; + } +}; diff --git a/src/rules/no-html.js b/src/rules/no-html.js new file mode 100644 index 00000000..810ece3e --- /dev/null +++ b/src/rules/no-html.js @@ -0,0 +1,71 @@ +/** + * @fileoverview Rule to disallow HTML inside of content. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: "problem", + + docs: { + description: "Disallow HTML tags." + }, + + messages: { + disallowedElement: 'HTML element "{{name}}" is not allowed.' + }, + + schema: [ + { + type: "object", + properties: { + allowed: { + type: "array", + items: { + type: "string" + }, + uniqueItems: true + } + }, + additionalProperties: false + } + ] + }, + + create(context) { + + const allowed = new Set(context.options[0]?.allowed ?? []); + + return { + html(node) { + + // don't care about closing tags + if (node.value.startsWith("} The missing references. + */ +function findMissingReferences(node, text) { + + const missing = []; + let startIndex = 0; + + while (startIndex < node.value.length) { + + const value = node.value.slice(startIndex); + + const match = labelPatterns.reduce((previous, pattern) => { + if (previous) { + return previous; + } + + return value.match(pattern); + }, null); + + if (!match) { + break; + } + + let label = match[1]; + let columnStart = startIndex + match.index + 1; + + // need to look backward to get the label + if (match[0] === "][]") { + + // adding 1 to the index just in case we're in a ![] and need to skip the !. + const startFrom = node.position.start.offset + startIndex + 1; + const lastOpenBracket = text.lastIndexOf("[", startFrom); + + if (lastOpenBracket === -1) { + startIndex += match.index + match[0].length; + continue; + } + + label = text.slice(lastOpenBracket, match.index + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1]; + columnStart -= label.length; + } else if (match[0].startsWith("]")) { + columnStart += 2; + } else { + columnStart += 1; + } + + const { + lineOffset, + columnOffset + } = findOffsets(node.value, columnStart); + + const line = node.position.start.line + lineOffset; + + missing.push({ + label, + position: { + start: { + line, + column: columnOffset + }, + end: { + line, + column: columnOffset + label.length + } + } + }); + + startIndex += match.index + match[0].length; + } + + return missing; + +} + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +export default { + meta: { + type: "problem", + + docs: { + recommended: true, + description: "Disallow missing label references." + }, + + messages: { + notFound: "Label reference '{{label}}' not found." + } + }, + + create(context) { + + const { sourceCode } = context; + + return { + text(node) { + + const missingReferences = findMissingReferences(node, sourceCode.text); + + for (const missingReference of missingReferences) { + context.report({ + loc: missingReference.position, + messageId: "notFound", + data: { + label: missingReference.label + } + }); + } + } + }; + } +}; diff --git a/tests/language/markdown-source-code.test.js b/tests/language/markdown-source-code.test.js new file mode 100644 index 00000000..96dc2234 --- /dev/null +++ b/tests/language/markdown-source-code.test.js @@ -0,0 +1,113 @@ +/** + * @fileoverview Tests for MarkdownSourceCode. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import assert from "node:assert"; +import { MarkdownSourceCode } from "../../src/language/markdown-source-code.js"; +import { fromMarkdown } from "mdast-util-from-markdown"; + +//------------------------------------------------------------------------------ +// Helpers +//------------------------------------------------------------------------------ + +const markdownText = `# Hello, world! + +This is a paragraph. + +\`\`\`js +console.log("Hello, world!"); +\`\`\` + +## This is a heading level 2 + +This is *another* paragraph.`; + +const ast = fromMarkdown(markdownText); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +describe("MarkdownSourceCode", () => { + + let sourceCode; + + beforeEach(() => { + sourceCode = new MarkdownSourceCode({ text: markdownText, ast }); + }); + + describe("getText()", () => { + it("should return the text of the Markdown source code", () => { + assert.strictEqual(sourceCode.getText(), markdownText); + }); + + it("should return just the text of the first paragraph", () => { + assert.strictEqual(sourceCode.getText(ast.children[1]), "This is a paragraph."); + }); + + it("should return the text of the code block plus the ## of the following heading", () => { + assert.strictEqual(sourceCode.getText(ast.children[2], 0, 4), "```js\nconsole.log(\"Hello, world!\");\n```\n\n##"); + }); + }); + + describe("getLoc()", () => { + + it("should return the location of a node", () => { + assert.deepStrictEqual(sourceCode.getLoc(ast.children[0]), ast.children[0].position); + }); + + }); + + describe("getRange()", () => { + + it("should return the range of a node", () => { + assert.deepStrictEqual(sourceCode.getRange(ast.children[0]), [ast.children[0].position.start.offset, ast.children[0].position.end.offset]); + }); + + }); + + describe("traverse()", () => { + + it("should traverse the AST", () => { + + const steps = sourceCode.traverse(); + const stepsArray = Array.from(steps).map(step => [step.phase, step.target.type, step.target.value]); + + assert.deepStrictEqual(stepsArray, [ + [1, "root", void 0], + [1, "heading", void 0], + [1, "text", "Hello, world!"], + [2, "text", "Hello, world!"], + [2, "heading", void 0], + [1, "paragraph", void 0], + [1, "text", "This is a paragraph."], + [2, "text", "This is a paragraph."], + [2, "paragraph", void 0], + [1, "code", "console.log(\"Hello, world!\");"], + [2, "code", "console.log(\"Hello, world!\");"], + [1, "heading", void 0], + [1, "text", "This is a heading level 2"], + [2, "text", "This is a heading level 2"], + [2, "heading", void 0], + [1, "paragraph", void 0], + [1, "text", "This is "], + [2, "text", "This is "], + [1, "emphasis", void 0], + [1, "text", "another"], + [2, "text", "another"], + [2, "emphasis", void 0], + [1, "text", " paragraph."], + [2, "text", " paragraph."], + [2, "paragraph", void 0], + [2, "root", void 0] + ]); + }); + + }); + +}); diff --git a/tests/rules/fenced-code-language.test.js b/tests/rules/fenced-code-language.test.js new file mode 100644 index 00000000..184674bb --- /dev/null +++ b/tests/rules/fenced-code-language.test.js @@ -0,0 +1,79 @@ +/** + * @fileoverview Tests for fenced-code-language rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/fenced-code-language.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown + }, + language: "markdown/commonmark" +}); + +ruleTester.run("fenced-code-language", rule, { + valid: [ + `\`\`\`js + console.log("Hello, world!"); + \`\`\``, + `\`\`\`javascript + console.log("Hello, world!"); + \`\`\``, + + // indented code block + ` + console.log("Hello, world!"); + `, + { + code: + `\`\`\`js + console.log("Hello, world!"); + \`\`\``, + options: [{ required: ["js"] }] + } + ], + invalid: [ + { + code: + `\`\`\` + console.log("Hello, world!"); + \`\`\``, + errors: [ + { + messageId: "missingLanguage", + line: 1, + column: 1, + endLine: 3, + endColumn: 20 + } + ] + }, + { + code: + `\`\`\`javascript + console.log("Hello, world!"); + \`\`\``, + options: [{ required: ["js"] }], + errors: [ + { + messageId: "disallowedLanguage", + line: 1, + column: 1, + endLine: 3, + endColumn: 20 + } + ] + } + ] +}); diff --git a/tests/rules/heading-increment.test.js b/tests/rules/heading-increment.test.js new file mode 100644 index 00000000..e35a0c61 --- /dev/null +++ b/tests/rules/heading-increment.test.js @@ -0,0 +1,100 @@ +/** + * @fileoverview Tests for heading-increment rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/heading-increment.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; +import dedent from "dedent"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown + }, + language: "markdown/commonmark" +}); + +ruleTester.run("heading-increment", rule, { + valid: [ + "# Heading 1", + "## Heading 2", + dedent`# Heading 1 + + ## Heading 2`, + dedent`# Heading 1 + + # Heading 2` + ], + invalid: [ + { + code: dedent` + # Heading 1 + + ### Heading 3 + `, + errors: [ + { + messageId: "skippedHeading", + line: 3, + column: 1, + endLine: 3, + endColumn: 14, + data: { + fromLevel: 1, + toLevel: 3 + } + } + ] + }, + { + code: dedent` + ## Heading 2 + + ##### Heading 5 + `, + errors: [ + { + messageId: "skippedHeading", + line: 3, + column: 1, + endLine: 3, + endColumn: 16, + data: { + fromLevel: 2, + toLevel: 5 + } + } + ] + }, + { + code: dedent` + Heading 1 + ========= + + ### Heading 3 + `, + errors: [ + { + messageId: "skippedHeading", + line: 4, + column: 1, + endLine: 4, + endColumn: 14, + data: { + fromLevel: 1, + toLevel: 3 + } + } + ] + } + ] +}); diff --git a/tests/rules/no-duplicate-headings.test.js b/tests/rules/no-duplicate-headings.test.js new file mode 100644 index 00000000..60e328f2 --- /dev/null +++ b/tests/rules/no-duplicate-headings.test.js @@ -0,0 +1,99 @@ +/** + * @fileoverview Tests for no-duplicate-heading rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-duplicate-headings.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown + }, + language: "markdown/commonmark" +}); + +ruleTester.run("no-duplicate-headings", rule, { + valid: [ + `# Heading 1 + + ## Heading 2` + ], + invalid: [ + { + code: ` +# Heading 1 + +# Heading 1 + `, + errors: [ + { + messageId: "duplicateHeading", + line: 4, + column: 1, + endLine: 4, + endColumn: 12 + } + ] + }, + { + code: ` +# Heading 1 + +## Heading 1 + `, + errors: [ + { + messageId: "duplicateHeading", + line: 4, + column: 1, + endLine: 4, + endColumn: 13 + } + ] + }, + { + code: ` +# Heading 1 + +Heading 1 +--------- + `, + errors: [ + { + messageId: "duplicateHeading", + line: 4, + column: 1, + endLine: 5, + endColumn: 10 + } + ] + }, + { + code: ` +# Heading 1 + +Heading 1 +========= + `, + errors: [ + { + messageId: "duplicateHeading", + line: 4, + column: 1, + endLine: 5, + endColumn: 10 + } + ] + } + ] +}); diff --git a/tests/rules/no-empty-links.test.js b/tests/rules/no-empty-links.test.js new file mode 100644 index 00000000..9ea02558 --- /dev/null +++ b/tests/rules/no-empty-links.test.js @@ -0,0 +1,69 @@ +/** + * @fileoverview Tests for no-empty-links rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-empty-links.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown + }, + language: "markdown/commonmark" +}); + +ruleTester.run("no-empty-links", rule, { + valid: [ + "[foo](bar)", + "[foo](#bar)", + "[foo](http://bar.com)" + ], + invalid: [ + { + code: "[foo]()", + errors: [ + { + messageId: "emptyLink", + line: 1, + column: 1, + endLine: 1, + endColumn: 8 + } + ] + }, + { + code: "[foo](#)", + errors: [ + { + messageId: "emptyLink", + line: 1, + column: 1, + endLine: 1, + endColumn: 9 + } + ] + }, + { + code: "[foo]( )", + errors: [ + { + messageId: "emptyLink", + line: 1, + column: 1, + endLine: 1, + endColumn: 9 + } + ] + } + ] +}); diff --git a/tests/rules/no-html.test.js b/tests/rules/no-html.test.js new file mode 100644 index 00000000..69053566 --- /dev/null +++ b/tests/rules/no-html.test.js @@ -0,0 +1,72 @@ +/** + * @fileoverview Tests for no-html rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-html.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; +import dedent from "dedent"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown + }, + language: "markdown/commonmark" +}); + +ruleTester.run("no-html", rule, { + valid: [ + "Hello world!", + " 1 < 5", + "", + dedent`\`\`\`html + Hello world! + \`\`\``, + { + code: "Hello world!", + options: [{ allowed: ["b"] }] + } + ], + invalid: [ + { + code: "Hello world!", + errors: [ + { + messageId: "disallowedElement", + line: 1, + column: 1, + endLine: 1, + endColumn: 4, + data: { + name: "b" + } + } + ] + }, + { + code: "Hello world!", + options: [{ allowed: ["em"] }], + errors: [ + { + messageId: "disallowedElement", + line: 1, + column: 1, + endLine: 1, + endColumn: 4, + data: { + name: "b" + } + } + ] + } + ] +}); diff --git a/tests/rules/no-missing-label-refs.test.js b/tests/rules/no-missing-label-refs.test.js new file mode 100644 index 00000000..82137a06 --- /dev/null +++ b/tests/rules/no-missing-label-refs.test.js @@ -0,0 +1,135 @@ +/** + * @fileoverview Tests for no-missing-label-refs rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-missing-label-refs.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown + }, + language: "markdown/commonmark" +}); + +ruleTester.run("no-missing-label-refs", rule, { + valid: [ + "[*foo*]", + "[foo]\n\n[foo]: http://bar.com", + "[foo][foo]\n\n[foo]: http://bar.com", + "![foo][foo]\n\n[foo]: http://bar.com/image.jpg", + "[foo][]\n\n[foo]: http://bar.com/image.jpg", + "![foo][]\n\n[foo]: http://bar.com/image.jpg" + ], + invalid: [ + { + code: "[foo][bar]", + errors: [ + { + messageId: "notFound", + data: { label: "bar" }, + line: 1, + column: 7, + endLine: 1, + endColumn: 10 + } + ] + }, + { + code: "![foo][bar]", + errors: [ + { + messageId: "notFound", + data: { label: "bar" }, + line: 1, + column: 8, + endLine: 1, + endColumn: 11 + } + ] + }, + { + code: "[foo][]", + errors: [ + { + messageId: "notFound", + data: { label: "foo" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 5 + } + ] + }, + { + code: "![foo][]", + errors: [ + { + messageId: "notFound", + data: { label: "foo" }, + line: 1, + column: 3, + endLine: 1, + endColumn: 6 + } + ] + }, + { + code: "[foo]", + errors: [ + { + messageId: "notFound", + data: { label: "foo" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 5 + } + ] + }, + { + code: "![foo]", + errors: [ + { + messageId: "notFound", + data: { label: "foo" }, + line: 1, + column: 3, + endLine: 1, + endColumn: 6 + } + ] + }, + { + code: "[foo]\n[bar]", + errors: [ + { + messageId: "notFound", + data: { label: "foo" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 5 + }, + { + messageId: "notFound", + data: { label: "bar" }, + line: 2, + column: 2, + endLine: 2, + endColumn: 5 + } + ] + } + ] +}); diff --git a/tools/build-rules.js b/tools/build-rules.js new file mode 100644 index 00000000..0c1d99ca --- /dev/null +++ b/tools/build-rules.js @@ -0,0 +1,53 @@ +/** + * @fileoverview Generates the recommended configuration and import file for rules. + * + * Usage: + * node tools/build-rules.js + * + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +//----------------------------------------------------------------------------- +// Main +//----------------------------------------------------------------------------- + +const thisDir = path.dirname(fileURLToPath(import.meta.url)); +const rulesPath = path.resolve(thisDir, "../src/rules"); +const rules = fs.readdirSync(rulesPath); +const recommended = []; + +for (const ruleId of rules) { + const rulePath = path.resolve(rulesPath, ruleId); + const rule = await import(pathToFileURL(rulePath)); + + if (rule.default.meta.docs.recommended) { + recommended.push(ruleId); + } +} + +const output = `export default { + ${recommended.map(id => `"markdown/${id.slice(0, -3)}": "error"`).join(",\n ")} +}; +`; + +fs.mkdirSync(path.resolve(thisDir, "../src/build"), { recursive: true }); +fs.writeFileSync(path.resolve(thisDir, "../src/build/recommended-config.js"), output); + +console.log("Recommended rules generated successfully."); + +const rulesOutput = `export default { + ${rules.map(id => `"${id.slice(0, -3)}": (await import("../rules/${id}")).default,`).join("\n ")} +}; +`; + +fs.writeFileSync(path.resolve(thisDir, "../src/build/rules.js"), rulesOutput); + +console.log("Rules import file generated successfully."); diff --git a/tools/dedupe-types.js b/tools/dedupe-types.js index ce8b16d1..e6bba030 100644 --- a/tools/dedupe-types.js +++ b/tools/dedupe-types.js @@ -23,21 +23,21 @@ import fs from "node:fs"; const files = process.argv.slice(2); files.forEach(filePath => { - const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/gu); - const typedefs = new Set(); + const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/gu); + const typedefs = new Set(); - const remainingLines = lines.filter(line => { - if (!line.startsWith("/** @typedef {import")) { - return true; - } + const remainingLines = lines.filter(line => { + if (!line.startsWith("/** @typedef {import")) { + return true; + } - if (typedefs.has(line)) { - return false; - } + if (typedefs.has(line)) { + return false; + } - typedefs.add(line); - return true; - }); + typedefs.add(line); + return true; + }); - fs.writeFileSync(filePath, remainingLines.join("\n"), "utf8"); + fs.writeFileSync(filePath, remainingLines.join("\n"), "utf8"); }); From cd13b0cc7a6c6b3bbfc36602836e6159e01777a0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Aug 2024 11:02:03 -0400 Subject: [PATCH 04/20] feat: Add Markdown language --- docs/rules/fenced-code-language.md | 5 +++++ docs/rules/heading-increment.md | 5 +++++ docs/rules/no-duplicate-headings.md | 5 +++++ docs/rules/no-empty-links.md | 4 ++++ docs/rules/no-html.md | 5 +++++ docs/rules/no-missing-label-refs.md | 4 ++++ examples/react/eslint.config.mjs | 2 +- examples/typescript/eslint.config.mjs | 2 +- package.json | 4 ++-- src/language/markdown-source-code.js | 2 +- src/rules/fenced-code-language.js | 10 +++++++++- src/rules/heading-increment.js | 9 ++++++++- src/rules/no-duplicate-headings.js | 7 +++++++ src/rules/no-empty-links.js | 6 ++++++ src/rules/no-html.js | 7 +++++++ src/rules/no-missing-label-refs.js | 7 +++++-- src/types.ts | 2 ++ tests/fixtures/recommended.js | 2 +- tools/build-rules.js | 9 ++++++--- 19 files changed, 84 insertions(+), 13 deletions(-) diff --git a/docs/rules/fenced-code-language.md b/docs/rules/fenced-code-language.md index 258f9919..c8ef46e1 100644 --- a/docs/rules/fenced-code-language.md +++ b/docs/rules/fenced-code-language.md @@ -46,3 +46,8 @@ console.log(message); ## When Not to Use It If you don't mind omitting the language for fenced code blocks, you can safely disable this rule. + +## Prior Art + +* [MD040 - Fenced code blocks should have a language specified](https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md040---fenced-code-blocks-should-have-a-language-specified) +* [MD040 fenced-code-language](https://github.com/DavidAnson/markdownlint/blob/main/doc/md040.md) diff --git a/docs/rules/heading-increment.md b/docs/rules/heading-increment.md index 1cc3eb42..a41ef970 100644 --- a/docs/rules/heading-increment.md +++ b/docs/rules/heading-increment.md @@ -26,3 +26,8 @@ Goodbye World! ## When Not to Use It If you aren't concerned with enforcing heading levels increment by one, you can safely disable this rule. + +## Prior Art + +* [MD001 - Header levels should only increment by one level at a time](https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md001---header-levels-should-only-increment-by-one-level-at-a-time) +* [MD001 - heading-increment](https://github.com/DavidAnson/markdownlint/blob/main/doc/md001.md) diff --git a/docs/rules/no-duplicate-headings.md b/docs/rules/no-duplicate-headings.md index 30a46da0..6d0853eb 100644 --- a/docs/rules/no-duplicate-headings.md +++ b/docs/rules/no-duplicate-headings.md @@ -31,3 +31,8 @@ Goodbye World! ## When Not to Use It If you aren't concerned with autolinking heading or autogenerating a table of contents, you can safely disable this rule. + +## Prior Art + +* [MD024 - Multiple headers with the same content](https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md024---multiple-headers-with-the-same-content) +* [MD024 - no-duplicate-heading](https://github.com/DavidAnson/markdownlint/blob/main/doc/md024.md) diff --git a/docs/rules/no-empty-links.md b/docs/rules/no-empty-links.md index cdf79790..2736727e 100644 --- a/docs/rules/no-empty-links.md +++ b/docs/rules/no-empty-links.md @@ -21,3 +21,7 @@ Examples of incorrect code: ## When Not to Use It If you aren't concerned with empty links, you can safely disable this rule. + +## Prior Art + +* [MD042 - no-empty-links](https://github.com/DavidAnson/markdownlint/blob/main/doc/md042.md) diff --git a/docs/rules/no-html.md b/docs/rules/no-html.md index e0c16423..76b4599c 100644 --- a/docs/rules/no-html.md +++ b/docs/rules/no-html.md @@ -43,3 +43,8 @@ Hello world! ## When Not to Use It If you aren't concerned with empty links, you can safely disable this rule. + +## Prior Art + +* [MD033 - Inline HTML](https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md033---inline-html) +* [MD033 - no-inline-html](https://github.com/DavidAnson/markdownlint/blob/main/doc/md033.md) diff --git a/docs/rules/no-missing-label-refs.md b/docs/rules/no-missing-label-refs.md index bb3c60b9..716f2ac7 100644 --- a/docs/rules/no-missing-label-refs.md +++ b/docs/rules/no-missing-label-refs.md @@ -31,3 +31,7 @@ Examples of incorrect code: ## When Not to Use It If you aren't concerned with missing label references, you can safely disable this rule. + +## Prior Art + +* [MD052 - reference-links-images](https://github.com/DavidAnson/markdownlint/blob/main/doc/md052.md) diff --git a/examples/react/eslint.config.mjs b/examples/react/eslint.config.mjs index 443d3eb2..297f8c77 100644 --- a/examples/react/eslint.config.mjs +++ b/examples/react/eslint.config.mjs @@ -6,7 +6,7 @@ import reactPlugin from "eslint-plugin-react"; export default [ js.configs.recommended, - ...markdown.configs.recommended, + ...markdown.configs.processor, reactPlugin.configs.flat.recommended, { settings: { diff --git a/examples/typescript/eslint.config.mjs b/examples/typescript/eslint.config.mjs index 6a76dfac..c2760dff 100644 --- a/examples/typescript/eslint.config.mjs +++ b/examples/typescript/eslint.config.mjs @@ -4,7 +4,7 @@ import tseslint from "typescript-eslint"; export default tseslint.config( js.configs.recommended, - ...markdown.configs.recommended, + ...markdown.configs.processor, ...tseslint.configs.recommended.map(config => ({ ...config, files: ["**/*.ts"] diff --git a/package.json b/package.json index 5b11d2c7..3bc1c19d 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "lint:fix": "eslint --fix . && eslint --fix -c eslint.config-content.js .", "build:dedupe-types": "node tools/dedupe-types.js dist/esm/index.js", "build:rules": "node tools/build-rules.js", - "build": "build:rules && rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json", + "build": "npm run build:rules && rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json", "prepare": "node ./npm-prepare.cjs", "test": "c8 mocha \"tests/**/*.test.js\" --timeout 30000" }, @@ -49,7 +49,7 @@ "c8": "^10.1.2", "chai": "^5.1.1", "dedent": "^1.5.3", - "eslint": "github:eslint/eslint", + "eslint": "^9.8.0", "eslint-config-eslint": "^11.0.0", "globals": "^15.1.0", "mocha": "^10.6.0", diff --git a/src/language/markdown-source-code.js b/src/language/markdown-source-code.js index 17c80932..010acfba 100644 --- a/src/language/markdown-source-code.js +++ b/src/language/markdown-source-code.js @@ -19,7 +19,7 @@ /** @typedef {import("@eslint/core").TraversalStep} TraversalStep */ /** @typedef {import("@eslint/core").VisitTraversalStep} VisitTraversalStep */ /** @typedef {import("@eslint/core").TextSourceCode} TextSourceCode */ -/** @typedef {import("@eslint/core").ParseResult} ParseResult */ +/** @typedef {import("@eslint/core").ParseResult} ParseResult */ /** @typedef {import("@eslint/core").SourceLocation} SourceLocation */ /** @typedef {import("@eslint/core").SourceRange} SourceRange */ diff --git a/src/rules/fenced-code-language.js b/src/rules/fenced-code-language.js index 21383f13..3e0a9a23 100644 --- a/src/rules/fenced-code-language.js +++ b/src/rules/fenced-code-language.js @@ -3,10 +3,17 @@ * @author Nicholas C. Zakas */ +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- +/** @type {RuleModule} */ export default { meta: { type: "problem", @@ -41,9 +48,10 @@ export default { create(context) { const required = new Set(context.options[0]?.required ?? []); - const { sourceCode } = context + const { sourceCode } = context; return { + code(node) { if (!node.lang) { diff --git a/src/rules/heading-increment.js b/src/rules/heading-increment.js index 804905a5..15f213f4 100644 --- a/src/rules/heading-increment.js +++ b/src/rules/heading-increment.js @@ -3,10 +3,17 @@ * @author Nicholas C. Zakas */ +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- +/** @type {RuleModule} */ export default { meta: { type: "problem", @@ -32,7 +39,7 @@ export default { loc: node.position, messageId: "skippedHeading", data: { - fromLevel: lastHeadingDepth, + fromLevel: lastHeadingDepth.toString(), toLevel: node.depth } }); diff --git a/src/rules/no-duplicate-headings.js b/src/rules/no-duplicate-headings.js index 86381be4..c0230a4e 100644 --- a/src/rules/no-duplicate-headings.js +++ b/src/rules/no-duplicate-headings.js @@ -3,10 +3,17 @@ * @author Nicholas C. Zakas */ +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- +/** @type {RuleModule} */ export default { meta: { type: "problem", diff --git a/src/rules/no-empty-links.js b/src/rules/no-empty-links.js index 949f09e9..2730ad74 100644 --- a/src/rules/no-empty-links.js +++ b/src/rules/no-empty-links.js @@ -2,11 +2,17 @@ * @fileoverview Rule to prevent empty links in Markdown. * @author Nicholas C. Zakas */ +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- +/** @type {RuleModule} */ export default { meta: { type: "problem", diff --git a/src/rules/no-html.js b/src/rules/no-html.js index 810ece3e..05daef9a 100644 --- a/src/rules/no-html.js +++ b/src/rules/no-html.js @@ -3,10 +3,17 @@ * @author Nicholas C. Zakas */ +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- +/** @type {RuleModule} */ export default { meta: { type: "problem", diff --git a/src/rules/no-missing-label-refs.js b/src/rules/no-missing-label-refs.js index c681be25..e7ca523f 100644 --- a/src/rules/no-missing-label-refs.js +++ b/src/rules/no-missing-label-refs.js @@ -9,6 +9,7 @@ /** @typedef {import("unist").Position} Position */ /** @typedef {import("mdast").Text} TextNode */ +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ //----------------------------------------------------------------------------- // Helpers @@ -30,7 +31,7 @@ const labelPatterns = [ * Finds the line and column offsets for a given start offset in a string. * @param {string} text The text to search. * @param {number} startOffset The offset to find. - * @returns {{lineoffset:number,columnOffset:number}} The location of the offset. + * @returns {{lineOffset:number,columnOffset:number}} The location of the offset. */ function findOffsets(text, startOffset) { @@ -75,7 +76,8 @@ function findMissingReferences(node, text) { return value.match(pattern); }, null); - if (!match) { + // check for array instead of null to appease TypeScript + if (!Array.isArray(match)) { break; } @@ -134,6 +136,7 @@ function findMissingReferences(node, text) { // Rule Definition //----------------------------------------------------------------------------- +/** @type {RuleModule} */ export default { meta: { type: "problem", diff --git a/src/types.ts b/src/types.ts index 02638045..757e1371 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,3 +17,5 @@ export interface BlockBase { export interface Block extends Node, BlockBase {} export type Message = Linter.LintMessage; + +export type RuleType = "problem" | "suggestion" | "layout"; diff --git a/tests/fixtures/recommended.js b/tests/fixtures/recommended.js index d04e32b1..fa8c7329 100644 --- a/tests/fixtures/recommended.js +++ b/tests/fixtures/recommended.js @@ -3,7 +3,7 @@ import js from "@eslint/js"; export default [ js.configs.recommended, - ...markdown.configs.recommended, + ...markdown.configs.processor, { "rules": { "no-console": "error" diff --git a/tools/build-rules.js b/tools/build-rules.js index 0c1d99ca..82464ba5 100644 --- a/tools/build-rules.js +++ b/tools/build-rules.js @@ -43,10 +43,13 @@ fs.writeFileSync(path.resolve(thisDir, "../src/build/recommended-config.js"), ou console.log("Recommended rules generated successfully."); -const rulesOutput = `export default { - ${rules.map(id => `"${id.slice(0, -3)}": (await import("../rules/${id}")).default,`).join("\n ")} +const rulesOutput = ` +${rules.map((id, index) => `import rule${index} from "../rules/${id}";`).join("\n")} + +export default { + ${rules.map((id, index) => `"${id.slice(0, -3)}": rule${index},`).join("\n ")} }; -`; +`.trim(); fs.writeFileSync(path.resolve(thisDir, "../src/build/rules.js"), rulesOutput); From 99f8b7cc1edbe922e39dde8a4b5d2342ac4157c0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 6 Aug 2024 11:25:04 -0400 Subject: [PATCH 05/20] Update prepare script --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3bc1c19d..8ee55345 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "build:dedupe-types": "node tools/dedupe-types.js dist/esm/index.js", "build:rules": "node tools/build-rules.js", "build": "npm run build:rules && rollup -c && npm run build:dedupe-types && tsc -p tsconfig.esm.json", - "prepare": "node ./npm-prepare.cjs", + "prepare": "node ./npm-prepare.cjs && npm run build", "test": "c8 mocha \"tests/**/*.test.js\" --timeout 30000" }, "devDependencies": { From c332c6bee5d5a55abdb2debbcad963dc598ebaca Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:11:57 -0400 Subject: [PATCH 06/20] Update src/language/markdown-source-code.js Co-authored-by: Francesco Trotta --- src/language/markdown-source-code.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/language/markdown-source-code.js b/src/language/markdown-source-code.js index 010acfba..c85dc931 100644 --- a/src/language/markdown-source-code.js +++ b/src/language/markdown-source-code.js @@ -275,6 +275,6 @@ export class MarkdownSourceCode { visit(this.ast); - return steps; + return steps.values(); } } From 59918a00276cbbdacdc194ecbde8b4e549f84681 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:12:21 -0400 Subject: [PATCH 07/20] Update src/rules/fenced-code-language.js Co-authored-by: Francesco Trotta --- src/rules/fenced-code-language.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/fenced-code-language.js b/src/rules/fenced-code-language.js index 3e0a9a23..ccc37d39 100644 --- a/src/rules/fenced-code-language.js +++ b/src/rules/fenced-code-language.js @@ -47,7 +47,7 @@ export default { create(context) { - const required = new Set(context.options[0]?.required ?? []); + const required = new Set(context.options[0]?.required); const { sourceCode } = context; return { From c7530f12f7b22b0661e5faa71f3539150e4334a9 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:12:31 -0400 Subject: [PATCH 08/20] Update src/rules/no-html.js Co-authored-by: Francesco Trotta --- src/rules/no-html.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/no-html.js b/src/rules/no-html.js index 05daef9a..9e2cb994 100644 --- a/src/rules/no-html.js +++ b/src/rules/no-html.js @@ -45,7 +45,7 @@ export default { create(context) { - const allowed = new Set(context.options[0]?.allowed ?? []); + const allowed = new Set(context.options[0]?.allowed); return { html(node) { From 35a516fd8a41bfb0933a5d390e48fcf71fcb888f Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:14:32 -0400 Subject: [PATCH 09/20] Update docs/processors/markdown.md Co-authored-by: Francesco Trotta --- docs/processors/markdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/processors/markdown.md b/docs/processors/markdown.md index cdd50994..2d55da66 100644 --- a/docs/processors/markdown.md +++ b/docs/processors/markdown.md @@ -70,7 +70,7 @@ The virtual filename's extension will match the fenced code block's syntax tag, For example, ```` ```js ```` code blocks in `README.md` would match `README.md/*.js` and ```` ```typescript ```` in `CONTRIBUTING.md` would match `CONTRIBUTING.md/*.ts`. You can use glob patterns for these virtual filenames to customize configuration for code blocks without affecting regular code. -For more information on configuring processors, refer to the [ESLint documentation](https://eslint.org/docs/user-guide/configuring#specifying-processor). +For more information on configuring processors, refer to the [ESLint documentation](https://eslint.org/docs/latest/use/configure/plugins#specify-a-processor). Here's an example: From 5281a3f7064d37eaa9fb2e970e07db404ecd5c1f Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:14:45 -0400 Subject: [PATCH 10/20] Update docs/processors/markdown.md Co-authored-by: Francesco Trotta --- docs/processors/markdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/processors/markdown.md b/docs/processors/markdown.md index 2d55da66..4bc9a480 100644 --- a/docs/processors/markdown.md +++ b/docs/processors/markdown.md @@ -152,7 +152,7 @@ Here are some other things to keep in mind when linting code blocks. ### Strict Mode `"use strict"` directives in every code block would be annoying. -The `markdown.configs.processor` config enables the [`impliedStrict` parser option](https://eslint.org/docs/user-guide/configuring#specifying-parser-options) and disables the [`strict` rule](https://eslint.org/docs/rules/strict) in Markdown files. +The `markdown.configs.processor` config enables the [`impliedStrict` parser option](https://eslint.org/docs/latest/use/configure/parser#configure-parser-options) and disables the [`strict` rule](https://eslint.org/docs/rules/strict) in Markdown files. This opts into strict mode parsing without repeated `"use strict"` directives. ### Unsatisfiable Rules From 8d03f484bac9fb8d3773322861d65179091727a3 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:14:56 -0400 Subject: [PATCH 11/20] Update docs/processors/markdown.md Co-authored-by: Francesco Trotta --- docs/processors/markdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/processors/markdown.md b/docs/processors/markdown.md index 4bc9a480..6a51b10f 100644 --- a/docs/processors/markdown.md +++ b/docs/processors/markdown.md @@ -165,7 +165,7 @@ The `markdown.configs.processor` config disables these rules in Markdown files: ### Autofixing -With this plugin, [ESLint's `--fix` option](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can automatically fix some issues in your Markdown fenced code blocks. +With this plugin, [ESLint's `--fix` option](https://eslint.org/docs/latest/use/command-line-interface#fix-problems) can automatically fix some issues in your Markdown fenced code blocks. To enable this, pass the `--fix` flag when you run ESLint: ```bash From d7f96ccaa04500352126e692efaf878722ca9976 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:29:54 -0400 Subject: [PATCH 12/20] Fix link --- docs/processors/markdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/processors/markdown.md b/docs/processors/markdown.md index 6a51b10f..3f97eb9b 100644 --- a/docs/processors/markdown.md +++ b/docs/processors/markdown.md @@ -1,6 +1,6 @@ # Using the Markdown processor -With this processor, ESLint will lint [fenced code blocks](https://help.github.com/articles/github-flavored-markdown/#fenced-code-blocks) in your Markdown documents. This processor uses [CommonMark](https://commonmark.org) format to evaluate the Markdown code, but this shouldn't matter because all Markdown dialects use the same format for code blocks. Here are some examples: +With this processor, ESLint will lint [fenced code blocks](https://www.markdownguide.org/extended-syntax/#fenced-code-blocks) in your Markdown documents. This processor uses [CommonMark](https://commonmark.org) format to evaluate the Markdown code, but this shouldn't matter because all Markdown dialects use the same format for code blocks. Here are some examples: ````markdown ```js From c618611be9aa190828ae7dd502f5511e16143ffa Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:30:01 -0400 Subject: [PATCH 13/20] fix no-html --- src/rules/no-html.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rules/no-html.js b/src/rules/no-html.js index 9e2cb994..f6f0c333 100644 --- a/src/rules/no-html.js +++ b/src/rules/no-html.js @@ -60,7 +60,7 @@ export default { return; } - const tagName = node.value.match(/<([a-zA-Z0-9]+)/u)?.[1]; + const tagName = node.value.match(/<([a-z0-9]+(?:-[a-z0-9]+)*)/ui)?.[1]; if (allowed.size === 0 || !allowed.has(tagName)) { context.report({ From 47d94e75dcfb2674853c2b049c401cbd43f9eed0 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Mon, 12 Aug 2024 11:56:23 -0400 Subject: [PATCH 14/20] Fix no-missing-label-refs --- src/rules/no-missing-label-refs.js | 31 ++++++++++++++++++----- tests/rules/no-html.test.js | 20 +++++++++++++++ tests/rules/no-missing-label-refs.test.js | 5 +++- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/rules/no-missing-label-refs.js b/src/rules/no-missing-label-refs.js index e7ca523f..efe2b9cc 100644 --- a/src/rules/no-missing-label-refs.js +++ b/src/rules/no-missing-label-refs.js @@ -21,12 +21,14 @@ const labelPatterns = [ /\]\[([^\]]+)\]/u, // [foo][] - /(\]\[\])/u, + /(\]\[\s*\])/u, // [foo] /\[([^\]]+)\]/u ]; +const shorthandTailPattern = /\]\[\s*\]$/u; + /** * Finds the line and column offsets for a given start offset in a string. * @param {string} text The text to search. @@ -85,7 +87,7 @@ function findMissingReferences(node, text) { let columnStart = startIndex + match.index + 1; // need to look backward to get the label - if (match[0] === "][]") { + if (shorthandTailPattern.test(match[0])) { // adding 1 to the index just in case we're in a ![] and need to skip the !. const startFrom = node.position.start.offset + startIndex + 1; @@ -112,7 +114,7 @@ function findMissingReferences(node, text) { const line = node.position.start.line + lineOffset; missing.push({ - label, + label: label.trim(), position: { start: { line, @@ -154,13 +156,13 @@ export default { create(context) { const { sourceCode } = context; + let allMissingReferences = []; return { - text(node) { - const missingReferences = findMissingReferences(node, sourceCode.text); + "root:exit"() { - for (const missingReference of missingReferences) { + for (const missingReference of allMissingReferences) { context.report({ loc: missingReference.position, messageId: "notFound", @@ -169,6 +171,23 @@ export default { } }); } + + }, + + text(node) { + allMissingReferences.push(...findMissingReferences(node, sourceCode.text)); + }, + + definition(node) { + + /* + * Sometimes a poorly-formatted link will end up a text node instead of a link node + * even though the label definition exists. Here, we remove any missing references + * that have a matching label definition. + */ + allMissingReferences = allMissingReferences.filter( + missingReference => missingReference.label !== node.identifier + ); } }; } diff --git a/tests/rules/no-html.test.js b/tests/rules/no-html.test.js index 69053566..b3269004 100644 --- a/tests/rules/no-html.test.js +++ b/tests/rules/no-html.test.js @@ -34,6 +34,10 @@ ruleTester.run("no-html", rule, { { code: "Hello world!", options: [{ allowed: ["b"] }] + }, + { + code: "Hello world!", + options: [{ allowed: ["custom-element"] }] } ], invalid: [ @@ -67,6 +71,22 @@ ruleTester.run("no-html", rule, { } } ] + }, + { + code: "Hello world!", + options: [{ allowed: ["em"] }], + errors: [ + { + messageId: "disallowedElement", + line: 1, + column: 1, + endLine: 1, + endColumn: 17, + data: { + name: "custom-element" + } + } + ] } ] }); diff --git a/tests/rules/no-missing-label-refs.test.js b/tests/rules/no-missing-label-refs.test.js index 82137a06..3f11030c 100644 --- a/tests/rules/no-missing-label-refs.test.js +++ b/tests/rules/no-missing-label-refs.test.js @@ -29,7 +29,10 @@ ruleTester.run("no-missing-label-refs", rule, { "[foo][foo]\n\n[foo]: http://bar.com", "![foo][foo]\n\n[foo]: http://bar.com/image.jpg", "[foo][]\n\n[foo]: http://bar.com/image.jpg", - "![foo][]\n\n[foo]: http://bar.com/image.jpg" + "![foo][]\n\n[foo]: http://bar.com/image.jpg", + "[ foo ][]\n\n[foo]: http://bar.com/image.jpg", + "[foo][ ]\n\n[foo]: http://bar.com/image.jpg", + "[\nfoo\n][\n]\n\n[foo]: http://bar.com/image.jpg" ], invalid: [ { From 21b5226f3c4601d0e8b0e1f0fe2d0b35d9ba0eb8 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 13 Aug 2024 12:07:31 -0400 Subject: [PATCH 15/20] Add no-invalid-label-ref; add util --- README.md | 1 + docs/rules/no-invalid-label-refs.md | 37 ++++++ src/rules/no-invalid-label-refs.js | 140 ++++++++++++++++++++++ src/rules/no-missing-label-refs.js | 42 +++---- src/util.js | 38 ++++++ tests/rules/no-invalid-label-refs.test.js | 63 ++++++++++ tests/rules/no-missing-label-refs.test.js | 2 + 7 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 docs/rules/no-invalid-label-refs.md create mode 100644 src/rules/no-invalid-label-refs.js create mode 100644 src/util.js create mode 100644 tests/rules/no-invalid-label-refs.test.js diff --git a/README.md b/README.md index ab3c063f..4e08f923 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ export default [ | [`no-duplicate-headings`](./docs/rules/no-duplicate-headings.md) | Disallow duplicate headings in the same document. | | [`no-empty-links`](./docs/rules/no-empty-links.md) | Disallow empty links. | | [`no-html`](./docs/rules/no-html.md) | Enforce fenced code blocks to specify a language. | +| [`no-invalid-label-refs`](./docs/rules/no-invalid-label-refs.md) | Disallow invalid label references. | | [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references. | **Note:** This plugin does not provide formatting rules. We recommend using a source code formatter such as [Prettier](https://prettier.io) for that purpose. diff --git a/docs/rules/no-invalid-label-refs.md b/docs/rules/no-invalid-label-refs.md new file mode 100644 index 00000000..454530e7 --- /dev/null +++ b/docs/rules/no-invalid-label-refs.md @@ -0,0 +1,37 @@ +# no-invalid-label-refs + +Disallow invalid label references. + +## Background + +CommonMark allows you to specify a label as a placeholder for a URL in both links and images using square brackets, such as: + +```markdown +[ESLint][eslint] +[eslint][] +[eslint] + +[eslint]: https://eslint.org +``` + +The shorthand form, `[label][]` does not allow any white space between the brackets, and when found, doesn't treat this as a link reference. + +Confusingly, GitHub still treats this as a label reference and will render it as if there is no white space between the brackets. Relying on this behavior could result in errors when using CommonMark-compliant renderers. + +## Rule Details + +This rule warns when it finds text that looks like it's a shorthand label reference and there's white space between the brackets. + +Examples of incorrect code: + +```markdown +[eslint][ ] + +[eslint][ + +] +``` + +## When Not to Use It + +If you publish your Markdown exclusively on GitHub, then you can safely disable this rule. diff --git a/src/rules/no-invalid-label-refs.js b/src/rules/no-invalid-label-refs.js new file mode 100644 index 00000000..50ad3abf --- /dev/null +++ b/src/rules/no-invalid-label-refs.js @@ -0,0 +1,140 @@ +/** + * @fileoverview Rule to prevent non-complaint link references. + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { findOffsets, illegalShorthandTailPattern } from "../util.js"; + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** @typedef {import("unist").Position} Position */ +/** @typedef {import("mdast").Text} TextNode */ +/** @typedef {import("eslint").Rule.RuleModule} RuleModule */ + +//----------------------------------------------------------------------------- +// Helpers +//----------------------------------------------------------------------------- + +// matches i.e., [foo][bar] +const labelPattern = /\]\[([^\]]+)\]/u; + + +/** + * Finds missing references in a node. + * @param {TextNode} node The node to check. + * @param {string} text The text of the node. + * @returns {Array<{label:string,position:Position}>} The missing references. + */ +function findIllegalLabelReferences(node, text) { + + const invalid = []; + let startIndex = 0; + + while (startIndex < node.value.length) { + + const value = node.value.slice(startIndex); + const match = value.match(labelPattern); + + if (!match) { + break; + } + + if (!illegalShorthandTailPattern.test(match[0])) { + continue; + } + + let columnStart = startIndex + match.index + 1; + + // adding 1 to the index just in case we're in a ![] and need to skip the !. + const startFrom = node.position.start.offset + startIndex + 1; + const lastOpenBracket = text.lastIndexOf("[", startFrom); + + if (lastOpenBracket === -1) { + startIndex += match.index + match[0].length; + continue; + } + + const label = text.slice(lastOpenBracket, match.index + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1]; + + columnStart -= label.length; + + const { + lineOffset, + columnOffset + } = findOffsets(node.value, columnStart); + + const line = node.position.start.line + lineOffset; + + /* + * If the columnOffset is 0, then the column is at the start of the line. + * In that case, we need to adjust the column number to be 1. + */ + invalid.push({ + label: label.trim(), + position: { + start: { + line, + column: columnOffset || 1 + }, + end: { + line, + column: columnOffset + label.length + } + } + }); + + startIndex += match.index + match[0].length; + } + + return invalid; + +} + +//----------------------------------------------------------------------------- +// Rule Definition +//----------------------------------------------------------------------------- + +/** @type {RuleModule} */ +export default { + meta: { + type: "problem", + + docs: { + recommended: true, + description: "Disallow invalid label references." + }, + + messages: { + illegalLabelRef: "Label reference '{{label}}' is invalid due to white space between [ and ]." + } + }, + + create(context) { + + const { sourceCode } = context; + + return { + + text(node) { + const invalidReferences = findIllegalLabelReferences(node, sourceCode.text); + + for (const invalidReference of invalidReferences) { + context.report({ + loc: invalidReference.position, + messageId: "illegalLabelRef", + data: { + label: invalidReference.label + } + }); + } + } + + }; + } +}; diff --git a/src/rules/no-missing-label-refs.js b/src/rules/no-missing-label-refs.js index efe2b9cc..cd99341b 100644 --- a/src/rules/no-missing-label-refs.js +++ b/src/rules/no-missing-label-refs.js @@ -3,6 +3,12 @@ * @author Nicholas C. Zakas */ +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { findOffsets, illegalShorthandTailPattern } from "../util.js"; + //----------------------------------------------------------------------------- // Type Definitions //----------------------------------------------------------------------------- @@ -21,39 +27,13 @@ const labelPatterns = [ /\]\[([^\]]+)\]/u, // [foo][] - /(\]\[\s*\])/u, + /(\]\[\])/u, // [foo] /\[([^\]]+)\]/u ]; -const shorthandTailPattern = /\]\[\s*\]$/u; - -/** - * Finds the line and column offsets for a given start offset in a string. - * @param {string} text The text to search. - * @param {number} startOffset The offset to find. - * @returns {{lineOffset:number,columnOffset:number}} The location of the offset. - */ -function findOffsets(text, startOffset) { - - let lineOffset = 0; - let columnOffset = 0; - - for (let i = 0; i < startOffset; i++) { - if (text[i] === "\n") { - lineOffset++; - columnOffset = 0; - } else { - columnOffset++; - } - } - - return { - lineOffset, - columnOffset - }; -} +const shorthandTailPattern = /\]\[\]$/u; /** * Finds missing references in a node. @@ -86,6 +66,12 @@ function findMissingReferences(node, text) { let label = match[1]; let columnStart = startIndex + match.index + 1; + // check for illegal shorthand tail + if (illegalShorthandTailPattern.test(match[0])) { + startIndex += match.index + match[0].length; + continue; + } + // need to look backward to get the label if (shorthandTailPattern.test(match[0])) { diff --git a/src/util.js b/src/util.js new file mode 100644 index 00000000..50bcfcc0 --- /dev/null +++ b/src/util.js @@ -0,0 +1,38 @@ +/** + * @fileoverview Utility Library + * @author Nicholas C. Zakas + */ + + +/* + * CommonMark does not allow any white space between the brackets in a reference link. + * If that pattern is detected, then it's treated as text and not as a link. This pattern + * is used to detect that situation. + */ +export const illegalShorthandTailPattern = /\]\[\s+\]$/u; + +/** + * Finds the line and column offsets for a given start offset in a string. + * @param {string} text The text to search. + * @param {number} startOffset The offset to find. + * @returns {{lineOffset:number,columnOffset:number}} The location of the offset. + */ +export function findOffsets(text, startOffset) { + + let lineOffset = 0; + let columnOffset = 0; + + for (let i = 0; i < startOffset; i++) { + if (text[i] === "\n") { + lineOffset++; + columnOffset = 0; + } else { + columnOffset++; + } + } + + return { + lineOffset, + columnOffset + }; +} diff --git a/tests/rules/no-invalid-label-refs.test.js b/tests/rules/no-invalid-label-refs.test.js new file mode 100644 index 00000000..e3e6647c --- /dev/null +++ b/tests/rules/no-invalid-label-refs.test.js @@ -0,0 +1,63 @@ +/** + * @fileoverview Tests for no-invalid-label-refs rule. + * @author Nicholas C. Zakas + */ + +//------------------------------------------------------------------------------ +// Imports +//------------------------------------------------------------------------------ + +import rule from "../../src/rules/no-invalid-label-refs.js"; +import markdown from "../../src/index.js"; +import { RuleTester } from "eslint"; + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + plugins: { + markdown + }, + language: "markdown/commonmark" +}); + +ruleTester.run("no-invalid-label-refs", rule, { + valid: [ + "[*foo*]", + "[foo]\n\n[foo]: http://bar.com", + "[foo][ foo ]\n\n[foo]: http://bar.com", + "![foo][foo]\n\n[foo]: http://bar.com/image.jpg", + "[foo][]\n\n[foo]: http://bar.com/image.jpg", + "![foo][]\n\n[foo]: http://bar.com/image.jpg", + "[ foo ][]\n\n[foo]: http://bar.com/image.jpg", + ], + invalid: [ + { + code: "[foo][ ]\n\n[foo]: http://bar.com/image.jpg", + errors: [ + { + messageId: "illegalLabelRef", + data: { label: "foo" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 5 + } + ] + }, + { + code: "[\nfoo\n][\n]\n\n[foo]: http://bar.com/image.jpg", + errors: [ + { + messageId: "illegalLabelRef", + data: { label: "foo" }, + line: 2, + column: 1, + endLine: 2, + endColumn: 5 + } + ] + } + ] +}); diff --git a/tests/rules/no-missing-label-refs.test.js b/tests/rules/no-missing-label-refs.test.js index 3f11030c..8c114b08 100644 --- a/tests/rules/no-missing-label-refs.test.js +++ b/tests/rules/no-missing-label-refs.test.js @@ -27,6 +27,8 @@ ruleTester.run("no-missing-label-refs", rule, { "[*foo*]", "[foo]\n\n[foo]: http://bar.com", "[foo][foo]\n\n[foo]: http://bar.com", + "[foo][foo]\n\n[ foo ]: http://bar.com", + "[foo][ foo ]\n\n[ foo ]: http://bar.com", "![foo][foo]\n\n[foo]: http://bar.com/image.jpg", "[foo][]\n\n[foo]: http://bar.com/image.jpg", "![foo][]\n\n[foo]: http://bar.com/image.jpg", From 7b6e77087b08a0fbfe0d389e7b2e1f153bd95f47 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 15 Aug 2024 13:50:13 -0400 Subject: [PATCH 16/20] Fix linting error --- tests/rules/no-invalid-label-refs.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/rules/no-invalid-label-refs.test.js b/tests/rules/no-invalid-label-refs.test.js index e3e6647c..1b417d5e 100644 --- a/tests/rules/no-invalid-label-refs.test.js +++ b/tests/rules/no-invalid-label-refs.test.js @@ -30,7 +30,7 @@ ruleTester.run("no-invalid-label-refs", rule, { "![foo][foo]\n\n[foo]: http://bar.com/image.jpg", "[foo][]\n\n[foo]: http://bar.com/image.jpg", "![foo][]\n\n[foo]: http://bar.com/image.jpg", - "[ foo ][]\n\n[foo]: http://bar.com/image.jpg", + "[ foo ][]\n\n[foo]: http://bar.com/image.jpg" ], invalid: [ { From 48cfd9a0d8929caab5de2f89ee8b46a6bccab8c3 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Thu, 15 Aug 2024 14:03:09 -0400 Subject: [PATCH 17/20] Fix no-invalid-label-ref --- src/rules/no-invalid-label-refs.js | 10 ++++----- tests/rules/no-invalid-label-refs.test.js | 27 ++++++++++++++++++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/rules/no-invalid-label-refs.js b/src/rules/no-invalid-label-refs.js index 50ad3abf..6022acfa 100644 --- a/src/rules/no-invalid-label-refs.js +++ b/src/rules/no-invalid-label-refs.js @@ -31,7 +31,7 @@ const labelPattern = /\]\[([^\]]+)\]/u; * @param {string} text The text of the node. * @returns {Array<{label:string,position:Position}>} The missing references. */ -function findIllegalLabelReferences(node, text) { +function findInvalidLabelReferences(node, text) { const invalid = []; let startIndex = 0; @@ -60,7 +60,7 @@ function findIllegalLabelReferences(node, text) { continue; } - const label = text.slice(lastOpenBracket, match.index + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1]; + const label = text.slice(lastOpenBracket, columnStart + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1]; columnStart -= label.length; @@ -111,7 +111,7 @@ export default { }, messages: { - illegalLabelRef: "Label reference '{{label}}' is invalid due to white space between [ and ]." + invalidLabelRef: "Label reference '{{label}}' is invalid due to white space between [ and ]." } }, @@ -122,12 +122,12 @@ export default { return { text(node) { - const invalidReferences = findIllegalLabelReferences(node, sourceCode.text); + const invalidReferences = findInvalidLabelReferences(node, sourceCode.text); for (const invalidReference of invalidReferences) { context.report({ loc: invalidReference.position, - messageId: "illegalLabelRef", + messageId: "invalidLabelRef", data: { label: invalidReference.label } diff --git a/tests/rules/no-invalid-label-refs.test.js b/tests/rules/no-invalid-label-refs.test.js index 1b417d5e..f463b506 100644 --- a/tests/rules/no-invalid-label-refs.test.js +++ b/tests/rules/no-invalid-label-refs.test.js @@ -37,7 +37,7 @@ ruleTester.run("no-invalid-label-refs", rule, { code: "[foo][ ]\n\n[foo]: http://bar.com/image.jpg", errors: [ { - messageId: "illegalLabelRef", + messageId: "invalidLabelRef", data: { label: "foo" }, line: 1, column: 2, @@ -50,7 +50,7 @@ ruleTester.run("no-invalid-label-refs", rule, { code: "[\nfoo\n][\n]\n\n[foo]: http://bar.com/image.jpg", errors: [ { - messageId: "illegalLabelRef", + messageId: "invalidLabelRef", data: { label: "foo" }, line: 2, column: 1, @@ -58,6 +58,27 @@ ruleTester.run("no-invalid-label-refs", rule, { endColumn: 5 } ] - } + }, + { + code: "[foo][ ]\n[bar][ ]\n\n[foo]: http://foo.com\n[bar]: http://bar.com", + errors: [ + { + messageId: "invalidLabelRef", + data: { label: "foo" }, + line: 1, + column: 2, + endLine: 1, + endColumn: 5 + }, + { + messageId: "invalidLabelRef", + data: { label: "bar" }, + line: 2, + column: 2, + endLine: 2, + endColumn: 5 + } + ] + }, ] }); From 548fdab98f5dc187d0bafad5ed70a40ecdd117dc Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Fri, 16 Aug 2024 15:16:32 -0400 Subject: [PATCH 18/20] Fix bugs in no-invalid-label-refs --- src/rules/no-invalid-label-refs.js | 70 ++++++++++++++++------- src/util.js | 8 +-- tests/rules/no-invalid-label-refs.test.js | 65 ++++++++++++++++++--- 3 files changed, 108 insertions(+), 35 deletions(-) diff --git a/src/rules/no-invalid-label-refs.js b/src/rules/no-invalid-label-refs.js index 6022acfa..3ea1e6a5 100644 --- a/src/rules/no-invalid-label-refs.js +++ b/src/rules/no-invalid-label-refs.js @@ -28,14 +28,24 @@ const labelPattern = /\]\[([^\]]+)\]/u; /** * Finds missing references in a node. * @param {TextNode} node The node to check. - * @param {string} text The text of the node. + * @param {string} docText The text of the node. * @returns {Array<{label:string,position:Position}>} The missing references. */ -function findInvalidLabelReferences(node, text) { +function findInvalidLabelReferences(node, docText) { const invalid = []; let startIndex = 0; - + const offset = node.position.start.offset; + const nodeStartLine = node.position.start.line; + const nodeStartColumn = node.position.start.column; + + /* + * This loop works by searching the string inside the node for the next + * label reference. If it finds one, it checks to see if there is any + * white space between the [ and ]. If there is, it reports an error. + * It then moves the start index to the end of the label reference and + * continues searching the text until the end of the text is found. + */ while (startIndex < node.value.length) { const value = node.value.slice(startIndex); @@ -46,45 +56,61 @@ function findInvalidLabelReferences(node, text) { } if (!illegalShorthandTailPattern.test(match[0])) { + startIndex += match.index + match[0].length; continue; } - let columnStart = startIndex + match.index + 1; + /* + * Calculate the match index relative to just the node and + * to the entire document text. + */ + const nodeMatchIndex = startIndex + match.index; + const docMatchIndex = offset + nodeMatchIndex; - // adding 1 to the index just in case we're in a ![] and need to skip the !. - const startFrom = node.position.start.offset + startIndex + 1; - const lastOpenBracket = text.lastIndexOf("[", startFrom); + /* + * Search the entire document text to find the preceding open bracket. + * We add one to the start index to account for a preceding ! in an image. + */ + const lastOpenBracketIndex = docText.lastIndexOf("[", docMatchIndex); - if (lastOpenBracket === -1) { + if (lastOpenBracketIndex === -1) { startIndex += match.index + match[0].length; continue; } - const label = text.slice(lastOpenBracket, columnStart + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1]; + /* + * Note: `label` can contain leading and trailing newlines, so we need to + * take that into account when calculating the line and column offsets. + */ + const label = docText.slice(lastOpenBracketIndex, docMatchIndex + match[0].length).match(/!?\[([^\]]+)\]/u)[1]; - columnStart -= label.length; + // find location of [ in the document text + const { + lineOffset: startLineOffset, + columnOffset: startColumnOffset + } = findOffsets(node.value, nodeMatchIndex + 1); + // find location of [ in the document text const { - lineOffset, - columnOffset - } = findOffsets(node.value, columnStart); + lineOffset: endLineOffset, + columnOffset: endColumnOffset + } = findOffsets(node.value, nodeMatchIndex + match[0].length); - const line = node.position.start.line + lineOffset; + const startLine = nodeStartLine + startLineOffset; + const startColumn = nodeStartColumn + startColumnOffset; + const endLine = nodeStartLine + endLineOffset; + const endColumn = (endLine === startLine ? nodeStartColumn : 0) + endColumnOffset; - /* - * If the columnOffset is 0, then the column is at the start of the line. - * In that case, we need to adjust the column number to be 1. - */ invalid.push({ label: label.trim(), position: { start: { - line, - column: columnOffset || 1 + line: startLine, + column: startColumn }, end: { - line, - column: columnOffset + label.length + line: endLine, + column: endColumn } } }); diff --git a/src/util.js b/src/util.js index 50bcfcc0..0d9007f0 100644 --- a/src/util.js +++ b/src/util.js @@ -12,17 +12,17 @@ export const illegalShorthandTailPattern = /\]\[\s+\]$/u; /** - * Finds the line and column offsets for a given start offset in a string. + * Finds the line and column offsets for a given offset in a string. * @param {string} text The text to search. - * @param {number} startOffset The offset to find. + * @param {number} offset The offset to find. * @returns {{lineOffset:number,columnOffset:number}} The location of the offset. */ -export function findOffsets(text, startOffset) { +export function findOffsets(text, offset) { let lineOffset = 0; let columnOffset = 0; - for (let i = 0; i < startOffset; i++) { + for (let i = 0; i < offset; i++) { if (text[i] === "\n") { lineOffset++; columnOffset = 0; diff --git a/tests/rules/no-invalid-label-refs.test.js b/tests/rules/no-invalid-label-refs.test.js index f463b506..ea3e289a 100644 --- a/tests/rules/no-invalid-label-refs.test.js +++ b/tests/rules/no-invalid-label-refs.test.js @@ -40,9 +40,22 @@ ruleTester.run("no-invalid-label-refs", rule, { messageId: "invalidLabelRef", data: { label: "foo" }, line: 1, - column: 2, + column: 6, + endLine: 1, + endColumn: 9 + } + ] + }, + { + code: "![foo][ ]\n\n[foo]: http://bar.com/image.jpg", + errors: [ + { + messageId: "invalidLabelRef", + data: { label: "foo" }, + line: 1, + column: 7, endLine: 1, - endColumn: 5 + endColumn: 10 } ] }, @@ -52,33 +65,67 @@ ruleTester.run("no-invalid-label-refs", rule, { { messageId: "invalidLabelRef", data: { label: "foo" }, + line: 3, + column: 2, + endLine: 4, + endColumn: 1 + } + ] + }, + { + code: "[foo][ ]\n[bar][ ]\n\n[foo]: http://foo.com\n[bar]: http://bar.com", + errors: [ + { + messageId: "invalidLabelRef", + data: { label: "foo" }, + line: 1, + column: 6, + endLine: 1, + endColumn: 9 + }, + { + messageId: "invalidLabelRef", + data: { label: "bar" }, line: 2, - column: 1, + column: 6, endLine: 2, - endColumn: 5 + endColumn: 9 } ] }, { - code: "[foo][ ]\n[bar][ ]\n\n[foo]: http://foo.com\n[bar]: http://bar.com", + code: "[foo][ ]\n![bar][ ]\n\n[foo]: http://foo.com\n[bar]: http://bar.com", errors: [ { messageId: "invalidLabelRef", data: { label: "foo" }, line: 1, - column: 2, + column: 6, endLine: 1, - endColumn: 5 + endColumn: 9 }, { messageId: "invalidLabelRef", data: { label: "bar" }, line: 2, - column: 2, + column: 7, endLine: 2, - endColumn: 5 + endColumn: 10 } ] }, + { + code: "- - - [foo][ ]\n\n[foo]: http://foo.com", + errors: [ + { + messageId: "invalidLabelRef", + data: { label: "foo" }, + line: 1, + column: 12, + endLine: 1, + endColumn: 15 + } + ] + } ] }); From 5b0c2cce390733c1c865e5937066159e9b3f9176 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Tue, 20 Aug 2024 11:28:39 -0400 Subject: [PATCH 19/20] fix no-missing-label-refs locations --- src/rules/no-missing-label-refs.js | 46 ++++++++++++++--------- tests/rules/no-missing-label-refs.test.js | 13 +++++++ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/rules/no-missing-label-refs.js b/src/rules/no-missing-label-refs.js index cd99341b..d899ca0a 100644 --- a/src/rules/no-missing-label-refs.js +++ b/src/rules/no-missing-label-refs.js @@ -38,14 +38,23 @@ const shorthandTailPattern = /\]\[\]$/u; /** * Finds missing references in a node. * @param {TextNode} node The node to check. - * @param {string} text The text of the node. + * @param {string} docText The text of the node. * @returns {Array<{label:string,position:Position}>} The missing references. */ -function findMissingReferences(node, text) { +function findMissingReferences(node, docText) { const missing = []; let startIndex = 0; - + const offset = node.position.start.offset; + const nodeStartLine = node.position.start.line; + const nodeStartColumn = node.position.start.column; + + /* + * This loop works by searching the string inside the node for the next + * label reference. If there is, it reports an error. + * It then moves the start index to the end of the label reference and + * continues searching the text until the end of the text is found. + */ while (startIndex < node.value.length) { const value = node.value.slice(startIndex); @@ -63,28 +72,30 @@ function findMissingReferences(node, text) { break; } - let label = match[1]; - let columnStart = startIndex + match.index + 1; - - // check for illegal shorthand tail + // skip illegal shorthand tail -- handled by no-invalid-label-refs if (illegalShorthandTailPattern.test(match[0])) { startIndex += match.index + match[0].length; continue; } + + // Calculate the match index relative to just the node. + let columnStart = startIndex + match.index; + let label = match[1]; + // need to look backward to get the label if (shorthandTailPattern.test(match[0])) { // adding 1 to the index just in case we're in a ![] and need to skip the !. - const startFrom = node.position.start.offset + startIndex + 1; - const lastOpenBracket = text.lastIndexOf("[", startFrom); + const startFrom = offset + startIndex + 1; + const lastOpenBracket = docText.lastIndexOf("[", startFrom); if (lastOpenBracket === -1) { startIndex += match.index + match[0].length; continue; } - label = text.slice(lastOpenBracket, match.index + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1]; + label = docText.slice(lastOpenBracket, match.index + match[0].length).match(/!?\[([^\]]+)\]/u)?.[1]; columnStart -= label.length; } else if (match[0].startsWith("]")) { columnStart += 2; @@ -93,22 +104,23 @@ function findMissingReferences(node, text) { } const { - lineOffset, - columnOffset + lineOffset: startLineOffset, + columnOffset: startColumnOffset } = findOffsets(node.value, columnStart); - const line = node.position.start.line + lineOffset; + const startLine = nodeStartLine + startLineOffset; + const startColumn = nodeStartColumn + startColumnOffset; missing.push({ label: label.trim(), position: { start: { - line, - column: columnOffset + line: startLine, + column: startColumn }, end: { - line, - column: columnOffset + label.length + line: startLine, + column: startColumn + label.length } } }); diff --git a/tests/rules/no-missing-label-refs.test.js b/tests/rules/no-missing-label-refs.test.js index 8c114b08..f6328f1f 100644 --- a/tests/rules/no-missing-label-refs.test.js +++ b/tests/rules/no-missing-label-refs.test.js @@ -135,6 +135,19 @@ ruleTester.run("no-missing-label-refs", rule, { endColumn: 5 } ] + }, + { + code: "- - - [foo]", + errors: [ + { + messageId: "notFound", + data: { label: "foo" }, + line: 1, + column: 8, + endLine: 1, + endColumn: 11 + } + ] } ] }); From aa625a1221e2702293a3b8edf8856ffee2bc2b84 Mon Sep 17 00:00:00 2001 From: "Nicholas C. Zakas" Date: Wed, 21 Aug 2024 09:59:59 -0400 Subject: [PATCH 20/20] Update comment --- src/rules/no-invalid-label-refs.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/rules/no-invalid-label-refs.js b/src/rules/no-invalid-label-refs.js index 3ea1e6a5..764cef6d 100644 --- a/src/rules/no-invalid-label-refs.js +++ b/src/rules/no-invalid-label-refs.js @@ -69,7 +69,6 @@ function findInvalidLabelReferences(node, docText) { /* * Search the entire document text to find the preceding open bracket. - * We add one to the start index to account for a preceding ! in an image. */ const lastOpenBracketIndex = docText.lastIndexOf("[", docMatchIndex);