diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c3db03..0aec3c46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Fixes - `failproofai policies --uninstall` interactive CLI selector now says "Remove Hooks" / "Choose where to remove from:" instead of "Install Hooks" / "Choose where to install:" (#236) - README: replace the GitHub Copilot logo with the current canonical mark and add a dark-mode variant (`copilot-light.svg` + `copilot-dark.svg` via ``); the previous SVG used outdated path data with a hard-coded black fill that rendered invisibly on GitHub's dark theme (#236) +- Auto-translated MDX: stop the recurring `mintlify validate` parse error in `docs/de/dashboard.mdx` (``) by adding a `sanitizeJsxAttributes` post-processor to the translation pipeline that strips stray ASCII `"` left after typographic-quote pairs (and any unmatched opening typographic quote) in JSX attribute values, and by tightening the translator system prompt to forbid ASCII `"` inside attribute values. Same regression PR #229 fixed by hand — now it can't recur. Includes the immediate file fix on `docs/de/dashboard.mdx`. (#247) ## 0.0.9 — 2026-04-28 diff --git a/__tests__/scripts/translate-docs/mdx-translator.test.ts b/__tests__/scripts/translate-docs/mdx-translator.test.ts index 9f78d842..dc805bd6 100644 --- a/__tests__/scripts/translate-docs/mdx-translator.test.ts +++ b/__tests__/scripts/translate-docs/mdx-translator.test.ts @@ -1,6 +1,9 @@ // @vitest-environment node import { describe, it, expect } from "vitest"; -import { rewriteInternalLinks } from "@/scripts/translate-docs/mdx-translator"; +import { + rewriteInternalLinks, + sanitizeJsxAttributes, +} from "@/scripts/translate-docs/mdx-translator"; describe("rewriteInternalLinks", () => { it("rewrites MDX component href attributes with language prefix", () => { @@ -69,3 +72,60 @@ See [config](/configuration) and [testing](/testing). expect(result).toBe(`[link](/es/getting-started#install)`); }); }); + +describe("sanitizeJsxAttributes", () => { + it("strips stray trailing ASCII quotes after a JSX attribute close", () => { + // The exact failure mode that broke `mintlify validate` on de/dashboard.mdx + const input = ` `; + const result = sanitizeJsxAttributes(input); + expect(result).toBe(` `); + }); + + it("strips trailing extras when attribute is followed by a self-close", () => { + const input = ``; + const result = sanitizeJsxAttributes(input); + expect(result).toBe(``); + }); + + it("strips trailing extras when attribute is followed by another attribute", () => { + const input = ``; + const result = sanitizeJsxAttributes(input); + expect(result).toBe(``); + }); + + it("leaves well-formed attributes untouched", () => { + const input = `\n`; + expect(sanitizeJsxAttributes(input)).toBe(input); + }); + + it("preserves matched typographic quote pairs", () => { + // Japanese 「…」 has matched open/close so should NOT be stripped even if + // there were stray ASCII trailing quotes — though here there are none. + const input = ``; + expect(sanitizeJsxAttributes(input)).toBe(input); + }); + + it("strips unmatched typographic opening quotes when extras are present", () => { + // German „ without a matching " (U+201D) — drop the dangling open + const input = ``; + expect(sanitizeJsxAttributes(input)).toBe(``); + }); + + it("drops only the surplus opener when a matched pair is also present", () => { + // One properly matched „…“ German pair plus one dangling „ — keep the + // pair, strip only the unmatched trailing opener. + const input = ``; + expect(sanitizeJsxAttributes(input)).toBe(``); + }); + + it("does not mangle empty attributes", () => { + const input = ``; + expect(sanitizeJsxAttributes(input)).toBe(input); + }); + + it("handles multiple malformed attributes on the same line", () => { + const input = ``; + const result = sanitizeJsxAttributes(input); + expect(result).toBe(``); + }); +}); diff --git a/docs/de/dashboard.mdx b/docs/de/dashboard.mdx index 0d0a7629..acf8e188 100644 --- a/docs/de/dashboard.mdx +++ b/docs/de/dashboard.mdx @@ -62,13 +62,13 @@ Sie können die Sitzung als ZIP- oder JSONL-Datei über die Download-Schaltfläc Eine Seite mit zwei Tabs zur Verwaltung von Richtlinien und Einsicht der Aktivitäten. - + - Einzelne Richtlinien mit einem Klick aktivieren oder deaktivieren (schreibt in `~/.failproofai/policies-config.json`) - Eine Richtlinie aufklappen, um ihre Parameter zu konfigurieren (für Richtlinien, die `policyParams` unterstützen) - Hooks für einen bestimmten Scope installieren oder entfernen - Einen benutzerdefinierten Pfad für die Richtliniendatei festlegen - + - Vollständige, seitenweise Übersicht aller Hook-Ereignisse, die über alle Sitzungen hinweg ausgelöst wurden - Filtern nach Entscheidung, Ereignistyp, CLI (Claude Code / OpenAI Codex / GitHub Copilot _(Beta)_), Richtlinienname oder Sitzungs-ID - Jede Zeile zeigt: Zeitstempel, Richtlinienname, Entscheidung, CLI-Badge (orange = Claude Code, lila = OpenAI Codex, blau = GitHub Copilot), Tool-Name, Sitzungs-ID und den Grund für deny/instruct-Entscheidungen diff --git a/scripts/translate-docs/mdx-translator.ts b/scripts/translate-docs/mdx-translator.ts index 8435a798..65b010f7 100644 --- a/scripts/translate-docs/mdx-translator.ts +++ b/scripts/translate-docs/mdx-translator.ts @@ -14,6 +14,59 @@ import type { TranslationResult, TranslationCache } from "./types"; const __dirname = dirname(fileURLToPath(import.meta.url)); const DOCS_DIR = join(__dirname, "..", "..", "docs"); +/** + * Strip stray ASCII `"` that appear right after a JSX attribute's closing + * quote — e.g. ``. The translator sometimes + * wraps an inner phrase in language-specific typographic quotes (`„…"`, + * `「…」`, etc.) but uses an ASCII `"` for the closing instead of the + * proper U+201D, which terminates the attribute and leaves the real + * closing `"` as a stray character that breaks `mintlify validate`. + * + * Also drops unmatched typographic opening quotes inside the same attribute + * value so the rendered title doesn't end with a dangling `„` after we strip + * the extras. + */ +export function sanitizeJsxAttributes(content: string): string { + // Each pair must use an OPENER that is unambiguously an opener — i.e. the + // codepoint never serves as a CLOSER of a different pair. That's why we + // skip English curly “…” (U+201C/U+201D): U+201C is also the German + // closer, so processing English curly after German would strip the very + // German closer we just preserved. + const openings: Array<[string, string]> = [ + ["„", "“"], // German „ … " + ["«", "»"], // French « … » + ["‹", "›"], // French single ‹ … › + ["「", "」"], // Japanese 「 … 」 + ["『", "』"], // Japanese 『 … 』 + ]; + return content.replace( + /([a-zA-Z_-]+=")([^"\n]*)"+(?=\s|\/|>)/g, + (match, prefix: string, value: string) => { + // If the original had exactly one closing " (i.e. no extras), + // leave it alone — the regex's `"+` would still match a single + // quote, so we need to re-check the match length to be safe. + const expectedMinLen = `${prefix}${value}"`.length; + if (match.length === expectedMinLen) return match; + let cleaned = value; + for (const [open, close] of openings) { + const opens = cleaned.split(open).length - 1; + const closes = cleaned.split(close).length - 1; + // Drop only the surplus unmatched openers, removing from the right. + // A value like `„Foo“ und „Bar` (one matched pair plus one stray + // opener) keeps the leading `„Foo“` intact and only the dangling + // `„Bar` opener gets stripped. + let surplus = opens - closes; + while (surplus-- > 0) { + const i = cleaned.lastIndexOf(open); + if (i < 0) break; + cleaned = cleaned.slice(0, i) + cleaned.slice(i + open.length); + } + } + return `${prefix}${cleaned}"`; + }, + ); +} + /** * Rewrite internal doc links to include the language prefix. * e.g. href="/built-in-policies" -> href="/es/built-in-policies" @@ -94,8 +147,9 @@ export async function translateMdxPage( options.model, ); - // Rewrite internal links - const withLinks = rewriteInternalLinks(translated, lang); + // Strip stray quote artifacts from JSX attribute values, then rewrite links + const sanitized = sanitizeJsxAttributes(translated); + const withLinks = rewriteInternalLinks(sanitized, lang); // Write output mkdirSync(dirname(outputPath), { recursive: true }); diff --git a/scripts/translate-docs/translator.ts b/scripts/translate-docs/translator.ts index c7e1145f..57f4726a 100644 --- a/scripts/translate-docs/translator.ts +++ b/scripts/translate-docs/translator.ts @@ -15,7 +15,7 @@ const SYSTEM_PROMPT = `You are a professional technical documentation translator ## Rules 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. +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. 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.