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>
103 lines
3.9 KiB
TypeScript
103 lines
3.9 KiB
TypeScript
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
|
|
// `middleware` convention with an explicit edge runtime to stay deployable to
|
|
// 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 response;
|
|
|
|
const supabase = createServerClient(supabaseUrl, supabaseKey, {
|
|
cookies: {
|
|
getAll: () => request.cookies.getAll(),
|
|
setAll: (cookiesToSet) => {
|
|
for (const { name, value } of cookiesToSet) {
|
|
request.cookies.set(name, value);
|
|
}
|
|
for (const { name, value, options } of cookiesToSet) {
|
|
response.cookies.set(name, value, options);
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
await supabase.auth.getUser();
|
|
} catch {
|
|
return response;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
"/((?!_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)).*)",
|
|
],
|
|
runtime: "experimental-edge",
|
|
};
|