fix(i18n): render case files in one language per locale
Case bodies hold EN and PT-BR scenes interleaved (## I. EN, ## I. PT, ...),
but the reader rendered the whole body, so an EN visitor saw every scene
twice — once in English, once in Portuguese — and vice-versa.
Add pickLocaleBody(): split by section-number occurrence (first = EN, second
= PT-BR, the narrator's mandated order), with language sniffing only as a
fallback for unpaired/unnumbered sections, and full-body fallback when a body
can't be split (English-only legacy files). Wire it into the case reader
(body, lead, OG description) and the homepage card openings.
Also fix extractBody: it discarded the English H1 by treating the second
(PT-BR) H1 as "the first real heading". Future narrations keep both titles.
Verified live: d44 and doc-65 render EN-only under locale=en and PT-only
under locale=pt-br. Bulk-checked 89 files: 80 clean, 8 structurally clean
(source-language verbatim quotes retained per spec), 1 English-only legacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:57:46 +00:00
|
|
|
/**
|
|
|
|
|
* bilingual.ts — split a narrated case-file body into a single language.
|
|
|
|
|
*
|
|
|
|
|
* The case-writer emits each scene twice: an English `## N. <title>` section
|
|
|
|
|
* followed by its Brazilian-Portuguese twin `## N. <título>` (CLAUDE.md §3).
|
|
|
|
|
* The reader, however, must see ONE language at a time — otherwise an EN
|
|
|
|
|
* visitor reads the EN scene immediately followed by the same scene in PT,
|
|
|
|
|
* and vice-versa.
|
|
|
|
|
*
|
|
|
|
|
* We classify each `## ` section by language (PT-BR carries diacritics and
|
|
|
|
|
* Portuguese function words; the English scene text does not) and keep only
|
|
|
|
|
* the sections matching the active locale. This is more robust than relying
|
|
|
|
|
* on EN-then-PT ordering: it survives a missing twin or an out-of-order
|
|
|
|
|
* section. If a body can't be split (no `## ` sections, or none match the
|
|
|
|
|
* locale), we return it unchanged — better to show everything than to hide
|
|
|
|
|
* content.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
const PT_DIACRITICS = /[áàâãéêíóôõúüç]/gi;
|
|
|
|
|
const PT_WORDS =
|
|
|
|
|
/\b(não|que|com|para|sobre|uma|um|também|após|às|aos|nos|nas|pelo|pela|seu|sua|foi|era|está|são|sem|entre|quando|onde|índice|registro|relatório|noite|céu)\b/gi;
|
|
|
|
|
const EN_WORDS =
|
|
|
|
|
/\b(the|and|with|what|which|after|over|about|into|from|that|this|their|was|were|been|when|where|night|sky|report|record)\b/gi;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Heuristic: does this markdown block read as Brazilian Portuguese?
|
|
|
|
|
* Diacritics are the strongest signal — English narration here has none,
|
|
|
|
|
* while PT-BR is accent-rich. Function-word counts break ties.
|
|
|
|
|
*/
|
|
|
|
|
export function isPortuguese(block: string): boolean {
|
|
|
|
|
const diacritics = (block.match(PT_DIACRITICS) || []).length;
|
|
|
|
|
const pt = (block.match(PT_WORDS) || []).length;
|
|
|
|
|
const en = (block.match(EN_WORDS) || []).length;
|
|
|
|
|
// Diacritics weigh heavily: a few accents reliably mark PT-BR even when an
|
|
|
|
|
// English place name (e.g. "Áden") sneaks one into an EN block.
|
|
|
|
|
return diacritics * 2 + pt > en;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Section number token ("I", "IV", "3") from an H2 like "## IV. Cables". */
|
|
|
|
|
function sectionKey(section: string): string | null {
|
|
|
|
|
const m = section.match(/^##\s+([ivxlcdm\d]+)[.)]/i);
|
|
|
|
|
return m ? m[1].toUpperCase() : null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 03:29:05 +00:00
|
|
|
/**
|
|
|
|
|
* Some legacy files tag the heading language explicitly:
|
|
|
|
|
* "## §1 — The Case at Hand (EN)" / "## §1 — O Caso em Mãos (PT-BR)".
|
|
|
|
|
* That tag is the strongest possible signal. Returns the language when a tag
|
|
|
|
|
* is present, else null.
|
|
|
|
|
*/
|
|
|
|
|
function langTag(section: string): "en" | "pt-br" | null {
|
|
|
|
|
const head = section.split("\n", 1)[0] ?? "";
|
|
|
|
|
if (/\((?:pt-?br|pt|português|portugues)\)\s*$/i.test(head)) return "pt-br";
|
|
|
|
|
if (/\((?:en|en-?us|english|inglês|ingles)\)\s*$/i.test(head)) return "en";
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Strip a trailing "(EN)" / "(PT-BR)" tag and dangling rule from a section. */
|
|
|
|
|
function cleanSection(section: string): string {
|
|
|
|
|
return section
|
|
|
|
|
.replace(/^(##\s+.*?)\s*\((?:en|en-?us|english|inglês|ingles|pt-?br|pt|português|portugues)\)\s*$/im, "$1")
|
|
|
|
|
.replace(/\n+---\s*$/m, "")
|
|
|
|
|
.trimEnd();
|
|
|
|
|
}
|
|
|
|
|
|
fix(i18n): render case files in one language per locale
Case bodies hold EN and PT-BR scenes interleaved (## I. EN, ## I. PT, ...),
but the reader rendered the whole body, so an EN visitor saw every scene
twice — once in English, once in Portuguese — and vice-versa.
Add pickLocaleBody(): split by section-number occurrence (first = EN, second
= PT-BR, the narrator's mandated order), with language sniffing only as a
fallback for unpaired/unnumbered sections, and full-body fallback when a body
can't be split (English-only legacy files). Wire it into the case reader
(body, lead, OG description) and the homepage card openings.
Also fix extractBody: it discarded the English H1 by treating the second
(PT-BR) H1 as "the first real heading". Future narrations keep both titles.
Verified live: d44 and doc-65 render EN-only under locale=en and PT-only
under locale=pt-br. Bulk-checked 89 files: 80 clean, 8 structurally clean
(source-language verbatim quotes retained per spec), 1 English-only legacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:57:46 +00:00
|
|
|
/**
|
|
|
|
|
* Return only the portion of a bilingual case body that matches `locale`.
|
|
|
|
|
* Operates on a body whose H1 title lines have already been stripped.
|
|
|
|
|
*
|
|
|
|
|
* Primary signal is structural, not linguistic: the narrator emits each
|
|
|
|
|
* numbered scene as an English `## N.` section immediately followed by its
|
|
|
|
|
* Brazilian-Portuguese twin `## N.`. So for a section number seen twice, the
|
|
|
|
|
* first occurrence is English and the second is Portuguese — a far more
|
|
|
|
|
* reliable split than language sniffing (an English scene quoting accented
|
|
|
|
|
* names would otherwise be misread as Portuguese). Language detection is the
|
|
|
|
|
* fallback only for unpaired or unnumbered sections.
|
|
|
|
|
*/
|
|
|
|
|
export function pickLocaleBody(body: string, locale: "en" | "pt-br"): string {
|
|
|
|
|
const parts = body.split(/(?=^##\s)/m);
|
|
|
|
|
// Content before the first H2 (rare intro/orphan) is shown in both languages.
|
|
|
|
|
const head = parts.length > 0 && !/^##\s/.test(parts[0]) ? parts[0].trimEnd() : "";
|
|
|
|
|
const sections = parts.filter((p) => /^##\s/.test(p));
|
|
|
|
|
if (sections.length === 0) return body;
|
|
|
|
|
|
|
|
|
|
const counts = new Map<string, number>();
|
|
|
|
|
for (const s of sections) {
|
|
|
|
|
const k = sectionKey(s);
|
|
|
|
|
if (k) counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const wantPt = locale === "pt-br";
|
|
|
|
|
const seen = new Map<string, number>();
|
|
|
|
|
const picked: string[] = [];
|
|
|
|
|
for (const s of sections) {
|
2026-05-27 03:29:05 +00:00
|
|
|
const tag = langTag(s);
|
fix(i18n): render case files in one language per locale
Case bodies hold EN and PT-BR scenes interleaved (## I. EN, ## I. PT, ...),
but the reader rendered the whole body, so an EN visitor saw every scene
twice — once in English, once in Portuguese — and vice-versa.
Add pickLocaleBody(): split by section-number occurrence (first = EN, second
= PT-BR, the narrator's mandated order), with language sniffing only as a
fallback for unpaired/unnumbered sections, and full-body fallback when a body
can't be split (English-only legacy files). Wire it into the case reader
(body, lead, OG description) and the homepage card openings.
Also fix extractBody: it discarded the English H1 by treating the second
(PT-BR) H1 as "the first real heading". Future narrations keep both titles.
Verified live: d44 and doc-65 render EN-only under locale=en and PT-only
under locale=pt-br. Bulk-checked 89 files: 80 clean, 8 structurally clean
(source-language verbatim quotes retained per spec), 1 English-only legacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:57:46 +00:00
|
|
|
const k = sectionKey(s);
|
|
|
|
|
let isPt: boolean;
|
2026-05-27 03:29:05 +00:00
|
|
|
if (tag) {
|
|
|
|
|
isPt = tag === "pt-br"; // explicit heading tag — most reliable
|
|
|
|
|
} else if (k && (counts.get(k) ?? 0) >= 2) {
|
fix(i18n): render case files in one language per locale
Case bodies hold EN and PT-BR scenes interleaved (## I. EN, ## I. PT, ...),
but the reader rendered the whole body, so an EN visitor saw every scene
twice — once in English, once in Portuguese — and vice-versa.
Add pickLocaleBody(): split by section-number occurrence (first = EN, second
= PT-BR, the narrator's mandated order), with language sniffing only as a
fallback for unpaired/unnumbered sections, and full-body fallback when a body
can't be split (English-only legacy files). Wire it into the case reader
(body, lead, OG description) and the homepage card openings.
Also fix extractBody: it discarded the English H1 by treating the second
(PT-BR) H1 as "the first real heading". Future narrations keep both titles.
Verified live: d44 and doc-65 render EN-only under locale=en and PT-only
under locale=pt-br. Bulk-checked 89 files: 80 clean, 8 structurally clean
(source-language verbatim quotes retained per spec), 1 English-only legacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:57:46 +00:00
|
|
|
const order = seen.get(k) ?? 0;
|
|
|
|
|
seen.set(k, order + 1);
|
|
|
|
|
isPt = order >= 1; // first = EN, second = PT-BR
|
|
|
|
|
} else {
|
|
|
|
|
isPt = isPortuguese(s); // unpaired / unnumbered — sniff the language
|
|
|
|
|
}
|
2026-05-27 03:29:05 +00:00
|
|
|
if (isPt === wantPt) picked.push(cleanSection(s));
|
fix(i18n): render case files in one language per locale
Case bodies hold EN and PT-BR scenes interleaved (## I. EN, ## I. PT, ...),
but the reader rendered the whole body, so an EN visitor saw every scene
twice — once in English, once in Portuguese — and vice-versa.
Add pickLocaleBody(): split by section-number occurrence (first = EN, second
= PT-BR, the narrator's mandated order), with language sniffing only as a
fallback for unpaired/unnumbered sections, and full-body fallback when a body
can't be split (English-only legacy files). Wire it into the case reader
(body, lead, OG description) and the homepage card openings.
Also fix extractBody: it discarded the English H1 by treating the second
(PT-BR) H1 as "the first real heading". Future narrations keep both titles.
Verified live: d44 and doc-65 render EN-only under locale=en and PT-only
under locale=pt-br. Bulk-checked 89 files: 80 clean, 8 structurally clean
(source-language verbatim quotes retained per spec), 1 English-only legacy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:57:46 +00:00
|
|
|
}
|
|
|
|
|
// Can't isolate the requested language — fall back to the full body so no
|
|
|
|
|
// content silently disappears.
|
|
|
|
|
if (picked.length === 0) return body;
|
|
|
|
|
|
|
|
|
|
const out = picked.join("\n\n");
|
|
|
|
|
return head ? `${head}\n\n${out}` : out;
|
|
|
|
|
}
|