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..4e08f923 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,21 @@ 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-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. |
-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 +65,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 +133,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..3f97eb9b
--- /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://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
+// 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/latest/use/configure/plugins#specify-a-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/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
+
+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/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
+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..c8ef46e1
--- /dev/null
+++ b/docs/rules/fenced-code-language.md
@@ -0,0 +1,53 @@
+# 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.
+
+## 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
new file mode 100644
index 00000000..a41ef970
--- /dev/null
+++ b/docs/rules/heading-increment.md
@@ -0,0 +1,33 @@
+# 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.
+
+## 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
new file mode 100644
index 00000000..6d0853eb
--- /dev/null
+++ b/docs/rules/no-duplicate-headings.md
@@ -0,0 +1,38 @@
+# 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.
+
+## 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
new file mode 100644
index 00000000..2736727e
--- /dev/null
+++ b/docs/rules/no-empty-links.md
@@ -0,0 +1,27 @@
+# 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.
+
+## 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
new file mode 100644
index 00000000..76b4599c
--- /dev/null
+++ b/docs/rules/no-html.md
@@ -0,0 +1,50 @@
+# 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.
+
+## 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-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/docs/rules/no-missing-label-refs.md b/docs/rules/no-missing-label-refs.md
new file mode 100644
index 00000000..716f2ac7
--- /dev/null
+++ b/docs/rules/no-missing-label-refs.md
@@ -0,0 +1,37 @@
+# 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.
+
+## Prior Art
+
+* [MD052 - reference-links-images](https://github.com/DavidAnson/markdownlint/blob/main/doc/md052.md)
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/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 555415e6..8ee55345 100644
--- a/package.json
+++ b/package.json
@@ -34,20 +34,22 @@
"linter"
],
"scripts": {
- "lint": "eslint .",
- "lint:fix": "eslint --fix .",
+ "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",
- "prepare": "node ./npm-prepare.cjs",
+ "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 && npm run build",
"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": "^9.8.0",
"eslint-config-eslint": "^11.0.0",
"globals": "^15.1.0",
"mocha": "^10.6.0",
@@ -56,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..c85dc931
--- /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.values();
+ }
+}
diff --git a/src/rules/fenced-code-language.js b/src/rules/fenced-code-language.js
new file mode 100644
index 00000000..ccc37d39
--- /dev/null
+++ b/src/rules/fenced-code-language.js
@@ -0,0 +1,85 @@
+/**
+ * @fileoverview Rule to enforce languages for fenced code.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+/** @type {RuleModule} */
+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..15f213f4
--- /dev/null
+++ b/src/rules/heading-increment.js
@@ -0,0 +1,52 @@
+/**
+ * @fileoverview Rule to enforce heading levels increment by one.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+/** @type {RuleModule} */
+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.toString(),
+ 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..c0230a4e
--- /dev/null
+++ b/src/rules/no-duplicate-headings.js
@@ -0,0 +1,70 @@
+/**
+ * @fileoverview Rule to prevent duplicate headings in Markdown.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+/** @type {RuleModule} */
+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..2730ad74
--- /dev/null
+++ b/src/rules/no-empty-links.js
@@ -0,0 +1,45 @@
+/**
+ * @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",
+
+ 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..f6f0c333
--- /dev/null
+++ b/src/rules/no-html.js
@@ -0,0 +1,78 @@
+/**
+ * @fileoverview Rule to disallow HTML inside of content.
+ * @author Nicholas C. Zakas
+ */
+
+//-----------------------------------------------------------------------------
+// Type Definitions
+//-----------------------------------------------------------------------------
+
+/** @typedef {import("eslint").Rule.RuleModule} RuleModule */
+
+//-----------------------------------------------------------------------------
+// Rule Definition
+//-----------------------------------------------------------------------------
+
+/** @type {RuleModule} */
+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("")) {
+ return;
+ }
+
+ // don't care about comments
+ if (node.value.startsWith("",
+ dedent`\`\`\`html
+ Hello world!
+ \`\`\``,
+ {
+ code: "Hello world!",
+ options: [{ allowed: ["b"] }]
+ },
+ {
+ code: "Hello world!",
+ options: [{ allowed: ["custom-element"] }]
+ }
+ ],
+ 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"
+ }
+ }
+ ]
+ },
+ {
+ 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-invalid-label-refs.test.js b/tests/rules/no-invalid-label-refs.test.js
new file mode 100644
index 00000000..ea3e289a
--- /dev/null
+++ b/tests/rules/no-invalid-label-refs.test.js
@@ -0,0 +1,131 @@
+/**
+ * @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: "invalidLabelRef",
+ data: { label: "foo" },
+ line: 1,
+ 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: 10
+ }
+ ]
+ },
+ {
+ code: "[\nfoo\n][\n]\n\n[foo]: http://bar.com/image.jpg",
+ errors: [
+ {
+ 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: 6,
+ endLine: 2,
+ endColumn: 9
+ }
+ ]
+ },
+ {
+ 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: 7,
+ endLine: 2,
+ endColumn: 10
+ }
+ ]
+ },
+ {
+ code: "- - - [foo][ ]\n\n[foo]: http://foo.com",
+ errors: [
+ {
+ messageId: "invalidLabelRef",
+ data: { label: "foo" },
+ line: 1,
+ column: 12,
+ endLine: 1,
+ endColumn: 15
+ }
+ ]
+ }
+ ]
+});
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..f6328f1f
--- /dev/null
+++ b/tests/rules/no-missing-label-refs.test.js
@@ -0,0 +1,153 @@
+/**
+ * @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",
+ "[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",
+ "[\nfoo\n][\n]\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
+ }
+ ]
+ },
+ {
+ code: "- - - [foo]",
+ errors: [
+ {
+ messageId: "notFound",
+ data: { label: "foo" },
+ line: 1,
+ column: 8,
+ endLine: 1,
+ endColumn: 11
+ }
+ ]
+ }
+ ]
+});
diff --git a/tools/build-rules.js b/tools/build-rules.js
new file mode 100644
index 00000000..82464ba5
--- /dev/null
+++ b/tools/build-rules.js
@@ -0,0 +1,56 @@
+/**
+ * @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 = `
+${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);
+
+console.log("Rules import file generated successfully.");