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:
+60
-22
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user