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 })