From 3715eaff21555fc174de3d30ce0a4a9ab9e6e112 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 19:28:56 +0000 Subject: [PATCH] Translate event labels and timespan strings in OG images The /api/og route was passing the lang param to fetchWikidataEvents (so Wikidata labels came back localized), but downstream rendering fell back to English: eventDisplayName was called without a t function (hardcoded "Birth of...", "Death of..."), computeTimeline was called without spanT (defaultSpanT returns "year/years"), and the locale was hardcoded to "en-US" so month names rendered in English. Add a small ICU plural resolver and wire translators for the eventLabel and date namespaces, and pass the requested lang as the locale. --- src/app/api/og/route.tsx | 49 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx index a6a4684..536789a 100644 --- a/src/app/api/og/route.tsx +++ b/src/app/api/og/route.tsx @@ -38,6 +38,45 @@ async function loadMessages(lang: string): Promise): string { + if (!values) return template; + return template.replace(/\{(\w+)\}/g, (_, k) => (k in values ? String(values[k]) : `{${k}}`)); +} + +/** Resolve a single ICU plural message like `{count, plural, one {# year} other {# years}}`. */ +function resolvePlural(template: string, locale: string, values?: Record): string { + const pluralMatch = template.match(/^\{(\w+),\s*plural,\s*([\s\S]+)\}$/); + if (!pluralMatch) return interpolate(template, values); + + const [, varName, body] = pluralMatch; + const count = Number(values?.[varName] ?? 0); + + // Parse branches like: one {# year} other {# years} =0 {no years} + const branches = new Map(); + const branchRegex = /(=?\w+)\s*\{([^{}]*)\}/g; + let m: RegExpExecArray | null; + while ((m = branchRegex.exec(body))) { + branches.set(m[1], m[2]); + } + + let chosen = branches.get(`=${count}`); + if (chosen === undefined) { + const cat = new Intl.PluralRules(locale).select(count); + chosen = branches.get(cat) ?? branches.get("other") ?? template; + } + + return interpolate(chosen.replace(/#/g, String(count)), values); +} + +function makeTranslator(messages: Record>, namespace: string, locale: string) { + return (key: string, values?: Record): string => { + const template = messages[namespace]?.[key]; + if (!template) return key; + return resolvePlural(template, locale, values); + }; +} + export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const ids = searchParams.get("ids"); @@ -77,7 +116,9 @@ export async function GET(request: NextRequest) { const font = await getFont(); const nowLabel = messages.common?.now || "Now"; - let timeline = allEvents.length > 0 ? computeTimeline(allEvents, 2, "en-US", nowLabel, undefined, hideNow) : null; + const spanT = makeTranslator(messages, "date", lang); + const eventLabelT = makeTranslator(messages, "eventLabel", lang); + let timeline = allEvents.length > 0 ? computeTimeline(allEvents, 2, lang, nowLabel, spanT, hideNow) : null; const fallbackDescription = messages.meta?.siteDescription || "Visualize the time between historical events."; @@ -197,9 +238,9 @@ export async function GET(request: NextRequest) { overflow: "visible", }} > - {isNow ? nowLabel : eventDisplayName(marker.event).length > 25 - ? eventDisplayName(marker.event).slice(0, 23) + "\u2026" - : eventDisplayName(marker.event)} + {isNow ? nowLabel : eventDisplayName(marker.event, eventLabelT).length > 25 + ? eventDisplayName(marker.event, eventLabelT).slice(0, 23) + "\u2026" + : eventDisplayName(marker.event, eventLabelT)} );