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 [](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 });
}