fix(i18n): overhaul i18n with [locale] routing, SSR translations, and hreflang SEO

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>
This commit is contained in:
yuanzonghao
2026-06-18 23:16:17 +08:00
parent 941b54c3f8
commit 0a7076d5b9
301 changed files with 2447 additions and 4358 deletions
+60 -22
View File
@@ -1,6 +1,20 @@
import { type NextRequest, NextResponse } from "next/server";
import { createServerClient } from "@supabase/ssr";
// Locale prefixes that appear in the URL (default zh-CN has no prefix).
const LOCALE_PREFIXES = ["en", "ja"] as const;
const DEFAULT_LOCALE = "zh-CN";
function detectLocaleFromPath(pathname: string): { locale: string; stripped: string } | null {
for (const prefix of LOCALE_PREFIXES) {
if (pathname === `/${prefix}` || pathname.startsWith(`/${prefix}/`)) {
const stripped = pathname.slice(prefix.length + 1) || "/";
return { locale: prefix, stripped };
}
}
return null;
}
// Next.js 16 deprecated `middleware` in favor of `proxy`, but `proxy` is locked
// to the Node.js runtime. OpenNext for Cloudflare rejects Node.js middleware at
// build time ("Node.js middleware is not currently supported"), so we keep the
@@ -8,11 +22,55 @@ import { createServerClient } from "@supabase/ssr";
// both Vercel and Cloudflare Workers. Revisit once OpenNext supports Node.js
// middleware or `proxy` allows the edge runtime.
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// ── Locale routing ─────────────────────────────────────────────────
// Skip locale logic for API routes, auth, and static assets.
const skipLocale =
pathname.startsWith("/api/") ||
pathname.startsWith("/auth/") ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/home/") ||
pathname.startsWith("/docs/") ||
/\.(?:svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|woff2?|ttf|otf|json|xml|txt|map)$/i.test(pathname);
let locale = DEFAULT_LOCALE;
let response: NextResponse;
if (!skipLocale) {
// If someone visits /zh-CN/... explicitly, redirect to bare path (keep clean URLs).
if (pathname === "/zh-CN" || pathname.startsWith("/zh-CN/")) {
const bare = pathname.slice(6) || "/";
const url = request.nextUrl.clone();
url.pathname = bare;
return NextResponse.redirect(url);
}
const detected = detectLocaleFromPath(pathname);
if (detected) {
// URL has a locale prefix (e.g. /en/play) — pass through with locale header.
locale = detected.locale;
response = NextResponse.next({ request });
} else {
// No locale prefix — rewrite to /zh-CN/... internally (URL stays clean).
const url = request.nextUrl.clone();
url.pathname = `/${DEFAULT_LOCALE}${pathname}`;
response = NextResponse.rewrite(url);
}
} else {
response = NextResponse.next({ request });
}
// Set locale + pathname headers so root layout can read them for
// <html lang> and <link rel="alternate" hreflang>.
response.headers.set("x-locale", locale);
response.headers.set("x-pathname", pathname);
// ── Supabase auth token refresh ────────────────────────────────────
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
if (!supabaseUrl || !supabaseKey) return NextResponse.next();
if (!supabaseUrl || !supabaseKey) return response;
let response = NextResponse.next({ request });
const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll: () => request.cookies.getAll(),
@@ -20,7 +78,6 @@ export async function middleware(request: NextRequest) {
for (const { name, value } of cookiesToSet) {
request.cookies.set(name, value);
}
response = NextResponse.next({ request });
for (const { name, value, options } of cookiesToSet) {
response.cookies.set(name, value, options);
}
@@ -28,14 +85,6 @@ export async function middleware(request: NextRequest) {
},
});
// Must await: getUser() triggers the token refresh, and the refreshed
// cookies are written to `response` via the setAll callback above. Returning
// before it resolves can drop the refreshed session cookie.
// getUser() returns auth errors (expired/invalid token) as { error } but
// rethrows non-auth errors (e.g. fetch failures when Supabase is
// unreachable). Swallow those so a transient network blip doesn't 500 or
// crash the whole page request — the cookie simply isn't refreshed this
// round and retries on the next request.
try {
await supabase.auth.getUser();
} catch {
@@ -46,19 +95,8 @@ export async function middleware(request: NextRequest) {
}
export const config = {
// edge runtime is required for Cloudflare Workers via OpenNext; the Node.js
// middleware path is rejected by its build. Supabase SSR uses only Web APIs
// (fetch, cookies), so it is edge-compatible.
matcher: [
// Match everything except static assets. We exclude by known file
// extensions rather than "path contains a dot" so that future dotted
// dynamic routes (e.g. /u/john.doe) still get the Supabase cookie refresh.
"/((?!_next/static|_next/image|favicon.ico|icon.svg|.*\\.(?:svg|png|jpe?g|gif|webp|avif|ico|css|js|mjs|woff2?|ttf|otf|html|xml|txt|map)).*)",
],
// NOTE: must be "experimental-edge", NOT "edge". Next.js 16 routes the
// root middleware file through the pages-router static-info path, where
// runtime "edge" throws "edge runtime for rendering is currently
// experimental. Use runtime 'experimental-edge' instead." (E1015) at build.
// "experimental-edge" only warns. Both are treated as edge by isEdgeRuntime().
runtime: "experimental-edge",
};