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|.*\\..*).*)"],