first commit

This commit is contained in:
2026-08-06 12:00:39 +10:00
commit 91221f5a8e
3259 changed files with 569069 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright OpenJS Foundation and other contributors, https://openjsf.org
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+313
View File
@@ -0,0 +1,313 @@
# ESLint Markdown Language Plugin
[![npm Version](https://img.shields.io/npm/v/@eslint/markdown.svg)](https://www.npmjs.com/package/@eslint/markdown)
[![Downloads](https://img.shields.io/npm/dm/@eslint/markdown.svg)](https://www.npmjs.com/package/@eslint/markdown)
[![Build Status](https://github.com/eslint/markdown/workflows/CI/badge.svg)](https://github.com/eslint/markdown/actions)
Lint Markdown with ESLint, as well JS, JSX, TypeScript, and more inside Markdown.
<img
src="screenshot.png"
height="142"
width="432"
alt="A JS code snippet in a Markdown editor has red squiggly underlines. A tooltip explains the problem."
/>
## Usage
### Installing
Install the plugin alongside ESLint v9.15.0 or greater. Type compatibility is guaranteed with ESLint v9.39.0 or greater.
For Node.js and compatible runtimes:
```sh
npm install @eslint/markdown -D
# or
yarn add @eslint/markdown -D
# or
pnpm install @eslint/markdown -D
# or
bun add @eslint/markdown -D
```
For Deno:
```sh
deno add jsr:@eslint/markdown
```
### 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 Markdown parsing and linting:
```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import markdown from "@eslint/markdown";
export default defineConfig([
{
files: ["**/*.md"],
plugins: {
markdown,
},
extends: ["markdown/recommended"],
},
// your other configs here
]);
```
You can also modify the recommended config by using `extends`:
```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import markdown from "@eslint/markdown";
export default defineConfig([
{
plugins: {
markdown,
},
extends: ["markdown/recommended"],
rules: {
"markdown/no-html": "error",
},
},
// your other configs here
]);
```
### Rules
<!-- NOTE: The following table is autogenerated. Do not manually edit. -->
<!-- Rule Table Start -->
| **Rule Name** | **Description** | **Recommended** |
| :----------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------ | :-------------: |
| [`fenced-code-language`](./docs/rules/fenced-code-language.md) | Require languages for fenced code blocks | yes |
| [`fenced-code-meta`](./docs/rules/fenced-code-meta.md) | Require or disallow metadata for fenced code blocks | no |
| [`heading-increment`](./docs/rules/heading-increment.md) | Enforce heading levels increment by one | yes |
| [`no-bare-urls`](./docs/rules/no-bare-urls.md) | Disallow bare URLs | no |
| [`no-duplicate-definitions`](./docs/rules/no-duplicate-definitions.md) | Disallow duplicate definitions | yes |
| [`no-duplicate-headings`](./docs/rules/no-duplicate-headings.md) | Disallow duplicate headings in the same document | no |
| [`no-empty-definitions`](./docs/rules/no-empty-definitions.md) | Disallow empty definitions | yes |
| [`no-empty-images`](./docs/rules/no-empty-images.md) | Disallow empty images | yes |
| [`no-empty-links`](./docs/rules/no-empty-links.md) | Disallow empty links | yes |
| [`no-html`](./docs/rules/no-html.md) | Disallow HTML tags | no |
| [`no-invalid-label-refs`](./docs/rules/no-invalid-label-refs.md) | Disallow invalid label references | yes |
| [`no-missing-atx-heading-space`](./docs/rules/no-missing-atx-heading-space.md) | Disallow headings without a space after the hash characters | yes |
| [`no-missing-label-refs`](./docs/rules/no-missing-label-refs.md) | Disallow missing label references | yes |
| [`no-missing-link-fragments`](./docs/rules/no-missing-link-fragments.md) | Disallow link fragments that do not reference valid headings | yes |
| [`no-multiple-h1`](./docs/rules/no-multiple-h1.md) | Disallow multiple H1 headings in the same document | yes |
| [`no-reference-like-urls`](./docs/rules/no-reference-like-urls.md) | Disallow URLs that match defined reference identifiers | yes |
| [`no-reversed-media-syntax`](./docs/rules/no-reversed-media-syntax.md) | Disallow reversed link and image syntax | yes |
| [`no-space-in-emphasis`](./docs/rules/no-space-in-emphasis.md) | Disallow spaces around emphasis markers | yes |
| [`no-unused-definitions`](./docs/rules/no-unused-definitions.md) | Disallow unused definitions | yes |
| [`require-alt-text`](./docs/rules/require-alt-text.md) | Require alternative text for images | yes |
| [`table-column-count`](./docs/rules/table-column-count.md) | Disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row | yes |
<!-- Rule Table End -->
**Note:** This plugin does not provide formatting rules. We recommend using a source code formatter such as [Prettier](https://prettier.io) for that purpose.
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
import { defineConfig } from "eslint/config";
import markdown from "@eslint/markdown";
export default defineConfig([
{
files: ["**/*.md"],
plugins: {
markdown,
},
language: "markdown/commonmark",
rules: {
"markdown/no-html": "error",
},
},
]);
```
You can individually disable rules in Markdown using HTML comments, such as:
<!-- prettier-ignore-start -->
```markdown
<!-- eslint-disable-next-line markdown/no-html -- I want to allow HTML here -->
<custom-element>Hello world!</custom-element>
<!-- eslint-disable markdown/no-html -- here too -->
<another-element>Goodbye world!</another-element>
<!-- eslint-enable markdown/no-html -- safe to re-enable now -->
[Object] <!-- eslint-disable-line markdown/no-missing-label-refs -- not meant to be a link ref -->
```
<!-- prettier-ignore-end -->
### Languages
| **Language Name** | **Description** |
| ----------------- | ----------------------------------------------------------------------------- |
| `commonmark` | Parse using [CommonMark](https://commonmark.org) Markdown format |
| `gfm` | Parse using [GitHub-Flavored Markdown](https://github.github.com/gfm/) format |
In order to individually configure a language in your `eslint.config.js` file, import `@eslint/markdown` and configure a `language`:
```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import markdown from "@eslint/markdown";
export default defineConfig([
{
files: ["**/*.md"],
plugins: {
markdown,
},
language: "markdown/gfm",
rules: {
"markdown/no-html": "error",
},
},
]);
```
### Language Options
#### Enabling Front Matter in both `commonmark` and `gfm`
By default, Markdown parsers do not support [front matter](https://jekyllrb.com/docs/front-matter/). To enable front matter in both `commonmark` and `gfm`, you can use the `frontmatter` option in `languageOptions`.
> `@eslint/markdown` internally uses [`micromark-extension-frontmatter`](https://github.com/micromark/micromark-extension-frontmatter) and [`mdast-util-frontmatter`](https://github.com/syntax-tree/mdast-util-frontmatter) to parse front matter.
| **Option Value** | **Description** |
| ---------------- | ---------------------------------------------------------- |
| `false` | Disables front matter parsing in Markdown files. (Default) |
| `"yaml"` | Enables YAML front matter parsing in Markdown files. |
| `"toml"` | Enables TOML front matter parsing in Markdown files. |
| `"json"` | Enables JSON front matter parsing in Markdown files. |
```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import markdown from "@eslint/markdown";
export default defineConfig([
{
files: ["**/*.md"],
plugins: {
markdown,
},
language: "markdown/gfm",
languageOptions: {
frontmatter: "yaml", // Or pass `"toml"` or `"json"` to enable TOML or JSON front matter parsing.
},
rules: {
"markdown/no-html": "error",
},
},
]);
```
#### Enabling Math (LaTeX) in both `commonmark` and `gfm`
By default, Markdown parsers do not support [math](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/writing-mathematical-expressions) ([LaTeX](https://www.latex-project.org/)). To enable math in both `commonmark` and `gfm`, you can use the `math` option in `languageOptions`.
> `@eslint/markdown` internally uses [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) and [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) to parse math.
| **Option Value** | **Description** |
| ---------------- | -------------------------------------------------- |
| `false` | Disables math parsing in Markdown files. (Default) |
| `true` | Enables math parsing in Markdown files. |
```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import markdown from "@eslint/markdown";
export default defineConfig([
{
files: ["**/*.md"],
plugins: {
markdown,
},
language: "markdown/gfm",
languageOptions: {
math: true, // Or pass `false` to disable math parsing.
},
rules: {
"markdown/no-html": "error",
},
},
]);
```
### Processors
| **Processor Name** | **Description** |
| ------------------------------------------- | ----------------------------------------------------------------------------------- |
| [`markdown`](./docs/processors/markdown.md) | Extract fenced code blocks from the Markdown code so they can be linted separately. |
## Migration from `eslint-plugin-markdown`
See [Migration](./docs/migration.md#from-eslint-plugin-markdown).
## Editor Integrations
### VSCode
[`vscode-eslint`](https://github.com/microsoft/vscode-eslint) has built-in support for the Markdown processor.
## File Name Details
This processor will use file names from blocks if a `filename` meta is present.
For example, the following block will result in a parsed file name of `src/index.js`:
````md
```js filename="src/index.js"
export const value = "Hello, world!";
```
````
This can be useful for user configurations that include linting overrides for specific file paths. In this example, you could then target the specific code block in your configuration using `"file-name.md/*src/index.js"`.
## Contributing
```sh
$ git clone https://github.com/eslint/markdown.git
$ cd markdown
$ npm install
$ npm test
```
This project follows the [ESLint contribution guidelines](https://eslint.org/docs/latest/contribute/).
<!-- NOTE: This section is autogenerated. Do not manually edit.-->
<!--sponsorsstart-->
## Sponsors
The following companies, organizations, and individuals support ESLint's ongoing maintenance and development. [Become a Sponsor](https://eslint.org/donate)
to get your logo on our READMEs and [website](https://eslint.org/sponsors).
<h3>Platinum Sponsors</h3>
<p><a href="https://automattic.com"><img src="https://images.opencollective.com/automattic/d0ef3e1/logo.png" alt="Automattic" height="128"></a></p><h3>Gold Sponsors</h3>
<p><a href="https://qlty.sh/"><img src="https://images.opencollective.com/qltysh/33d157d/logo.png" alt="Qlty Software" height="96"></a> <a href="https://shopify.engineering/"><img src="https://avatars.githubusercontent.com/u/8085" alt="Shopify" height="96"></a> <a href="https://www.coderabbit.ai/?utm_source=cr_org&utm_medium=github"><img src="https://avatars.githubusercontent.com/u/132028505" alt="CodeRabbit" height="96"></a></p><h3>Silver Sponsors</h3>
<p><a href="https://vite.dev/"><img src="https://images.opencollective.com/vite/d472863/logo.png" alt="Vite" height="64"></a> <a href="https://liftoff.io/"><img src="https://images.opencollective.com/liftoff/2d6c3b6/logo.png" alt="Liftoff" height="64"></a> <a href="https://stackblitz.com"><img src="https://avatars.githubusercontent.com/u/28635252" alt="StackBlitz" height="64"></a></p><h3>Bronze Sponsors</h3>
<p><a href="https://cybozu.co.jp/"><img src="https://images.opencollective.com/cybozu/933e46d/logo.png" alt="Cybozu" height="32"></a> <a href="https://opensource.sap.com"><img src="https://avatars.githubusercontent.com/u/2531208" alt="SAP" height="32"></a> <a href="https://www.crawljobs.com/"><img src="https://images.opencollective.com/crawljobs-poland/fa43a17/logo.png" alt="CrawlJobs" height="32"></a> <a href="https://depot.dev"><img src="https://images.opencollective.com/depot/39125a1/logo.png" alt="Depot" height="32"></a> <a href="https://icons8.com/"><img src="https://images.opencollective.com/icons8/7fa1641/logo.png" alt="Icons8" height="32"></a> <a href="https://discord.com"><img src="https://images.opencollective.com/discordapp/f9645d9/logo.png" alt="Discord" height="32"></a> <a href="https://www.gitbook.com"><img src="https://avatars.githubusercontent.com/u/7111340" alt="GitBook" height="32"></a> <a href="https://citadel.co.jp"><img src="https://avatars.githubusercontent.com/u/75781367" alt="Citadel AI" height="32"></a></p>
<h3>Technology Sponsors</h3>
Technology sponsors allow us to use their products and services for free as part of a contribution to the open source ecosystem and our work.
<p><a href="https://netlify.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/netlify-icon.svg" alt="Netlify" height="32"></a> <a href="https://algolia.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/algolia-icon.svg" alt="Algolia" height="32"></a> <a href="https://1password.com"><img src="https://raw.githubusercontent.com/eslint/eslint.org/main/src/assets/images/techsponsors/1password-icon.svg" alt="1Password" height="32"></a></p>
<!--sponsorsend-->
@@ -0,0 +1,20 @@
export default rules;
declare const rules: {
readonly "markdown/fenced-code-language": "error";
readonly "markdown/heading-increment": "error";
readonly "markdown/no-duplicate-definitions": "error";
readonly "markdown/no-empty-definitions": "error";
readonly "markdown/no-empty-images": "error";
readonly "markdown/no-empty-links": "error";
readonly "markdown/no-invalid-label-refs": "error";
readonly "markdown/no-missing-atx-heading-space": "error";
readonly "markdown/no-missing-label-refs": "error";
readonly "markdown/no-missing-link-fragments": "error";
readonly "markdown/no-multiple-h1": "error";
readonly "markdown/no-reference-like-urls": "error";
readonly "markdown/no-reversed-media-syntax": "error";
readonly "markdown/no-space-in-emphasis": "error";
readonly "markdown/no-unused-definitions": "error";
readonly "markdown/require-alt-text": "error";
readonly "markdown/table-column-count": "error";
};
@@ -0,0 +1,20 @@
const rules = /** @type {const} */ ({
"markdown/fenced-code-language": "error",
"markdown/heading-increment": "error",
"markdown/no-duplicate-definitions": "error",
"markdown/no-empty-definitions": "error",
"markdown/no-empty-images": "error",
"markdown/no-empty-links": "error",
"markdown/no-invalid-label-refs": "error",
"markdown/no-missing-atx-heading-space": "error",
"markdown/no-missing-label-refs": "error",
"markdown/no-missing-link-fragments": "error",
"markdown/no-multiple-h1": "error",
"markdown/no-reference-like-urls": "error",
"markdown/no-reversed-media-syntax": "error",
"markdown/no-space-in-emphasis": "error",
"markdown/no-unused-definitions": "error",
"markdown/require-alt-text": "error",
"markdown/table-column-count": "error"
});
export default rules;
+108
View File
@@ -0,0 +1,108 @@
declare const _default: {
"fenced-code-language": {
meta: typeof rule0.meta;
create: (context: unknown) => any;
};
"fenced-code-meta": {
meta: typeof rule1.meta;
create: (context: unknown) => any;
};
"heading-increment": {
meta: typeof rule2.meta;
create: (context: unknown) => any;
};
"no-bare-urls": {
meta: typeof rule3.meta;
create: (context: unknown) => any;
};
"no-duplicate-definitions": {
meta: typeof rule4.meta;
create: (context: unknown) => any;
};
"no-duplicate-headings": {
meta: typeof rule5.meta;
create: (context: unknown) => any;
};
"no-empty-definitions": {
meta: typeof rule6.meta;
create: (context: unknown) => any;
};
"no-empty-images": {
meta: typeof rule7.meta;
create: (context: unknown) => any;
};
"no-empty-links": {
meta: typeof rule8.meta;
create: (context: unknown) => any;
};
"no-html": {
meta: typeof rule9.meta;
create: (context: unknown) => any;
};
"no-invalid-label-refs": {
meta: typeof rule10.meta;
create: (context: unknown) => any;
};
"no-missing-atx-heading-space": {
meta: typeof rule11.meta;
create: (context: unknown) => any;
};
"no-missing-label-refs": {
meta: typeof rule12.meta;
create: (context: unknown) => any;
};
"no-missing-link-fragments": {
meta: typeof rule13.meta;
create: (context: unknown) => any;
};
"no-multiple-h1": {
meta: typeof rule14.meta;
create: (context: unknown) => any;
};
"no-reference-like-urls": {
meta: typeof rule15.meta;
create: (context: unknown) => any;
};
"no-reversed-media-syntax": {
meta: typeof rule16.meta;
create: (context: unknown) => any;
};
"no-space-in-emphasis": {
meta: typeof rule17.meta;
create: (context: unknown) => any;
};
"no-unused-definitions": {
meta: typeof rule18.meta;
create: (context: unknown) => any;
};
"require-alt-text": {
meta: typeof rule19.meta;
create: (context: unknown) => any;
};
"table-column-count": {
meta: typeof rule20.meta;
create: (context: unknown) => any;
};
};
export default _default;
import rule0 from "../rules/fenced-code-language.js";
import rule1 from "../rules/fenced-code-meta.js";
import rule2 from "../rules/heading-increment.js";
import rule3 from "../rules/no-bare-urls.js";
import rule4 from "../rules/no-duplicate-definitions.js";
import rule5 from "../rules/no-duplicate-headings.js";
import rule6 from "../rules/no-empty-definitions.js";
import rule7 from "../rules/no-empty-images.js";
import rule8 from "../rules/no-empty-links.js";
import rule9 from "../rules/no-html.js";
import rule10 from "../rules/no-invalid-label-refs.js";
import rule11 from "../rules/no-missing-atx-heading-space.js";
import rule12 from "../rules/no-missing-label-refs.js";
import rule13 from "../rules/no-missing-link-fragments.js";
import rule14 from "../rules/no-multiple-h1.js";
import rule15 from "../rules/no-reference-like-urls.js";
import rule16 from "../rules/no-reversed-media-syntax.js";
import rule17 from "../rules/no-space-in-emphasis.js";
import rule18 from "../rules/no-unused-definitions.js";
import rule19 from "../rules/require-alt-text.js";
import rule20 from "../rules/table-column-count.js";
+44
View File
@@ -0,0 +1,44 @@
import rule0 from "../rules/fenced-code-language.js";
import rule1 from "../rules/fenced-code-meta.js";
import rule2 from "../rules/heading-increment.js";
import rule3 from "../rules/no-bare-urls.js";
import rule4 from "../rules/no-duplicate-definitions.js";
import rule5 from "../rules/no-duplicate-headings.js";
import rule6 from "../rules/no-empty-definitions.js";
import rule7 from "../rules/no-empty-images.js";
import rule8 from "../rules/no-empty-links.js";
import rule9 from "../rules/no-html.js";
import rule10 from "../rules/no-invalid-label-refs.js";
import rule11 from "../rules/no-missing-atx-heading-space.js";
import rule12 from "../rules/no-missing-label-refs.js";
import rule13 from "../rules/no-missing-link-fragments.js";
import rule14 from "../rules/no-multiple-h1.js";
import rule15 from "../rules/no-reference-like-urls.js";
import rule16 from "../rules/no-reversed-media-syntax.js";
import rule17 from "../rules/no-space-in-emphasis.js";
import rule18 from "../rules/no-unused-definitions.js";
import rule19 from "../rules/require-alt-text.js";
import rule20 from "../rules/table-column-count.js";
export default {
"fenced-code-language": /** @type {{meta: typeof rule0.meta; create: (context: unknown) => any}} */ (rule0),
"fenced-code-meta": /** @type {{meta: typeof rule1.meta; create: (context: unknown) => any}} */ (rule1),
"heading-increment": /** @type {{meta: typeof rule2.meta; create: (context: unknown) => any}} */ (rule2),
"no-bare-urls": /** @type {{meta: typeof rule3.meta; create: (context: unknown) => any}} */ (rule3),
"no-duplicate-definitions": /** @type {{meta: typeof rule4.meta; create: (context: unknown) => any}} */ (rule4),
"no-duplicate-headings": /** @type {{meta: typeof rule5.meta; create: (context: unknown) => any}} */ (rule5),
"no-empty-definitions": /** @type {{meta: typeof rule6.meta; create: (context: unknown) => any}} */ (rule6),
"no-empty-images": /** @type {{meta: typeof rule7.meta; create: (context: unknown) => any}} */ (rule7),
"no-empty-links": /** @type {{meta: typeof rule8.meta; create: (context: unknown) => any}} */ (rule8),
"no-html": /** @type {{meta: typeof rule9.meta; create: (context: unknown) => any}} */ (rule9),
"no-invalid-label-refs": /** @type {{meta: typeof rule10.meta; create: (context: unknown) => any}} */ (rule10),
"no-missing-atx-heading-space": /** @type {{meta: typeof rule11.meta; create: (context: unknown) => any}} */ (rule11),
"no-missing-label-refs": /** @type {{meta: typeof rule12.meta; create: (context: unknown) => any}} */ (rule12),
"no-missing-link-fragments": /** @type {{meta: typeof rule13.meta; create: (context: unknown) => any}} */ (rule13),
"no-multiple-h1": /** @type {{meta: typeof rule14.meta; create: (context: unknown) => any}} */ (rule14),
"no-reference-like-urls": /** @type {{meta: typeof rule15.meta; create: (context: unknown) => any}} */ (rule15),
"no-reversed-media-syntax": /** @type {{meta: typeof rule16.meta; create: (context: unknown) => any}} */ (rule16),
"no-space-in-emphasis": /** @type {{meta: typeof rule17.meta; create: (context: unknown) => any}} */ (rule17),
"no-unused-definitions": /** @type {{meta: typeof rule18.meta; create: (context: unknown) => any}} */ (rule18),
"require-alt-text": /** @type {{meta: typeof rule19.meta; create: (context: unknown) => any}} */ (rule19),
"table-column-count": /** @type {{meta: typeof rule20.meta; create: (context: unknown) => any}} */ (rule20),
};
+77
View File
@@ -0,0 +1,77 @@
export default plugin;
export { MarkdownSourceCode };
export * from "./language/markdown-language.js";
export * from "./types.js";
declare namespace plugin {
export namespace meta {
let name: string;
let version: string;
}
export namespace processors {
export { processor as markdown };
}
export namespace languages {
let commonmark: MarkdownLanguage;
let gfm: MarkdownLanguage;
}
export { rules };
export let configs: {
"recommended-legacy": {
plugins: string[];
overrides: ({
files: string[];
processor: string;
parserOptions?: undefined;
rules?: undefined;
} | {
files: string[];
parserOptions: {
ecmaFeatures: {
impliedStrict: boolean;
};
};
rules: {
"eol-last": "off";
"no-undef": "off";
"no-unused-expressions": "off";
"no-unused-vars": "off";
"padded-blocks": "off";
strict: "off";
"unicode-bom": "off";
};
processor?: undefined;
})[];
};
recommended: {
name: string;
files: string[];
language: string;
plugins: {};
rules: {
readonly "markdown/fenced-code-language": "error";
readonly "markdown/heading-increment": "error";
readonly "markdown/no-duplicate-definitions": "error";
readonly "markdown/no-empty-definitions": "error";
readonly "markdown/no-empty-images": "error";
readonly "markdown/no-empty-links": "error";
readonly "markdown/no-invalid-label-refs": "error";
readonly "markdown/no-missing-atx-heading-space": "error";
readonly "markdown/no-missing-label-refs": "error";
readonly "markdown/no-missing-link-fragments": "error";
readonly "markdown/no-multiple-h1": "error";
readonly "markdown/no-reference-like-urls": "error";
readonly "markdown/no-reversed-media-syntax": "error";
readonly "markdown/no-space-in-emphasis": "error";
readonly "markdown/no-unused-definitions": "error";
readonly "markdown/require-alt-text": "error";
readonly "markdown/table-column-count": "error";
};
}[];
processor: ConfigObject[];
};
}
import { MarkdownSourceCode } from "./language/markdown-source-code.js";
import { processor } from "./processor.js";
import { MarkdownLanguage } from "./language/markdown-language.js";
import rules from "./build/rules.js";
import type { ConfigObject } from "@eslint/core";
+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview Markdown plugin.
* @author Brandon Mills
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { processor } from "./processor.js";
import { MarkdownLanguage } from "./language/markdown-language.js";
import { MarkdownSourceCode } from "./language/markdown-source-code.js";
import recommendedRules from "./build/recommended-config.js";
import rules from "./build/rules.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { ConfigObject, RulesConfig } from "@eslint/core";
*/
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/** @satisfies {RulesConfig} */
const processorRulesConfig = {
// The Markdown parser automatically trims trailing
// newlines from code blocks.
"eol-last": "off",
// In code snippets and examples, these rules are often
// counterproductive to clarity and brevity.
"no-undef": "off",
"no-unused-expressions": "off",
"no-unused-vars": "off",
"padded-blocks": "off",
// Adding a "use strict" directive at the top of every
// code block is tedious and distracting. The config
// opts into strict mode parsing without the directive.
strict: "off",
// The processor will not receive a Unicode Byte Order
// Mark from the Markdown parser.
"unicode-bom": "off",
};
let recommendedPlugins, processorPlugins;
const plugin = {
meta: {
name: "@eslint/markdown",
version: "8.0.3", // x-release-please-version
},
processors: {
markdown: processor,
},
languages: {
commonmark: new MarkdownLanguage({ mode: "commonmark" }),
gfm: new MarkdownLanguage({ mode: "gfm" }),
},
rules,
configs: {
"recommended-legacy": {
plugins: ["markdown"],
overrides: [
{
files: ["*.md"],
processor: "markdown/markdown",
},
{
files: ["**/*.md/**"],
parserOptions: {
ecmaFeatures: {
// Adding a "use strict" directive at the top of
// every code block is tedious and distracting, so
// opt into strict mode parsing without the
// directive.
impliedStrict: true,
},
},
rules: {
...processorRulesConfig,
},
},
],
},
recommended: [
{
name: "markdown/recommended",
files: ["**/*.md"],
language: "markdown/commonmark",
plugins: (recommendedPlugins = {}),
rules: recommendedRules,
},
],
processor: /** @type {ConfigObject[]} */ ([
{
name: "markdown/recommended/plugin",
plugins: (processorPlugins = {}),
},
{
name: "markdown/recommended/processor",
files: ["**/*.md"],
processor: "markdown/markdown",
},
{
name: "markdown/recommended/code-blocks",
files: ["**/*.md/**"],
languageOptions: {
parserOptions: {
ecmaFeatures: {
// Adding a "use strict" directive at the top of
// every code block is tedious and distracting, so
// opt into strict mode parsing without the
// directive.
impliedStrict: true,
},
},
},
rules: {
...processorRulesConfig,
},
},
]),
},
};
Object.assign(recommendedPlugins, { markdown: plugin });
Object.assign(processorPlugins, { markdown: plugin });
export default plugin;
export { MarkdownSourceCode };
export * from "./language/markdown-language.js";
export * from "./types.js";
@@ -0,0 +1,73 @@
/**
* Markdown Language Object
* @implements {Language}
*/
export class MarkdownLanguage implements Language {
/**
* 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 }?: {
mode?: ParserMode;
});
/**
* The type of file to read.
* @type {"text"}
*/
fileType: "text";
/**
* The line number at which the parser starts counting.
* @type {0|1}
*/
lineStart: 0 | 1;
/**
* The column number at which the parser starts counting.
* @type {0|1}
*/
columnStart: 0 | 1;
/**
* The name of the key that holds the type of the node.
* @type {string}
*/
nodeTypeKey: string;
/**
* Default language options. User-defined options are merged with this object.
* @type {MarkdownLanguageOptions}
*/
defaultLanguageOptions: MarkdownLanguageOptions;
/**
* Validates the language options.
* @param {MarkdownLanguageOptions} languageOptions The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions: MarkdownLanguageOptions): void;
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {MarkdownLanguageContext} context The options to use for parsing.
* @returns {ParseResult<Root>} The result of parsing.
*/
parse(file: File, context: MarkdownLanguageContext): ParseResult<Root>;
/**
* Creates a new `MarkdownSourceCode` object from the given information.
* @param {File} file The virtual file to create a `MarkdownSourceCode` object from.
* @param {OkParseResult<Root>} parseResult The result returned from `parse()`.
* @returns {MarkdownSourceCode} The new `MarkdownSourceCode` object.
*/
createSourceCode(file: File, parseResult: OkParseResult<Root>): MarkdownSourceCode;
#private;
}
export type Extensions = Options["extensions"];
export type MdastExtensions = Options["mdastExtensions"];
export type ParserMode = "commonmark" | "gfm";
import type { Language } from "@eslint/core";
import type { MarkdownLanguageOptions } from "../types.js";
import type { File } from "@eslint/core";
import type { MarkdownLanguageContext } from "../types.js";
import type { Root } from "mdast";
import type { ParseResult } from "@eslint/core";
import type { OkParseResult } from "@eslint/core";
import { MarkdownSourceCode } from "./markdown-source-code.js";
import type { Options } from "mdast-util-from-markdown";
@@ -0,0 +1,211 @@
/**
* @fileoverview The MarkdownLanguage class.
* @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 { frontmatterFromMarkdown } from "mdast-util-frontmatter";
import { gfmFromMarkdown } from "mdast-util-gfm";
import { mathFromMarkdown } from "mdast-util-math";
import { frontmatter } from "micromark-extension-frontmatter";
import { gfm } from "micromark-extension-gfm";
import { math } from "micromark-extension-math";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
* @import { Language, File, ParseResult, OkParseResult } from "@eslint/core";
* @import { Root } from "mdast";
* @import { Options } from "mdast-util-from-markdown";
* @import { MarkdownLanguageOptions, MarkdownLanguageContext } from "../types.js";
* @typedef {Options['extensions']} Extensions
* @typedef {Options['mdastExtensions']} MdastExtensions
* @typedef {"commonmark"|"gfm"} ParserMode
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Parser configuration for JSON frontmatter.
* Example of supported frontmatter format:
* ```markdown
* ---
* {
* "title": "My Document",
* "date": "2025-06-09"
* }
* ---
* ```
*/
const jsonFrontmatterConfig = {
type: "json",
marker: "-",
};
/**
* Create parser options based on `mode` and `languageOptions`.
* @param {ParserMode} mode The markdown parser mode.
* @param {MarkdownLanguageOptions} languageOptions Language options.
* @returns {{extensions: Extensions, mdastExtensions: MdastExtensions}} Parser options for micromark and mdast.
*/
function createParserOptions(mode, languageOptions) {
/** @type {Extensions} */
const extensions = [];
/** @type {MdastExtensions} */
const mdastExtensions = [];
// 1. `mode`: Add GFM extensions if mode is "gfm"
if (mode === "gfm") {
extensions.push(gfm());
mdastExtensions.push(gfmFromMarkdown());
}
// 2. `languageOptions.frontmatter`: Handle frontmatter options
const frontmatterOption = languageOptions?.frontmatter;
// Skip frontmatter entirely if false
if (frontmatterOption !== false) {
if (frontmatterOption === "yaml") {
extensions.push(frontmatter(["yaml"]));
mdastExtensions.push(frontmatterFromMarkdown(["yaml"]));
}
else if (frontmatterOption === "toml") {
extensions.push(frontmatter(["toml"]));
mdastExtensions.push(frontmatterFromMarkdown(["toml"]));
}
else if (frontmatterOption === "json") {
extensions.push(frontmatter(jsonFrontmatterConfig));
mdastExtensions.push(frontmatterFromMarkdown(jsonFrontmatterConfig));
}
}
// 3. `languageOptions.math`: Handle math option
const mathOption = languageOptions?.math;
// Skip math entirely if false
if (mathOption === true) {
extensions.push(math());
mdastExtensions.push(mathFromMarkdown());
}
return {
extensions,
mdastExtensions,
};
}
//-----------------------------------------------------------------------------
// 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";
/**
* Default language options. User-defined options are merged with this object.
* @type {MarkdownLanguageOptions}
*/
defaultLanguageOptions = {
frontmatter: false,
math: false,
};
/**
* 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;
}
}
/**
* Validates the language options.
* @param {MarkdownLanguageOptions} languageOptions The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions) {
// `frontmatter` option validation
const frontmatterOption = languageOptions?.frontmatter;
const validFrontmatterOptions = new Set([
false,
"yaml",
"toml",
"json",
]);
if (frontmatterOption !== undefined &&
!validFrontmatterOptions.has(frontmatterOption)) {
throw new Error(`Invalid language option value \`${frontmatterOption}\` for frontmatter. Expected one of \`false\`, \`"yaml"\`, \`"toml"\`, or \`"json"\`.`);
}
// `math` option validation
const mathOption = languageOptions?.math;
if (mathOption !== undefined && typeof mathOption !== "boolean") {
throw new Error(`Invalid language option value \`${mathOption}\` for math. Expected a boolean.`);
}
}
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {MarkdownLanguageContext} context The options to use for parsing.
* @returns {ParseResult<Root>} The result of parsing.
*/
parse(file, context) {
// 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 = createParserOptions(this.#mode, context?.languageOptions);
const root = fromMarkdown(text, options);
return {
ok: true,
ast: root,
};
}
catch (ex) {
return {
ok: false,
errors: [ex],
};
}
}
/**
* Creates a new `MarkdownSourceCode` object from the given information.
* @param {File} file The virtual file to create a `MarkdownSourceCode` object from.
* @param {OkParseResult<Root>} parseResult The result returned from `parse()`.
* @returns {MarkdownSourceCode} The new `MarkdownSourceCode` object.
*/
createSourceCode(file, parseResult) {
return new MarkdownSourceCode({
text: /** @type {string} */ (file.body),
ast: parseResult.ast,
});
}
}
@@ -0,0 +1,96 @@
/**
* Represents an inline config comment in the source code.
*/
export class InlineConfigComment {
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.value The comment text.
* @param {Position} options.position The position of the comment in the source code.
*/
constructor({ value, position }: {
value: string;
position: Position;
});
/**
* The comment text.
* @type {string}
*/
value: string;
/**
* The position of the comment in the source code.
* @type {Position}
*/
position: Position;
}
/**
* Markdown Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: MarkdownLanguageOptions, RootNode: Root, SyntaxElementWithLoc: Node, ConfigNode: { value: string; position: Position }}>}
*/
export class MarkdownSourceCode extends TextSourceCodeBase<{
LangOptions: MarkdownLanguageOptions;
RootNode: Root;
SyntaxElementWithLoc: Node;
ConfigNode: {
value: string;
position: Position;
};
}> {
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {Root} options.ast The root AST node.
*/
constructor({ text, ast }: {
text: string;
ast: Root;
});
/**
* Returns the parent of the given node.
* @param {Node} node The node to get the parent of.
* @returns {Parent|undefined} The parent of the node.
*/
getParent(node: Node): Parent | undefined;
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<InlineConfigComment>} An array of all inline configuration nodes.
*/
getInlineConfigNodes(): Array<InlineConfigComment>;
/**
* Returns an all directive nodes that enable or disable rules along with any problems
* encountered while parsing the directives.
* @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
* that ESLint needs to further process the directives.
*/
getDisableDirectives(): {
problems: Array<FileProblem>;
directives: Array<Directive>;
};
/**
* Returns inline rule configurations along with any problems
* encountered while parsing the configurations.
* @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:Position}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig(): {
problems: Array<FileProblem>;
configs: Array<{
config: {
rules: RulesConfig;
};
loc: Position;
}>;
};
#private;
}
import type { Position } from "unist";
import type { MarkdownLanguageOptions } from "../types.js";
import type { Root } from "mdast";
import type { Node } from "mdast";
import { TextSourceCodeBase } from "@eslint/plugin-kit";
import type { Parent } from "mdast";
import type { FileProblem } from "@eslint/core";
import { Directive } from "@eslint/plugin-kit";
import type { RulesConfig } from "@eslint/core";
@@ -0,0 +1,279 @@
/**
* @fileoverview The MarkdownSourceCode class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { VisitNodeStep, TextSourceCodeBase, ConfigCommentParser, Directive, } from "@eslint/plugin-kit";
import { lineEndingPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
* @import { Position } from "unist";
* @import { Parent, Root, Node, Html } from "mdast";
* @import { TraversalStep, FileProblem, DirectiveType, RulesConfig } from "@eslint/core";
* @import { MarkdownLanguageOptions } from "../types.js";
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const commentParser = new ConfigCommentParser();
const configCommentStart = /<!--\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|-->)/u;
const htmlComment = /<!--(.*?)-->/gsu;
/**
* Represents an inline config comment in the source code.
*/
export class InlineConfigComment {
/**
* The comment text.
* @type {string}
*/
value;
/**
* The position of the comment in the source code.
* @type {Position}
*/
position;
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.value The comment text.
* @param {Position} options.position The position of the comment in the source code.
*/
constructor({ value, position }) {
this.value = value.trim();
this.position = position;
}
}
/**
* Extracts inline configuration comments from an HTML node.
* @param {Html} node The HTML node to extract comments from.
* @param {MarkdownSourceCode} sourceCode The Markdown source code object.
* @returns {Array<InlineConfigComment>} The inline configuration comments found in the node.
*/
function extractInlineConfigCommentsFromHTML(node, sourceCode) {
if (!configCommentStart.test(node.value)) {
return [];
}
/** @type {Array<InlineConfigComment>} */
const comments = [];
/** @type {RegExpExecArray | null} */
let match;
while ((match = htmlComment.exec(node.value))) {
if (configCommentStart.test(match[0])) {
// calculate offset of the comment inside the node
const startOffset = match.index + node.position.start.offset;
const endOffset = startOffset + match[0].length;
comments.push(new InlineConfigComment({
value: match[1].trim(),
position: {
start: {
...sourceCode.getLocFromIndex(startOffset),
offset: startOffset,
},
end: {
...sourceCode.getLocFromIndex(endOffset),
offset: endOffset,
},
},
}));
}
}
return comments;
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* Markdown Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: MarkdownLanguageOptions, RootNode: Root, SyntaxElementWithLoc: Node, ConfigNode: { value: string; position: Position }}>}
*/
export class MarkdownSourceCode extends TextSourceCodeBase {
/**
* Cached traversal steps.
* @type {Array<VisitNodeStep>|undefined}
*/
#steps;
/**
* Cache of parent nodes.
* @type {WeakMap<Node, Parent|undefined>}
*/
#parents = new WeakMap();
/**
* Collection of HTML nodes. Used to find directive comments.
* @type {Array<Html>}
*/
#htmlNodes = [];
/**
* Collection of inline configuration comments.
* @type {Array<InlineConfigComment>}
*/
#inlineConfigComments;
/**
* The AST of the source code.
* @type {Root}
*/
ast = undefined;
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {Root} options.ast The root AST node.
*/
constructor({ text, ast }) {
super({ ast, text, lineEndingPattern });
this.ast = ast;
// need to traverse the source code to get the inline config nodes
this.traverse();
}
/**
* Returns the parent of the given node.
* @param {Node} node The node to get the parent of.
* @returns {Parent|undefined} The parent of the node.
*/
getParent(node) {
return this.#parents.get(node);
}
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<InlineConfigComment>} An array of all inline configuration nodes.
*/
getInlineConfigNodes() {
if (!this.#inlineConfigComments) {
this.#inlineConfigComments = this.#htmlNodes.flatMap(htmlNode => extractInlineConfigCommentsFromHTML(htmlNode, this));
}
return this.#inlineConfigComments;
}
/**
* Returns an all directive nodes that enable or disable rules along with any problems
* encountered while parsing the directives.
* @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
* that ESLint needs to further process the directives.
*/
getDisableDirectives() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<Directive>} */
const directives = [];
this.getInlineConfigNodes().forEach(comment => {
// Step 1: Parse the directive
const { label, value, justification: justificationPart, } = commentParser.parseDirective(comment.value);
// Step 2: Validate the directive does not span multiple lines
if (label === "eslint-disable-line" &&
comment.position.start.line !== comment.position.end.line) {
const message = `${label} comment should not span multiple lines.`;
problems.push({
ruleId: null,
message,
loc: comment.position,
});
return;
}
// Step 3: Extract the directive value and create the Directive object
switch (label) {
case "eslint-disable":
case "eslint-enable":
case "eslint-disable-next-line":
case "eslint-disable-line": {
const directiveType = label.slice("eslint-".length);
directives.push(new Directive({
type: /** @type {DirectiveType} */ (directiveType),
node: comment,
value,
justification: justificationPart,
}));
}
// no default
}
});
return { problems, directives };
}
/**
* Returns inline rule configurations along with any problems
* encountered while parsing the configurations.
* @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:Position}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<{config:{rules:RulesConfig},loc:Position}>} */
const configs = [];
this.getInlineConfigNodes().forEach(comment => {
const { label, value } = commentParser.parseDirective(comment.value);
if (label === "eslint") {
const parseResult = commentParser.parseJSONLikeConfig(value);
if (parseResult.ok) {
configs.push({
config: {
rules: parseResult.config,
},
loc: comment.position,
});
}
else {
problems.push({
ruleId: null,
message:
/** @type {{ok: false, error: { message: string }}} */ (parseResult).error.message,
loc: comment.position,
});
}
}
});
return {
configs,
problems,
};
}
/**
* Traverse the source code and return the steps that were taken.
* @returns {Iterable<TraversalStep>} 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();
}
/** @type {Array<VisitNodeStep>} */
const steps = (this.#steps = []);
/**
* Recursively visits a node and its children.
* @param {Node} node The node to visit.
* @param {Parent} [parent] The parent of the node.
* @returns {void}
*/
const visit = (node, parent) => {
// first set the parent
this.#parents.set(node, parent);
// then add the step
steps.push(new VisitNodeStep({
target: node,
phase: 1,
args: [node, parent],
}));
// save HTML nodes
if (node.type === "html") {
this.#htmlNodes.push(/** @type {Html} */ (node));
}
// then visit the children
if ("children" in node) {
const parentNode = /** @type {Parent} */ (node);
parentNode.children.forEach(child => {
visit(child, parentNode);
});
}
// then add the exit step
steps.push(new VisitNodeStep({
target: node,
phase: 2,
args: [node, parent],
}));
};
visit(this.ast);
return steps.values();
}
}
+30
View File
@@ -0,0 +1,30 @@
export namespace processor {
export namespace meta {
let name: string;
let version: string;
}
export { preprocess };
export { postprocess };
export { SUPPORTS_AUTOFIX as supportsAutofix };
}
/**
* Extracts lintable code blocks from Markdown text.
* @param {string} sourceText The text of the file.
* @param {string} filename The filename of the file.
* @returns {Array<{ filename: string, text: string }>} Source code blocks to lint.
*/
declare function preprocess(sourceText: string, filename: string): Array<{
filename: string;
text: string;
}>;
/**
* Transforms generated messages for output.
* @param {Array<LintMessage[]>} messages An array containing one array of messages
* for each code block returned from `preprocess`.
* @param {string} filename The filename of the file
* @returns {LintMessage[]} A flattened array of messages with mapped locations.
*/
declare function postprocess(messages: Array<LintMessage[]>, filename: string): LintMessage[];
declare const SUPPORTS_AUTOFIX: true;
import type { LintMessage } from "@eslint/core";
export {};
+394
View File
@@ -0,0 +1,394 @@
/**
* @fileoverview Processes Markdown files for consumption by ESLint.
* @author Brandon Mills
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { fromMarkdown } from "mdast-util-from-markdown";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { LintMessage, RuleTextEdit, SourceRange } from "@eslint/core";
* @import { Node, Parent, Code, Html } from "mdast";
* @import { Block, RangeMap } from "./types.js";
*/
//-----------------------------------------------------------------------------
// 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
]);
const SUPPORTS_AUTOFIX = true;
const BOM = "\uFEFF";
/**
* @type {Map<string, Block[]>}
*/
const blocksCache = new Map();
/**
* Performs a depth-first traversal of the Markdown AST.
* @param {Node} node A Markdown AST node.
* @param {{[key: string]: (() => void) | ((node: Code) => void) | ((node: Html) => void)}} callbacks A map of node types to callbacks.
* @returns {void}
*/
function traverse(node, callbacks) {
if (callbacks[node.type]) {
/** @type {(node: Node) => void} */ (callbacks[node.type])(node);
}
else {
/** @type {() => void} */ (callbacks["*"])();
}
const parent = /** @type {Parent} */ (node);
if (typeof parent.children !== "undefined") {
for (let i = 0; i < parent.children.length; i++) {
traverse(parent.children[i], callbacks);
}
}
}
/**
* Extracts `eslint-*` or `global` comments from HTML comments if present.
* @param {string} html The text content of an HTML AST node.
* @returns {string} The comment's text without the opening and closing tags or
* an empty string if the text is not an ESLint HTML comment.
*/
function getComment(html) {
const commentStart = "<!--";
const commentEnd = "-->";
const regex = /^(?:eslint\b|global\s)/u;
if (html.slice(0, commentStart.length) !== commentStart ||
html.slice(-commentEnd.length) !== commentEnd) {
return "";
}
const comment = html.slice(commentStart.length, -commentEnd.length);
if (!regex.test(comment.trim())) {
return "";
}
return comment;
}
// Before a code block, blockquote characters (`>`) are also considered
// "whitespace".
const leadingWhitespaceRegex = /^[>\s]*/u;
/**
* Gets the offset for the first column of the node's first line in the
* original source text.
* @param {Code} node A Markdown code block AST node.
* @returns {number} The offset for the first column of the node's first line.
*/
function getBeginningOfLineOffset(node) {
return node.position.start.offset - node.position.start.column + 1;
}
/**
* 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 {Code} 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.
*/
function getIndentText(text, node) {
return leadingWhitespaceRegex.exec(text.slice(getBeginningOfLineOffset(node)))[0];
}
/**
* When applying fixes, the postprocess step needs to know how to map fix ranges
* from their location in the linted JS to the original offset in the Markdown.
* Configuration comments and indentation trimming both complicate this process.
*
* Configuration comments appear in the linted JS but not in the Markdown code
* block. Fixes to configuration comments would cause undefined behavior and
* should be ignored during postprocessing. Fixes to actual code after
* configuration comments need to be mapped back to the code block after
* removing any offset due to configuration comments.
*
* Fenced code blocks can be indented by up to three spaces at the opening
* fence. Inside of a list, for example, this indent can be in addition to the
* indent already required for list item children. Leading whitespace inside
* indented code blocks is trimmed up to the level of the opening fence and does
* not appear in the linted code. Further, lines can have less leading
* whitespace than the opening fence, so not all lines are guaranteed to have
* the same column offset as the opening fence.
*
* The source code of a non-configuration-comment line in the linted JS is a
* suffix of the corresponding line in the Markdown code block. There are no
* 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 {Code} 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
* 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.
*/
function getBlockRangeMap(text, node, comments) {
/*
* The parser sets the fenced code block's start offset to wherever content
* should normally begin (typically the first column of the line, but more
* inside a list item, for example). The code block's opening fence may be
* further indented by up to three characters. If the code block has
* additional indenting, the opening fence's first backtick may be up to
* three whitespace characters after the start offset.
*/
const startOffset = getBeginningOfLineOffset(node);
/*
* Extract the Markdown source to determine the leading whitespace for each
* line.
*/
const code = text.slice(startOffset, node.position.end.offset);
const lines = code.split("\n");
/*
* The parser trims leading whitespace from each line of code within the
* fenced code block up to the opening fence's first backtick. The first
* backtick's column is the AST node's starting column plus any additional
* indentation.
*/
const baseIndent = getIndentText(text, node).length;
/*
* Track the length of any inserted configuration comments at the beginning
* of the linted JS and start the JS offset lookup keys at this index.
*/
const commentLength = comments.reduce((len, comment) => len + comment.length + 1, 0);
/*
* In case there are configuration comments, initialize the map so that the
* first lookup index is always 0. If there are no configuration comments,
* the lookup index will also be 0, and the lookup should always go to the
* last range that matches, skipping this initialization entry.
*/
const rangeMap = [
{
indent: baseIndent,
js: 0,
md: 0,
},
];
// Start the JS offset after any configuration comments.
let jsOffset = commentLength;
/*
* Start the Markdown offset at the beginning of the block's first line of
* actual code. The first line of the block is always the opening fence, so
* the code begins on the second line.
*/
let mdOffset = startOffset + lines[0].length + 1;
/*
* For each line, determine how much leading whitespace was trimmed due to
* indentation. Increase the JS lookup offset by the length of the line
* post-trimming and the Markdown offset by the total line length.
*/
for (let i = 0; i + 1 < lines.length; i++) {
const line = lines[i + 1];
const leadingWhitespaceLength = leadingWhitespaceRegex.exec(line)[0].length;
// The parser trims leading whitespace up to the level of the opening
// fence, so keep any additional indentation beyond that.
const trimLength = Math.min(baseIndent, leadingWhitespaceLength);
rangeMap.push({
indent: trimLength,
js: jsOffset,
// Advance `trimLength` character from the beginning of the Markdown
// line to the beginning of the equivalent JS line, then compute the
// delta.
md: mdOffset + trimLength - jsOffset,
});
// Accumulate the current line in the offsets, and don't forget the
// newline.
mdOffset += line.length + 1;
jsOffset += line.length - trimLength + 1;
}
return rangeMap;
}
const codeBlockFileNameRegex = /filename=(?<quote>["'])(?<filename>.*?)\1/u;
/**
* Parses the file name from a block meta, if available.
* @param {Block} block A code block.
* @returns {string | null | undefined} The filename, if parsed from block meta.
*/
function fileNameFromMeta(block) {
return block.meta
?.match(codeBlockFileNameRegex)
?.groups.filename.replaceAll(/\s+/gu, "_");
}
const languageToFileExtension = {
javascript: "js",
ecmascript: "js",
typescript: "ts",
markdown: "md",
};
/**
* Extracts lintable code blocks from Markdown text.
* @param {string} sourceText The text of the file.
* @param {string} filename The filename of the file.
* @returns {Array<{ filename: string, text: string }>} Source code blocks to lint.
*/
function preprocess(sourceText, filename) {
const text = sourceText.startsWith(BOM) ? sourceText.slice(1) : sourceText;
const ast = fromMarkdown(text);
/** @type {Block[]} */
const blocks = [];
blocksCache.set(filename, blocks);
/**
* During the depth-first traversal, keep track of any sequences of HTML
* comment nodes containing `eslint-*` or `global` comments. If a code
* block immediately follows such a sequence, insert the comments at the
* top of the code block. Any non-ESLint comment or other node type breaks
* and empties the sequence.
* @type {string[]}
*/
let htmlComments = [];
traverse(ast, {
"*"() {
htmlComments = [];
},
/**
* Visit a code node.
* @param {Code} node The visited node.
* @returns {void}
*/
code(node) {
if (node.lang) {
/** @type {string[]} */
const comments = [];
for (const comment of htmlComments) {
if (comment.trim() === "eslint-skip") {
htmlComments = [];
return;
}
comments.push(`/*${comment}*/`);
}
htmlComments = [];
blocks.push({
...node,
baseIndentText: getIndentText(text, node),
comments,
rangeMap: getBlockRangeMap(text, node, comments),
});
}
},
/**
* Visit an HTML node.
* @param {Html} node The visited node.
* @returns {void}
*/
html(node) {
const comment = getComment(node.value);
if (comment) {
htmlComments.push(comment);
}
else {
htmlComments = [];
}
},
});
return blocks.map((block, index) => {
const [language] = block.lang.trim().split(" ");
const fileExtension = Object.hasOwn(languageToFileExtension, language)
? languageToFileExtension[
/** @type {keyof typeof languageToFileExtension} */ (language)]
: language;
return {
filename: fileNameFromMeta(block) ?? `${index}.${fileExtension}`,
text: [...block.comments, block.value, ""].join("\n"),
};
});
}
/**
* Adjusts a fix in a code block.
* @param {Block} block A code block.
* @param {RuleTextEdit} fix A fix to adjust.
* @returns {RuleTextEdit} The fix with adjusted ranges.
*/
function adjustFix(block, fix) {
return {
range: /** @type {SourceRange} */ (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
// then going back one.
let i = 1;
while (i < block.rangeMap.length &&
block.rangeMap[i].js <= range) {
i++;
}
// Apply the mapping delta for this range.
return range + block.rangeMap[i - 1].md;
})),
text: fix.text.replace(/\n/gu, `\n${block.baseIndentText}`),
};
}
/**
* Creates a map function that adjusts messages in a code block.
* @param {Block} block A code block.
* @returns {(message: LintMessage) => LintMessage | null} A function that adjusts messages in a code block.
*/
function adjustBlock(block) {
const leadingCommentLines = block.comments.reduce((count, comment) => count + comment.split("\n").length, 0);
const blockStart = block.position.start.line;
/**
* Adjusts ESLint messages to point to the correct location in the Markdown.
* @param {LintMessage} message A message from ESLint.
* @returns {LintMessage} The same message, but adjusted to the correct location.
*/
return function adjustMessage(message) {
if (!Number.isInteger(message.line)) {
return {
...message,
line: blockStart,
column: block.position.start.column,
};
}
const lineInCode = message.line - leadingCommentLines;
if (lineInCode < 1 || lineInCode >= block.rangeMap.length) {
return null;
}
/** @type {Pick<LintMessage, "line" | "column" | "endLine" | "suggestions">} */
const out = {
line: lineInCode + blockStart,
column: message.column + block.rangeMap[lineInCode].indent,
};
if (Number.isInteger(message.endLine)) {
out.endLine = message.endLine - leadingCommentLines + blockStart;
}
if (Array.isArray(message.suggestions)) {
out.suggestions = message.suggestions.map(suggestion => ({
...suggestion,
fix: adjustFix(block, suggestion.fix),
}));
}
const adjustedFix = {};
if (message.fix) {
adjustedFix.fix = adjustFix(block, message.fix);
}
return { ...message, ...out, ...adjustedFix };
};
}
/**
* Excludes unsatisfiable rules from the list of messages.
* @param {LintMessage} message A message from the linter.
* @returns {boolean} True if the message should be included in output.
*/
function excludeUnsatisfiableRules(message) {
return message && !UNSATISFIABLE_RULES.has(message.ruleId);
}
/**
* Transforms generated messages for output.
* @param {Array<LintMessage[]>} messages An array containing one array of messages
* for each code block returned from `preprocess`.
* @param {string} filename The filename of the file
* @returns {LintMessage[]} A flattened array of messages with mapped locations.
*/
function postprocess(messages, filename) {
const blocks = blocksCache.get(filename);
blocksCache.delete(filename);
return messages.flatMap((group, i) => {
const adjust = adjustBlock(blocks[i]);
return group.map(adjust).filter(excludeUnsatisfiableRules);
});
}
export const processor = {
meta: {
name: "@eslint/markdown/markdown",
version: "8.0.3", // x-release-please-version
},
preprocess,
postprocess,
supportsAutofix: SUPPORTS_AUTOFIX,
};
@@ -0,0 +1,49 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let missingLanguage: string;
let disallowedLanguage: string;
}
let schema: {
type: "object";
properties: {
required: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
required: any[];
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: FencedCodeLanguageOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: FencedCodeLanguageMessageIds;
}>): {
code(node: import("mdast").Code): void;
};
}
export default _default;
export type FencedCodeLanguageMessageIds = "missingLanguage" | "disallowedLanguage";
export type FencedCodeLanguageOptions = [{
required?: string[];
}];
export type FencedCodeLanguageRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: FencedCodeLanguageOptions;
MessageIds: FencedCodeLanguageMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,106 @@
/**
* @fileoverview Rule to enforce languages for fenced code.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"missingLanguage" | "disallowedLanguage"} FencedCodeLanguageMessageIds
* @typedef {[{ required?: string[] }]} FencedCodeLanguageOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: FencedCodeLanguageOptions, MessageIds: FencedCodeLanguageMessageIds }>} FencedCodeLanguageRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const fencedCodeCharacters = new Set(["`", "~"]);
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {FencedCodeLanguageRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Require languages for fenced code blocks",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/fenced-code-language.md",
},
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,
},
],
defaultOptions: [
{
required: [],
},
],
},
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 (!fencedCodeCharacters.has(sourceCode.text[node.position.start.offset])) {
return;
}
/** @type {number} */
let openingCodeFenceEndOffset;
for (openingCodeFenceEndOffset = node.position.start.offset; fencedCodeCharacters.has(sourceCode.text[openingCodeFenceEndOffset]); openingCodeFenceEndOffset++) {
// Find the end offset of the opening code fence.
}
context.report({
loc: {
start: node.position.start,
end: {
line: node.position.start.line,
column: node.position.start.column +
openingCodeFenceEndOffset -
node.position.start.offset,
},
},
messageId: "missingLanguage",
});
return;
}
if (required.size && !required.has(node.lang)) {
const langIndex = sourceCode
.getText(node)
.indexOf(node.lang);
context.report({
loc: {
start: node.position.start,
end: {
line: node.position.start.line,
column: node.position.start.column +
langIndex +
node.lang.length,
},
},
messageId: "disallowedLanguage",
data: {
lang: node.lang,
},
});
}
},
};
},
});
@@ -0,0 +1,35 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let missingMetadata: string;
let disallowedMetadata: string;
}
let schema: {
enum: string[];
}[];
let defaultOptions: ["always"];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: FencedCodeMetaOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: FencedCodeMetaMessageIds;
}>): {
code(node: import("mdast").Code): void;
};
}
export default _default;
export type FencedCodeMetaMessageIds = "missingMetadata" | "disallowedMetadata";
export type FencedCodeMetaOptions = ["always" | "never"];
export type FencedCodeMetaRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: FencedCodeMetaOptions;
MessageIds: FencedCodeMetaMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,82 @@
/**
* @fileoverview Rule to require or disallow metadata for fenced code blocks.
* @author TKDev7
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"missingMetadata" | "disallowedMetadata"} FencedCodeMetaMessageIds
* @typedef {["always" | "never"]} FencedCodeMetaOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: FencedCodeMetaOptions, MessageIds: FencedCodeMetaMessageIds }>} FencedCodeMetaRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {FencedCodeMetaRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: false,
description: "Require or disallow metadata for fenced code blocks",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/fenced-code-meta.md",
},
messages: {
missingMetadata: "Missing code block metadata.",
disallowedMetadata: "Code block metadata is not allowed.",
},
schema: [
{
enum: ["always", "never"],
},
],
defaultOptions: ["always"],
},
create(context) {
const [mode] = context.options;
const { sourceCode } = context;
return {
code(node) {
const lineText = sourceCode.lines[node.position.start.line - 1];
const fenceLineText = lineText.slice(node.position.start.column - 1);
if (mode === "always") {
if (node.lang && !node.meta) {
const langIndex = fenceLineText.indexOf(node.lang);
context.report({
loc: {
start: node.position.start,
end: {
line: node.position.start.line,
column: node.position.start.column +
langIndex +
node.lang.length,
},
},
messageId: "missingMetadata",
});
}
return;
}
if (node.meta) {
const metaIndex = fenceLineText.lastIndexOf(node.meta);
context.report({
loc: {
start: {
line: node.position.start.line,
column: node.position.start.column + metaIndex,
},
end: {
line: node.position.start.line,
column: node.position.start.column +
metaIndex +
node.meta.trimEnd().length,
},
},
messageId: "disallowedMetadata",
});
}
},
};
},
});
@@ -0,0 +1,47 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let skippedHeading: string;
}
let schema: {
type: "object";
properties: {
frontmatterTitle: {
type: "string";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
frontmatterTitle: string;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: HeadingIncrementOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "skippedHeading";
}>): {
yaml(node: import("mdast").Yaml): void;
toml(node: import("../types.js").Toml): void;
json(node: import("../types.js").Json): void;
heading(node: import("mdast").Heading): void;
};
}
export default _default;
export type HeadingIncrementMessageIds = "skippedHeading";
export type HeadingIncrementOptions = [{
frontmatterTitle?: string;
}];
export type HeadingIncrementRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: HeadingIncrementOptions;
MessageIds: HeadingIncrementMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,84 @@
/**
* @fileoverview Rule to enforce heading levels increment by one.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { frontmatterHasTitle } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"skippedHeading"} HeadingIncrementMessageIds
* @typedef {[{ frontmatterTitle?: string }]} HeadingIncrementOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: HeadingIncrementOptions, MessageIds: HeadingIncrementMessageIds }>} HeadingIncrementRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {HeadingIncrementRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Enforce heading levels increment by one",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/heading-increment.md",
},
messages: {
skippedHeading: "Heading level skipped from {{fromLevel}} to {{toLevel}}.",
},
schema: [
{
type: "object",
properties: {
frontmatterTitle: {
type: "string",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
frontmatterTitle: "^(?!\\s*['\"]title[:=]['\"])\\s*\\{?\\s*['\"]?title['\"]?\\s*[:=]",
},
],
},
create(context) {
const [{ frontmatterTitle }] = context.options;
const titlePattern = frontmatterTitle === "" ? null : new RegExp(frontmatterTitle, "iu");
let lastHeadingDepth = 0;
return {
yaml(node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
lastHeadingDepth = 1;
}
},
toml(node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
lastHeadingDepth = 1;
}
},
json(node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
lastHeadingDepth = 1;
}
},
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;
},
};
},
});
+37
View File
@@ -0,0 +1,37 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let url: string;
}
let fixable: "code";
namespace messages {
let bareUrl: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "bareUrl";
}>): {
":matches(heading, paragraph, tableCell) html"(node: Html): void;
":matches(heading, paragraph, tableCell) link"(node: Link): void;
"heading:exit"(): void;
"paragraph:exit"(): void;
"tableCell:exit"(): void;
"root:exit"(): void;
};
}
export default _default;
export type NoBareUrlsMessageIds = "bareUrl";
export type NoBareUrlsOptions = [];
export type NoBareUrlsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoBareUrlsOptions;
MessageIds: NoBareUrlsMessageIds;
}>;
import type { Html } from "mdast";
import type { Link } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
+147
View File
@@ -0,0 +1,147 @@
/**
* @fileoverview Rule to prevent bare URLs in Markdown.
* @author xbinaryx
*/
/*
* Here's a note on how the approach (algorithm) works:
*
* - When entering an `Html` node that is a child of a `Heading`, `Paragraph` or `TableCell`,
* we check whether it is an opening or closing tag.
* If we encounter an opening tag, we store the tag name and set `lastTagName`.
* (`lastTagName` serves as a state to represent whether we're between opening and closing HTML tags.)
* If we encounter a closing tag, we reset the stored tag name and `tempLinkNodes`.
*
* - When entering a `Link` node that is a child of a `Heading`, `Paragraph` or `TableCell`,
* we check whether it is between opening and closing HTML tags.
* If it's between opening and closing HTML tags, we add it to `tempLinkNodes`.
* If it's not between opening and closing HTML tags, we add it to `linkNodes`.
*
* - When exiting a `Heading`, `Paragraph` or `TableCell`, we add all `tempLinkNodes` to `linkNodes`.
* If there are any remaining `tempLinkNodes`, it means they are not between opening and closing HTML tags. (ex. `<br> ... <br>`)
* If there are no remaining `tempLinkNodes`, it means they are between opening and closing HTML tags.
*
* - When exiting a `root` node, we report all `Link` nodes for bare URLs.
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Link, Html } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"bareUrl"} NoBareUrlsMessageIds
* @typedef {[]} NoBareUrlsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoBareUrlsOptions, MessageIds: NoBareUrlsMessageIds }>} NoBareUrlsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const htmlTagNamePattern = /^<(?<tagName>[^!>][^/\s>]*)/u;
/**
* Parses an HTML tag to extract its name and closing status
* @param {string} tagText The HTML tag text to parse
* @returns {{ name: string, isClosing: boolean } | null} Object containing tag name and closing status, or null if not a valid tag
*/
function parseHtmlTag(tagText) {
const match = tagText.match(htmlTagNamePattern);
if (match) {
const tagName = match.groups.tagName.toLowerCase();
const isClosing = tagName.startsWith("/");
return {
name: isClosing ? tagName.slice(1) : tagName,
isClosing,
};
}
return null;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoBareUrlsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow bare URLs",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-bare-urls.md",
},
fixable: "code",
messages: {
bareUrl: "Unexpected bare URL. Use autolink (<URL>) or link ([text](URL)) instead.",
},
},
create(context) {
const { sourceCode } = context;
/**
* This array is used to store all `Link` nodes for the final report.
* @type {Array<Link>}
*/
const linkNodes = [];
/**
* This array is used to store `Link` nodes that are estimated to be between opening and closing HTML tags.
* @type {Array<Link>}
*/
const tempLinkNodes = [];
/** @type {string | null} */
let lastTagName = null;
/**
* Resets `tempLinkNodes` and `lastTagName`
* @returns {void}
*/
function reset() {
tempLinkNodes.length = 0;
lastTagName = null;
}
return {
":matches(heading, paragraph, tableCell) html"(
/** @type {Html} */ node) {
const tagInfo = parseHtmlTag(node.value);
if (!tagInfo) {
return;
}
if (!tagInfo.isClosing && lastTagName === null) {
lastTagName = tagInfo.name;
}
if (tagInfo.isClosing && lastTagName === tagInfo.name) {
reset();
}
},
":matches(heading, paragraph, tableCell) link"(
/** @type {Link} */ node) {
if (lastTagName !== null) {
tempLinkNodes.push(node);
}
else {
linkNodes.push(node);
}
},
"heading:exit"() {
linkNodes.push(...tempLinkNodes);
reset();
},
"paragraph:exit"() {
linkNodes.push(...tempLinkNodes);
reset();
},
"tableCell:exit"() {
linkNodes.push(...tempLinkNodes);
reset();
},
"root:exit"() {
for (const linkNode of linkNodes) {
const text = sourceCode.getText(linkNode);
const { url } = linkNode;
if (url === text ||
url === `http://${text}` ||
url === `mailto:${text}`) {
context.report({
node: linkNode,
messageId: "bareUrl",
fix(fixer) {
return fixer.replaceText(linkNode, `<${text}>`);
},
});
}
}
},
};
},
});
@@ -0,0 +1,66 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let duplicateDefinition: string;
let duplicateFootnoteDefinition: string;
}
let schema: {
type: "object";
properties: {
allowDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowFootnoteDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
checkFootnoteDefinitions: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowDefinitions: string[];
allowFootnoteDefinitions: any[];
checkFootnoteDefinitions: true;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoDuplicateDefinitionsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: NoDuplicateDefinitionsMessageIds;
}>): {
definition(node: Definition): void;
footnoteDefinition(node: FootnoteDefinition): void;
};
}
export default _default;
export type NoDuplicateDefinitionsMessageIds = "duplicateDefinition" | "duplicateFootnoteDefinition";
export type NoDuplicateDefinitionsOptions = [{
allowDefinitions?: string[];
allowFootnoteDefinitions?: string[];
checkFootnoteDefinitions?: boolean;
}];
export type NoDuplicateDefinitionsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoDuplicateDefinitionsOptions;
MessageIds: NoDuplicateDefinitionsMessageIds;
}>;
import type { Definition } from "mdast";
import type { FootnoteDefinition } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,121 @@
/**
* @fileoverview Rule to prevent duplicate definitions in Markdown.
* @author 루밀LuMir(lumirlumir)
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Definition, FootnoteDefinition } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"duplicateDefinition" | "duplicateFootnoteDefinition"} NoDuplicateDefinitionsMessageIds
* @typedef {[{ allowDefinitions?: string[], allowFootnoteDefinitions?: string[], checkFootnoteDefinitions?: boolean }]} NoDuplicateDefinitionsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoDuplicateDefinitionsOptions, MessageIds: NoDuplicateDefinitionsMessageIds }>} NoDuplicateDefinitionsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoDuplicateDefinitionsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow duplicate definitions",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-duplicate-definitions.md",
},
messages: {
duplicateDefinition: "Unexpected duplicate definition `{{ identifier }}` (label: `{{ label }}`) found. First defined at line {{ firstLine }} (label: `{{ firstLabel }}`).",
duplicateFootnoteDefinition: "Unexpected duplicate footnote definition `{{ identifier }}` (label: `{{ label }}`) found. First defined at line {{ firstLine }} (label: `{{ firstLabel }}`).",
},
schema: [
{
type: "object",
properties: {
allowDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowFootnoteDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
checkFootnoteDefinitions: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowDefinitions: ["//"],
allowFootnoteDefinitions: [],
checkFootnoteDefinitions: true,
},
],
},
create(context) {
const allowDefinitions = new Set(context.options[0].allowDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const allowFootnoteDefinitions = new Set(context.options[0].allowFootnoteDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const [{ checkFootnoteDefinitions }] = context.options;
/** @type {Map<string, Definition>} */
const definitions = new Map();
/** @type {Map<string, FootnoteDefinition>} */
const footnoteDefinitions = new Map();
return {
definition(node) {
if (allowDefinitions.has(node.identifier)) {
return;
}
if (definitions.has(node.identifier)) {
const firstDefinitionNode = definitions.get(node.identifier);
context.report({
node,
messageId: "duplicateDefinition",
data: {
identifier: node.identifier,
label: node.label.trim(),
firstLine: firstDefinitionNode.position.start.line,
firstLabel: firstDefinitionNode.label.trim(),
},
});
}
else {
definitions.set(node.identifier, node);
}
},
footnoteDefinition(node) {
if (!checkFootnoteDefinitions ||
allowFootnoteDefinitions.has(node.identifier)) {
return;
}
if (footnoteDefinitions.has(node.identifier)) {
const firstFootnoteDefinitionNode = footnoteDefinitions.get(node.identifier);
context.report({
node,
messageId: "duplicateFootnoteDefinition",
data: {
identifier: node.identifier,
label: node.label,
firstLine: firstFootnoteDefinitionNode.position.start.line,
firstLabel: firstFootnoteDefinitionNode.label,
},
});
}
else {
footnoteDefinitions.set(node.identifier, node);
}
},
};
},
});
@@ -0,0 +1,45 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let url: string;
}
namespace messages {
let duplicateHeading: string;
}
let schema: {
type: "object";
properties: {
checkSiblingsOnly: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkSiblingsOnly: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoDuplicateHeadingsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "duplicateHeading";
}>): {
heading(node: import("mdast").Heading): void;
"heading *"({ type, value }: any): void;
"heading:exit"(node: import("mdast").Heading): void;
};
}
export default _default;
export type NoDuplicateHeadingsMessageIds = "duplicateHeading";
export type NoDuplicateHeadingsOptions = [{
checkSiblingsOnly?: boolean;
}];
export type NoDuplicateHeadingsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoDuplicateHeadingsOptions;
MessageIds: NoDuplicateHeadingsMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,101 @@
/**
* @fileoverview Rule to prevent duplicate headings in Markdown.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"duplicateHeading"} NoDuplicateHeadingsMessageIds
* @typedef {[{ checkSiblingsOnly?: boolean }]} NoDuplicateHeadingsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoDuplicateHeadingsOptions, MessageIds: NoDuplicateHeadingsMessageIds }>} NoDuplicateHeadingsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoDuplicateHeadingsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow duplicate headings in the same document",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-duplicate-headings.md",
},
messages: {
duplicateHeading: 'Duplicate heading "{{text}}" found.',
},
schema: [
{
type: "object",
properties: {
checkSiblingsOnly: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [{ checkSiblingsOnly: false }],
},
create(context) {
const [{ checkSiblingsOnly }] = context.options;
/** @type {Map<number, Set<string>>} */
const headingsByLevel = checkSiblingsOnly
? new Map([
[1, new Set()],
[2, new Set()],
[3, new Set()],
[4, new Set()],
[5, new Set()],
[6, new Set()],
])
: new Map([[1, new Set()]]);
let lastLevel = 1;
let currentLevelHeadings = headingsByLevel.get(lastLevel);
/** @type {string} */
let headingChildrenSequence;
/** @type {string} */
let headingText;
return {
heading(node) {
headingChildrenSequence = "";
headingText = "";
if (checkSiblingsOnly) {
const currentLevel = node.depth;
if (currentLevel < lastLevel) {
for (let level = lastLevel; level > currentLevel; level--) {
headingsByLevel.get(level).clear();
}
}
lastLevel = currentLevel;
currentLevelHeadings = headingsByLevel.get(currentLevel);
}
},
"heading *"({ type, value }) {
if (value) {
headingChildrenSequence += `[${type},${value}]`; // We use a custom sequence representation to keep track of heading children.
if (type !== "html") {
headingText += value;
}
}
else {
headingChildrenSequence += `[${type}]`;
}
},
"heading:exit"(node) {
if (currentLevelHeadings.has(headingChildrenSequence)) {
context.report({
loc: node.position,
messageId: "duplicateHeading",
data: {
text: headingText,
},
});
}
else {
currentLevelHeadings.add(headingChildrenSequence);
}
},
};
},
});
@@ -0,0 +1,64 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let emptyDefinition: string;
let emptyFootnoteDefinition: string;
}
let schema: {
type: "object";
properties: {
allowDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowFootnoteDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
checkFootnoteDefinitions: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowDefinitions: string[];
allowFootnoteDefinitions: any[];
checkFootnoteDefinitions: true;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoEmptyDefinitionsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: NoEmptyDefinitionsMessageIds;
}>): {
definition(node: import("mdast").Definition): void;
footnoteDefinition(node: import("mdast").FootnoteDefinition): void;
};
}
export default _default;
export type NoEmptyDefinitionsMessageIds = "emptyDefinition" | "emptyFootnoteDefinition";
export type NoEmptyDefinitionsOptions = [{
allowDefinitions?: string[];
allowFootnoteDefinitions?: string[];
checkFootnoteDefinitions?: boolean;
}];
export type NoEmptyDefinitionsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoEmptyDefinitionsOptions;
MessageIds: NoEmptyDefinitionsMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,115 @@
/**
* @fileoverview Rule to prevent empty definitions in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
import { htmlCommentPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"emptyDefinition" | "emptyFootnoteDefinition"} NoEmptyDefinitionsMessageIds
* @typedef {[{ allowDefinitions?: string[], allowFootnoteDefinitions?: string[], checkFootnoteDefinitions?: boolean }]} NoEmptyDefinitionsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoEmptyDefinitionsOptions, MessageIds: NoEmptyDefinitionsMessageIds }>} NoEmptyDefinitionsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Checks if a string contains only HTML comments.
* @param {string} value The input string to check.
* @returns {boolean} True if the string contains only HTML comments, false otherwise.
*/
function isOnlyComments(value) {
const withoutComments = value.replace(htmlCommentPattern, "");
return withoutComments.trim().length === 0;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoEmptyDefinitionsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow empty definitions",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-empty-definitions.md",
},
messages: {
emptyDefinition: "Unexpected empty definition `{{ identifier }}` (label: `{{ label }}`) found.",
emptyFootnoteDefinition: "Unexpected empty footnote definition `{{ identifier }}` (label: `{{ label }}`) found.",
},
schema: [
{
type: "object",
properties: {
allowDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowFootnoteDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
checkFootnoteDefinitions: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowDefinitions: ["//"],
allowFootnoteDefinitions: [],
checkFootnoteDefinitions: true,
},
],
},
create(context) {
const allowDefinitions = new Set(context.options[0].allowDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const allowFootnoteDefinitions = new Set(context.options[0].allowFootnoteDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const [{ checkFootnoteDefinitions }] = context.options;
return {
definition(node) {
if ((!node.url || node.url === "#") &&
!allowDefinitions.has(node.identifier)) {
context.report({
loc: node.position,
messageId: "emptyDefinition",
data: {
identifier: node.identifier,
label: node.label.trim(),
},
});
}
},
footnoteDefinition(node) {
if (checkFootnoteDefinitions &&
!allowFootnoteDefinitions.has(node.identifier) &&
(node.children.length === 0 ||
node.children.every(child => child.type === "html" &&
isOnlyComments(child.value)))) {
context.report({
loc: node.position,
messageId: "emptyFootnoteDefinition",
data: {
identifier: node.identifier,
label: node.label,
},
});
}
},
};
},
});
@@ -0,0 +1,30 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let emptyImage: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "emptyImage";
}>): {
image(node: import("mdast").Image): void;
};
}
export default _default;
export type NoEmptyImagesMessageIds = "emptyImage";
export type NoEmptyImagesOptions = [];
export type NoEmptyImagesRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoEmptyImagesOptions;
MessageIds: NoEmptyImagesMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,41 @@
/**
* @fileoverview Rule to prevent empty images in Markdown.
* @author 루밀LuMir(lumirlumir)
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"emptyImage"} NoEmptyImagesMessageIds
* @typedef {[]} NoEmptyImagesOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoEmptyImagesOptions, MessageIds: NoEmptyImagesMessageIds }>} NoEmptyImagesRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoEmptyImagesRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow empty images",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-empty-images.md",
},
messages: {
emptyImage: "Unexpected empty image found.",
},
},
create(context) {
return {
image(node) {
if (!node.url || node.url === "#") {
context.report({
loc: node.position,
messageId: "emptyImage",
});
}
},
};
},
});
@@ -0,0 +1,30 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let emptyLink: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "emptyLink";
}>): {
link(node: import("mdast").Link): void;
};
}
export default _default;
export type NoEmptyLinksMessageIds = "emptyLink";
export type NoEmptyLinksOptions = [];
export type NoEmptyLinksRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoEmptyLinksOptions;
MessageIds: NoEmptyLinksMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
+41
View File
@@ -0,0 +1,41 @@
/**
* @fileoverview Rule to prevent empty links in Markdown.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"emptyLink"} NoEmptyLinksMessageIds
* @typedef {[]} NoEmptyLinksOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoEmptyLinksOptions, MessageIds: NoEmptyLinksMessageIds }>} NoEmptyLinksRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoEmptyLinksRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow empty links",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-empty-links.md",
},
messages: {
emptyLink: "Unexpected empty link found.",
},
},
create(context) {
return {
link(node) {
if (!node.url || node.url === "#") {
context.report({
loc: node.position,
messageId: "emptyLink",
});
}
},
};
},
});
+52
View File
@@ -0,0 +1,52 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let description: string;
let url: string;
}
namespace messages {
let disallowedElement: string;
}
let schema: {
type: "object";
properties: {
allowed: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowedIgnoreCase: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowed: any[];
allowedIgnoreCase: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoHtmlOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "disallowedElement";
}>): {
html(node: import("mdast").Html): void;
};
}
export default _default;
export type NoHtmlMessageIds = "disallowedElement";
export type NoHtmlOptions = [{
allowed?: string[];
allowedIgnoreCase?: boolean;
}];
export type NoHtmlRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoHtmlOptions;
MessageIds: NoHtmlMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
+103
View File
@@ -0,0 +1,103 @@
/**
* @fileoverview Rule to disallow HTML inside of content.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { lineEndingPattern, stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"disallowedElement"} NoHtmlMessageIds
* @typedef {[{ allowed?: string[], allowedIgnoreCase?: boolean }]} NoHtmlOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoHtmlOptions, MessageIds: NoHtmlMessageIds }>} NoHtmlRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const htmlTagPattern = /<(?<tagName>[a-z0-9]+(?:-[a-z0-9]+)*)(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?\/?>/giu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoHtmlRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
description: "Disallow HTML tags",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-html.md",
},
messages: {
disallowedElement: 'HTML element "{{name}}" is not allowed.',
},
schema: [
{
type: "object",
properties: {
allowed: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowedIgnoreCase: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowed: [],
allowedIgnoreCase: false,
},
],
},
create(context) {
const { sourceCode } = context;
const [{ allowed, allowedIgnoreCase }] = context.options;
/**
* Normalize a tag name based on the `allowedIgnoreCase` option.
* @param {string} tagName The tag name to normalize.
* @returns {string} The normalized tag name.
*/
function normalizeTagName(tagName) {
return allowedIgnoreCase ? tagName.toLowerCase() : tagName;
}
const allowedElements = new Set(allowed.map(normalizeTagName));
return {
html(node) {
const text = stripHtmlComments(sourceCode.getText(node));
/** @type {RegExpExecArray | null} */
let match;
while ((match = htmlTagPattern.exec(text)) !== null) {
const fullMatch = match[0];
const { tagName } = match.groups;
const firstNewlineIndex = fullMatch.search(lineEndingPattern);
const startOffset = // Adjust `htmlTagPattern` match index to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset +
(firstNewlineIndex === -1
? fullMatch.length
: firstNewlineIndex);
if (!allowedElements.has(normalizeTagName(tagName))) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "disallowedElement",
data: {
name: tagName,
},
});
}
}
},
};
},
});
@@ -0,0 +1,32 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let invalidLabelRef: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "invalidLabelRef";
}>): {
text(node: Text): void;
};
}
export default _default;
export type NoInvalidLabelRefsMessageIds = "invalidLabelRef";
export type NoInvalidLabelRefsOptions = [];
export type NoInvalidLabelRefsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoInvalidLabelRefsOptions;
MessageIds: NoInvalidLabelRefsMessageIds;
}>;
import type { MarkdownSourceCode } from "../language/markdown-source-code.js";
import type { Text } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,117 @@
/**
* @fileoverview Rule to prevent non-complaint link references.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { illegalShorthandTailPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Position } from "unist";
* @import { Text } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @import { MarkdownSourceCode } from "../language/markdown-source-code.js";
* @typedef {"invalidLabelRef"} NoInvalidLabelRefsMessageIds
* @typedef {[]} NoInvalidLabelRefsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoInvalidLabelRefsOptions, MessageIds: NoInvalidLabelRefsMessageIds }>} NoInvalidLabelRefsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/** matches i.e., `[foo][bar]` */
const labelPattern = /\]\[([^\]]+)\]/u;
/**
* Finds missing references in a node.
* @param {Text} node The node to check.
* @param {MarkdownSourceCode} sourceCode The Markdown source code object.
* @returns {Array<{label:string,position:Position}>} The missing references.
*/
function findInvalidLabelReferences(node, sourceCode) {
const nodeText = sourceCode.getText(node);
const docText = sourceCode.text;
const invalid = [];
let startIndex = 0;
/*
* 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 < nodeText.length) {
const value = nodeText.slice(startIndex);
const match = value.match(labelPattern);
if (!match) {
break;
}
if (!illegalShorthandTailPattern.test(match[0])) {
startIndex += match.index + match[0].length;
continue;
}
/*
* Adjust `labelPattern` match index to the full source code.
*/
const startOffset = startIndex + match.index + node.position.start.offset;
const endOffset = startOffset + match[0].length;
/*
* Search the entire document text to find the preceding open bracket.
*/
const lastOpenBracketIndex = docText.lastIndexOf("[", startOffset);
if (lastOpenBracketIndex === -1) {
startIndex += match.index + match[0].length;
continue;
}
/*
* 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, endOffset)
.match(/!?\[([^\]]+)\]/u)[1];
invalid.push({
label: label.trim(),
position: {
start: sourceCode.getLocFromIndex(startOffset + 1),
end: sourceCode.getLocFromIndex(endOffset),
},
});
startIndex += match.index + match[0].length;
}
return invalid;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoInvalidLabelRefsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow invalid label references",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-invalid-label-refs.md",
},
messages: {
invalidLabelRef: "Label reference '{{label}}' is invalid due to white space between [ and ].",
},
},
create(context) {
const { sourceCode } = context;
return {
text(node) {
const invalidReferences = findInvalidLabelReferences(node, sourceCode);
for (const invalidReference of invalidReferences) {
context.report({
loc: invalidReference.position,
messageId: "invalidLabelRef",
data: {
label: invalidReference.label,
},
});
}
},
};
},
});
@@ -0,0 +1,46 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "whitespace";
namespace messages {
let missingSpace: string;
}
let schema: {
type: "object";
properties: {
checkClosedHeadings: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkClosedHeadings: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoMissingAtxHeadingSpaceOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "missingSpace";
}>): {
heading(node: import("mdast").Heading): void;
paragraph(node: import("mdast").Paragraph): void;
};
}
export default _default;
export type NoMissingAtxHeadingSpaceMessageIds = "missingSpace";
export type NoMissingAtxHeadingSpaceOptions = [{
checkClosedHeadings?: boolean;
}];
export type NoMissingAtxHeadingSpaceRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMissingAtxHeadingSpaceOptions;
MessageIds: NoMissingAtxHeadingSpaceMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,101 @@
/**
* @fileoverview Rule to ensure there is a space after hash on ATX style headings in Markdown.
* @author Sweta Tanwar (@SwetaTanwar)
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"missingSpace"} NoMissingAtxHeadingSpaceMessageIds
* @typedef {[{ checkClosedHeadings?: boolean }]} NoMissingAtxHeadingSpaceOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMissingAtxHeadingSpaceOptions, MessageIds: NoMissingAtxHeadingSpaceMessageIds }>} NoMissingAtxHeadingSpaceRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const leadingAtxHeadingHashPattern = /(?:^|(?<=[\r\n]))(?<hashes>#{1,6})(?:[^# \t]|$)/gu;
const trailingAtxHeadingHashPattern = /(?<![ \t])(?<spaces>[ \t]*)(?<=(?<!\\)(?:\\{2})*)(?<hashes>#+)[ \t]*$/u;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMissingAtxHeadingSpaceRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow headings without a space after the hash characters",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-atx-heading-space.md",
},
fixable: "whitespace",
messages: {
missingSpace: "Missing space {{position}} hash(es) on ATX style heading.",
},
schema: [
{
type: "object",
properties: {
checkClosedHeadings: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [{ checkClosedHeadings: false }],
},
create(context) {
const { sourceCode } = context;
const [{ checkClosedHeadings }] = context.options;
return {
heading(node) {
if (!checkClosedHeadings) {
return;
}
const text = sourceCode.getText(node);
const match = trailingAtxHeadingHashPattern.exec(text);
if (match === null) {
return;
}
const { spaces, hashes } = match.groups;
if (spaces.length > 0) {
return;
}
const startOffset = node.position.start.offset + match.index;
const endOffset = startOffset + hashes.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset - 1),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "missingSpace",
data: { position: "before" },
fix(fixer) {
return fixer.insertTextBeforeRange([startOffset, startOffset + 1], " ");
},
});
},
paragraph(node) {
const text = sourceCode.getText(node);
/** @type {RegExpExecArray | null} */
let match;
while ((match = leadingAtxHeadingHashPattern.exec(text)) !== null) {
const { hashes } = match.groups;
const startOffset = node.position.start.offset + match.index;
const endOffset = startOffset + hashes.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset + 1),
},
messageId: "missingSpace",
data: { position: "after" },
fix(fixer) {
return fixer.insertTextAfterRange([endOffset - 1, endOffset], " ");
},
});
}
},
};
},
});
@@ -0,0 +1,52 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let schema: {
type: "object";
properties: {
allowLabels: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowLabels: any[];
}];
namespace messages {
let notFound: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: MarkdownSourceCode;
RuleOptions: NoMissingLabelRefsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "notFound";
}>): {
"root:exit"(): void;
text(node: Text): void;
definition(node: import("mdast").Definition): void;
};
}
export default _default;
export type NoMissingLabelRefsMessageIds = "notFound";
export type NoMissingLabelRefsOptions = [{
allowLabels?: string[];
}];
export type NoMissingLabelRefsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMissingLabelRefsOptions;
MessageIds: NoMissingLabelRefsMessageIds;
}>;
import type { MarkdownSourceCode } from "../language/markdown-source-code.js";
import type { Text } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,147 @@
/**
* @fileoverview Rule to prevent missing label references in Markdown.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { illegalShorthandTailPattern } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Position } from "unist";
* @import { Text } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @import { MarkdownSourceCode } from "../language/markdown-source-code.js";
* @typedef {"notFound"} NoMissingLabelRefsMessageIds
* @typedef {[{ allowLabels?: string[] }]} NoMissingLabelRefsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMissingLabelRefsOptions, MessageIds: NoMissingLabelRefsMessageIds }>} NoMissingLabelRefsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Finds missing references in a node.
* @param {Text} node The node to check.
* @param {MarkdownSourceCode} sourceCode The Markdown source code object.
* @returns {Array<{label:string,position:Position}>} The missing references.
*/
function findMissingReferences(node, sourceCode) {
/** @type {Array<{label:string,position:Position}>} */
const missing = [];
const nodeText = sourceCode.getText(node);
/**
* Matches substrings like `"[foo]"`, `"[]"`, `"[foo][bar]"`, `"[foo][]"`, `"[][bar]"`, or `"[][]"`.
* `left` is the content between the first brackets. It can be empty.
* `right` is the content between the second brackets. It can be empty, and it can be undefined.
*/
const labelPattern = /(?<=(?<!\\)(?:\\{2})*)\[(?<left>(?:\\.|[^[\]\\])*)\](?:\[(?<right>(?:\\.|[^\]\\])*)\])?/dgu;
/** @type {RegExpExecArray | null} */
let match;
/*
* This loop searches the text inside the node for sequences that
* look like label references and reports an error for each one found.
*/
while ((match = labelPattern.exec(nodeText))) {
// skip illegal shorthand tail -- handled by no-invalid-label-refs
if (illegalShorthandTailPattern.test(match[0])) {
continue;
}
const { left, right } = match.groups;
// `[][]` or `[]`
if (!left && !right) {
continue;
}
let label, labelIndices;
if (right) {
label = right;
labelIndices = match.indices.groups.right;
}
else {
label = left;
labelIndices = match.indices.groups.left;
}
const startOffset = labelIndices[0] + node.position.start.offset;
const endOffset = labelIndices[1] + node.position.start.offset;
missing.push({
label: label.trim(),
position: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
});
}
return missing;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMissingLabelRefsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow missing label references",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-label-refs.md",
},
schema: [
{
type: "object",
properties: {
allowLabels: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowLabels: [],
},
],
messages: {
notFound: "Label reference '{{label}}' not found.",
},
},
create(context) {
const { sourceCode } = context;
const allowLabels = new Set(context.options[0].allowLabels);
/** @type {Array<{label:string,position:Position}>} */
let allMissingReferences = [];
return {
"root:exit"() {
for (const missingReference of allMissingReferences) {
context.report({
loc: missingReference.position,
messageId: "notFound",
data: {
label: missingReference.label,
},
});
}
},
text(node) {
const missingReferences = findMissingReferences(node, sourceCode);
for (const missingReference of missingReferences) {
if (!allowLabels.has(missingReference.label)) {
allMissingReferences.push(missingReference);
}
}
},
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);
},
};
},
});
@@ -0,0 +1,56 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let schema: {
type: "object";
properties: {
ignoreCase: {
type: "boolean";
};
allowPattern: {
type: "string";
};
};
additionalProperties: false;
}[];
namespace messages {
let invalidFragment: string;
}
let defaultOptions: [{
ignoreCase: true;
allowPattern: string;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoMissingLinkFragmentsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "invalidFragment";
}>): {
heading(): void;
"heading *:not(html)"({ value }: any): void;
"heading:exit"(): void;
html(node: import("mdast").Html): void;
"definition, link"(node: Definition | Link): void;
"root:exit"(): void;
};
}
export default _default;
export type NoMissingLinkFragmentsMessageIds = "invalidFragment";
export type NoMissingLinkFragmentsOptions = [{
ignoreCase?: boolean;
allowPattern?: string;
}];
export type NoMissingLinkFragmentsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMissingLinkFragmentsOptions;
MessageIds: NoMissingLinkFragmentsMessageIds;
}>;
import type { Definition } from "mdast";
import type { Link } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,141 @@
/**
* @fileoverview Rule to ensure link fragments (URLs that start with #) reference valid headings
* @author Sweta Tanwar (@SwetaTanwar)
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import GithubSlugger from "github-slugger";
import { stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Definition, Link } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"invalidFragment"} NoMissingLinkFragmentsMessageIds
* @typedef {[{ ignoreCase?: boolean; allowPattern?: string }]} NoMissingLinkFragmentsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMissingLinkFragmentsOptions, MessageIds: NoMissingLinkFragmentsMessageIds }>} NoMissingLinkFragmentsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const githubLineReferencePattern = /^L\d+(?:C\d+)?(?:-L\d+(?:C\d+)?)?$/u;
const customHeadingIdPattern = /\{#(?<id>[^}\s]+)\}\s*$/u;
const htmlHeadingPattern = /<h(?<depth>[1-6])[^>]*>(?<children>[\s\S]*?)<\/h\k<depth>\s*>/giu;
const htmlIdNamePattern = /(?<!<)<[^>]+\s(?:id|name)\s*=\s*["']?(?<id>[^"'\s>]+)["']?/giu;
const htmlTagPattern = /<\/?[a-z0-9]+(?:-[a-z0-9]+)*(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?(?:\/\s*)?>/giu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMissingLinkFragmentsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow link fragments that do not reference valid headings",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-missing-link-fragments.md",
},
schema: [
{
type: "object",
properties: {
ignoreCase: {
type: "boolean",
},
allowPattern: {
type: "string",
},
},
additionalProperties: false,
},
],
messages: {
invalidFragment: "Link fragment '#{{fragment}}' does not reference a heading or anchor in this document.",
},
defaultOptions: [
{
ignoreCase: true,
allowPattern: "",
},
],
},
create(context) {
const [{ allowPattern, ignoreCase }] = context.options;
const allowPatternOrNull = allowPattern
? new RegExp(allowPattern, "u")
: null;
const fragmentIds = new Set(["top"]);
const slugger = new GithubSlugger();
/** @type {Array<Definition | Link>} */
const relevantNodes = [];
/** @type {string} */
let headingText;
return {
heading() {
headingText = "";
},
"heading *:not(html)"({ value }) {
headingText += value ?? "";
},
"heading:exit"() {
const customIdMatch = headingText.match(customHeadingIdPattern);
const id = customIdMatch
? customIdMatch.groups.id
: headingText;
fragmentIds.add(slugger.slug(id));
},
html(node) {
// 1. Remove all comments
const htmlTextWithoutComments = stripHtmlComments(node.value);
// 2. Then look for IDs in the remaining text
for (const match of htmlTextWithoutComments.matchAll(htmlIdNamePattern)) {
const { id } = match.groups;
fragmentIds.add(slugger.slug(id));
}
// 3. Finally, look for headings in the HTML
for (const match of htmlTextWithoutComments.matchAll(htmlHeadingPattern)) {
const { children } = match.groups;
// Remove any HTML tags within the heading content to get plain text
const id = children.replace(htmlTagPattern, "");
fragmentIds.add(slugger.slug(id));
}
},
"definition, link"(/** @type {Definition | Link} */ node) {
const { url } = node;
// If `url` is empty, `"#"`, or does not start with `"#"`, skip it.
if (url === "" || url === "#" || !url.startsWith("#")) {
return;
}
relevantNodes.push(node);
},
"root:exit"() {
for (const node of relevantNodes) {
const fragment = node.url.slice(1);
let decodedFragment = fragment;
// Decode URI component to handle encoded characters such as `%20`.
try {
decodedFragment = decodeURIComponent(fragment);
}
catch {
// If decoding fails due to an invalid URI sequence, use the original fragment.
}
if (allowPatternOrNull?.test(decodedFragment) ||
githubLineReferencePattern.test(decodedFragment)) {
continue;
}
const normalizedFragment = ignoreCase
? decodedFragment.toLowerCase()
: decodedFragment;
if (!fragmentIds.has(normalizedFragment)) {
context.report({
loc: node.position,
messageId: "invalidFragment",
data: { fragment },
});
}
}
},
};
},
});
@@ -0,0 +1,49 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let multipleH1: string;
}
let schema: {
type: "object";
properties: {
frontmatterTitle: {
type: "string";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
frontmatterTitle: string;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoMultipleH1Options;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "multipleH1";
}>): {
"yaml, toml, json"(node: Yaml | Toml | Json): void;
html(node: import("mdast").Html): void;
heading(node: import("mdast").Heading): void;
};
}
export default _default;
export type NoMultipleH1MessageIds = "multipleH1";
export type NoMultipleH1Options = [{
frontmatterTitle?: string;
}];
export type NoMultipleH1RuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoMultipleH1Options;
MessageIds: NoMultipleH1MessageIds;
}>;
import type { Yaml } from "mdast";
import type { Toml } from "../types.js";
import type { Json } from "../types.js";
import type { MarkdownRuleDefinition } from "../types.js";
+98
View File
@@ -0,0 +1,98 @@
/**
* @fileoverview Rule to enforce at most one H1 heading in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { frontmatterHasTitle, stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Yaml } from "mdast";
* @import { MarkdownRuleDefinition, Toml, Json } from "../types.js";
* @typedef {"multipleH1"} NoMultipleH1MessageIds
* @typedef {[{ frontmatterTitle?: string }]} NoMultipleH1Options
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoMultipleH1Options, MessageIds: NoMultipleH1MessageIds }>} NoMultipleH1RuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const h1TagPattern = /<h1[^>]*>[\s\S]*?<\/h1\s*>/giu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoMultipleH1RuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow multiple H1 headings in the same document",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-multiple-h1.md",
},
messages: {
multipleH1: "Unexpected additional H1 heading found.",
},
schema: [
{
type: "object",
properties: {
frontmatterTitle: {
type: "string",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
frontmatterTitle: "^(?!\\s*['\"]title[:=]['\"])\\s*\\{?\\s*['\"]?title['\"]?\\s*[:=]",
},
],
},
create(context) {
const { sourceCode } = context;
const [{ frontmatterTitle }] = context.options;
const titlePattern = frontmatterTitle === "" ? null : new RegExp(frontmatterTitle, "iu");
let h1Count = 0;
return {
"yaml, toml, json"(/** @type {Yaml | Toml | Json} */ node) {
if (frontmatterHasTitle(node.value, titlePattern)) {
h1Count++;
}
},
html(node) {
const text = stripHtmlComments(node.value);
/** @type {RegExpExecArray | null} */
let match;
while ((match = h1TagPattern.exec(text)) !== null) {
h1Count++;
if (h1Count > 1) {
const startOffset = // Adjust `h1TagPattern` match index to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset + match[0].length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "multipleH1",
});
}
}
},
heading(node) {
if (node.depth === 1) {
h1Count++;
if (h1Count > 1) {
context.report({
loc: node.position,
messageId: "multipleH1",
});
}
}
},
};
},
});
@@ -0,0 +1,35 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "code";
namespace messages {
let referenceLikeUrl: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "referenceLikeUrl";
}>): {
definition(node: import("mdast").Definition): void;
"image, link"(node: Image | Link): void;
"root:exit"(): void;
};
}
export default _default;
export type NoReferenceLikeUrlsMessageIds = "referenceLikeUrl";
export type NoReferenceLikeUrlsOptions = [];
export type NoReferenceLikeUrlsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoReferenceLikeUrlsOptions;
MessageIds: NoReferenceLikeUrlsMessageIds;
}>;
import type { Image } from "mdast";
import type { Link } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,84 @@
/**
* @fileoverview Rule to enforce reference-style links when URL matches a defined identifier.
* @author TKDev7
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Image, Link } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"referenceLikeUrl"} NoReferenceLikeUrlsMessageIds
* @typedef {[]} NoReferenceLikeUrlsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoReferenceLikeUrlsOptions, MessageIds: NoReferenceLikeUrlsMessageIds }>} NoReferenceLikeUrlsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/** Pattern to match both inline links: `[text](url)` and images: `![alt](url)`, with optional title */
const linkOrImagePattern = /\[(?<label>(?:\\.|[^()\\]|\([\s\S]*\))*?)\]\((?<destination>[ \t]*\r?\n?(?<![ \t])[ \t]*(?:<[^>]*>|[^ \t()]+))(?:[ \t]*\r?\n?(?<![ \t])[ \t]*(?:"[^"]*"|'[^']*'|\([^)]*\)))?[ \t]*\r?\n?(?<![ \t])[ \t]*\)$/u;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoReferenceLikeUrlsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow URLs that match defined reference identifiers",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-reference-like-urls.md",
},
fixable: "code",
messages: {
referenceLikeUrl: "Unexpected resource {{type}} ('{{prefix}}[text](url)') with URL that matches a definition identifier. Use '[text][id]' syntax instead.",
},
},
create(context) {
const { sourceCode } = context;
/** @type {Set<string>} */
const definitionIdentifiers = new Set();
/** @type {Array<Image | Link>} */
const relevantNodes = [];
return {
definition(node) {
definitionIdentifiers.add(node.identifier);
},
"image, link"(/** @type {Image | Link} */ node) {
relevantNodes.push(node);
},
"root:exit"() {
for (const node of relevantNodes) {
const text = sourceCode.getText(node);
const match = linkOrImagePattern.exec(text);
if (match !== null) {
const { label, destination } = match.groups;
const { type, title } = node;
const prefix = type === "image" ? "!" : "";
const url = normalizeIdentifier(destination).toLowerCase();
if (definitionIdentifiers.has(url)) {
context.report({
loc: node.position,
messageId: "referenceLikeUrl",
data: {
type,
prefix,
},
fix(fixer) {
// The AST treats both missing and empty titles as null, so it's safe to auto-fix in both cases.
if (title) {
return null;
}
return fixer.replaceText(node, `${prefix}[${label}][${destination}]`);
},
});
}
}
}
},
};
},
});
@@ -0,0 +1,42 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "code";
namespace messages {
let reversedSyntax: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "reversedSyntax";
}>): {
"heading, paragraph, tableCell"(node: Heading | Paragraph | TableCell): void;
":matches(heading, paragraph, tableCell) :matches(html, image, imageReference, inlineCode, linkReference, inlineMath)"(node: Html | Image | ImageReference | InlineCode | LinkReference | InlineMath): void;
":matches(heading, paragraph, tableCell):exit"(): void;
};
}
export default _default;
export type NoReversedMediaSyntaxMessageIds = "reversedSyntax";
export type NoReversedMediaSyntaxOptions = [];
export type NoReversedMediaSyntaxRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoReversedMediaSyntaxOptions;
MessageIds: NoReversedMediaSyntaxMessageIds;
}>;
import type { Heading } from "mdast";
import type { Paragraph } from "mdast";
import type { TableCell } from "mdast";
import type { Html } from "mdast";
import type { Image } from "mdast";
import type { ImageReference } from "mdast";
import type { InlineCode } from "mdast";
import type { LinkReference } from "mdast";
import type { InlineMath } from "mdast-util-math";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,85 @@
/**
* @fileoverview Rule to prevent reversed link and image syntax in Markdown.
* @author xbinaryx
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Heading, Paragraph, TableCell, Html, Image, ImageReference, InlineCode, LinkReference } from "mdast";
* @import { InlineMath } from "mdast-util-math";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"reversedSyntax"} NoReversedMediaSyntaxMessageIds
* @typedef {[]} NoReversedMediaSyntaxOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoReversedMediaSyntaxOptions, MessageIds: NoReversedMediaSyntaxMessageIds }>} NoReversedMediaSyntaxRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/** Matches reversed link/image syntax like `(text)[url]`, ignoring escaped characters like `\(text\)[url]`. */
const reversedPattern = /(?<=(?<!\\)(?:\\{2})*)\((?<label>(?:\\.|[^()\\]|\([\s\S]*\))*)\)\[(?<url>(?:\\.|[^\]\\\r\n])*)\](?!\()/gu;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoReversedMediaSyntaxRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow reversed link and image syntax",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-reversed-media-syntax.md",
},
fixable: "code",
messages: {
reversedSyntax: "Unexpected reversed syntax found. Use [label](URL) syntax instead.",
},
},
create(context) {
const { sourceCode } = context;
/** @type {string[]} */
let buffer;
/** @type {number} */
let nodeStartOffset;
return {
"heading, paragraph, tableCell"(
/** @type {Heading | Paragraph | TableCell} */ node) {
// Use UTF-16 code units so the buffer stays aligned with source offsets.
buffer = sourceCode.getText(node).split("");
// Store the start offset of the node for later calculations.
nodeStartOffset = node.position.start.offset;
},
":matches(heading, paragraph, tableCell) :matches(html, image, imageReference, inlineCode, linkReference, inlineMath)"(
/** @type {Html | Image | ImageReference | InlineCode | LinkReference | InlineMath} */ node) {
const [startOffset, endOffset] = sourceCode.getRange(node);
// Mask the content of `html`, `image`, `imageReference`, `inlineCode`, `linkReference`, and `inlineMath` nodes with whitespaces.
for (let i = startOffset; i < endOffset; i++) {
buffer[i - nodeStartOffset] = " ";
}
},
":matches(heading, paragraph, tableCell):exit"() {
const maskedText = buffer.join("");
/** @type {RegExpExecArray | null} */
let match;
while ((match = reversedPattern.exec(maskedText)) !== null) {
const { label, url } = match.groups;
const startOffset = match.index + nodeStartOffset; // Adjust `reversedPattern` match index to the full source code.
const endOffset = startOffset + match[0].length;
const labelStartOffset = startOffset + 1; // Skip "("
const labelEndOffset = labelStartOffset + label.length;
const urlStartOffset = labelEndOffset + 2; // Skip ")["
const urlEndOffset = urlStartOffset + url.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "reversedSyntax",
fix(fixer) {
return fixer.replaceTextRange([startOffset, endOffset], `[${sourceCode.text.slice(labelStartOffset, labelEndOffset)}](${sourceCode.text.slice(urlStartOffset, urlEndOffset)})`);
},
});
}
},
};
},
});
@@ -0,0 +1,51 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
let fixable: "whitespace";
namespace messages {
let spaceInEmphasis: string;
}
let schema: {
type: "object";
properties: {
checkStrikethrough: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkStrikethrough: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoSpaceInEmphasisOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "spaceInEmphasis";
}>): {
"heading, paragraph, tableCell"(node: Heading | Paragraph | TableCell): void;
":matches(heading, paragraph, tableCell) > text"(node: Text): void;
":matches(heading, paragraph, tableCell):exit"(node: Heading | Paragraph | TableCell): void;
};
}
export default _default;
export type NoSpaceInEmphasisMessageIds = "spaceInEmphasis";
export type NoSpaceInEmphasisOptions = [{
checkStrikethrough?: boolean;
}];
export type NoSpaceInEmphasisRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoSpaceInEmphasisOptions;
MessageIds: NoSpaceInEmphasisMessageIds;
}>;
import type { Heading } from "mdast";
import type { Paragraph } from "mdast";
import type { TableCell } from "mdast";
import type { Text } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,134 @@
/**
* @fileoverview Rule to prevent spaces around emphasis markers in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { SourceRange } from "@eslint/core";
* @import { Heading, Paragraph, TableCell, Text } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"spaceInEmphasis"} NoSpaceInEmphasisMessageIds
* @typedef {[{ checkStrikethrough?: boolean }]} NoSpaceInEmphasisOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoSpaceInEmphasisOptions, MessageIds: NoSpaceInEmphasisMessageIds }>} NoSpaceInEmphasisRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const whitespacePattern = /[ \t]/u;
/**
* Creates a marker pattern based on whether strikethrough should be included.
* @param {boolean} checkStrikethrough Whether to include strikethrough markers.
* @returns {RegExp} The marker pattern.
*/
function createMarkerPattern(checkStrikethrough) {
return checkStrikethrough
? /(?<=(?<!\\)(?:\\{2})*)(?:\*\*\*|\*\*|\*|___|__|_|~~|~)/gu
: /(?<=(?<!\\)(?:\\{2})*)(?:\*\*\*|\*\*|\*|___|__|_)/gu;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoSpaceInEmphasisRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow spaces around emphasis markers",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-space-in-emphasis.md",
},
fixable: "whitespace",
messages: {
spaceInEmphasis: "Unexpected space around emphasis marker.",
},
schema: [
{
type: "object",
properties: {
checkStrikethrough: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
checkStrikethrough: false,
},
],
},
create(context) {
const { sourceCode } = context;
const [{ checkStrikethrough }] = context.options;
const markerPattern = createMarkerPattern(checkStrikethrough);
/** @type {string[]} */
let buffer;
/**
* Reports a surrounding-space violation if present.
* @param {number} checkIndex Character index to test for whitespace.
* @param {number} highlightStartIndex Start index for highlighting.
* @param {number} highlightEndIndex End index for highlighting.
* @returns {void}
*/
function reportWhitespace(checkIndex, highlightStartIndex, highlightEndIndex) {
if (whitespacePattern.test(sourceCode.text[checkIndex])) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(highlightStartIndex),
end: sourceCode.getLocFromIndex(highlightEndIndex),
},
messageId: "spaceInEmphasis",
fix(fixer) {
return fixer.removeRange([checkIndex, checkIndex + 1]);
},
});
}
}
return {
"heading, paragraph, tableCell"(
/** @type {Heading | Paragraph | TableCell} */ node) {
const [startOffset, endOffset] = sourceCode.getRange(node);
// Initialize `buffer` with a whitespace-masked character array.
buffer = new Array(endOffset - startOffset).fill(" ");
},
":matches(heading, paragraph, tableCell) > text"(
/** @type {Text} */ node) {
const [startOffset, endOffset] = sourceCode.getRange(node);
const parentNodeStartOffset = // Parent node can be `Heading`, `Paragraph`, or `TableCell`.
sourceCode.getParent(node).position.start.offset;
// Add the content of a `Text` node into the current buffer at the correct offsets.
for (let i = startOffset; i < endOffset; i++) {
buffer[i - parentNodeStartOffset] = sourceCode.text[i];
}
},
":matches(heading, paragraph, tableCell):exit"(
/** @type {Heading | Paragraph | TableCell} */ node) {
const maskedText = buffer.join("");
/** @type {Map<string, SourceRange[]>} */
const markerGroups = new Map();
/** @type {RegExpExecArray | null} */
let match;
while ((match = markerPattern.exec(maskedText)) !== null) {
const marker = match[0];
const startOffset = // Adjust `markerPattern` match index to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset + marker.length;
if (!markerGroups.has(marker)) {
markerGroups.set(marker, []);
}
markerGroups.get(marker).push([startOffset, endOffset]);
}
for (const group of markerGroups.values()) {
for (let i = 0; i < group.length - 1; i += 2) {
const startMarker = group[i];
reportWhitespace(startMarker[1], startMarker[0], startMarker[1] + 2);
const endMarker = group[i + 1];
reportWhitespace(endMarker[0] - 1, endMarker[0] - 2, endMarker[1]);
}
}
},
};
},
});
@@ -0,0 +1,70 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let unusedDefinition: string;
let unusedFootnoteDefinition: string;
}
let schema: {
type: "object";
properties: {
allowDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
allowFootnoteDefinitions: {
type: "array";
items: {
type: "string";
};
uniqueItems: true;
};
checkFootnoteDefinitions: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
allowDefinitions: string[];
allowFootnoteDefinitions: any[];
checkFootnoteDefinitions: true;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: NoUnusedDefinitionsOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: NoUnusedDefinitionsMessageIds;
}>): {
imageReference(node: import("mdast").ImageReference): void;
linkReference(node: import("mdast").LinkReference): void;
footnoteReference(node: import("mdast").FootnoteReference): void;
definition(node: Definition): void;
footnoteDefinition(node: FootnoteDefinition): void;
"root:exit"(): void;
};
}
export default _default;
export type NoUnusedDefinitionsMessageIds = "unusedDefinition" | "unusedFootnoteDefinition";
export type NoUnusedDefinitionsOptions = [{
allowDefinitions?: string[];
allowFootnoteDefinitions?: string[];
checkFootnoteDefinitions?: boolean;
}];
export type NoUnusedDefinitionsRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: NoUnusedDefinitionsOptions;
MessageIds: NoUnusedDefinitionsMessageIds;
}>;
import type { Definition } from "mdast";
import type { FootnoteDefinition } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,130 @@
/**
* @fileoverview Rule to prevent unused definitions in Markdown.
* @author 루밀LuMir(lumirlumir)
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { normalizeIdentifier } from "micromark-util-normalize-identifier";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Definition, FootnoteDefinition } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"unusedDefinition" | "unusedFootnoteDefinition"} NoUnusedDefinitionsMessageIds
* @typedef {[{ allowDefinitions?: string[], allowFootnoteDefinitions?: string[], checkFootnoteDefinitions?: boolean }]} NoUnusedDefinitionsOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: NoUnusedDefinitionsOptions, MessageIds: NoUnusedDefinitionsMessageIds }>} NoUnusedDefinitionsRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {NoUnusedDefinitionsRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow unused definitions",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/no-unused-definitions.md",
},
messages: {
unusedDefinition: "Unexpected unused definition `{{ identifier }}` (label: `{{ label }}`) found.",
unusedFootnoteDefinition: "Unexpected unused footnote definition `{{ identifier }}` (label: `{{ label }}`) found.",
},
schema: [
{
type: "object",
properties: {
allowDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
allowFootnoteDefinitions: {
type: "array",
items: {
type: "string",
},
uniqueItems: true,
},
checkFootnoteDefinitions: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [
{
allowDefinitions: ["//"],
allowFootnoteDefinitions: [],
checkFootnoteDefinitions: true,
},
],
},
create(context) {
const allowDefinitions = new Set(context.options[0].allowDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const allowFootnoteDefinitions = new Set(context.options[0].allowFootnoteDefinitions.map(identifier => normalizeIdentifier(identifier).toLowerCase()));
const [{ checkFootnoteDefinitions }] = context.options;
/** @type {Set<string>} Set to track used identifiers */
const usedIdentifiers = new Set();
/** @type {Set<string>} Set to track used footnote identifiers */
const usedFootnoteIdentifiers = new Set();
/** @type {Set<Definition>} */
const definitions = new Set();
/** @type {Set<FootnoteDefinition>} */
const footnoteDefinitions = new Set();
return {
imageReference(node) {
usedIdentifiers.add(node.identifier);
},
linkReference(node) {
usedIdentifiers.add(node.identifier);
},
footnoteReference(node) {
usedFootnoteIdentifiers.add(node.identifier);
},
definition(node) {
if (allowDefinitions.has(node.identifier)) {
return;
}
definitions.add(node);
},
footnoteDefinition(node) {
if (!checkFootnoteDefinitions ||
allowFootnoteDefinitions.has(node.identifier)) {
return;
}
footnoteDefinitions.add(node);
},
"root:exit"() {
for (const definition of definitions) {
if (!usedIdentifiers.has(definition.identifier)) {
context.report({
node: definition,
messageId: "unusedDefinition",
data: {
identifier: definition.identifier,
label: definition.label.trim(),
},
});
}
}
for (const footnoteDefinition of footnoteDefinitions) {
if (!usedFootnoteIdentifiers.has(footnoteDefinition.identifier)) {
context.report({
node: footnoteDefinition,
messageId: "unusedFootnoteDefinition",
data: {
identifier: footnoteDefinition.identifier,
label: footnoteDefinition.label,
},
});
}
}
},
};
},
});
@@ -0,0 +1,33 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let altTextRequired: string;
}
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: [];
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: "altTextRequired";
}>): {
"image, imageReference"(node: Image | ImageReference): void;
html(node: import("mdast").Html): void;
};
}
export default _default;
export type RequireAltTextMessageIds = "altTextRequired";
export type RequireAltTextOptions = [];
export type RequireAltTextRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: RequireAltTextOptions;
MessageIds: RequireAltTextMessageIds;
}>;
import type { Image } from "mdast";
import type { ImageReference } from "mdast";
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,90 @@
/**
* @fileoverview Rule to require alternative text for images in Markdown.
* @author Pixel998
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import { stripHtmlComments } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { Image, ImageReference } from "mdast";
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"altTextRequired"} RequireAltTextMessageIds
* @typedef {[]} RequireAltTextOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: RequireAltTextOptions, MessageIds: RequireAltTextMessageIds }>} RequireAltTextRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const imgTagPattern = /<img(?:\s(?:[^>"']|"[^"]*"|'[^']*')*)?\/?>/giu;
/**
* Creates a regex to match HTML attributes
* @param {string} name The attribute name to match
* @returns {RegExp} Regular expression for matching the attribute
*/
function getHtmlAttributeRe(name) {
return new RegExp(`\\s${name}(?:\\s*=\\s*['"]([^'"]*)['"])?`, "iu");
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {RequireAltTextRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Require alternative text for images",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/require-alt-text.md",
},
messages: {
altTextRequired: "Alternative text for image is required.",
},
},
create(context) {
const { sourceCode } = context;
return {
"image, imageReference"(
/** @type {Image | ImageReference} */ node) {
if (node.alt.trim().length === 0) {
context.report({
loc: node.position,
messageId: "altTextRequired",
});
}
},
html(node) {
const text = stripHtmlComments(sourceCode.getText(node));
/** @type {RegExpExecArray | null} */
let match;
while ((match = imgTagPattern.exec(text)) !== null) {
const imgTag = match[0];
const ariaHiddenMatch = imgTag.match(getHtmlAttributeRe("aria-hidden"));
if (ariaHiddenMatch &&
ariaHiddenMatch[1] &&
ariaHiddenMatch[1].toLowerCase() === "true") {
continue;
}
const altMatch = imgTag.match(getHtmlAttributeRe("alt"));
if (!altMatch ||
(altMatch[1] &&
altMatch[1].trim().length === 0 &&
altMatch[1].length > 0)) {
const startOffset = // Adjust `imgTagPattern` match indices to the full source code.
match.index + node.position.start.offset;
const endOffset = startOffset + imgTag.length;
context.report({
loc: {
start: sourceCode.getLocFromIndex(startOffset),
end: sourceCode.getLocFromIndex(endOffset),
},
messageId: "altTextRequired",
});
}
}
},
};
},
});
@@ -0,0 +1,45 @@
declare namespace _default {
namespace meta {
let type: "problem";
namespace docs {
let recommended: boolean;
let description: string;
let url: string;
}
namespace messages {
let extraCells: string;
let missingCells: string;
}
let schema: {
type: "object";
properties: {
checkMissingCells: {
type: "boolean";
};
};
additionalProperties: false;
}[];
let defaultOptions: [{
checkMissingCells: false;
}];
}
function create(context: import("@eslint/core").RuleContext<{
LangOptions: import("../types.js").MarkdownLanguageOptions;
Code: import("../index.js").MarkdownSourceCode;
RuleOptions: TableColumnCountOptions;
Node: import("mdast").Node | import("../language/markdown-source-code.js").InlineConfigComment;
MessageIds: TableColumnCountMessageIds;
}>): {
table(node: import("mdast").Table): void;
};
}
export default _default;
export type TableColumnCountMessageIds = "extraCells" | "missingCells";
export type TableColumnCountOptions = [{
checkMissingCells?: boolean;
}];
export type TableColumnCountRuleDefinition = MarkdownRuleDefinition<{
RuleOptions: TableColumnCountOptions;
MessageIds: TableColumnCountMessageIds;
}>;
import type { MarkdownRuleDefinition } from "../types.js";
@@ -0,0 +1,91 @@
/**
* @fileoverview Rule to disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row
* @author Sweta Tanwar (@SwetaTanwar)
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { MarkdownRuleDefinition } from "../types.js";
* @typedef {"extraCells" | "missingCells"} TableColumnCountMessageIds
* @typedef {[{ checkMissingCells?: boolean }]} TableColumnCountOptions
* @typedef {MarkdownRuleDefinition<{ RuleOptions: TableColumnCountOptions, MessageIds: TableColumnCountMessageIds }>} TableColumnCountRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
export default /** @satisfies {TableColumnCountRuleDefinition} */ ({
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow data rows in a GitHub Flavored Markdown table from having more cells than the header row",
url: "https://github.com/eslint/markdown/blob/main/docs/rules/table-column-count.md",
},
messages: {
extraCells: "Table column count mismatch (Expected: {{expectedCells}}, Actual: {{actualCells}}), extra data starting here will be ignored.",
missingCells: "Table column count mismatch (Expected: {{expectedCells}}, Actual: {{actualCells}}), row might be missing data.",
},
schema: [
{
type: "object",
properties: {
checkMissingCells: {
type: "boolean",
},
},
additionalProperties: false,
},
],
defaultOptions: [{ checkMissingCells: false }],
},
create(context) {
const [{ checkMissingCells }] = context.options;
return {
table(node) {
if (node.children.length < 1) {
return;
}
const headerRow = node.children[0];
const expectedCellsLength = headerRow.children.length;
for (let i = 1; i < node.children.length; i++) {
const currentRow = node.children[i];
const actualCellsLength = currentRow.children.length;
const lastActualCellNode = currentRow.children[actualCellsLength - 1];
if (actualCellsLength > expectedCellsLength) {
const firstExtraCellNode = currentRow.children[expectedCellsLength];
context.report({
loc: {
start: firstExtraCellNode.position.start,
end: lastActualCellNode.position.end,
},
messageId: "extraCells",
data: {
actualCells: actualCellsLength,
expectedCells: expectedCellsLength,
},
});
}
else if (checkMissingCells &&
actualCellsLength < expectedCellsLength) {
context.report({
loc: {
start: {
column: lastActualCellNode.position.end.column -
1,
line: lastActualCellNode.position.end.line,
},
end: currentRow.position.end,
},
messageId: "missingCells",
data: {
actualCells: actualCellsLength,
expectedCells: expectedCellsLength,
},
});
}
}
},
};
},
});
+87
View File
@@ -0,0 +1,87 @@
import type { Node, Data, Literal, Parent, Blockquote, Break, Code, Definition, Emphasis, Heading, Html, Image, ImageReference, InlineCode, Link, LinkReference, List, ListItem, Paragraph, Root, Strong, Text, ThematicBreak, Delete, FootnoteDefinition, FootnoteReference, Table, TableCell, TableRow, Yaml } from "mdast";
import type { InlineMath, Math } from "mdast-util-math";
import type { LanguageContext, LanguageOptions, RuleVisitor } from "@eslint/core";
import type { CustomRuleDefinitionType, CustomRuleTypeDefinitions, CustomRuleVisitorWithExit } from "@eslint/plugin-kit";
import type { InlineConfigComment, MarkdownSourceCode } from "./language/markdown-source-code.js";
export interface RangeMap {
indent: number;
js: number;
md: number;
}
export interface BlockBase {
baseIndentText: string;
comments: string[];
rangeMap: RangeMap[];
}
export type Block = Code & BlockBase;
/**
* Markdown TOML.
*/
export interface Toml extends Literal {
/**
* Node type of mdast TOML.
*/
type: "toml";
/**
* Data associated with the mdast TOML.
*/
data?: TomlData | undefined;
}
/**
* Info associated with mdast TOML nodes by the ecosystem.
*/
export interface TomlData extends Data {
}
/**
* Markdown JSON.
*/
export interface Json extends Literal {
/**
* Node type of mdast JSON.
*/
type: "json";
/**
* Data associated with the mdast JSON.
*/
data?: JsonData | undefined;
}
/**
* Info associated with mdast JSON nodes by the ecosystem.
*/
export interface JsonData extends Data {
}
/**
* Language options provided for Markdown files.
*/
export interface MarkdownLanguageOptions extends LanguageOptions {
/**
* The options for parsing frontmatter.
*/
frontmatter?: false | "yaml" | "toml" | "json";
/**
* The options for parsing math.
*/
math?: boolean;
}
/**
* The context object that is passed to the Markdown language plugin methods.
*/
export type MarkdownLanguageContext = LanguageContext<MarkdownLanguageOptions>;
/**
* A Markdown syntax element, including nodes and comments.
*/
type MarkdownSyntaxElement = Node | InlineConfigComment;
export interface MarkdownRuleVisitor extends RuleVisitor, CustomRuleVisitorWithExit<{
root?(node: Root): void;
} & {
[NodeType in Blockquote | Break | Code | Definition | Emphasis | Heading | Html | Image | ImageReference | InlineCode | Link | LinkReference | List | ListItem | Paragraph | Strong | Text | ThematicBreak | Delete | FootnoteDefinition | FootnoteReference | Table | TableCell | TableRow | Yaml | Toml | Json | InlineMath | Math as NodeType["type"]]?: (node: NodeType, parent?: Parent) => void;
}> {
}
export type MarkdownRuleDefinitionTypeOptions = CustomRuleTypeDefinitions;
export type MarkdownRuleDefinition<Options extends Partial<MarkdownRuleDefinitionTypeOptions> = {}> = CustomRuleDefinitionType<{
LangOptions: MarkdownLanguageOptions;
Code: MarkdownSourceCode;
Visitor: MarkdownRuleVisitor;
Node: MarkdownSyntaxElement;
}, Options>;
export {};
+4
View File
@@ -0,0 +1,4 @@
//------------------------------------------------------------------------------
// Imports
//------------------------------------------------------------------------------
export {};
+35
View File
@@ -0,0 +1,35 @@
/**
* Checks if a frontmatter block contains a title matching the given pattern.
* @param {string} value The frontmatter content.
* @param {RegExp|null} pattern The pattern to match against.
* @returns {boolean} Whether a title was found.
*/
export function frontmatterHasTitle(value: string, pattern: RegExp | null): boolean;
/**
* Replaces all HTML comments with whitespace.
* This preserves offsets and locations of characters
* outside HTML comments by keeping line breaks and replacing
* other code units with a space character.
* @param {string} value The string to remove HTML comments from.
* @returns {string} The string with HTML comments removed.
*/
export function stripHtmlComments(value: string): string;
/**
* @fileoverview Utility Library
* @author Nicholas C. Zakas
*/
/**
* Line ending pattern to match all line endings (CRLF, CR, LF). (CommonMark spec)
* @see https://spec.commonmark.org/0.31.2/#line-ending
*/
export const lineEndingPattern: RegExp;
/**
* 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: RegExp;
/**
* Regular expression to match HTML comments, including multiline comments.
*/
export const htmlCommentPattern: RegExp;
+58
View File
@@ -0,0 +1,58 @@
/**
* @fileoverview Utility Library
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Regex Patterns
//-----------------------------------------------------------------------------
/**
* Line ending pattern to match all line endings (CRLF, CR, LF). (CommonMark spec)
* @see https://spec.commonmark.org/0.31.2/#line-ending
*/
export const lineEndingPattern = /\r\n|[\r\n]/u;
/**
* 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;
/**
* Regular expression to match HTML comments, including multiline comments.
*/
export const htmlCommentPattern = /<!--[\s\S]*?-->/gu;
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Checks if a frontmatter block contains a title matching the given pattern.
* @param {string} value The frontmatter content.
* @param {RegExp|null} pattern The pattern to match against.
* @returns {boolean} Whether a title was found.
*/
export function frontmatterHasTitle(value, pattern) {
if (!pattern) {
return false;
}
const lines = value.split(lineEndingPattern);
for (const line of lines) {
if (pattern.test(line)) {
return true;
}
}
return false;
}
/**
* Replaces all HTML comments with whitespace.
* This preserves offsets and locations of characters
* outside HTML comments by keeping line breaks and replacing
* other code units with a space character.
* @param {string} value The string to remove HTML comments from.
* @returns {string} The string with HTML comments removed.
*/
export function stripHtmlComments(value) {
return value.replace(htmlCommentPattern, match =>
/* eslint-disable-next-line require-unicode-regexp
-- we want to replace each code unit with a space
*/
match.replace(/[^\r\n]/g, " "));
}
+116
View File
@@ -0,0 +1,116 @@
{
"name": "@eslint/markdown",
"version": "8.0.3",
"description": "The official ESLint language plugin for Markdown",
"license": "MIT",
"author": {
"name": "Brandon Mills",
"url": "https://github.com/btmills"
},
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"repository": "eslint/markdown",
"bugs": {
"url": "https://github.com/eslint/markdown/issues"
},
"homepage": "https://github.com/eslint/markdown#readme",
"keywords": [
"eslint",
"eslintplugin",
"markdown",
"lint",
"linter"
],
"workspaces": [
"examples/*"
],
"gitHooks": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.js": [
"eslint --fix",
"prettier --write"
],
"*.md": [
"eslint --fix",
"eslint --fix -c eslint.config-content.js"
],
"!(*.{js,md})": "prettier --write --ignore-unknown",
"{src/rules/*.js,tools/update-rules-docs.js,README.md}": [
"npm run build:update-rules-docs",
"git add README.md"
]
},
"scripts": {
"lint": "eslint && eslint -c eslint.config-content.js",
"lint:fix": "eslint --fix && eslint --fix -c eslint.config-content.js",
"lint:types": "attw --pack --profile esm-only",
"lint:unused": "knip",
"fmt": "prettier --write .",
"fmt:check": "prettier --check .",
"build": "npm run build:rules && npm run build:types && npm run build:update-rules-docs",
"build:rules": "node tools/build-rules.js",
"build:types": "tsc",
"build:update-rules-docs": "node tools/update-rules-docs.js",
"prepare": "npm run build",
"test": "mocha \"tests/**/*.test.js\" --timeout 30000",
"test:coverage": "c8 npm test",
"test:jsr": "npx -y jsr@latest publish --dry-run",
"test:types": "tsc -p tests/types/tsconfig.json",
"test:types:5.3": "npx -p typescript@5.3 -y -- tsc -p tests/types/tsconfig.legacy.json",
"test:types:5.x": "npx -p typescript@5.x -y -- tsc -p tests/types/tsconfig.json",
"test:types:7.x": "npx -p @typescript/native-preview@latest -y -- tsgo -p tests/types/tsconfig.json",
"test:types:all": "npm run test:types && npm run test:types:5.3 && npm run test:types:5.x && npm run test:types:7.x"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.3",
"@eslint/js": "^10.0.1",
"@eslint/json": "^2.0.0",
"@types/mdast": "^4.0.4",
"@types/unist": "^3.0.3",
"c8": "^11.0.0",
"dedent": "^1.7.1",
"eslint": "^10.0.3",
"eslint-v9": "npm:eslint@9.x",
"eslint-config-eslint": "^14.0.0",
"eslint-plugin-eslint-plugin": "^7.3.2",
"globals": "^17.1.0",
"knip": "^6.0.0",
"lint-staged": "^16.0.0",
"mocha": "^11.7.5",
"prettier": "3.8.4",
"semver": "^7.7.3",
"typescript": "^6.0.3",
"yorkie": "^2.0.0"
},
"dependencies": {
"@eslint/core": "^1.2.1",
"@eslint/plugin-kit": "^0.7.2",
"github-slugger": "^2.0.0",
"mdast-util-from-markdown": "^2.0.2",
"mdast-util-frontmatter": "^2.0.1",
"mdast-util-gfm": "^3.1.0",
"mdast-util-math": "^3.0.0",
"micromark-extension-frontmatter": "^2.0.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-util-normalize-identifier": "^2.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
}
}