0a7076d5b9
Rewrites the i18n system introduced in PR #94 to use Next.js App Router [locale] dynamic segments with SSR-rendered translations and proper middleware locale routing. - Add middleware locale detection: / rewrites to /zh-CN/ internally, /en and /ja pass through, /zh-CN/... redirects to bare path - Move all 7 pages under app/[locale]/ with SSR translation injection - Fix server→client serialization: pre-evaluate function-valued translations (makeSerializable) to eliminate hydration flash - Fix language switch key flash: use hard navigation with localStorage- only persistence, avoiding React state update before page reload - Add <link rel="alternate" hreflang> tags for multilingual SEO - Fix Supabase setAll overwriting locale rewrite response - Trim locales from 22 to 3 (zh-CN/en/ja), delete 19 incomplete files - LLM-translate 240 firstact game preset JSONs (en + ja, landscape + portrait) and story titles via gemini-3.5-flash - Delete 11 one-off migration scripts and outdated i18n docs - Add useLocalePath hook and navigation utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
118 lines
3.7 KiB
TypeScript
118 lines
3.7 KiB
TypeScript
import type { Locale } from "./config";
|
|
import { DEFAULT_LOCALE, LOCALES } from "./config";
|
|
import { getNestedValue, formatTranslation } from "./utils";
|
|
|
|
// Server-side translation cache (functions stripped for client serialization)
|
|
const translationCache = new Map<Locale, Record<string, unknown>>();
|
|
|
|
// Make translations serializable for the server→client boundary.
|
|
// Functions are pre-evaluated with empty params so the SSR HTML contains
|
|
// real text (the base variant without optional auth/analytics additions).
|
|
// The client loads the full locale (with live functions) via useEffect.
|
|
function makeSerializable(obj: Record<string, unknown>): Record<string, unknown> {
|
|
const out: Record<string, unknown> = {};
|
|
for (const [k, v] of Object.entries(obj)) {
|
|
if (typeof v === "function") {
|
|
try { out[k] = (v as (p: Record<string, never>) => string)({}); } catch { /* skip */ }
|
|
} else if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
out[k] = makeSerializable(v as Record<string, unknown>);
|
|
} else {
|
|
out[k] = v;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Get locale from request headers
|
|
export function getLocaleFromHeaders(headers: Headers): Locale {
|
|
// Check for custom locale header
|
|
const customLocale = headers.get("x-locale");
|
|
if (customLocale && (LOCALES as readonly string[]).includes(customLocale)) {
|
|
return customLocale as Locale;
|
|
}
|
|
|
|
// Check Accept-Language header
|
|
const acceptLanguage = headers.get("accept-language");
|
|
if (acceptLanguage) {
|
|
const localeMap: Record<string, Locale> = {
|
|
en: "en",
|
|
zh: "zh-CN",
|
|
ja: "ja",
|
|
};
|
|
|
|
const browserLangBase = acceptLanguage.split(",")[0]?.split("-")[0];
|
|
if (browserLangBase) {
|
|
const matched = localeMap[browserLangBase];
|
|
if (matched) return matched;
|
|
}
|
|
}
|
|
|
|
return DEFAULT_LOCALE;
|
|
}
|
|
|
|
// Load translations for server-side
|
|
export async function loadTranslations(locale: Locale): Promise<Record<string, unknown>> {
|
|
// Check cache first
|
|
if (translationCache.has(locale)) {
|
|
return translationCache.get(locale)!;
|
|
}
|
|
|
|
try {
|
|
let translations;
|
|
switch (locale) {
|
|
case "zh-CN":
|
|
translations = (await import("./locales/zh-CN")).zhCN;
|
|
break;
|
|
case "en":
|
|
translations = (await import("./locales/en")).en;
|
|
break;
|
|
case "ja":
|
|
translations = (await import("./locales/ja")).ja;
|
|
break;
|
|
default:
|
|
translations = (await import("./locales/zh-CN")).zhCN;
|
|
break;
|
|
}
|
|
|
|
const serializable = makeSerializable(translations as Record<string, unknown>);
|
|
translationCache.set(locale, serializable);
|
|
return serializable;
|
|
} catch (error) {
|
|
console.error(`Failed to load translations for ${locale}:`, error);
|
|
const fallback = await import("./locales/zh-CN");
|
|
return fallback.zhCN as Record<string, unknown>;
|
|
}
|
|
}
|
|
|
|
// Server-side translation function
|
|
export async function getTranslations(locale: Locale): Promise<Record<string, unknown>> {
|
|
return loadTranslations(locale);
|
|
}
|
|
|
|
// Create a translation function for server components
|
|
export function createTranslator(translations: Record<string, unknown>) {
|
|
return function t(key: string, params: Record<string, string | number | boolean> = {}): string {
|
|
const value = getNestedValue(translations, key);
|
|
|
|
if (value === undefined) {
|
|
console.warn(`Translation missing for key: ${key}`);
|
|
return key;
|
|
}
|
|
|
|
if (typeof value === "function") {
|
|
return (value as (params: Record<string, string | number | boolean>) => string)(params);
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return formatTranslation(value, params);
|
|
}
|
|
|
|
return String(value);
|
|
};
|
|
}
|
|
|
|
// Get initial locale for server components
|
|
export function getServerLocale(): Locale {
|
|
return DEFAULT_LOCALE;
|
|
}
|