diff --git a/README.md b/README.md index a941276..608819b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Stack: **Next.js 15** · **Web Serial** · **esp-web-tools** · **next-intl** · | | | |---|---| -| Locales | English (default) `/en` · 中文 `/zh` | +| Locales | English (default) `/en` · 中文 `/zh` only when Chinese is detected (browser/OS `Accept-Language`, `navigator.languages`, Intl locale, or prior cookie) | | Boards | CYD 2.8\" available · 2.4\" / 3.5\" / S3 Touch shown as Soon | | Catalog | Searchable card grid · detail pane · media-ready | | SEO | `generateMetadata`, Open Graph, `/sitemap.xml`, `/robots.txt` | diff --git a/components/language-switcher.tsx b/components/language-switcher.tsx index c181438..202c988 100644 --- a/components/language-switcher.tsx +++ b/components/language-switcher.tsx @@ -1,8 +1,13 @@ "use client"; +import { useEffect, useMemo, useState } from "react"; import { useLocale, useTranslations } from "next-intl"; import { usePathname, useRouter } from "@/i18n/navigation"; -import { routing, type AppLocale } from "@/i18n/routing"; +import type { AppLocale } from "@/i18n/routing"; +import { + detectChineseClient, + markChineseLocaleAllowed, +} from "@/lib/chinese-locale"; import { cn } from "@/lib/utils"; export function LanguageSwitcher() { @@ -10,6 +15,31 @@ export function LanguageSwitcher() { const locale = useLocale() as AppLocale; const router = useRouter(); const pathname = usePathname(); + const [ready, setReady] = useState(false); + const [zhAvailable, setZhAvailable] = useState(false); + + useEffect(() => { + const { likely } = detectChineseClient(); + setZhAvailable(likely); + setReady(true); + + if (likely) { + markChineseLocaleAllowed(); + } else if (locale === "zh") { + router.replace(pathname, { locale: "en" }); + } + }, [locale, pathname, router]); + + const locales = useMemo(() => { + const list: AppLocale[] = ["en"]; + if (zhAvailable) list.push("zh"); + return list; + }, [zhAvailable]); + + // English-only environments: no language control + if (!ready || locales.length < 2) { + return null; + } return (
- {routing.locales.map((loc) => ( + {locales.map((loc) => ( diff --git a/lib/chinese-locale.ts b/lib/chinese-locale.ts new file mode 100644 index 0000000..c3c5293 --- /dev/null +++ b/lib/chinese-locale.ts @@ -0,0 +1,97 @@ +/** + * Detect whether the environment has Chinese language signals. + * Used to show/hide the 中文 UI option — English-only otherwise. + */ + +const ZH_TAG = /^(zh|yue|cmn)([-_]|$)/i; +export const ZH_OK_COOKIE = "cyd-zh-ok"; + +export function isChineseLanguageTag(tag: string | null | undefined): boolean { + if (!tag) return false; + const primary = tag.trim().replace("_", "-"); + return ZH_TAG.test(primary); +} + +/** Parse Accept-Language / comma-separated locale list. */ +export function acceptLanguageHasChinese( + header: string | null | undefined +): boolean { + if (!header) return false; + return header + .split(",") + .map((part) => part.split(";")[0]?.trim()) + .some((tag) => isChineseLanguageTag(tag)); +} + +export type ChineseSignal = + | "navigator.language" + | "navigator.languages" + | "intl.locale" + | "accept-language" + | "cookie"; + +export type ChineseDetection = { + likely: boolean; + signals: ChineseSignal[]; +}; + +function readCookie(name: string): string | null { + if (typeof document === "undefined") return null; + const match = document.cookie.match( + new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}=([^;]*)`) + ); + return match ? decodeURIComponent(match[1]) : null; +} + +function writeCookie(name: string, value: string) { + if (typeof document === "undefined") return; + const maxAge = 60 * 60 * 24 * 365; + document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; SameSite=Lax`; +} + +/** + * Client-side detection (browser / OS language prefs exposed to the page). + */ +export function detectChineseClient(): ChineseDetection { + const signals: ChineseSignal[] = []; + + if (typeof navigator !== "undefined") { + if (isChineseLanguageTag(navigator.language)) { + signals.push("navigator.language"); + } + if (navigator.languages?.some((l) => isChineseLanguageTag(l))) { + signals.push("navigator.languages"); + } + const legacy = (navigator as Navigator & { userLanguage?: string }) + .userLanguage; + if (isChineseLanguageTag(legacy) && !signals.includes("navigator.language")) { + signals.push("navigator.language"); + } + } + + try { + const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale; + if (isChineseLanguageTag(intlLocale)) { + signals.push("intl.locale"); + } + } catch { + /* ignore */ + } + + if (readCookie(ZH_OK_COOKIE) === "1") { + signals.push("cookie"); + } + + return { likely: signals.length > 0, signals }; +} + +/** Persist so middleware allows /zh after client-side Chinese detection. */ +export function markChineseLocaleAllowed() { + if (typeof window === "undefined") return; + try { + writeCookie(ZH_OK_COOKIE, "1"); + window.localStorage.setItem(ZH_OK_COOKIE, "1"); + } catch { + /* ignore */ + } +} diff --git a/messages/en.json b/messages/en.json index ae30ab5..f57d410 100644 --- a/messages/en.json +++ b/messages/en.json @@ -50,7 +50,8 @@ "lang": { "label": "Language", "en": "English", - "zh": "中文" + "zh": "中文", + "zhHint": "Shown when the browser or system prefers Chinese" }, "footer": { "offsets": "Flash @ 0x1000 / 0x8000 / 0xe000 / 0x10000", diff --git a/messages/zh.json b/messages/zh.json index 2f796b3..c5f99b2 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -50,7 +50,8 @@ "lang": { "label": "语言", "en": "English", - "zh": "中文" + "zh": "中文", + "zhHint": "仅在检测到浏览器或系统中文时显示" }, "footer": { "offsets": "刷写地址 @ 0x1000 / 0x8000 / 0xe000 / 0x10000", diff --git a/middleware.ts b/middleware.ts index d02ce16..3815660 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,7 +1,37 @@ import createMiddleware from "next-intl/middleware"; +import { NextRequest, NextResponse } from "next/server"; import { routing } from "./i18n/routing"; +import { + acceptLanguageHasChinese, + ZH_OK_COOKIE, +} from "./lib/chinese-locale"; -export default createMiddleware(routing); +const intlMiddleware = createMiddleware({ + ...routing, + localeDetection: true, +}); + +function requestAllowsChinese(request: NextRequest): boolean { + if (acceptLanguageHasChinese(request.headers.get("accept-language"))) { + return true; + } + return request.cookies.get(ZH_OK_COOKIE)?.value === "1"; +} + +export default function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + // Shared /zh links: without Chinese signals → English only + if (pathname === "/zh" || pathname.startsWith("/zh/")) { + if (!requestAllowsChinese(request)) { + const url = request.nextUrl.clone(); + url.pathname = pathname.replace(/^\/zh/, "/en") || "/en"; + return NextResponse.redirect(url); + } + } + + return intlMiddleware(request); +} export const config = { matcher: ["/", "/(en|zh)/:path*", "/((?!api|_next|_vercel|.*\\..*).*)"],