Show Chinese locale only when Chinese language signals are detected.
Hide the language switcher for English-only browsers; gate /zh via Accept-Language and a client cookie. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 |
|
| Boards | CYD 2.8\" available · 2.4\" / 3.5\" / S3 Touch shown as Soon |
|
||||||
| Catalog | Searchable card grid · detail pane · media-ready |
|
| Catalog | Searchable card grid · detail pane · media-ready |
|
||||||
| SEO | `generateMetadata`, Open Graph, `/sitemap.xml`, `/robots.txt` |
|
| SEO | `generateMetadata`, Open Graph, `/sitemap.xml`, `/robots.txt` |
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
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";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function LanguageSwitcher() {
|
export function LanguageSwitcher() {
|
||||||
@@ -10,6 +15,31 @@ export function LanguageSwitcher() {
|
|||||||
const locale = useLocale() as AppLocale;
|
const locale = useLocale() as AppLocale;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -17,7 +47,7 @@ export function LanguageSwitcher() {
|
|||||||
role="group"
|
role="group"
|
||||||
aria-label={t("label")}
|
aria-label={t("label")}
|
||||||
>
|
>
|
||||||
{routing.locales.map((loc) => (
|
{locales.map((loc) => (
|
||||||
<button
|
<button
|
||||||
key={loc}
|
key={loc}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -28,7 +58,10 @@ export function LanguageSwitcher() {
|
|||||||
: "text-[var(--muted)] hover:text-[var(--ink)]"
|
: "text-[var(--muted)] hover:text-[var(--ink)]"
|
||||||
)}
|
)}
|
||||||
aria-pressed={loc === locale}
|
aria-pressed={loc === locale}
|
||||||
onClick={() => router.replace(pathname, { locale: loc })}
|
onClick={() => {
|
||||||
|
if (loc === "zh") markChineseLocaleAllowed();
|
||||||
|
router.replace(pathname, { locale: loc });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t(loc)}
|
{t(loc)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
97
lib/chinese-locale.ts
Normal file
97
lib/chinese-locale.ts
Normal file
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,7 +50,8 @@
|
|||||||
"lang": {
|
"lang": {
|
||||||
"label": "Language",
|
"label": "Language",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
"zh": "中文"
|
"zh": "中文",
|
||||||
|
"zhHint": "Shown when the browser or system prefers Chinese"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"offsets": "Flash @ 0x1000 / 0x8000 / 0xe000 / 0x10000",
|
"offsets": "Flash @ 0x1000 / 0x8000 / 0xe000 / 0x10000",
|
||||||
|
|||||||
@@ -50,7 +50,8 @@
|
|||||||
"lang": {
|
"lang": {
|
||||||
"label": "语言",
|
"label": "语言",
|
||||||
"en": "English",
|
"en": "English",
|
||||||
"zh": "中文"
|
"zh": "中文",
|
||||||
|
"zhHint": "仅在检测到浏览器或系统中文时显示"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"offsets": "刷写地址 @ 0x1000 / 0x8000 / 0xe000 / 0x10000",
|
"offsets": "刷写地址 @ 0x1000 / 0x8000 / 0xe000 / 0x10000",
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import createMiddleware from "next-intl/middleware";
|
import createMiddleware from "next-intl/middleware";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { routing } from "./i18n/routing";
|
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 = {
|
export const config = {
|
||||||
matcher: ["/", "/(en|zh)/:path*", "/((?!api|_next|_vercel|.*\\..*).*)"],
|
matcher: ["/", "/(en|zh)/:path*", "/((?!api|_next|_vercel|.*\\..*).*)"],
|
||||||
|
|||||||
Reference in New Issue
Block a user