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>
This commit is contained in:
parent
6acc587dd5
commit
2e59b01f1f
5 changed files with 113 additions and 10 deletions
|
|
@ -266,13 +266,18 @@ function buildPrompt(
|
|||
function extractBody(text: string): string | null {
|
||||
const t = text.trim();
|
||||
if (/^`?INSUFFICIENT_ARTEFACTS`?\b/i.test(t)) return null;
|
||||
const stripped = t.replace(/^```(?:markdown|md)?\s*\n?/i, "").replace(/\n?```\s*$/i, "");
|
||||
// Find first H1 — anything before is preamble we drop.
|
||||
const h1 = stripped.indexOf("\n# ");
|
||||
if (h1 === -1) {
|
||||
const stripped = t
|
||||
.replace(/^```(?:markdown|md)?\s*\n?/i, "")
|
||||
.replace(/\n?```\s*$/i, "")
|
||||
.trimStart();
|
||||
// If the text already opens on the title there is no preamble to drop.
|
||||
// Crucial: the narrator emits TWO H1s (EN title then PT-BR title) — both are
|
||||
// the title. Searching for the "first \n# " would mistake the PT-BR H1 for
|
||||
// the real start and silently discard the English title.
|
||||
if (stripped.startsWith("# ")) return stripped;
|
||||
throw new Error(`case-writer returned no H1: ${t.slice(0, 200)}`);
|
||||
}
|
||||
// Otherwise drop any preamble before the first H1.
|
||||
const h1 = stripped.indexOf("\n# ");
|
||||
if (h1 === -1) throw new Error(`case-writer returned no H1: ${t.slice(0, 200)}`);
|
||||
return stripped.slice(h1 + 1);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { MarkdownBody } from "@/components/markdown-body";
|
|||
import { AuthBar } from "@/components/auth-bar";
|
||||
import { BureauNav } from "@/components/bureau-nav";
|
||||
import { getLocale } from "@/components/locale-toggle";
|
||||
import { pickLocaleBody } from "@/lib/bilingual";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -89,7 +90,8 @@ export async function generateMetadata(
|
|||
const title = locale === "pt-br"
|
||||
? (h1s[1] ?? h1s[0] ?? c.fm.topic_pt_br ?? c.fm.topic ?? slug)
|
||||
: (h1s[0] ?? c.fm.topic ?? slug);
|
||||
const desc = pickLead(c.body, locale).slice(0, 200);
|
||||
const localeBody = pickLocaleBody(c.body.replace(/^#\s+.+$\n?/gm, ""), locale);
|
||||
const desc = pickLead(localeBody, locale).slice(0, 200);
|
||||
const canonical = `${SITE_URL}/c/${slug}`;
|
||||
// OG image — use the case's editorial illustration when present. WhatsApp,
|
||||
// Twitter, Slack, Telegram, ChatGPT search all pull this as the link card.
|
||||
|
|
@ -138,7 +140,10 @@ export default async function CaseReportPage({
|
|||
const bodyTitle = locale === "pt-br" ? (h1s[1] ?? h1s[0]) : h1s[0];
|
||||
// Strip H1 lines ("# Title") — the "## section" headers survive since the
|
||||
// regex requires whitespace after a single #. Avoids duplicating the title.
|
||||
const body = c.body.replace(/^#\s+.+$\n?/gm, "").replace(/^\s+/, "");
|
||||
const fullBody = c.body.replace(/^#\s+.+$\n?/gm, "").replace(/^\s+/, "");
|
||||
// Keep only the active language's scenes — the file holds EN and PT-BR
|
||||
// sections interleaved; showing both was the language-mixing bug.
|
||||
const body = pickLocaleBody(fullBody, locale);
|
||||
const title = bodyTitle ?? (locale === "pt-br" ? (fm.topic_pt_br ?? fm.topic ?? slug) : (fm.topic ?? slug));
|
||||
const dateLabel = fm.created_at ? new Date(fm.created_at).toLocaleDateString(locale === "pt-br" ? "pt-BR" : "en-US", { day: "numeric", month: "long", year: "numeric" }) : null;
|
||||
const canonical = `${SITE_URL}/c/${slug}`;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import Link from "next/link";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pickLocaleBody } from "@/lib/bilingual";
|
||||
|
||||
interface CaseFile {
|
||||
slug: string;
|
||||
|
|
@ -102,7 +103,7 @@ async function loadCases(locale: "pt-br" | "en"): Promise<CaseFile[]> {
|
|||
slug: f.replace(/\.md$/, ""),
|
||||
topic: enTitle,
|
||||
topic_pt_br: ptTitle,
|
||||
opening: pickOpening(body, locale),
|
||||
opening: pickOpening(pickLocaleBody(body, locale), locale),
|
||||
mtimeMs: st.mtimeMs,
|
||||
});
|
||||
} catch { /* skip broken file */ }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
import Link from "next/link";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pickLocaleBody } from "@/lib/bilingual";
|
||||
|
||||
interface FeaturedCaseData {
|
||||
slug: string;
|
||||
|
|
@ -86,7 +87,7 @@ async function loadFeatured(locale: "pt-br" | "en"): Promise<FeaturedCaseData |
|
|||
slug,
|
||||
topic: h1s[0] ?? fm.topic ?? f,
|
||||
topic_pt_br: h1s[1] ?? h1s[0] ?? fm.topic_pt_br ?? null,
|
||||
opening: pickOpening(body, locale),
|
||||
opening: pickOpening(pickLocaleBody(body, locale), locale),
|
||||
hero_doc_id: docRef?.doc_id ?? null,
|
||||
hero_page: docRef?.page ?? null,
|
||||
hero_illustration: await illustrationFor(slug),
|
||||
|
|
|
|||
91
web/lib/bilingual.ts
Normal file
91
web/lib/bilingual.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const k = sectionKey(s);
|
||||
let isPt: boolean;
|
||||
if (k && (counts.get(k) ?? 0) >= 2) {
|
||||
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
|
||||
}
|
||||
if (isPt === wantPt) picked.push(s.trimEnd());
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
Loading…
Reference in a new issue