diff --git a/README.en-US.md b/README.en-US.md
index 1bd7a7f..6ef738f 100644
--- a/README.en-US.md
+++ b/README.en-US.md
@@ -84,6 +84,10 @@ The image runs as a non-root user by default. If the mounted directory has stric
- `-d, --dev`: enable development debug mode
- `-v, --version`: print the current version
+`--stdin` always parses input as Markdown. Do not use `--stdin --fix` on MDX content. MDX autofix currently supports file-path mode only.
+
+`--fix` supports `.mdx` files. The CLI calls Core `fixMdx()` for `.mdx` and `fixMarkdown()` for Markdown. The CLI uses the MDX Parser to separate Markdown body text from MDX code regions. Built-in rules preserve ESM, JSX attributes, and JavaScript expressions in supported cases. The fix safety of third-party rules depends on the rule implementation. To exclude MDX, set `"extensions": [".md", ".markdown"]`.
+
Use `--threads 1` or `--threads 2` for complex Markdown or memory-limited CI environments.
Higher explicit concurrency can increase peak memory.
diff --git a/README.md b/README.md
index 66b71ae..1f0ad5c 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,9 @@ docker run --rm \
- `-d, --dev`:开发调试模式
- `-v, --version`:查看版本
-`--fix` 不支持 `.mdx` 文件。只要待修复文件中包含 `.mdx`,命令就以退出码 `1` 结束,且不修改任何文件。如需排除 MDX,请在配置中设置 `"extensions": [".md", ".markdown"]`。
+`--stdin` 始终按 Markdown 解析。请勿通过 `--stdin --fix` 修复 MDX 内容;MDX 自动修复目前仅支持文件路径模式。
+
+`--fix` 支持 `.mdx` 文件。CLI 对 `.mdx` 调用 Core 的 `fixMdx()`,对普通 Markdown 调用 `fixMarkdown()`。CLI 使用 MDX Parser 区分 Markdown 正文与 MDX 代码区域。内置规则在已支持的场景下保留 ESM、JSX 属性和 JavaScript 表达式;第三方规则的修复安全性取决于规则自身实现。如需排除 MDX,请在配置中设置 `"extensions": [".md", ".markdown"]`。
未指定 `--threads` 时,程序等同于使用 `--threads auto`。`auto` 根据过滤后最大文件的大小设置 worker 上限:
diff --git a/__tests__/fix-exit-code.spec.ts b/__tests__/fix-exit-code.spec.ts
index fee659e..289fa09 100644
--- a/__tests__/fix-exit-code.spec.ts
+++ b/__tests__/fix-exit-code.spec.ts
@@ -97,17 +97,21 @@ describe.each(["file", "stdin"] as const)("--fix exit code with %s", (mode) => {
});
});
-test("--fix rejects a mixed Markdown and MDX batch without changing files", () => {
+test("--fix repairs a mixed Markdown and MDX batch", () => {
const tmpDir = mkdtempSync(path.join(tmpdir(), "lint-md-mixed-mdx-"));
const mdPath = path.join(tmpDir, "test.md");
const mdxPath = path.join(tmpDir, "test.mdx");
- const mdContent = readFileSync(FIXABLE, "utf8");
- const mdxContent = "export const 用户ID = 42\n";
+ const mdContent = "正文English内容。\n";
+ const mdxContent =
+ 'export const title = "中文English";\n\n正文English内容。\n';
try {
writeFileSync(mdPath, mdContent);
writeFileSync(mdxPath, mdxContent);
- writeFileSync(path.join(tmpDir, ".lintmdrc"), "{}");
+ writeFileSync(
+ path.join(tmpDir, ".lintmdrc"),
+ JSON.stringify({ rules: { "space-around-alphabet": 2 } })
+ );
const result = spawnSync(
process.execPath,
@@ -115,12 +119,12 @@ test("--fix rejects a mixed Markdown and MDX batch without changing files", () =
{ cwd: tmpDir, encoding: "utf8" }
);
- expect(result.status).toBe(1);
- expect(result.stderr).toContain(
- "[lint-md] --fix does not support .mdx files. No files were changed."
+ expect(result.status).toBe(0);
+ expect(result.stderr).not.toContain("does not support .mdx");
+ expect(readFileSync(mdPath, "utf8")).toBe("正文 English 内容。\n");
+ expect(readFileSync(mdxPath, "utf8")).toBe(
+ 'export const title = "中文English";\n\n正文 English 内容。\n'
);
- expect(readFileSync(mdPath, "utf8")).toBe(mdContent);
- expect(readFileSync(mdxPath, "utf8")).toBe(mdxContent);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
diff --git a/__tests__/lint-worker.spec.ts b/__tests__/lint-worker.spec.ts
index 32b2cd1..a52e944 100644
--- a/__tests__/lint-worker.spec.ts
+++ b/__tests__/lint-worker.spec.ts
@@ -4,8 +4,13 @@ jest.mock("@lint-md/core", () => ({
fixMarkdown: jest.fn(),
lintMarkdown: jest.fn(),
}));
+jest.mock("@lint-md/core/mdx", () => ({
+ fixMdx: jest.fn(),
+ lintMdx: jest.fn(),
+}));
import { fixMarkdown, lintMarkdown } from "@lint-md/core";
+import { fixMdx, lintMdx } from "@lint-md/core/mdx";
import lintWorker from "../src/utils/lint-worker";
import { writeFile, mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
@@ -17,6 +22,8 @@ const mockedLintMarkdown = lintMarkdown as jest.MockedFunction<
const mockedFixMarkdown = fixMarkdown as jest.MockedFunction<
typeof fixMarkdown
>;
+const mockedLintMdx = lintMdx as jest.MockedFunction;
+const mockedFixMdx = fixMdx as jest.MockedFunction;
describe("lintWorker executionErrors passthrough", () => {
let tmpDir: string;
@@ -25,6 +32,8 @@ describe("lintWorker executionErrors passthrough", () => {
tmpDir = await mkdtemp(path.join(tmpdir(), "lint-worker-exec-"));
mockedFixMarkdown.mockReset();
mockedLintMarkdown.mockReset();
+ mockedFixMdx.mockReset();
+ mockedLintMdx.mockReset();
});
afterEach(async () => {
@@ -183,6 +192,96 @@ describe("lintWorker executionErrors passthrough", () => {
expect(result.changed).toBe(false);
});
+ test("uses fixMdx for MDX fix mode", async () => {
+ const file = path.join(tmpDir, "fix.mdx");
+ const rules = { "space-around-alphabet": 2 };
+ const source = "正文English内容。\n";
+ await writeFile(file, source, "utf8");
+
+ mockedFixMdx.mockReturnValue({
+ lintResult: [],
+ diagnostics: [],
+ summary: {
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ },
+ remainingSummary: {
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ },
+ fixedResult: { result: "正文 English 内容。\n" },
+ executionErrors: [],
+ } as any);
+
+ const result = await lintWorker({ filePath: file, rules, isFixMode: true });
+
+ expect(mockedFixMdx).toHaveBeenCalledWith(source, { rules });
+ expect(mockedFixMarkdown).not.toHaveBeenCalled();
+ expect(result.changed).toBe(true);
+ });
+
+ test("does not mark an unchanged MDX fix as changed", async () => {
+ const file = path.join(tmpDir, "unchanged.mdx");
+ const source = '\n';
+ await writeFile(file, source, "utf8");
+
+ mockedFixMdx.mockReturnValue({
+ lintResult: [],
+ diagnostics: [],
+ summary: {
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ },
+ remainingSummary: {
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ },
+ fixedResult: { result: source },
+ executionErrors: [],
+ } as any);
+
+ const result = await lintWorker({
+ filePath: file,
+ rules: {},
+ isFixMode: true,
+ });
+
+ expect(result.changed).toBe(false);
+ expect(mockedFixMarkdown).not.toHaveBeenCalled();
+ });
+
+ test("uses lintMdx for MDX lint mode", async () => {
+ const file = path.join(tmpDir, "lint.mdx");
+ const source = "正文English内容。\n";
+ await writeFile(file, source, "utf8");
+
+ mockedLintMdx.mockReturnValue({
+ lintResult: [],
+ diagnostics: [],
+ summary: {
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ },
+ fixedResult: null,
+ } as any);
+
+ await lintWorker({ filePath: file, rules: {}, isFixMode: false });
+
+ expect(mockedLintMdx).toHaveBeenCalledWith(source, { rules: {} });
+ expect(mockedFixMdx).not.toHaveBeenCalled();
+ expect(mockedLintMarkdown).not.toHaveBeenCalled();
+ });
+
test("does not mark changed outside fix mode", async () => {
const file = path.join(tmpDir, "lint-only.md");
await writeFile(file, "# Lint\n", "utf8");
diff --git a/__tests__/mdx-lint.spec.ts b/__tests__/mdx-lint.spec.ts
index 7374cc9..3b34926 100644
--- a/__tests__/mdx-lint.spec.ts
+++ b/__tests__/mdx-lint.spec.ts
@@ -1,10 +1,20 @@
import { spawnSync } from "child_process";
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
+import { fixMarkdown } from "@lint-md/core";
+import {
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ statSync,
+ utimesSync,
+ writeFileSync,
+} from "fs";
import { tmpdir } from "os";
import * as path from "path";
import lintWorker from "../src/utils/lint-worker";
const CLI = path.resolve(__dirname, "../lib/src/lint-md.js");
+const TSX = path.resolve(__dirname, "../node_modules/tsx/dist/cli.mjs");
+const SOURCE_CLI = path.resolve(__dirname, "../src/lint-md.ts");
const RULES = { "space-around-alphabet": 2 };
const MDX = [
'import Widget from "中文English";',
@@ -18,6 +28,10 @@ const MDX = [
"正文English内容。",
"",
].join("\n");
+const FIXED_MDX = MDX.replace(
+ "正文English内容。",
+ "正文 English 内容。"
+);
describe("MDX lint", () => {
let tmpDir: string;
@@ -73,42 +87,38 @@ describe("MDX lint", () => {
}
);
- test("routes Markdown and MDX through the CLI", () => {
- const markdownPath = path.join(tmpDir, "document.md");
- const mdxPath = path.join(tmpDir, "document.mdx");
- writeFileSync(markdownPath, "正文English内容。\n", "utf8");
- writeFileSync(mdxPath, MDX, "utf8");
-
- const result = spawnSync(
- process.execPath,
- [CLI, markdownPath, mdxPath, "--config", configPath],
- { encoding: "utf8" }
- );
-
- expect(result.error).toBeUndefined();
- expect(result.status).toBe(1);
- expect(result.stdout).toContain(markdownPath);
- expect(result.stdout).toContain(mdxPath);
- expect(result.stdout).toContain("4 problems");
- });
-
- test("rejects a direct worker fix request for MDX", async () => {
+ test("fixes MDX through the worker and preserves ESM, JSX, and expressions", async () => {
const filePath = path.join(tmpDir, "document.mdx");
writeFileSync(filePath, MDX, "utf8");
- await expect(
- lintWorker({ filePath, rules: RULES, isFixMode: true })
- ).rejects.toThrow("--fix does not support .mdx files");
- expect(readFileSync(filePath, "utf8")).toBe(MDX);
+ const result = await lintWorker({
+ filePath,
+ rules: RULES,
+ isFixMode: true,
+ });
+
+ expect(result.changed).toBe(true);
+ expect(result.fixedResult?.result).toBe(FIXED_MDX);
+ expect(result.remainingSummary).toEqual({
+ errorCount: 0,
+ warningCount: 0,
+ fixableErrorCount: 0,
+ fixableWarningCount: 0,
+ });
+ expect(FIXED_MDX).toContain('import Widget from "中文English";');
+ expect(FIXED_MDX).toContain('export const title = "中文English";');
+ expect(FIXED_MDX).toContain(
+ ''
+ );
+ expect(FIXED_MDX).toContain('{"中文English"}');
});
test.each([".mdx", ".MDX"])(
- "rejects %s fix requests before changing any file",
+ "fixes %s in a mixed batch and exits 0",
(extension) => {
const markdownPath = path.join(tmpDir, "document.md");
const mdxPath = path.join(tmpDir, `document${extension}`);
- const markdown = "正文English内容。\n";
- writeFileSync(markdownPath, markdown, "utf8");
+ writeFileSync(markdownPath, "正文English内容。\n", "utf8");
writeFileSync(mdxPath, MDX, "utf8");
const result = spawnSync(
@@ -118,12 +128,113 @@ describe("MDX lint", () => {
);
expect(result.error).toBeUndefined();
- expect(result.status).toBe(1);
- expect(result.stderr).toContain(
- "--fix does not support .mdx files. No files were changed."
- );
- expect(readFileSync(markdownPath, "utf8")).toBe(markdown);
- expect(readFileSync(mdxPath, "utf8")).toBe(MDX);
+ expect(result.status).toBe(0);
+ expect(result.stderr).not.toContain("does not support .mdx");
+ expect(readFileSync(markdownPath, "utf8")).toBe("正文 English 内容。\n");
+ expect(readFileSync(mdxPath, "utf8")).toBe(FIXED_MDX);
}
);
+
+ test("fixing MDX is idempotent", async () => {
+ const filePath = path.join(tmpDir, "document.mdx");
+ writeFileSync(filePath, MDX, "utf8");
+
+ const first = spawnSync(
+ process.execPath,
+ [CLI, filePath, "--fix", "--config", configPath],
+ { encoding: "utf8" }
+ );
+ const afterFirst = readFileSync(filePath, "utf8");
+
+ const second = spawnSync(
+ process.execPath,
+ [CLI, filePath, "--fix", "--config", configPath],
+ { encoding: "utf8" }
+ );
+
+ expect(first.status).toBe(0);
+ expect(second.status).toBe(0);
+ expect(afterFirst).toBe(FIXED_MDX);
+ expect(readFileSync(filePath, "utf8")).toBe(afterFirst);
+ expect(second.stderr).not.toContain("Fix completed with");
+ });
+
+ test("fixed MDX still compiles", () => {
+ const filePath = path.join(tmpDir, "document.mdx");
+ writeFileSync(filePath, MDX, "utf8");
+
+ const result = spawnSync(
+ process.execPath,
+ [CLI, filePath, "--fix", "--config", configPath],
+ { encoding: "utf8" }
+ );
+ // @mdx-js/mdx is ESM-only. Compile in a child process because Jest runs
+ // CommonJS without VM modules.
+ const compileScript = [
+ 'import { compile } from "@mdx-js/mdx";',
+ 'import { readFileSync } from "node:fs";',
+ "const source = readFileSync(process.env.MDX_FILE, 'utf8');",
+ "const compiled = await compile(source);",
+ "process.stdout.write(compiled.value.length > 0 ? 'ok' : 'empty');",
+ ].join("\n");
+ const compiled = spawnSync(
+ process.execPath,
+ ["--input-type=module", "--eval", compileScript],
+ {
+ encoding: "utf8",
+ env: { ...process.env, MDX_FILE: filePath },
+ }
+ );
+
+ expect(result.status).toBe(0);
+ expect(compiled.status).toBe(0);
+ expect(compiled.stdout).toBe("ok");
+ });
+
+ test("does not rewrite an unchanged MDX file", () => {
+ const filePath = path.join(tmpDir, "document.mdx");
+ const content = "```js\n12345678901234567890\n```\n";
+ const oldTime = new Date("2000-01-01T00:00:00.000Z");
+ writeFileSync(filePath, content, "utf8");
+ utimesSync(filePath, oldTime, oldTime);
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ rules: { "no-long-code": [2, { length: 10, exclude: [] }] },
+ }),
+ "utf8"
+ );
+
+ const result = spawnSync(
+ process.execPath,
+ [CLI, filePath, "--fix", "--config", configPath],
+ { encoding: "utf8" }
+ );
+
+ expect(result.status).toBe(1);
+ expect(result.stderr).toContain("Fix completed with 1 error(s)");
+ expect(readFileSync(filePath, "utf8")).toBe(content);
+ expect(statSync(filePath).mtimeMs).toBe(oldTime.getTime());
+ });
+
+ test("stdin without a file extension keeps Markdown behavior", () => {
+ // stdin has no extension. This test locks the documented Markdown-only
+ // behavior, including its risk for MDX content.
+ const content =
+ 'export const title = "中文English";\n\n正文English内容。\n';
+
+ const result = spawnSync(
+ process.execPath,
+ [TSX, SOURCE_CLI, "--stdin", "--fix", "--config", configPath],
+ { input: content, encoding: "utf8" }
+ );
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toBe(
+ fixMarkdown(content, { rules: RULES }).fixedResult?.result
+ );
+ expect(result.stdout).toBe(
+ 'export const title = "中文 English";\n\n正文 English 内容。\n'
+ );
+ });
});
diff --git a/__tests__/run-file-lint.spec.ts b/__tests__/run-file-lint.spec.ts
index 30c3dee..2ba92ed 100644
--- a/__tests__/run-file-lint.spec.ts
+++ b/__tests__/run-file-lint.spec.ts
@@ -124,22 +124,17 @@ describe("runFileLint", () => {
expect(mockBatchLint).not.toHaveBeenCalled();
});
- test.each([
- ["only MDX", ["document.mdx"]],
- ["mixed Markdown and MDX", ["document.md", "document.mdx"]],
- ["uppercase MDX extension", ["document.MDX"]],
- ])("rejects --fix for %s before linting", async (_case, paths) => {
- mockLoadMdFiles.mockResolvedValue(paths);
- const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
+ test("passes mixed Markdown and MDX files to the fix batch", async () => {
+ mockLoadMdFiles.mockResolvedValue(["document.md", "document.mdx"]);
- const outcome = await runFileLint(makeOptions({ isFixMode: true }));
+ await runFileLint(makeOptions({ isFixMode: true }));
- expect(consoleErrorSpy).toHaveBeenCalledWith(
- "[lint-md] --fix does not support .mdx files. No files were changed."
+ expect(mockBatchLint).toHaveBeenCalledWith(
+ 1,
+ ["document.md", "document.mdx"],
+ true,
+ {}
);
- expect(outcome).toEqual({ exitCode: 1 });
- expect(mockBatchLint).not.toHaveBeenCalled();
- expect(mockSafeWriteFile).not.toHaveBeenCalled();
});
test("escapes quotes inside unmatched patterns", async () => {
diff --git a/package.json b/package.json
index 87b9313..a218746 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"text-table": "^0.2.0"
},
"devDependencies": {
+ "@mdx-js/mdx": "^3.1.1",
"@types/jest": "^30.0.0",
"@types/node": "^22.9.3",
"@types/text-table": "^0.2.5",
diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs
index 7044cef..84ced0b 100644
--- a/scripts/test-package.mjs
+++ b/scripts/test-package.mjs
@@ -89,12 +89,28 @@ try {
const cleanFile = join(fixtureDirectory, "clean.md");
const cleanMdxFile = join(fixtureDirectory, "clean.mdx");
const fixableFile = join(fixtureDirectory, "fixable.md");
+ const fixableMdxFile = join(fixtureDirectory, "fixable.mdx");
+ const configFile = join(fixtureDirectory, ".lintmdrc");
mkdirSync(fixtureDirectory);
cpSync(join(projectRoot, "examples", "space-around.md"), fixableFile, {
recursive: false,
});
writeFileSync(cleanFile, cleanMarkdown, "utf8");
writeFileSync(cleanMdxFile, '# Hello\n\n\n', "utf8");
+ writeFileSync(
+ configFile,
+ JSON.stringify({ rules: { "space-around-alphabet": 2 } }),
+ "utf8"
+ );
+ const originalMdxContent = [
+ 'export const title = "中文English";',
+ "",
+ '',
+ " 正文English内容。",
+ "",
+ "",
+ ].join("\n");
+ writeFileSync(fixableMdxFile, originalMdxContent, "utf8");
run(lintMd, [cleanFile]);
run(lintMd, [cleanMdxFile]);
@@ -103,6 +119,26 @@ try {
const fixedContent = readFileSync(fixableFile, "utf8");
assert(fixedContent !== originalFixableContent, "--fix did not update the fixture");
run(lintMd, [fixableFile]);
+
+ run(lintMd, [fixableMdxFile, "--fix", "--config", configFile]);
+ const fixedMdxContent = readFileSync(fixableMdxFile, "utf8");
+ assert(
+ fixedMdxContent.includes('export const title = "中文English";'),
+ "installed --fix changed an ESM string"
+ );
+ assert(
+ fixedMdxContent.includes(''),
+ "installed --fix changed a JSX attribute"
+ );
+ assert(
+ fixedMdxContent.includes("正文 English 内容。"),
+ "installed --fix did not fix MDX body text"
+ );
+ assert(
+ fixedMdxContent !== originalMdxContent,
+ "installed --fix did not update the MDX fixture"
+ );
+ run(lintMd, [fixableMdxFile, "--config", configFile]);
} finally {
rmSync(temporaryDirectory, { recursive: true, force: true });
}
diff --git a/src/cli/run-lint.ts b/src/cli/run-lint.ts
index 5d1ef7c..fa65f98 100644
--- a/src/cli/run-lint.ts
+++ b/src/cli/run-lint.ts
@@ -200,16 +200,6 @@ export const runFileLint = async ({
}
}
- if (
- isFixMode &&
- mdFiles.some((file) => file.toLowerCase().endsWith(".mdx"))
- ) {
- console.error(
- "[lint-md] --fix does not support .mdx files. No files were changed."
- );
- return FAILURE_EXIT;
- }
-
const concurrencyDecision = resolveAdaptiveConcurrency(
threadCount,
mdFiles.length,
diff --git a/src/utils/lint-worker.ts b/src/utils/lint-worker.ts
index 6ab1205..1bed492 100644
--- a/src/utils/lint-worker.ts
+++ b/src/utils/lint-worker.ts
@@ -9,16 +9,14 @@ const lintWorker = async (options: LintWorkerOptions) => {
const { filePath, rules, isFixMode } = options;
const isMdx = extname(filePath).toLowerCase() === ".mdx";
- if (isMdx && isFixMode) {
- throw new Error("--fix does not support .mdx files");
- }
-
const content = await readFile(filePath, "utf8");
let result: LintMdResult;
if (isMdx) {
- const { lintMdx } =
+ const { fixMdx, lintMdx } =
require("@lint-md/core/mdx") as typeof import("@lint-md/core/mdx");
- result = lintMdx(content, { rules: rules ?? {} });
+ result = isFixMode
+ ? fixMdx(content, { rules: rules ?? {} })
+ : lintMdx(content, { rules: rules ?? {} });
} else {
result = isFixMode
? fixMarkdown(content, { rules })