diff --git a/.github/workflows/translate-docs.yml b/.github/workflows/translate-docs.yml index 1fa03c71..fc542563 100644 --- a/.github/workflows/translate-docs.yml +++ b/.github/workflows/translate-docs.yml @@ -46,9 +46,13 @@ jobs: strategy: fail-fast: false # Cap matrix fan-out so the LiteLLM proxy at ANTHROPIC_BASE_URL - # never sees the full 14-way burst. With 4 jobs x MAX_CONCURRENT=2 - # = 8 simultaneous requests, well under the proxy's empirical - # connection-drop knee point (see scripts/translate-docs/cli.ts). + # never sees the full 14-way burst. With max-parallel 4 jobs x + # MAX_CONCURRENT=4 (the cli.ts default; the env block below does not set + # TRANSLATE_MAX_CONCURRENT) that is 16 simultaneous requests. A + # generation-time validation retry (TRANSLATE_MAX_ATTEMPTS) re-translates + # inside the worker's existing slot, so it lengthens that slot's + # wall-clock rather than adding a concurrent request — peak concurrency + # stays 16 no matter how many attempts a page needs. max-parallel: 4 matrix: lang: ${{ fromJson(needs.prepare.outputs.languages) }} diff --git a/CHANGELOG.md b/CHANGELOG.md index dc4631a6..48edcebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Redirect the 13 AgentEye pages the upstream syncs deleted (`/agenteye/deployment`, `/agenteye/kubernetes-deployment`, `/agenteye/troubleshooting`, …) to a live page instead of hard-404ing. (#556) ### Fixes +- Validate every translated docs page at generation time and re-translate with the validation error fed back, instead of discovering the breakage in `consolidate` after all 14 language jobs have already spent their tokens. The daily `translate-docs` run kept failing (5 of the last 15 runs) on errors like `de/agenteye/cli-skill.mdx: There is a syntax error in your frontmatter on line 2` — the model re-emitting an escaped inner quote from a `description:` value as an unescaped `"`, which breaks the YAML. The repo's own `validate:mdx` net could not catch this class at all: `findMdxParseError` blanks the frontmatter before compiling, so a frontmatter YAML error was structurally invisible to it and only the last-step `mintlify validate` in `consolidate` ever saw it — one bad page in one language sank the whole batch and nothing published. `validate-mdx.ts` now exports `findFrontmatterError` (YAML parse via the already-present `yaml` dep, file-relative line numbers) and `findPageError` (frontmatter then body), and `validate:mdx` runs the latter, so the CI net is now a strict superset of `mintlify validate` across all 647 pages. At generation time each translator renders the exact bytes it will write (sanitizers + link-rewrite for MDX pages, disclaimer + RTL `
` wrapper for the README) and validates them through a shared `findTranslationError` — frontmatter YAML, frontmatter key-parity against the English source (catches a dropped or renamed block, which is still valid YAML and which `mintlify` tolerates), and MDX body — re-translating with the error appended to the prompt until it passes or `TRANSLATE_MAX_ATTEMPTS` (integer ≥ 1, default 3, distinct from the SDK-transport `TRANSLATE_MAX_RETRIES`) is reached. On exhaustion it throws *before* the write, so a broken page is never written to disk and never cached — the cache keys only the English source hash, so a cached-invalid page could otherwise never self-heal. System-prompt rule 3 now carries the same frontmatter-quoting guidance rule 2 has always given for JSX attributes, cutting the failure off at the source. (#570) - Stop shipping npm lifecycle scripts, removing the `npm warn` every install printed. npm 12 (`allowScripts`, released 2026-07-08 and now the `latest` dist-tag) blocks dependency install scripts by default, so `npm install failproofai` warned `1 package had install scripts blocked … failproofai@… (postinstall: node scripts/postinstall.mjs)` and silently skipped the script; npm 11.16+ prints the advisory `allow-scripts` variant and still runs it. `allowScripts` is purely consumer-side — a package cannot opt itself in (verified: a package declaring `allowScripts`/`trustedDependencies` for *itself* is ignored) — so the only fix is to ship no install scripts. The `postinstall` script's install telemetry (`first_install` / `version_changed` / `package_installed`, with identical event names and properties) now fires from the CLI's first non-hook invocation via `lib/install-check.ts`, reporting at most once per version and no-op'ing on the steady-state path; it is deliberately kept out of `bin/failproofai.mjs`'s `--hook` fast path, which runs on every tool call. This also *recovers* telemetry already being dropped for bun, pnpm, and Yarn ≥4.14 users, which have blocked install scripts for some time. The script's `server.js` check and shadowed-PATH diagnosis were redundant — `scripts/launch.ts` already performs both at launch, with a better error message. `package_installed` now measures install→activation rather than raw installs, and same-version reinstalls (`direction: "reinstall"`) are no longer reported, as the CLI has no signal to detect them. The install-time welcome message is dropped; the `config` wizard's first-run redirect already handles onboarding. (#560) - Remove `scripts/preuninstall.mjs`, which never ran. npm honours no uninstall lifecycle scripts (verified with a probe package: `postinstall` fired, `preuninstall` did not), so the hook cleanup it appeared to perform has never happened — uninstalling failproofai leaves `__failproofai_hook__` entries behind in settings files. Removing the dead code makes the gap visible; cleanup needs an explicit `failproofai policies --uninstall` before removing the package. (#560) - Stop this repo's dogfood hooks from silently no-op'ing when `bun` isn't on the hook's PATH. All 75 hook commands across the 8 project-level agent-CLI configs fired `bun bin/failproofai.mjs --hook ` directly, so whenever the agent CLI was handed a PATH without bun — `npm i -g bun` installs into a single nvm version's bin dir, so `nvm use ` drops it; a macOS GUI launch inherits a launchd PATH built without `~/.zshrc` — every event died with exit 127 and the session ran with **zero** policy enforcement, saying nothing. The configs now call a dev-only `node scripts/dev-hook.mjs` launcher that locates bun across `PATH`, `$BUN_INSTALL/bin`, `~/.bun/bin`, the node execPath sibling, Homebrew/`/usr/local`, and every `~/.nvm/versions/node/*/bin`; installs it via npm if it is genuinely absent; builds `dist/index.js` when missing so `.failproofai/policies/*.mjs` can resolve `import ... from 'failproofai'`; then re-execs the real binary with `stdio: "inherit"` (byte-exact stdout — the deny contracts are JSON on stdout) and propagates the exit code verbatim (2 still means deny; signals map to 128+signum as `sh` did). A `command -v node` pre-check fronts each command so a missing node is a loud one-liner rather than silence — exit 2 on tool events, exit 1 on stop-class events, where exit 2 would mean "retry" and loop forever. The `.opencode` dev shim shares the same resolver and no longer reports a never-run hook as `exitCode: 0` (a silent allow). Production users on `npx -y failproofai` are unaffected. A new drift-guard test reads every committed dogfood config and asserts the launcher form, guard, exit-code split, and per-file command counts — these configs are generated by nothing and read by nothing, which is how #337's opencode shim drifted and silently no-op'd `block-read-outside-cwd` repo-wide. (#564) diff --git a/__tests__/scripts/translate-docs/mdx-translator.test.ts b/__tests__/scripts/translate-docs/mdx-translator.test.ts index 6c1a8678..e7e09164 100644 --- a/__tests__/scripts/translate-docs/mdx-translator.test.ts +++ b/__tests__/scripts/translate-docs/mdx-translator.test.ts @@ -1,8 +1,26 @@ // @vitest-environment node -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + existsSync, + rmSync, +} from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; + +// Mock the Anthropic SDK so the validation-gate tests below drive translation +// output deterministically. Harmless to the pure/real-tree tests in this file, +// which never call the translator. +const { streamMock } = vi.hoisted(() => ({ streamMock: vi.fn() })); +vi.mock("@anthropic-ai/sdk", () => ({ + default: class MockAnthropic { + messages = { stream: streamMock }; + }, +})); + import { rewriteInternalLinks, sanitizeJsxAttributes, @@ -10,9 +28,36 @@ import { convertHtmlComments, getEnglishMdxPages, pruneOrphanedTranslations, + translateMdxPage, } from "@/scripts/translate-docs/mdx-translator"; import type { TranslationCache } from "@/scripts/translate-docs/types"; +/** Queue ONE `end_turn` translation response; call once per expected attempt. */ +function queueTranslation(text: string): void { + streamMock.mockReturnValueOnce({ + finalMessage: async () => ({ + stop_reason: "end_turn", + content: [{ type: "text", text }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); +} + +/** Sticky response — every attempt returns the same text. */ +function stickyTranslation(text: string): void { + streamMock.mockReturnValue({ + finalMessage: async () => ({ + stop_reason: "end_turn", + content: [{ type: "text", text }], + usage: { input_tokens: 10, output_tokens: 20 }, + }), + }); +} + +function emptyCache(): TranslationCache { + return { sourceHash: "", lastUpdated: "", translations: {} }; +} + describe("getEnglishMdxPages", () => { it("includes AgentEye pages in automatic translation", () => { const pages = getEnglishMdxPages(); @@ -404,3 +449,106 @@ describe("convertHtmlComments", () => { expect(convertHtmlComments(input)).toBe(input); }); }); + +describe("translateMdxPage validation gate", () => { + const EN_SOURCE = `---\ntitle: "Skill"\ndescription: "A page"\n---\n\n# Body\n`; + const REL = "agenteye/cli-skill.mdx"; + const VALID_DE = `---\ntitle: "Fähigkeit"\ndescription: "Eine Seite"\n---\n\n# Körper\n`; + // An unescaped inner quote in the description — the reported failure class. + const BROKEN_FM_DE = `---\ntitle: "Fähigkeit"\ndescription: "Fragen Sie "ist kaputt?""\n---\n\n# Körper\n`; + + let docsDir: string; + let srcPath: string; + let outputPath: string; + + beforeEach(() => { + streamMock.mockReset(); + vi.spyOn(console, "warn").mockImplementation(() => {}); + docsDir = mkdtempSync(join(tmpdir(), "mdx-gate-")); + srcPath = join(docsDir, REL); + mkdirSync(dirname(srcPath), { recursive: true }); + writeFileSync(srcPath, EN_SOURCE); + outputPath = join(docsDir, "de", REL); + }); + + afterEach(() => { + rmSync(docsDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("writes the translation when it validates on the first try", async () => { + queueTranslation(VALID_DE); + const cache = emptyCache(); + + const result = await translateMdxPage(srcPath, "de", { docsDir, cache }); + + expect(streamMock).toHaveBeenCalledTimes(1); + expect(existsSync(outputPath)).toBe(true); + expect(result.attempts).toBe(1); + }); + + it("writes the valid retry after a broken-frontmatter first attempt", async () => { + queueTranslation(BROKEN_FM_DE); + queueTranslation(VALID_DE); + const cache = emptyCache(); + + const result = await translateMdxPage(srcPath, "de", { docsDir, cache }); + + expect(streamMock).toHaveBeenCalledTimes(2); + expect(result.attempts).toBe(2); + expect(existsSync(outputPath)).toBe(true); + // The VALID translation is what got written, not the broken first attempt. + expect(readFileSync(outputPath, "utf-8")).toContain("Eine Seite"); + }); + + it("throws and writes no file when every attempt fails validation", async () => { + stickyTranslation(BROKEN_FM_DE); + const cache = emptyCache(); + + await expect( + translateMdxPage(srcPath, "de", { docsDir, cache }), + ).rejects.toThrow(/still fails validation/); + expect(existsSync(outputPath)).toBe(false); + }); + + it("adds no cache entry when every attempt fails validation", async () => { + stickyTranslation(BROKEN_FM_DE); + const cache = emptyCache(); + + await expect( + translateMdxPage(srcPath, "de", { docsDir, cache }), + ).rejects.toThrow(); + expect(cache.translations).not.toHaveProperty(`${REL}::de`); + expect(Object.keys(cache.translations)).toHaveLength(0); + }); + + it("validates the sanitized, link-rewritten bytes rather than the raw model output", async () => { + // Raw output has a stray doubled quote in a JSX attribute (invalid MDX); + // sanitizeJsxAttributes fixes it before validation, so it passes on the + // first attempt — proving validation runs on the RENDERED bytes. Only one + // response is queued, so a wrongly-triggered retry would fail the test. + const RAW_FIXABLE = `---\ntitle: "Fähigkeit"\ndescription: "Eine Seite"\n---\n\n\n`; + queueTranslation(RAW_FIXABLE); + const cache = emptyCache(); + + const result = await translateMdxPage(srcPath, "de", { docsDir, cache }); + + expect(streamMock).toHaveBeenCalledTimes(1); + expect(result.attempts).toBe(1); + const written = readFileSync(outputPath, "utf-8"); + expect(written).toContain('title="Foo"'); + expect(written).not.toContain('title="Foo""'); + }); + + it("performs no validation and no model call under dryRun", async () => { + const result = await translateMdxPage(srcPath, "de", { + docsDir, + cache: emptyCache(), + dryRun: true, + }); + + expect(streamMock).not.toHaveBeenCalled(); + expect(existsSync(outputPath)).toBe(false); + expect(result.cached).toBe(false); + }); +}); diff --git a/__tests__/scripts/translate-docs/translator.test.ts b/__tests__/scripts/translate-docs/translator.test.ts index f5ff2002..4ce1dd1e 100644 --- a/__tests__/scripts/translate-docs/translator.test.ts +++ b/__tests__/scripts/translate-docs/translator.test.ts @@ -12,13 +12,33 @@ vi.mock("@anthropic-ai/sdk", () => ({ }, })); -import { translateContent } from "@/scripts/translate-docs/translator"; +import { + translateContent, + translateValidated, +} from "@/scripts/translate-docs/translator"; -/** Configure the next `.finalMessage()` resolution. */ +/** Configure the next `.finalMessage()` resolution (sticky — same value forever). */ function mockFinalMessage(message: unknown): void { streamMock.mockReturnValue({ finalMessage: async () => message }); } +/** Queue ONE `end_turn` response with the given text; call once per attempt. */ +function queueFinalMessage( + text: string, + usage: { input_tokens: number; output_tokens: number } = { + input_tokens: 10, + output_tokens: 20, + }, +): void { + streamMock.mockReturnValueOnce({ + finalMessage: async () => ({ + stop_reason: "end_turn", + content: [{ type: "text", text }], + usage, + }), + }); +} + describe("translateContent", () => { beforeEach(() => { streamMock.mockReset(); @@ -69,3 +89,157 @@ describe("translateContent", () => { expect(requestArgs.max_tokens).toBe(64000); }); }); + +describe("translateValidated", () => { + const base = { + source: "# Doc", + lang: "de", + langName: "German", + label: "doc [de]", + render: (raw: string) => raw, + }; + /** Reject any rendered output that still contains the marker `BAD`. */ + const rejectBad = async (bytes: string): Promise => + bytes.includes("BAD") ? "the output still contains BAD" : null; + + beforeEach(() => { + streamMock.mockReset(); + // Silence the per-attempt retry warnings so test output stays readable. + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + it("makes exactly one model call when the first translation is valid", async () => { + queueFinalMessage("clean body"); + + const result = await translateValidated({ + ...base, + validate: async () => null, + }); + + expect(result.rendered).toBe("clean body"); + expect(result.attempts).toBe(1); + expect(streamMock).toHaveBeenCalledTimes(1); + }); + + it("retries with the validation error and returns the corrected translation", async () => { + queueFinalMessage("BAD body"); + queueFinalMessage("good body"); + + const result = await translateValidated({ ...base, validate: rejectBad }); + + expect(result.rendered).toBe("good body"); + expect(result.attempts).toBe(2); + expect(streamMock).toHaveBeenCalledTimes(2); + }); + + it("appends the retry feedback to the user turn and leaves the system prompt untouched", async () => { + queueFinalMessage("BAD body"); + queueFinalMessage("good body"); + + await translateValidated({ + ...base, + validate: async (bytes) => + bytes.includes("BAD") ? "the frontmatter is broken on line 2" : null, + }); + + const first = streamMock.mock.calls[0][0] as { + system: unknown; + messages: Array<{ role: string; content: string }>; + }; + const second = streamMock.mock.calls[1][0] as { + system: unknown; + messages: Array<{ role: string; content: string }>; + }; + // First attempt carries no repair note. + expect(first.messages[0].content).not.toContain("REJECTED"); + // The retry feeds the validation error back in the same user turn... + expect(second.messages[0].content).toContain( + "the frontmatter is broken on line 2", + ); + expect(second.messages[0].content).toContain("Retry 2 of 3"); + // ...and never mutates the (cached) system prompt. + expect(second.system).toEqual(first.system); + }); + + it("retries an empty translation instead of returning it", async () => { + queueFinalMessage(" "); // whitespace-only counts as empty + queueFinalMessage("good body"); + + const result = await translateValidated({ + ...base, + validate: async () => null, + }); + + expect(result.rendered).toBe("good body"); + expect(result.attempts).toBe(2); + }); + + it("throws after TRANSLATE_MAX_ATTEMPTS invalid translations", async () => { + mockFinalMessage({ + stop_reason: "end_turn", + content: [{ type: "text", text: "BAD body" }], + usage: { input_tokens: 10, output_tokens: 20 }, + }); + + await expect( + translateValidated({ ...base, validate: rejectBad }), + ).rejects.toThrow(/still fails validation after 3 attempt/); + }); + + it("stops calling the model once the attempt cap is reached", async () => { + mockFinalMessage({ + stop_reason: "end_turn", + content: [{ type: "text", text: "BAD" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + + await expect( + translateValidated({ ...base, validate: rejectBad }), + ).rejects.toThrow(); + expect(streamMock).toHaveBeenCalledTimes(3); + }); + + it("sums input and output tokens across attempts", async () => { + queueFinalMessage("BAD body", { input_tokens: 100, output_tokens: 200 }); + queueFinalMessage("good body", { input_tokens: 50, output_tokens: 60 }); + + const result = await translateValidated({ ...base, validate: rejectBad }); + + expect(result.inputTokens).toBe(150); + expect(result.outputTokens).toBe(260); + expect(result.attempts).toBe(2); + }); + + it("does not retry a response truncated at max_tokens", async () => { + // translateContent throws on max_tokens before returning; that is not a + // validity failure, so translateValidated must let it propagate uncaught. + mockFinalMessage({ + stop_reason: "max_tokens", + content: [{ type: "text", text: "partial…" }], + usage: { input_tokens: 1, output_tokens: 64000 }, + }); + + await expect( + translateValidated({ + ...base, + lang: "he", + langName: "Hebrew", + validate: async () => null, + }), + ).rejects.toThrow(/truncated at max_tokens/); + expect(streamMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry when the request itself throws", async () => { + streamMock.mockReturnValue({ + finalMessage: async () => { + throw new Error("Connection error."); + }, + }); + + await expect( + translateValidated({ ...base, validate: async () => null }), + ).rejects.toThrow(/Connection error/); + expect(streamMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/scripts/translate-docs/validate-translation.test.ts b/__tests__/scripts/translate-docs/validate-translation.test.ts new file mode 100644 index 00000000..97d4eb43 --- /dev/null +++ b/__tests__/scripts/translate-docs/validate-translation.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { describe, it, expect } from "vitest"; +import { findTranslationError } from "@/scripts/translate-docs/validate-translation"; + +// English source with frontmatter: keys are `title` and `description`. +const SOURCE = `---\ntitle: "Skill"\ndescription: "A page"\n---\n\n# Body\n`; + +// A source with NO frontmatter, like the root README.md. +const README_SOURCE = `# Title\n\nSome prose.\n`; + +describe("findTranslationError", () => { + it("returns null for a valid translated page", async () => { + const rendered = `---\ntitle: "Fähigkeit"\ndescription: "Eine Seite"\n---\n\n# Körper\n`; + expect(await findTranslationError(rendered, SOURCE)).toBeNull(); + }); + + it("flags a frontmatter YAML error and quotes the offending line", async () => { + // The reported class: an unescaped inner quote in the description value. + const rendered = `---\ntitle: "Fähigkeit"\ndescription: "Fragen Sie "ist etwas kaputt?""\n---\n\n# Körper\n`; + const error = await findTranslationError(rendered, SOURCE); + expect(error).not.toBeNull(); + expect(error).toContain("frontmatter"); + expect(error).toMatch(/does not parse/); + }); + + it("flags a frontmatter block the model dropped entirely", async () => { + // A missing block is still valid YAML (mintlify tolerates it), so only the + // key-parity check against the source catches it. + const rendered = `# Körper\n\nKein Frontmatter hier.\n`; + const error = await findTranslationError(rendered, SOURCE); + expect(error).not.toBeNull(); + expect(error).toContain("missing"); + // Names the keys the translation must restore. + expect(error).toContain("title"); + expect(error).toContain("description"); + }); + + it("flags frontmatter keys the model renamed or dropped", async () => { + // `title` renamed to `titel`. + const rendered = `---\ntitel: "Fähigkeit"\ndescription: "Eine Seite"\n---\n\n# Körper\n`; + const error = await findTranslationError(rendered, SOURCE); + expect(error).not.toBeNull(); + expect(error).toContain("keys changed"); + }); + + it("allows an extra frontmatter key the source does not have", async () => { + // Extra keys cannot break the deploy, so they are tolerated (subset check). + const rendered = `---\ntitle: "Fähigkeit"\ndescription: "Eine Seite"\nicon: "book"\n---\n\n# Körper\n`; + expect(await findTranslationError(rendered, SOURCE)).toBeNull(); + }); + + it("flags an MDX body error with an excerpt", async () => { + const rendered = `---\ntitle: "Fähigkeit"\ndescription: "Eine Seite"\n---\n\nInstall failproofai policy add now.\n`; + const error = await findTranslationError(rendered, SOURCE); + expect(error).not.toBeNull(); + expect(error).toContain("MDX body"); + expect(error).toMatch(/slug|closing tag/i); + // The excerpt marks the failing line with a leading "> ". + expect(error).toMatch(/^> .*policy add /m); + }); + + it("runs body-only when the English source has no frontmatter", async () => { + // README shape: no frontmatter, so the key check is skipped entirely. + const broken = `# Titel\n\nInstall failproofai policy add .\n`; + const error = await findTranslationError(broken, README_SOURCE); + expect(error).not.toBeNull(); + expect(error).toContain("MDX body"); + + const valid = `# Titel\n\nEinfache Prosa.\n`; + expect(await findTranslationError(valid, README_SOURCE)).toBeNull(); + }); + + it("flags malformed frontmatter the model added to a frontmatter-less source", async () => { + // The source has no frontmatter, but the translation invents a leading + // `---` block and botches its YAML. findMdxParseError would blank the block + // and miss it, so this only fails if the frontmatter is validated for every + // source shape — not just when the source itself had frontmatter. + const rendered = `---\ntitle: "Titel "kaputt""\n---\n\n# Körper\n`; + const error = await findTranslationError(rendered, README_SOURCE); + expect(error).not.toBeNull(); + expect(error).toContain("frontmatter"); + expect(error).toMatch(/does not parse/); + }); + + it("allows well-formed frontmatter the model added to a frontmatter-less source", async () => { + // A well-formed added block is harmless — Mintlify ignores unknown keys — + // so it must not be rejected. + const rendered = `---\ntitle: "Titel"\n---\n\n# Körper\n`; + expect(await findTranslationError(rendered, README_SOURCE)).toBeNull(); + }); +}); diff --git a/__tests__/scripts/validate-mdx.test.ts b/__tests__/scripts/validate-mdx.test.ts index 081c85d4..d94fbd56 100644 --- a/__tests__/scripts/validate-mdx.test.ts +++ b/__tests__/scripts/validate-mdx.test.ts @@ -6,7 +6,9 @@ import { tmpdir } from "node:os"; import { collectMdxFiles, encodeAnnotation, + findFrontmatterError, findMdxParseError, + findPageError, stripFrontmatter, } from "@/scripts/validate-mdx"; @@ -110,6 +112,120 @@ describe("findMdxParseError", () => { }); }); +describe("findFrontmatterError", () => { + it("catches an unescaped inner quote in a double-quoted description scalar", () => { + // The exact class that failed run 29575781632: the model re-emits an inner + // quote unescaped into the YAML value. + const src = [ + "---", + 'title: "Failproof AI Observability CLI Agent Skill"', + 'description: "Fragen Sie "ist etwas kaputt?" und lassen"', + "---", + "", + "# Body", + "", + ].join("\n"); + const error = findFrontmatterError(src); + expect(error).not.toBeNull(); + expect(error?.message).toMatch(/scalar|unexpected/i); + }); + + it("accepts a description with a properly escaped inner quote (the English source form)", () => { + // The English source escapes its inner quotes (`\"`), which is valid YAML; + // only the translation dropping the escape breaks it. + const src = [ + "---", + 'title: "Failproof AI Observability CLI Agent Skill"', + 'description: "Ask your coding agent \\"is anything broken today?\\" and let it answer."', + "---", + "", + "# Body", + "", + ].join("\n"); + expect(findFrontmatterError(src)).toBeNull(); + }); + + it("returns null for a page with no frontmatter block", () => { + expect(findFrontmatterError("# Title\n\nJust prose.\n")).toBeNull(); + }); + + it("returns null for well-formed frontmatter", () => { + const src = `---\ntitle: "Hello"\ndescription: "A page"\n---\n\n# Body\n`; + expect(findFrontmatterError(src)).toBeNull(); + }); + + it("reports a file-relative line that counts the opening ---", () => { + // Error is on the `title` line — block line 1, file line 2 (the opening + // `---` is file line 1). This is deliberately one greater than the + // block-relative number mintlify prints; do not "fix" it to match. + const src = [ + "---", + 'title: "Foo "bar" baz"', + 'description: "ok"', + "---", + "", + "# Body", + "", + ].join("\n"); + const error = findFrontmatterError(src); + expect(error).not.toBeNull(); + expect(error?.line).toBe(2); + }); + + it("strips the block-relative position from the message but keeps the caret excerpt", () => { + const src = `---\ntitle: "Foo "bar" baz"\n---\n\n# Body\n`; + const error = findFrontmatterError(src); + expect(error).not.toBeNull(); + // The redundant "at line N, column N:" phrase is removed (it would + // contradict our file-relative line)... + expect(error?.message).not.toMatch(/at line \d+, column \d+/); + // ...but the caret-underlined excerpt that points a model at the defect + // is preserved. + expect(error?.message).toContain("^"); + }); + + it("does not treat a mid-document --- thematic break as frontmatter", () => { + // The block must be at the very top; a `---` rule later in the body (and a + // broken-looking line after it) is not frontmatter. + const src = `# Title\n\nSome prose.\n\n---\n\ntitle: "not frontmatter "at all"\n`; + expect(findFrontmatterError(src)).toBeNull(); + }); +}); + +describe("findPageError", () => { + it("flags a frontmatter YAML error that the body compile misses", async () => { + // findMdxParseError blanks the frontmatter, so it alone returns null here; + // findPageError must still catch the frontmatter error. + const src = [ + "---", + 'title: "Foo "bar" baz"', + "---", + "", + "# A perfectly valid body", + "", + ].join("\n"); + expect(await findMdxParseError(src)).toBeNull(); + const error = await findPageError(src); + expect(error).not.toBeNull(); + expect(error?.message).toMatch(/scalar|unexpected/i); + }); + + it("flags a body MDX error that the frontmatter check misses", async () => { + const src = [ + "---", + 'title: "All good here"', + "---", + "", + "Install with failproofai policy add now.", + "", + ].join("\n"); + expect(findFrontmatterError(src)).toBeNull(); + const error = await findPageError(src); + expect(error).not.toBeNull(); + expect(error?.message).toMatch(/slug|closing tag/i); + }); +}); + describe("collectMdxFiles", () => { it("collects .md and .mdx pages recursively but ignores other files", () => { // Regression guard: Mintlify parses .md pages (e.g. the docs/i18n READMEs) diff --git a/scripts/translate-docs/cli.ts b/scripts/translate-docs/cli.ts index 07cd5a7f..a708c788 100644 --- a/scripts/translate-docs/cli.ts +++ b/scripts/translate-docs/cli.ts @@ -419,6 +419,12 @@ async function main() { if (totalInput > 0) { console.log(`Total tokens: ${totalInput} input + ${totalOutput} output`); } + // Surface pages that needed a re-translation to pass validation, so a run + // that quietly retried does not read as a clean one in the job log. + const retried = translated.filter((r) => (r.attempts ?? 1) > 1); + if (retried.length > 0) { + console.log(`Retried: ${retried.length}`); + } if (errors.length > 0) { process.exit(1); diff --git a/scripts/translate-docs/mdx-translator.ts b/scripts/translate-docs/mdx-translator.ts index 55f8c57e..360cb109 100644 --- a/scripts/translate-docs/mdx-translator.ts +++ b/scripts/translate-docs/mdx-translator.ts @@ -10,7 +10,8 @@ import { import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { getLanguageByCode } from "./config"; -import { translateContent } from "./translator"; +import { translateValidated } from "./translator"; +import { findTranslationError } from "./validate-translation"; import { readCache, writeCache, @@ -193,10 +194,13 @@ export async function translateMdxPage( dryRun?: boolean; model?: string; cache?: TranslationCache; + /** Override the docs root. Tests point this at a fixture tree. */ + docsDir?: string; } = {}, ): Promise { - const relPath = relative(DOCS_DIR, sourcePath); - const outputPath = join(DOCS_DIR, lang, relPath); + const docsDir = options.docsDir ?? DOCS_DIR; + const relPath = relative(docsDir, sourcePath); + const outputPath = join(docsDir, lang, relPath); const sourceContent = readFileSync(sourcePath, "utf-8"); const langConfig = getLanguageByCode(lang); @@ -228,25 +232,31 @@ export async function translateMdxPage( }; } - // Translate - const { translated, inputTokens, outputTokens } = await translateContent( - sourceContent, - lang, - langConfig.name, - options.model, - ); - - // Strip stray quote artifacts from JSX attribute values, drop any - // unmatched trailing code fence the model sometimes hallucinates, convert - // any HTML comments to MDX comments, then rewrite links. - const sanitized = convertHtmlComments( - stripStrayTrailingFence(sanitizeJsxAttributes(translated)), - ); - const withLinks = rewriteInternalLinks(sanitized, lang); + // Translate and validate the exact bytes we will write. The render callback + // reproduces the historical sanitize + link-rewrite chain byte-for-byte + // (strip stray JSX-attribute quotes, drop an unmatched trailing fence, + // convert HTML comments to MDX, then add the language prefix to links), so + // the validated bytes ARE the written bytes and a pass here equals a pass in + // the deploy. On exhaustion translateValidated throws before we reach the + // write, so an invalid page is never written or cached. + const { rendered, inputTokens, outputTokens, attempts } = + await translateValidated({ + source: sourceContent, + lang, + langName: langConfig.name, + model: options.model, + label: `${relPath} [${lang}]`, + render: (raw) => + rewriteInternalLinks( + convertHtmlComments(stripStrayTrailingFence(sanitizeJsxAttributes(raw))), + lang, + ), + validate: (bytes) => findTranslationError(bytes, sourceContent), + }); // Write output mkdirSync(dirname(outputPath), { recursive: true }); - writeFileSync(outputPath, withLinks); + writeFileSync(outputPath, rendered); // Update cache — skip if caller manages the cache (batch write) if (!options.cache) { @@ -269,6 +279,7 @@ export async function translateMdxPage( inputTokens, outputTokens, cached: false, + attempts, }; } diff --git a/scripts/translate-docs/readme-translator.ts b/scripts/translate-docs/readme-translator.ts index 0214d045..93be8b97 100644 --- a/scripts/translate-docs/readme-translator.ts +++ b/scripts/translate-docs/readme-translator.ts @@ -2,12 +2,13 @@ import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { LANGUAGES, getLanguageByCode } from "./config"; -import { translateContent } from "./translator"; +import { translateValidated } from "./translator"; import { stripStrayTrailingFence, convertHtmlComments, sanitizeJsxAttributes, } from "./mdx-translator"; +import { findTranslationError } from "./validate-translation"; import { readCache, writeCache, isCached, setCacheEntry } from "./cache"; import type { TranslationResult, TranslationCache } from "./types"; @@ -90,15 +91,12 @@ export async function translateReadme( }; } - // Translate - const { translated, inputTokens, outputTokens } = await translateContent( - sourceContent, - lang, - langConfig.name, - options.model, - ); - - // Build the final output with header + // Compute the wrapper (disclaimer, language selector, RTL
) up front so + // the render callback can assemble the FINAL bytes on every attempt. The + // assembled bytes — not the raw model output — are what gets validated: the + // swallowed-`
` class is introduced by the wrapper AFTER the model + // returns, and `mintlify validate` never sees the README at all, so this gate + // is the only thing standing between a broken README and the deploy. const disclaimer = langConfig.rtl ? `> **\u26a0\ufe0f** \u0647\u0630\u0647 \u062a\u0631\u062c\u0645\u0629 \u0622\u0644\u064a\u0629. \u0644\u0644\u0627\u0637\u0644\u0627\u0639 \u0639\u0644\u0649 \u0623\u062d\u062f\u062b \u0625\u0635\u062f\u0627\u0631\u060c \u0631\u0627\u062c\u0639 [English README](../../README.md).` : `> **\u26a0\ufe0f** This is an auto-generated translation. For the latest version, see the [English README](../../README.md). Community corrections welcome!`; @@ -107,20 +105,31 @@ export async function translateReadme( const rtlOpen = langConfig.rtl ? `
\n\n` : ""; const rtlClose = langConfig.rtl ? `\n\n
` : ""; - // Run the same MDX sanitizers as translateMdxPage — the README emits JSX - // (the logo table), so its output has to satisfy Mintlify's MDX parser too: - // strip stray quote artifacts from JSX attributes, drop any unmatched - // trailing code fence the model hallucinates (which would swallow the RTL - // `
` wrapper), and convert HTML comments to MDX comments. - const cleaned = convertHtmlComments( - stripStrayTrailingFence(sanitizeJsxAttributes(translated)), - ); - - const output = `${disclaimer}\n\n${langSelector}\n\n---\n${rtlOpen}\n${cleaned}\n${rtlClose}`; + // Translate and validate the assembled bytes, re-translating on failure. + const { rendered, inputTokens, outputTokens, attempts } = + await translateValidated({ + source: sourceContent, + lang, + langName: langConfig.name, + model: options.model, + label: `README.${lang}.md`, + render: (raw) => { + // Same MDX sanitizers as translateMdxPage — the README emits JSX (the + // logo table), so strip stray attribute quotes, drop any unmatched + // trailing code fence (which would swallow the RTL ``), and + // convert HTML comments to MDX — then wrap in disclaimer + selector + + // RTL div. + const cleaned = convertHtmlComments( + stripStrayTrailingFence(sanitizeJsxAttributes(raw)), + ); + return `${disclaimer}\n\n${langSelector}\n\n---\n${rtlOpen}\n${cleaned}\n${rtlClose}`; + }, + validate: (bytes) => findTranslationError(bytes, sourceContent), + }); // Write output mkdirSync(I18N_DIR, { recursive: true }); - writeFileSync(outputPath, output); + writeFileSync(outputPath, rendered); // Update cache — skip if caller manages the cache (batch write) if (!options.cache) { @@ -136,6 +145,7 @@ export async function translateReadme( inputTokens, outputTokens, cached: false, + attempts, }; } diff --git a/scripts/translate-docs/translator.ts b/scripts/translate-docs/translator.ts index 4f992f8d..fc93f4d4 100644 --- a/scripts/translate-docs/translator.ts +++ b/scripts/translate-docs/translator.ts @@ -25,6 +25,25 @@ const MAX_TOKENS = ? parsedMaxTokens : 64000; +// Maximum TOTAL validation attempts per page: 1 initial translation plus up to +// MAX_ATTEMPTS-1 re-translations when the rendered output fails validation +// (see translateValidated below). Distinct from the SDK transport budget +// TRANSLATE_MAX_RETRIES (default 5, in getClient): that retries a single HTTP +// request on a connection error; this re-translates a page whose *content* +// failed the docs-build checks. They multiply — at most +// MAX_ATTEMPTS x (1 + maxRetries) HTTP requests for one page in the worst case. +// 3 collapses the observed per-page failure rate to negligible while costing +// wall-clock (a serial retry inside one worker slot), not peak concurrency. +// Override via TRANSLATE_MAX_ATTEMPTS (integer >= 1; 1 disables retries). +const parsedMaxAttempts = Number.parseInt( + process.env.TRANSLATE_MAX_ATTEMPTS ?? "", + 10, +); +const MAX_ATTEMPTS = + Number.isInteger(parsedMaxAttempts) && parsedMaxAttempts > 0 + ? parsedMaxAttempts + : 3; + function getClient(): Anthropic { if (!client) { // Default 5 retries (up from SDK default of 2) so transient @@ -44,7 +63,7 @@ const SYSTEM_PROMPT = `You are a professional technical documentation translator 1. **Preserve all code blocks exactly as-is** — never translate content inside backtick-fenced code blocks (\`\`\`...\`\`\`) or inline code (\`...\`). 2. **Preserve MDX component syntax** — tags like , , , , , , , , , must remain unchanged. Their attribute names (title, icon, href, cols) must remain in English. Only translate the text content of the \`title\` attribute and the text body between tags. **Never put an ASCII straight \`"\` inside a \`title="…"\` (or any JSX attribute value)** — it terminates the attribute and breaks MDX parsing. If the target language would normally wrap a word in quotation marks (e.g. German „…", Japanese 「…」), drop the inner quotes inside attribute values and rely on the surrounding tag for emphasis. -3. **Preserve YAML frontmatter keys** — only translate the string values of \`title\` and \`description\`. Keep the \`icon\` value unchanged. +3. **Preserve YAML frontmatter keys** — only translate the string values of \`title\` and \`description\`. Keep the \`icon\` value unchanged. Never rename, add, or drop a frontmatter key. The \`title\` and \`description\` values are wrapped in double quotes: **never put an unescaped ASCII \`"\` inside them** — it terminates the YAML string and breaks the frontmatter parse (exactly as an ASCII \`"\` breaks a JSX attribute in rule 2). If the source value contains an escaped quote (\`\\"\`), keep it escaped in the same form; if the target language would quote a phrase, use typographic quotes (e.g. „…", «…», 「…」) or rephrase to avoid the inner quote. 4. **Preserve all URLs and paths** — never modify href values, image paths, or links. 5. **Preserve Markdown structure** — headers (#, ##), lists (-, *), tables (|), bold (**), italic (*), links ([text](url)) must keep their Markdown formatting. 6. **Preserve badge/shield URLs** — any [![...](https://img.shields.io/...)](url) pattern must remain completely unchanged. @@ -65,11 +84,40 @@ ${DO_NOT_TRANSLATE.map((t) => `- ${t}`).join("\n")} Return ONLY the translated content. Do not add explanations, notes, or commentary.`; +export interface RetryFeedback { + attempt: number; + maxAttempts: number; + /** The previous attempt's validation error — model-actionable text. */ + error: string; +} + +/** + * The repair note appended to the user turn on a retry. The failed attempt is + * NOT fed back as an assistant turn: a fresh re-translate keeps the input flat + * across attempts. Conversational repair would grow the input every attempt and + * push a large README toward the max_tokens ceiling on exactly the retry where + * a truncation would be worst — and the error is self-locating (it carries the + * caret excerpt / body snippet), so the model needs the note, not its own prior + * output, to fix the defect. + */ +function buildRetryFeedback(f: RetryFeedback): string { + return ( + `[Retry ${f.attempt} of ${f.maxAttempts}. The source document above is unchanged.]\n\n` + + "Your previous translation of this document was REJECTED by the docs build " + + "and discarded. It failed validation with:\n\n" + + `${f.error}\n\n` + + "Translate the source again from the beginning and return ONLY the " + + "corrected translation — do not comment on this note. Do not reproduce that " + + "defect. Every rule above still applies." + ); +} + export async function translateContent( content: string, targetLang: string, targetLangName: string, model: string = "claude-sonnet-4-6", + feedback?: RetryFeedback, ): Promise<{ translated: string; inputTokens: number; outputTokens: number }> { const anthropic = getClient(); @@ -81,16 +129,18 @@ export async function translateContent( // surfaces to the SDK as `APIConnectionError ("Connection error.")`. // `messages.stream(...).finalMessage()` returns the same Message shape // as `messages.create(...)`, so the rest of the pipeline is unchanged. + const base = `Translate the following documentation content into ${targetLangName} (${targetLang}).\n\n---\n\n${content}`; + // On a retry, append the repair note after the source in the SAME single user + // turn — no assistant turn is fed back, so the request stays flat-input. + const userContent = feedback + ? `${base}\n\n---\n\n${buildRetryFeedback(feedback)}` + : base; + const response = await anthropic.messages.stream({ model, max_tokens: MAX_TOKENS, system: [{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } }], - messages: [ - { - role: "user", - content: `Translate the following documentation content into ${targetLangName} (${targetLang}).\n\n---\n\n${content}`, - }, - ], + messages: [{ role: "user", content: userContent }], }).finalMessage(); // A truncated translation is worse than a failed one: the model stops @@ -116,3 +166,88 @@ export async function translateContent( outputTokens: response.usage.output_tokens, }; } + +/** + * Translate `source`, render it to the exact bytes that will be written, and + * validate those bytes — re-translating with the validation error fed back + * until it passes or MAX_ATTEMPTS is reached. + * + * The loop lives here, not in translateContent, because each caller renders + * different final bytes (the MDX pages add rewriteInternalLinks; the README + * wraps the body in a disclaimer + RTL `
`). Validating the RENDERED bytes — + * not the raw model output — is what makes a pass here equal to a pass in + * `mintlify validate` and the deploy. + * + * On exhaustion it THROWS (never returns a partial), so the caller's write is + * unreachable and no invalid page is ever written or cached — the same + * fail-loud contract translateContent already enforces for max_tokens + * truncation. Transport/auth/max_tokens errors from translateContent propagate + * unchanged and consume no attempt: only *validity* failures retry. + */ +export async function translateValidated(opts: { + source: string; + lang: string; + langName: string; + model?: string; + /** Label for the per-attempt warning line, e.g. `agenteye/cli.mdx [de]`. */ + label: string; + /** Turn raw model output into the exact bytes that will be written. */ + render: (raw: string) => string; + /** Validate the rendered bytes; return an error message, or null if valid. */ + validate: (rendered: string) => Promise; +}): Promise<{ + rendered: string; + inputTokens: number; + outputTokens: number; + attempts: number; +}> { + let inputTokens = 0; + let outputTokens = 0; + let lastError = ""; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + const feedback: RetryFeedback | undefined = + attempt > 1 + ? { attempt, maxAttempts: MAX_ATTEMPTS, error: lastError } + : undefined; + + // No try/catch: a transport/auth/max_tokens throw is not a validity + // failure — let it propagate so it is never silently retried as one. + const result = await translateContent( + opts.source, + opts.lang, + opts.langName, + opts.model, + feedback, + ); + inputTokens += result.inputTokens; + outputTokens += result.outputTokens; + + if (result.translated.trim() === "") { + // An empty response is a validity failure, not a usable page — resample + // rather than write a blank file. + lastError = + "The translation was empty. Return the full translated document."; + console.warn( + ` ${opts.label} -> attempt ${attempt}/${MAX_ATTEMPTS} produced empty output; retrying`, + ); + continue; + } + + const rendered = opts.render(result.translated); + const error = await opts.validate(rendered); + if (error === null) { + return { rendered, inputTokens, outputTokens, attempts: attempt }; + } + + lastError = error; + console.warn( + ` ${opts.label} -> attempt ${attempt}/${MAX_ATTEMPTS} failed validation: ${error.split("\n")[0]}`, + ); + } + + throw new Error( + `translation into ${opts.langName} (${opts.lang}) still fails validation ` + + `after ${MAX_ATTEMPTS} attempt(s): ${lastError.split("\n")[0]}`, + ); +} diff --git a/scripts/translate-docs/types.ts b/scripts/translate-docs/types.ts index 22ea32f6..1eae9e7c 100644 --- a/scripts/translate-docs/types.ts +++ b/scripts/translate-docs/types.ts @@ -43,4 +43,10 @@ export interface TranslationResult { inputTokens: number; outputTokens: number; cached: boolean; + /** + * Total translation attempts spent (1 = valid on first try). Optional so the + * cached / dry-run result literals need no change; absent means "not + * translated this run" (cached) or "1". + */ + attempts?: number; } diff --git a/scripts/translate-docs/validate-translation.ts b/scripts/translate-docs/validate-translation.ts new file mode 100644 index 00000000..63234302 --- /dev/null +++ b/scripts/translate-docs/validate-translation.ts @@ -0,0 +1,132 @@ +/** + * Generation-time validation for a freshly translated page. + * + * `findTranslationError` runs the exact checks the Mintlify deploy performs — + * frontmatter YAML then MDX body — plus one the deploy cannot: frontmatter + * KEY-PARITY against the English source. It is called on the exact bytes about + * to be written to disk (post-sanitize, post-link-rewrite), so a page that + * passes here is a page `mintlify validate` and the deploy accept. + * + * Why a translation-specific wrapper instead of `findPageError` alone: + * - Frontmatter YAML (class A): the model re-emits an inner `"` unescaped into + * a double-quoted `title:`/`description:` value, breaking the YAML. This is + * the class that failed run 29575781632; `findFrontmatterError` catches it. + * - Key parity (class B): the model drops the frontmatter block entirely, or + * renames a key. A dropped block is still *valid YAML* (mintlify tolerates + * it, deriving the title from the slug), so only comparing against the + * source's keys catches it. This is deliberately stricter than mintlify — + * the keys are prompt-forbidden to change, so it fails ~never but converts + * a silent content regression into a retry. + * - MDX body (classes C+): stray ``, `{#anchor}`, unmatched fence, + * swallowed `
` — `findMdxParseError` catches these. + * + * The message returned is written for a MODEL to act on: it names the failing + * construct and, for a body error, quotes the offending line, so the retry has + * a concrete defect to fix rather than a bare line number the RTL/README + * wrappers would have offset anyway. + */ +import { findFrontmatterError, findMdxParseError } from "../validate-mdx"; +import YAML from "yaml"; + +// Same matcher as validate-mdx.ts; duplicated locally so this module owns its +// frontmatter extraction and does not depend on an internal export. +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/; + +/** + * The sorted top-level keys of a page's frontmatter, or `null` when the page + * has no frontmatter block or its block is not a key/value mapping (an + * unparseable or scalar block). Callers distinguish "no block" from a YAML + * error separately via `findFrontmatterError`. + */ +function frontmatterKeys(page: string): string[] | null { + const match = FRONTMATTER_RE.exec(page); + if (!match) return null; + try { + const doc = YAML.parse(match[1]); + if (doc && typeof doc === "object" && !Array.isArray(doc)) { + return Object.keys(doc).sort(); + } + return null; + } catch { + return null; + } +} + +/** + * A ±2-line window around `line` (1-based), the failing line prefixed `> ` and + * its neighbours ` `. Empty string when `line` is undefined. + */ +function excerpt(page: string, line?: number): string { + if (!line) return ""; + const lines = page.split("\n"); + const start = Math.max(1, line - 2); + const end = Math.min(lines.length, line + 2); + const out: string[] = []; + for (let n = start; n <= end; n++) { + out.push(`${n === line ? "> " : " "}${lines[n - 1]}`); + } + return out.join("\n"); +} + +const backticked = (keys: string[]): string => + keys.map((k) => `\`${k}\``).join(", "); + +/** + * Validate a rendered translation against its English `source`. Returns a + * model-actionable error message, or `null` when the page is publishable. + */ +export async function findTranslationError( + rendered: string, + source: string, +): Promise { + // Validate the rendered frontmatter block FIRST, for every source shape — + // even when the source had none. findMdxParseError (below) blanks a leading + // `---` block before compiling, so a malformed block the model *added* to a + // frontmatter-less page would otherwise be invisible here yet still break the + // Mintlify deploy. Closing exactly that blind spot is this module's job, so + // it must hold regardless of whether the source has frontmatter. + const fm = findFrontmatterError(rendered); + if (fm) { + return ( + "The YAML frontmatter (the `---` block at the top of the file) does " + + `not parse:\n\n${fm.message}` + ); + } + + const sourceKeys = frontmatterKeys(source); + if (sourceKeys) { + // The source has frontmatter, so the translation must carry the same keys. + // (Its block, if present, already parsed cleanly above.) + const keys = frontmatterKeys(rendered); + if (keys === null) { + return ( + "The YAML frontmatter is missing. The English source starts with a " + + `\`---\` block containing ${backticked(sourceKeys)}; the translation ` + + "must start with the same block — translate the values, keep the keys." + ); + } + // Subset check BY DESIGN: a missing or renamed key is a content regression + // (the page loses its title/description), so reject it. An *extra* key is + // harmless — Mintlify ignores unknown frontmatter keys — so tolerate it + // rather than burn a retry (and risk exhausting the whole batch) on a page + // that would deploy fine. Reject content loss; allow harmless additions. + const missing = sourceKeys.filter((k) => !keys.includes(k)); + if (missing.length > 0) { + return ( + `The YAML frontmatter keys changed. Expected ${backticked(sourceKeys)} ` + + `but got ${backticked(keys)}. Translate the values, never rename or ` + + "drop a key." + ); + } + } + + const body = await findMdxParseError(rendered); + if (body) { + const snippet = excerpt(rendered, body.line); + return `The MDX body does not parse: ${body.message}${ + snippet ? `\n\n${snippet}` : "" + }`; + } + + return null; +} diff --git a/scripts/validate-mdx.ts b/scripts/validate-mdx.ts index ff4074ee..592b2d8a 100644 --- a/scripts/validate-mdx.ts +++ b/scripts/validate-mdx.ts @@ -2,12 +2,12 @@ * Validate that every docs page (`.mdx` and `.md` — Mintlify parses both as * MDX) parses with the same MDX engine Mintlify runs at deploy time. * - * Why this exists: `mintlify validate` (the existing `docs` CI job) only checks - * `docs.json` structure and nav-link resolution — it does NOT parse page - * content. A page that is structurally referenced but contains an MDX syntax - * error (e.g. a `` that escaped its surrounding backticks because a - * translation dropped a closing `` ` ``) passes `mintlify validate` but fails - * the Mintlify deploy with: + * Why this exists: `mintlify validate` (the existing `docs` CI job) checks + * `docs.json` structure, nav-link resolution, and frontmatter YAML — but it + * does NOT report MDX *body* syntax errors. A page whose frontmatter is valid + * but whose body contains an MDX syntax error (e.g. a `` that escaped its + * surrounding backticks because a translation dropped a closing `` ` ``) passes + * `mintlify validate` but fails the Mintlify deploy with: * * Failed to parse page content at path tr/cli/audit.mdx: * Expected a closing tag for `` (61:127-61:133) before the end of `paragraph` @@ -21,12 +21,16 @@ * * The error string above is emitted by `@mdx-js/mdx`'s micromark MDX layer, * which is the same engine Mintlify uses, so compiling here reproduces the - * deploy-time parse faithfully. + * deploy-time parse faithfully. `main()` validates the frontmatter too (via + * `findPageError`), so this net is a strict superset of `mintlify validate`: + * it catches both the frontmatter YAML class that fails `mintlify validate` + * and the body-MDX class that `mintlify validate` lets through to deploy. */ import { readdirSync, statSync, readFileSync } from "node:fs"; import { dirname, join, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { compile } from "@mdx-js/mdx"; +import YAML from "yaml"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DOCS_DIR = join(__dirname, "..", "docs"); @@ -37,21 +41,86 @@ export interface MdxParseError { column?: number; } +/** + * Shared frontmatter matcher. The capture group is the YAML block *body* (the + * text between the fences); `match[0]` is still the whole block including the + * fences and trailing newline, which is what `stripFrontmatter` blanks. + */ +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/; + /** * Replace a leading YAML frontmatter block (`--- … ---`) with blank lines. * * Mintlify parses frontmatter as YAML, not MDX, so it never causes an MDX parse - * error. We blank it rather than delete it so the remaining content keeps its - * original line numbers — error positions then match the real file. + * error — but it CAN contain a YAML syntax error, which blanking here hides + * from `findMdxParseError`. That gap is covered separately by + * `findFrontmatterError`; `findPageError` runs both. We blank rather than delete + * so the remaining body keeps its original line numbers — body error positions + * then match the real file. */ export function stripFrontmatter(source: string): string { - const match = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/.exec(source); + const match = FRONTMATTER_RE.exec(source); if (!match) return source; // Keep newlines, drop every other character, so line numbers stay aligned. const blanked = match[0].replace(/[^\n]/g, ""); return blanked + source.slice(match[0].length); } +/** + * Parse the leading YAML frontmatter block and return its parse error, or + * `null` when the block is absent (legal — some pages and every i18n README + * have none) or valid. + * + * This is the half of page validation that `findMdxParseError` structurally + * cannot do: it compiles only the *body* (frontmatter blanked), so a YAML + * syntax error in `title:`/`description:` — the exact class that failed the + * `consolidate` job's `mintlify validate` step, and that this repo's own + * `validate:mdx` net could not see — sailed straight through. `mintlify` + * parses the frontmatter as YAML, so parsing it here reproduces that check. + * + * The reported `line` is FILE-relative (the opening `---` is file line 1), so + * it matches the convention `findMdxParseError` already uses for body errors. + * That is deliberately one greater than the block-relative number `mintlify` + * prints — do NOT "fix" it to match mintlify; file-relative is what points a + * reader (or a model asked to repair the page) at the right line. + */ +export function findFrontmatterError(source: string): MdxParseError | null { + const match = FRONTMATTER_RE.exec(source); + if (!match) return null; + try { + YAML.parse(match[1]); + return null; + } catch (err) { + const e = err as { + message?: string; + linePos?: Array<{ line: number; col: number }>; + }; + const raw = e.message ?? String(err); + return { + // Drop yaml's block-relative "at line N, column N:" phrase (it would + // contradict the file-relative line we report) but keep the caret- + // underlined excerpt after it — the single most useful signal for a + // model asked to repair the offending line. + message: raw.replace(/ at line \d+, column \d+:/, ":"), + line: e.linePos?.[0] ? e.linePos[0].line + 1 : undefined, + column: e.linePos?.[0]?.col, + }; + } +} + +/** + * Validate a full page the way the Mintlify deploy does: frontmatter YAML + * first, then the MDX body. Returns the first error found, or `null`. This is + * the single entry point shared by `validate:mdx` (`main()` below) and the + * translation pipeline's generation-time gate, so the two can never disagree + * about what is publishable. + */ +export async function findPageError( + source: string, +): Promise { + return findFrontmatterError(source) ?? (await findMdxParseError(source)); +} + /** * Compile one MDX source string with the deploy-time parser. Returns `null` * when it parses cleanly, or the parse error (with position) otherwise. @@ -114,7 +183,7 @@ async function main(): Promise { const failures: Array<{ file: string; error: MdxParseError }> = []; for (const file of files) { - const error = await findMdxParseError(readFileSync(file, "utf-8")); + const error = await findPageError(readFileSync(file, "utf-8")); if (error) failures.push({ file: relative(process.cwd(), file), error }); }