Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,39 @@ export default defineConfig([
]);
```

#### 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** |
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@
"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": {
Expand Down
26 changes: 24 additions & 2 deletions src/language/markdown-language.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ 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
Expand Down Expand Up @@ -55,7 +57,7 @@ const jsonFrontmatterConfig = {
* 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
* @returns {{extensions: Extensions, mdastExtensions: MdastExtensions}} Parser options for micromark and mdast.
*/
function createParserOptions(mode, languageOptions) {
/** @type {Extensions} */
Expand Down Expand Up @@ -88,6 +90,15 @@ function createParserOptions(mode, languageOptions) {
}
}

// 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,
Expand Down Expand Up @@ -133,6 +144,7 @@ export class MarkdownLanguage {
*/
defaultLanguageOptions = {
frontmatter: false,
math: false,
};

/**
Expand All @@ -159,6 +171,7 @@ export class MarkdownLanguage {
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions) {
// `frontmatter` option validation
const frontmatterOption = languageOptions?.frontmatter;
const validFrontmatterOptions = new Set([
false,
Expand All @@ -172,7 +185,16 @@ export class MarkdownLanguage {
!validFrontmatterOptions.has(frontmatterOption)
) {
throw new Error(
`Invalid language option value \`${frontmatterOption}\` for frontmatter.`,
`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") {
Copy link
Copy Markdown
Member Author

@lumirlumir lumirlumir Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:

Are the undefined checks (e.g. mathOption !== undefined, frontmatterOption !== undefined) and the optional chaining (languageOptions?.math, languageOptions?.frontmatter) still necessary? (I initially implemented it to check for undefined.)

frontmatterOption !== undefined &&

const frontmatterOption = languageOptions?.frontmatter;


Initially, the motivation for introducing frontmatterOption !== undefined was in #328. At that time, the README.md stated that the minimum compatible ESLint version was any v9 release, so it made sense.

Later, we discovered that rules used defaultOptions, and we raised the minimum ESLint version to v9.15.0 (ref: #537).

The issue is that defaultLanguageOptions was introduced in ESLint v9.13.0. Since we now expect users to be running ESLint v9.15.0 or later, the frontmatter and math options can't be undefined.

defaultLanguageOptions = {
frontmatter: false,
};


However, currently there are no peerDependencies or other restrictions on the language plugins, so checking for undefined still seems sensible: some users might not depend on the rule and could only be using MarkdownSourceCode or MarkdownLanguage features with ESLint versions older than v9.13.0.

To summarize, for safety, would it be better to keep the undefined checks and optional chaining, or is it safe to remove them? If opening a separate issue would help, I'm happy to do that and work on it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that we should keep the optional chaining on languageOptions and undefined checks until the next major version.

throw new Error(
`Invalid language option value \`${mathOption}\` for math. Expected a boolean.`,
);
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/rules/no-reversed-media-syntax.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

/**
* @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
Expand Down Expand Up @@ -65,12 +66,12 @@ export default {
nodeStartOffset = node.position.start.offset;
},

":matches(heading, paragraph, tableCell) :matches(html, image, imageReference, inlineCode, linkReference)"(
/** @type {Html | Image | ImageReference | InlineCode | LinkReference} */ node,
":matches(heading, paragraph, tableCell) :matches(html, image, imageReference, inlineCode, linkReference, inlineMath)"(
Copy link
Copy Markdown
Member Author

@lumirlumir lumirlumir Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces two node types: math and inlineMath.

However, the math node is block-level, so it cannot be a child of a heading, paragraph or tableCell node. Only the inlineMath node can be a child of a heading, paragraph or tableCell, so I only added inlineMath to it.

/** @type {Html | Image | ImageReference | InlineCode | LinkReference | InlineMath} */ node,
) {
const [startOffset, endOffset] = sourceCode.getRange(node);

// Mask the content of `html`, `image`, `imageReference`, `inlineCode`, and `linkReference` nodes with whitespaces.
// Mask the content of `html`, `image`, `imageReference`, `inlineCode`, `linkReference`, and `inlineMath` nodes with whitespaces.
for (let i = startOffset; i < endOffset; i++) {
buffer[i - nodeStartOffset] = " ";
}
Expand Down
20 changes: 18 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
// Extensions (front matter)
Yaml,
} from "mdast";
import type { InlineMath, Math } from "mdast-util-math";
import type {
LanguageContext,
LanguageOptions,
Expand All @@ -51,7 +52,7 @@ import type {
import type { MarkdownSourceCode } from "./index.js";

//------------------------------------------------------------------------------
// Exports
// Exports: Processor
//------------------------------------------------------------------------------

export interface RangeMap {
Expand All @@ -68,6 +69,10 @@ export interface BlockBase {

export type Block = Code & BlockBase;

//------------------------------------------------------------------------------
// Exports: Nodes
//------------------------------------------------------------------------------

/**
* Markdown TOML.
*/
Expand Down Expand Up @@ -106,6 +111,10 @@ export interface Json extends Literal {
*/
export interface JsonData extends Data {}

//------------------------------------------------------------------------------
// Exports: Language and Source Code
//------------------------------------------------------------------------------

/**
* Language options provided for Markdown files.
*/
Expand All @@ -114,6 +123,11 @@ export interface MarkdownLanguageOptions extends LanguageOptions {
* The options for parsing frontmatter.
*/
frontmatter?: false | "yaml" | "toml" | "json";

/**
* The options for parsing math.
*/
math?: boolean;
}

/**
Expand Down Expand Up @@ -155,7 +169,9 @@ export interface MarkdownRuleVisitor
| TableRow
| Yaml // Extensions (front matter)
| Toml
| Json as NodeType["type"]]?: (
| Json
| InlineMath // Extensions (math)
| Math as NodeType["type"]]?: (
node: NodeType,
parent?: Parent,
) => void;
Expand Down
158 changes: 145 additions & 13 deletions tests/language/markdown-language.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,7 @@ import assert from "node:assert";

describe("MarkdownLanguage", () => {
describe("validateLanguageOptions()", () => {
it("should throw an error if `frontmatter` is not `false`, `'yaml'`, `'toml'`, or `'json'`", () => {
const language = new MarkdownLanguage();

assert.throws(() => {
language.validateLanguageOptions({ frontmatter: "invalid" });
}, /Invalid language option value/u);
assert.throws(() => {
language.validateLanguageOptions({ frontmatter: 123 });
}, /Invalid language option value/u);
});

it("should not throw an error when `frontmatter` is not provided", () => {
it("should not throw an error when `frontmatter` or `math` is not provided", () => {
const language = new MarkdownLanguage();

assert.doesNotThrow(() => {
Expand All @@ -38,13 +27,39 @@ describe("MarkdownLanguage", () => {
});
});

it("should not throw an error when `frontmatter` is not provided and other keys are present", () => {
it("should not throw an error when `frontmatter` or `math` is not provided and other keys are present", () => {
const language = new MarkdownLanguage();
assert.doesNotThrow(() => {
language.validateLanguageOptions({ foo: "bar" });
});
});

// Validation tests for the `frontmatter` option
it("should throw an error if `frontmatter` is not `false`, `'yaml'`, `'toml'`, or `'json'`", () => {
Copy link
Copy Markdown
Member Author

@lumirlumir lumirlumir Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous test logic for validateLanguageOptions() mixed frontmatter-specific tests with general tests, so I moved the general test suites to the top level. I also replaced the previous frontmatter error test that used a regex with a check against the full error message for better accuracy.

In summary, this diff here mainly restructures the test suites for improved clarity.

const language = new MarkdownLanguage();

assert.throws(
() => {
language.validateLanguageOptions({
frontmatter: "invalid",
});
},
{
message:
'Invalid language option value `invalid` for frontmatter. Expected one of `false`, `"yaml"`, `"toml"`, or `"json"`.',
},
);
assert.throws(
() => {
language.validateLanguageOptions({ frontmatter: 123 });
},
{
message:
'Invalid language option value `123` for frontmatter. Expected one of `false`, `"yaml"`, `"toml"`, or `"json"`.',
},
);
});

it("should not throw an error when `frontmatter` has a correct value in commonmark mode", () => {
const language = new MarkdownLanguage({ mode: "commonmark" });

Expand Down Expand Up @@ -78,6 +93,52 @@ describe("MarkdownLanguage", () => {
language.validateLanguageOptions({ frontmatter: "json" });
});
});

// Validation tests for the `math` option
it("should throw an error if `math` is not `true` or `false`", () => {
const language = new MarkdownLanguage();

assert.throws(
() => {
language.validateLanguageOptions({ math: "invalid" });
},
{
message:
"Invalid language option value `invalid` for math. Expected a boolean.",
},
);
assert.throws(
() => {
language.validateLanguageOptions({ math: 123 });
},
{
message:
"Invalid language option value `123` for math. Expected a boolean.",
},
);
});

it("should not throw an error when `math` has a correct value in commonmark mode", () => {
const language = new MarkdownLanguage({ mode: "commonmark" });

assert.doesNotThrow(() => {
language.validateLanguageOptions({ math: true });
});
assert.doesNotThrow(() => {
language.validateLanguageOptions({ math: false });
});
});

it("should not throw an error when `math` has a correct value in gfm mode", () => {
const language = new MarkdownLanguage({ mode: "gfm" });

assert.doesNotThrow(() => {
language.validateLanguageOptions({ math: true });
});
assert.doesNotThrow(() => {
language.validateLanguageOptions({ math: false });
});
});
});

describe("parse()", () => {
Expand Down Expand Up @@ -270,6 +331,77 @@ describe("MarkdownLanguage", () => {
assert.strictEqual(result.ast.children[1].type, "heading");
assert.strictEqual(result.ast.children[2].type, "paragraph");
});

it("should not parse math by default", () => {
const language = new MarkdownLanguage();
const result = language.parse({
body: "Inline math: $E=mc^2$\n\nBlock math:\n\n$$\nE=mc^2\n$$",
path: "test.md",
});

assert.strictEqual(result.ok, true);
assert.strictEqual(result.ast.type, "root");
assert.strictEqual(result.ast.children[0].type, "paragraph");
assert.strictEqual(result.ast.children[0].children[0].type, "text");
assert.strictEqual(result.ast.children[1].type, "paragraph");
assert.strictEqual(result.ast.children[1].children[0].type, "text");
assert.strictEqual(result.ast.children[2].type, "paragraph");
assert.strictEqual(result.ast.children[2].children[0].type, "text");
});

it("should parse math in commonmark mode when `math: true` is set", () => {
const language = new MarkdownLanguage({ mode: "commonmark" });
const result = language.parse(
{
body: "Inline math: $E=mc^2$\n\nBlock math:\n\n$$\nE=mc^2\n$$",
path: "test.md",
},
{
languageOptions: {
math: true,
},
},
);

assert.strictEqual(result.ok, true);
assert.strictEqual(result.ast.type, "root");
assert.strictEqual(result.ast.children[0].type, "paragraph");
assert.strictEqual(result.ast.children[0].children[0].type, "text");
assert.strictEqual(
result.ast.children[0].children[1].type,
"inlineMath",
);
assert.strictEqual(result.ast.children[1].type, "paragraph");
assert.strictEqual(result.ast.children[1].children[0].type, "text");
assert.strictEqual(result.ast.children[2].type, "math");
});

it("should parse math in gfm mode when `math: true` is set", () => {
const language = new MarkdownLanguage({ mode: "gfm" });
const result = language.parse(
{
body: "Inline math: $E=mc^2$\n\nBlock math:\n\n$$\nE=mc^2\n$$",
path: "test.md",
},
{
languageOptions: {
math: true,
},
},
);

assert.strictEqual(result.ok, true);
assert.strictEqual(result.ast.type, "root");
assert.strictEqual(result.ast.children[0].type, "paragraph");
assert.strictEqual(result.ast.children[0].children[0].type, "text");
assert.strictEqual(
result.ast.children[0].children[1].type,
"inlineMath",
);
assert.strictEqual(result.ast.children[1].type, "paragraph");
assert.strictEqual(result.ast.children[1].children[0].type, "text");
assert.strictEqual(result.ast.children[2].type, "math");
});
});

describe("createSourceCode()", () => {
Expand Down
Loading