i18n 代码进入URL路由,优化重定向规则

This commit is contained in:
2026-09-16 20:03:41 +08:00
parent d4bd36f5f8
commit f63f70d08b
42 changed files with 361 additions and 141 deletions
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "export", output: "export",
trailingSlash: true,
}; };
export default nextConfig; export default nextConfig;
+3 -3
View File
@@ -1,10 +1,10 @@
# Random 404 messages grouped by locale. # Random 404 messages grouped by locale.
zh-CN: zh-Hans:
- 人与人的悲欢不尽相同 - 人与人的悲欢不尽相同
- 此处没有列车经过 - 此处没有列车经过
- 这条线路还没有开通 - 这条线路还没有开通
- 你抵达了地图之外的地方 - 你抵达了地图之外的地方
zh-TW: zh-Hant:
- 人與人的悲歡並不相同 - 人與人的悲歡並不相同
- 此處沒有列車經過 - 此處沒有列車經過
- 這條路線尚未通車 - 這條路線尚未通車
@@ -14,7 +14,7 @@ en:
- No train stops here - No train stops here
- This line has not opened yet - This line has not opened yet
- You have reached the edge of the map - You have reached the edge of the map
ja: jp:
- 人それぞれ、喜びも悲しみも違う - 人それぞれ、喜びも悲しみも違う
- ここを通る列車はありません - ここを通る列車はありません
- この路線はまだ開通していません - この路線はまだ開通していません
+1
View File
@@ -0,0 +1 @@
export { default, metadata, viewport } from "@/components/layout/site-document";
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getNewsBySlug, getNewsCatalog, getNewsList } from "@/data/news"; import { getNewsBySlug, getNewsCatalog, getNewsList } from "@/data/news";
import { NewsDetailClient } from "@/components/news/news-detail-client"; import { NewsDetailClient } from "@/components/news/news-detail-client";
import { isLocale } from "@/lib/i18n";
export async function generateStaticParams() { export async function generateStaticParams() {
const news = await getNewsList(); const news = await getNewsList();
@@ -11,10 +12,10 @@ export async function generateStaticParams() {
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: {
params: Promise<{ slug: string }>; params: Promise<{ slug: string; locale?: string }>;
}): Promise<Metadata> { }): Promise<Metadata> {
const { slug } = await params; const { slug, locale } = await params;
const item = await getNewsBySlug(slug); const item = await getNewsBySlug(slug, isLocale(locale) ? locale : "zh-Hans");
return { return {
title: item?.title ?? "新闻", title: item?.title ?? "新闻",
@@ -3,12 +3,16 @@ import { getNewsCatalog } from "@/data/news";
import { PageBreadcrumb } from "@/components/layout/page-breadcrumb"; import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
import { NewsList } from "@/components/news/news-list"; import { NewsList } from "@/components/news/news-list";
import { NewsPageHeading } from "@/components/news/news-page-heading"; import { NewsPageHeading } from "@/components/news/news-page-heading";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
export const metadata: Metadata = { export async function generateMetadata({ params }: { params: Promise<{ locale?: string }> }): Promise<Metadata> {
title: "新闻", const { locale } = await params;
}; return { title: getLocaleMessages(isLocale(locale) ? locale : "zh-Hans").common.news };
}
export default async function NewsPage() { export default async function NewsPage({ params }: { params: Promise<{ locale?: string }> }) {
const { locale } = await params;
const messages = getLocaleMessages(isLocale(locale) ? locale : "zh-Hans");
const catalog = await getNewsCatalog(); const catalog = await getNewsCatalog();
return ( return (
@@ -22,7 +26,7 @@ export default async function NewsPage() {
</div> </div>
<div className="page-bottom-nav"> <div className="page-bottom-nav">
<div className="page-bottom-nav-inner"> <div className="page-bottom-nav-inner">
<PageBreadcrumb items={[{ label: "首页", href: "/" }, { label: "新闻" }]} /> <PageBreadcrumb items={[{ label: messages.common.home, href: "/" }, { label: messages.common.news }]} />
</div> </div>
</div> </div>
</main> </main>
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { PolicyPageContent } from "@/components/policy/policy-page-content"; import { PolicyPageContent } from "@/components/policy/policy-page-content";
import { getPolicyCatalog, getPolicySlugs } from "@/data/policy"; import { getPolicyCatalog, getPolicySlugs } from "@/data/policy";
import { isLocale } from "@/lib/i18n";
export const dynamicParams = false; export const dynamicParams = false;
@@ -9,16 +10,16 @@ export function generateStaticParams() {
return getPolicySlugs(); return getPolicySlugs();
} }
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ slug: string; locale?: string }> }): Promise<Metadata> {
const { slug } = await params; const { slug, locale } = await params;
const catalog = await getPolicyCatalog(slug); const catalog = await getPolicyCatalog(slug);
return { title: catalog["zh-CN"]?.title ?? "法律信息" }; return { title: catalog[isLocale(locale) ? locale : "zh-Hans"]?.title ?? "法律信息" };
} }
export default async function PolicyPage({ params }: { params: Promise<{ slug: string }> }) { export default async function PolicyPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; const { slug } = await params;
const catalog = await getPolicyCatalog(slug); const catalog = await getPolicyCatalog(slug);
if (!catalog["zh-CN"]) notFound(); if (!catalog["zh-Hans"]) notFound();
return ( return (
<main className="policy-page"> <main className="policy-page">
@@ -27,4 +28,4 @@ export default async function PolicyPage({ params }: { params: Promise<{ slug: s
</div> </div>
</main> </main>
); );
} }
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/(legacy)/403/page";
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/(legacy)/500/page";
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/(legacy)/502/page";
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/(legacy)/503/page";
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/(legacy)/504/page";
+43
View File
@@ -0,0 +1,43 @@
import { notFound } from "next/navigation";
import SiteDocument from "@/components/layout/site-document";
import { getLocaleMessages, locales, matchLocale } from "@/lib/i18n";
export { viewport } from "@/components/layout/site-document";
export const dynamicParams = false;
export function generateStaticParams() {
return [
...locales,
"zh-hans",
"zh-hant",
"zh-CN",
"zh-cn",
"zh-SG",
"zh-sg",
"zh-TW",
"zh-tw",
"zh-HK",
"zh-hk",
"zh-MO",
"zh-mo",
"ja",
].map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const canonicalLocale = matchLocale(locale);
if (!canonicalLocale) notFound();
const title = getLocaleMessages(canonicalLocale).common.siteTitle;
return { title: { default: title, template: `%s | ${title}` }, icons: { icon: "/icon/icon.svg" } };
}
export default async function LocaleLayout({ children, params }: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const canonicalLocale = matchLocale(locale);
if (!canonicalLocale) notFound();
return <SiteDocument locale={canonicalLocale}>{children}</SiteDocument>;
}
+1
View File
@@ -0,0 +1 @@
export { default, generateMetadata, generateStaticParams } from "@/app/(legacy)/news/[slug]/page";
+1
View File
@@ -0,0 +1 @@
export { default, generateMetadata } from "@/app/(legacy)/news/page";
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/(legacy)/not-found";
+1
View File
@@ -0,0 +1 @@
export { default } from "@/app/(legacy)/page";
+1
View File
@@ -0,0 +1 @@
export { default, generateMetadata, generateStaticParams } from "@/app/(legacy)/policy/[slug]/page";
-50
View File
@@ -1,50 +0,0 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { SiteFooter } from "@/components/layout/site-footer";
import { SiteHeader } from "@/components/layout/site-header";
export const metadata: Metadata = {
title: {
default: "基沃托斯铁道公团 KivotosRailway",
template: "%s | 基沃托斯铁道公团 KivotosRailway",
},
description: "Kivotos Railway website",
icons: {
icon: "/icon/icon.svg",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
{/* Run during HTML parsing, before the body can paint. */}
<script
id="theme-preference"
dangerouslySetInnerHTML={{ __html: `(() => {
const isTheme = (value) => value === 'light' || value === 'system' || value === 'dark';
let preference = 'system';
try {
const cookieTheme = document.cookie.split(';').map((entry) => entry.trim()).find((entry) => entry.startsWith('kr-theme='))?.slice('kr-theme='.length);
const savedTheme = isTheme(cookieTheme) ? cookieTheme : localStorage.getItem('kr-theme');
if (isTheme(savedTheme)) preference = savedTheme;
} catch {}
const theme = preference === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : preference;
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();` }}
/>
</head>
<body>
<SiteHeader />{children}<SiteFooter />
</body>
</html>
);
}
+7 -5
View File
@@ -1,12 +1,14 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { NewsItem } from "@/data/news"; import type { NewsItem } from "@/data/news";
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { applyLocale, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale, NewsItem[]> }) { export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale, NewsItem[]> }) {
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => { useEffect(() => {
const syncLocale = (nextLocale: Locale) => setLocale(nextLocale); const syncLocale = (nextLocale: Locale) => setLocale(nextLocale);
@@ -38,7 +40,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale,
}, []); }, []);
const t = getLocaleMessages(locale); const t = getLocaleMessages(locale);
const visibleNews = newsByLocale[locale] ?? newsByLocale["zh-CN"] ?? []; const visibleNews = newsByLocale[locale] ?? newsByLocale["zh-Hans"] ?? [];
return ( return (
<main className="home-page"> <main className="home-page">
@@ -56,7 +58,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale,
<section className="home-news" id="about" aria-labelledby="latest-news-title"> <section className="home-news" id="about" aria-labelledby="latest-news-title">
<div className="news-header"> <div className="news-header">
<h2 id="latest-news-title">{t.home.latest}</h2> <h2 id="latest-news-title">{t.home.latest}</h2>
<Link href="/news" className="news-more-link"> <Link href={withLocale("/news", locale)} className="news-more-link">
{t.home.more} <span aria-hidden="true">→</span> {t.home.more} <span aria-hidden="true">→</span>
</Link> </Link>
</div> </div>
@@ -64,7 +66,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale,
<div className="news-grid" aria-label={t.home.latest}> <div className="news-grid" aria-label={t.home.latest}>
{visibleNews.slice(0, 3).map((post) => ( {visibleNews.slice(0, 3).map((post) => (
<article key={post.id} className="news-card"> <article key={post.id} className="news-card">
<Link href={post.href} className="news-card-link" aria-label={post.title}> <Link href={withLocale(post.href, locale)} className="news-card-link" aria-label={post.title}>
<div className="news-visual" style={{ backgroundImage: `url(${post.cover})` }} /> <div className="news-visual" style={{ backgroundImage: `url(${post.cover})` }} />
<h3>{post.title}</h3> <h3>{post.title}</h3>
<time dateTime={post.date}>{post.date}</time> <time dateTime={post.date}>{post.date}</time>
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { createContext, useContext } from "react";
import type { Locale } from "@/lib/i18n";
export const LocaleContext = createContext<Locale>("zh-Hans");
export function useRouteLocale() { return useContext(LocaleContext); }
export function LocaleProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { applyLocale, getInitialLocale } from "@/lib/i18n";
export function LocaleUrlSync() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
applyLocale(getInitialLocale());
}, [pathname, searchParams]);
return null;
}
+16 -1
View File
@@ -1,4 +1,10 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react";
import { getInitialLocale, withLocale, type Locale } from "@/lib/i18n";
export function PageBreadcrumb({ export function PageBreadcrumb({
items, items,
@@ -7,12 +13,21 @@ export function PageBreadcrumb({
items: Array<{ label: string; href?: string }>; items: Array<{ label: string; href?: string }>;
ariaLabel?: string; ariaLabel?: string;
}) { }) {
const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => {
const syncLocale = () => setLocale(getInitialLocale());
syncLocale();
window.addEventListener("kr-locale-change", syncLocale);
return () => window.removeEventListener("kr-locale-change", syncLocale);
}, []);
return ( return (
<nav className="page-breadcrumb" aria-label={ariaLabel}> <nav className="page-breadcrumb" aria-label={ariaLabel}>
{items.map((item, index) => ( {items.map((item, index) => (
<span className="page-breadcrumb-item" key={`${item.label}-${index}`}> <span className="page-breadcrumb-item" key={`${item.label}-${index}`}>
{index > 0 && <span className="page-breadcrumb-separator" aria-hidden="true">›</span>} {index > 0 && <span className="page-breadcrumb-separator" aria-hidden="true">›</span>}
{item.href ? <Link href={item.href}>{item.label}</Link> : <span>{item.label}</span>} {item.href ? <Link href={withLocale(item.href, locale)}>{item.label}</Link> : <span>{item.label}</span>}
</span> </span>
))} ))}
</nav> </nav>
+93
View File
@@ -0,0 +1,93 @@
import type { Metadata, Viewport } from "next";
import { Suspense } from "react";
import "@/app/globals.css";
import type { Locale } from "@/lib/i18n";
import { LocaleProvider } from "@/components/layout/locale-context";
import { SiteFooter } from "@/components/layout/site-footer";
import { SiteHeader } from "@/components/layout/site-header";
import { LocaleUrlSync } from "@/components/layout/locale-url-sync";
export const metadata: Metadata = {
title: {
default: "基沃托斯铁道公团 KivotosRailway",
template: "%s | 基沃托斯铁道公团 KivotosRailway",
},
description: "Kivotos Railway website",
icons: {
icon: "/icon/icon.svg",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
locale = "zh-Hans",
}: Readonly<{ children: React.ReactNode; locale?: Locale }>) {
return (
<html lang={locale} suppressHydrationWarning>
<head>
{/* Run during HTML parsing, before the body can paint. */}
<script
id="theme-preference"
dangerouslySetInnerHTML={{ __html: `(() => {
const isTheme = (value) => value === 'light' || value === 'system' || value === 'dark';
let preference = 'system';
try {
const cookieTheme = document.cookie.split(';').map((entry) => entry.trim()).find((entry) => entry.startsWith('kr-theme='))?.slice('kr-theme='.length);
const savedTheme = isTheme(cookieTheme) ? cookieTheme : localStorage.getItem('kr-theme');
if (isTheme(savedTheme)) preference = savedTheme;
} catch {}
const theme = preference === 'system' ? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : preference;
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
})();` }}
/>
<script
id="locale-canonicalization"
dangerouslySetInnerHTML={{ __html: `(() => {
const canonicalLocale = (value) => {
const language = value?.toLowerCase().replaceAll('_', '-');
if (!language) return null;
if (/^zh(?:-|$)/.test(language)) return /^zh-(tw|hk|mo)(-|$)|-hant(?:-|$)/.test(language) ? 'zh-Hant' : 'zh-Hans';
if (/^en(?:-|$)/.test(language)) return 'en';
if (/^(ja|jp)(?:-|$)/.test(language)) return 'jp';
return null;
};
const parts = location.pathname.split('/');
const firstSegment = parts[1];
const pathLocale = canonicalLocale(firstSegment);
const isUnknownLanguagePath = !pathLocale && /^[a-z]{2,3}(?:-[a-z0-9]+)*$/i.test(firstSegment ?? '');
let locale = pathLocale;
if (!locale) {
try { locale = canonicalLocale(localStorage.getItem('kr-locale')); } catch {}
}
if (!locale) {
for (const language of navigator.languages?.length ? navigator.languages : [navigator.language]) {
locale = canonicalLocale(language);
if (locale) break;
}
}
locale ??= 'en';
const path = pathLocale || isUnknownLanguagePath ? '/' + parts.slice(2).join('/') : location.pathname;
const normalizedPath = path.replace(/\\/$/, '');
const destination = '/' + locale + normalizedPath + '/';
const search = new URLSearchParams(location.search);
search.delete('lang');
const canonicalUrl = destination + (search.size ? '?' + search : '') + location.hash;
if (location.pathname + location.search + location.hash !== canonicalUrl) location.replace(canonicalUrl);
})();` }}
/>
</head>
<body>
<LocaleProvider locale={locale}>
<Suspense fallback={null}><LocaleUrlSync /></Suspense>
<SiteHeader />{children}<SiteFooter />
</LocaleProvider>
</body>
</html>
);
}
+6 -4
View File
@@ -1,8 +1,10 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { applyLocale, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { applyLocale, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import { ThemeToggle } from "@/components/layout/theme-toggle"; import { ThemeToggle } from "@/components/layout/theme-toggle";
const footerColumnHrefs = [ const footerColumnHrefs = [
@@ -18,7 +20,7 @@ function isIcpVisibleHost(hostname: string) {
} }
export function SiteFooter() { export function SiteFooter() {
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [showIcp, setShowIcp] = useState(false); const [showIcp, setShowIcp] = useState(false);
useEffect(() => { useEffect(() => {
@@ -77,7 +79,7 @@ export function SiteFooter() {
<h3>{group.title}</h3> <h3>{group.title}</h3>
<ul> <ul>
{group.links.map((link, linkIndex) => ( {group.links.map((link, linkIndex) => (
<li key={`column-${groupIndex}-link-${linkIndex}`}><a href={footerColumnHrefs[groupIndex][linkIndex]}>{link}</a></li> <li key={`column-${groupIndex}-link-${linkIndex}`}><a href={withLocale(footerColumnHrefs[groupIndex][linkIndex], locale)}>{link}</a></li>
))} ))}
</ul> </ul>
</div> </div>
@@ -100,7 +102,7 @@ export function SiteFooter() {
<div className="site-footer-meta"> <div className="site-footer-meta">
<div className="site-footer-legal-row"> <div className="site-footer-legal-row">
{messages.footer.legal.map((item, itemIndex) => ( {messages.footer.legal.map((item, itemIndex) => (
<a key={`legal-${itemIndex}`} href={footerLegalHrefs[itemIndex]}>{item}</a> <a key={`legal-${itemIndex}`} href={withLocale(footerLegalHrefs[itemIndex], locale)}>{item}</a>
))} ))}
</div> </div>
<p className="site-footer-description">{messages.footer.description}</p> <p className="site-footer-description">{messages.footer.description}</p>
+7 -5
View File
@@ -1,18 +1,20 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { applyLocale, getInitialLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n"; import { applyLocale, getInitialLocale, withLocale, withoutLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
export function SiteHeader() { export function SiteHeader() {
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => { useEffect(() => {
const updateDocumentTitle = (nextLocale: Locale) => { const updateDocumentTitle = (nextLocale: Locale) => {
const messages = getLocaleMessages(nextLocale); const messages = getLocaleMessages(nextLocale);
const pathname = window.location.pathname; const pathname = withoutLocale(window.location.pathname).replace(/\/$/, "") || "/";
const currentTitle = document.title.split(" | ")[0]; const currentTitle = document.title.split(" | ")[0];
const pageLabel = pathname === "/" const pageLabel = pathname === "/"
? "" ? ""
@@ -54,7 +56,7 @@ export function SiteHeader() {
return ( return (
<header className="site-header"> <header className="site-header">
<Link className="site-logo" href="/" aria-label={`Kivotos Railway ${localeLabels[locale]}`}> <Link className="site-logo" href={withLocale("/", locale)} aria-label={`Kivotos Railway ${localeLabels[locale]}`}>
<Image <Image
className="site-logo-image" className="site-logo-image"
src="/icon/kivotosrailway.svg" src="/icon/kivotosrailway.svg"
@@ -68,7 +70,7 @@ export function SiteHeader() {
{messages.header.nav.map((label, index) => { {messages.header.nav.map((label, index) => {
const href = ["/", "/news", "/railway", "/about", "/members"][index] ?? "/"; const href = ["/", "/news", "/railway", "/about", "/members"][index] ?? "/";
return ( return (
<Link key={href} href={href} onClick={() => setMenuOpen(false)}> <Link key={href} href={withLocale(href, locale)} onClick={() => setMenuOpen(false)}>
{label} {label}
</Link> </Link>
); );
+3 -1
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
@@ -43,7 +45,7 @@ function applyTheme(preference: ThemePreference) {
export function ThemeToggle() { export function ThemeToggle() {
const [themePreference, setThemePreference] = useState<ThemePreference>("system"); const [themePreference, setThemePreference] = useState<ThemePreference>("system");
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => { useEffect(() => {
const syncTheme = () => { const syncTheme = () => {
+8 -6
View File
@@ -1,17 +1,19 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { NewsItem } from "@/data/news"; import type { NewsItem } from "@/data/news";
import { getAiTranslationNotice, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { getAiTranslationNotice, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import { markdownToHtml } from "@/lib/markdown"; import { markdownToHtml } from "@/lib/markdown";
import { PageBreadcrumb } from "@/components/layout/page-breadcrumb"; import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
import { NewsShare } from "@/components/news/news-share"; import { NewsShare } from "@/components/news/news-share";
export function NewsDetailClient({ catalog, slug }: { catalog: Record<Locale, NewsItem[]>; slug: string }) { export function NewsDetailClient({ catalog, slug }: { catalog: Record<Locale, NewsItem[]>; slug: string }) {
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
const messages = getLocaleMessages(locale); const messages = getLocaleMessages(locale);
const item = catalog[locale].find((entry) => entry.slug === slug) ?? catalog["zh-CN"].find((entry) => entry.slug === slug); const item = catalog[locale].find((entry) => entry.slug === slug) ?? catalog["zh-Hans"].find((entry) => entry.slug === slug);
useEffect(() => { useEffect(() => {
const syncLocale = () => setLocale(getInitialLocale()); const syncLocale = () => setLocale(getInitialLocale());
@@ -24,16 +26,16 @@ export function NewsDetailClient({ catalog, slug }: { catalog: Record<Locale, Ne
}; };
}, []); }, []);
if (!item) return <main className="news-detail-page"><div className="news-detail-inner"><p>未找到该文章。</p><Link href="/news">{messages.common.backToNews}</Link></div></main>; if (!item) return <main className="news-detail-page"><div className="news-detail-inner"><p>未找到该文章。</p><Link href={withLocale("/news", locale)}>{messages.common.backToNews}</Link></div></main>;
return <main className="news-detail-page"> return <main className="news-detail-page">
<div className="news-detail-inner"> <div className="news-detail-inner">
<Link href="/news" className="back-link">← {messages.common.backToNews}</Link> <Link href={withLocale("/news", locale)} className="back-link">← {messages.common.backToNews}</Link>
<article className="news-detail-card"> <article className="news-detail-card">
<p className="news-detail-tag">{item.category}</p> <p className="news-detail-tag">{item.category}</p>
<h1>{item.title}</h1> <h1>{item.title}</h1>
<time>{item.date}</time> <time>{item.date}</time>
{locale !== "zh-CN" && ( {locale !== "zh-Hans" && (
<div className="news-detail-ai-banner" aria-label="AI translated content"> <div className="news-detail-ai-banner" aria-label="AI translated content">
{getAiTranslationNotice(locale)} {getAiTranslationNotice(locale)}
</div> </div>
+5 -3
View File
@@ -1,15 +1,17 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { NewsItem } from "@/data/news"; import type { NewsItem } from "@/data/news";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
const ITEMS_PER_PAGE = 12; const ITEMS_PER_PAGE = 12;
export function NewsList({ catalog }: { catalog: Record<Locale, NewsItem[]> }) { export function NewsList({ catalog }: { catalog: Record<Locale, NewsItem[]> }) {
const [activeCategory, setActiveCategory] = useState<string | null>(null); const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const messages = getLocaleMessages(locale); const messages = getLocaleMessages(locale);
const news = catalog[locale]; const news = catalog[locale];
@@ -88,7 +90,7 @@ export function NewsList({ catalog }: { catalog: Record<Locale, NewsItem[]> }) {
<div className="news-list-grid"> <div className="news-list-grid">
{paginatedNews.map((item) => ( {paginatedNews.map((item) => (
<article key={item.id} className="news-list-card"> <article key={item.id} className="news-list-card">
<Link href={item.href} className="news-list-link"> <Link href={withLocale(item.href, locale)} className="news-list-link">
<div className="news-list-cover" style={{ backgroundImage: `url(${item.cover})` }} /> <div className="news-list-cover" style={{ backgroundImage: `url(${item.cover})` }} />
<div className="news-list-meta"> <div className="news-list-meta">
<span className="news-list-tag">{item.category}</span> <span className="news-list-tag">{item.category}</span>
+3 -1
View File
@@ -1,10 +1,12 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
export function NewsPageHeading() { export function NewsPageHeading() {
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => { useEffect(() => {
const sync = () => setLocale(getInitialLocale()); const sync = () => setLocale(getInitialLocale());
sync(); sync();
+3 -1
View File
@@ -1,11 +1,13 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
export function NewsShare({ title }: { title: string }) { export function NewsShare({ title }: { title: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
const messages = getLocaleMessages(locale); const messages = getLocaleMessages(locale);
useEffect(() => { useEffect(() => {
@@ -1,5 +1,7 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getAiTranslationNotice, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n"; import { getAiTranslationNotice, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import type { PolicyDocument } from "@/data/policy"; import type { PolicyDocument } from "@/data/policy";
@@ -8,9 +10,9 @@ import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
type PolicyCatalog = Partial<Record<Locale, PolicyDocument>>; type PolicyCatalog = Partial<Record<Locale, PolicyDocument>>;
export function PolicyPageContent({ catalog }: { catalog: PolicyCatalog }) { export function PolicyPageContent({ catalog }: { catalog: PolicyCatalog }) {
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
const document = catalog[locale] ?? catalog["zh-CN"]; const document = catalog[locale] ?? catalog["zh-Hans"];
const isTranslated = locale !== "zh-CN" && Boolean(catalog[locale]); const isTranslated = locale !== "zh-Hans" && Boolean(catalog[locale]);
const messages = getLocaleMessages(locale); const messages = getLocaleMessages(locale);
useEffect(() => { useEffect(() => {
+13 -11
View File
@@ -1,13 +1,15 @@
"use client"; "use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getInitialLocale, type Locale } from "@/lib/i18n"; import { getInitialLocale, withLocale, type Locale } from "@/lib/i18n";
export type HttpStatusCode = 403 | 404 | 500 | 502 | 503 | 504; export type HttpStatusCode = 403 | 404 | 500 | 502 | 503 | 504;
const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = { const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
"zh-CN": { "zh-Hans": {
403: "你没有权限访问此页面", 403: "你没有权限访问此页面",
404: "人与人的悲欢不尽相同", 404: "人与人的悲欢不尽相同",
500: "服务器遇到了一点问题", 500: "服务器遇到了一点问题",
@@ -15,7 +17,7 @@ const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
503: "服务暂时不可用", 503: "服务暂时不可用",
504: "服务响应超时", 504: "服务响应超时",
}, },
"zh-TW": { "zh-Hant": {
403: "你沒有權限存取此頁面", 403: "你沒有權限存取此頁面",
404: "人與人的悲歡並不相同", 404: "人與人的悲歡並不相同",
500: "伺服器遇到了一點問題", 500: "伺服器遇到了一點問題",
@@ -31,7 +33,7 @@ const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
503: "The service is temporarily unavailable", 503: "The service is temporarily unavailable",
504: "The service took too long to respond", 504: "The service took too long to respond",
}, },
ja: { jp: {
403: "このページにアクセスする権限がありません", 403: "このページにアクセスする権限がありません",
404: "人それぞれ、喜びも悲しみも違う", 404: "人それぞれ、喜びも悲しみも違う",
500: "サーバーで問題が発生しました", 500: "サーバーで問題が発生しました",
@@ -42,10 +44,10 @@ const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
}; };
const backLabels: Record<Locale, string> = { const backLabels: Record<Locale, string> = {
"zh-CN": "回到上一页", "zh-Hans": "回到上一页",
"zh-TW": "回到上一頁", "zh-Hant": "回到上一頁",
en: "Go back", en: "Go back",
ja: "前のページに戻る", jp: "前のページに戻る",
}; };
function parseLocalizedMessages(source: string): Partial<Record<Locale, string[]>> { function parseLocalizedMessages(source: string): Partial<Record<Locale, string[]>> {
@@ -53,7 +55,7 @@ function parseLocalizedMessages(source: string): Partial<Record<Locale, string[]
let locale: Locale | null = null; let locale: Locale | null = null;
for (const line of source.split(/\r?\n/)) { for (const line of source.split(/\r?\n/)) {
const localeMatch = line.match(/^(zh-CN|zh-TW|en|ja):\s*$/); const localeMatch = line.match(/^(zh-Hans|zh-Hant|en|jp):\s*$/);
if (localeMatch) { if (localeMatch) {
locale = localeMatch[1] as Locale; locale = localeMatch[1] as Locale;
messages[locale] = []; messages[locale] = [];
@@ -68,7 +70,7 @@ function parseLocalizedMessages(source: string): Partial<Record<Locale, string[]
} }
export function HttpStatusPage({ code }: { code: HttpStatusCode }) { export function HttpStatusPage({ code }: { code: HttpStatusCode }) {
const [locale, setLocale] = useState<Locale>("zh-CN"); const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [randomMessages, setRandomMessages] = useState<Partial<Record<Locale, string[]>>>({}); const [randomMessages, setRandomMessages] = useState<Partial<Record<Locale, string[]>>>({});
const [randomMessage, setRandomMessage] = useState<string | null>(null); const [randomMessage, setRandomMessage] = useState<string | null>(null);
@@ -93,7 +95,7 @@ export function HttpStatusPage({ code }: { code: HttpStatusCode }) {
}, [code]); }, [code]);
useEffect(() => { useEffect(() => {
const messages = randomMessages[locale] ?? randomMessages["zh-CN"]; const messages = randomMessages[locale] ?? randomMessages["zh-Hans"];
if (!messages?.length) return; if (!messages?.length) return;
setRandomMessage(messages[Math.floor(Math.random() * messages.length)]); setRandomMessage(messages[Math.floor(Math.random() * messages.length)]);
}, [locale, randomMessages]); }, [locale, randomMessages]);
@@ -103,7 +105,7 @@ export function HttpStatusPage({ code }: { code: HttpStatusCode }) {
window.history.back(); window.history.back();
return; return;
} }
window.location.assign("/"); window.location.assign(withLocale("/", locale));
}; };
return ( return (
+9 -9
View File
@@ -1,6 +1,6 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import type { Locale } from "@/lib/i18n"; import { localeContentDirectory, type Locale } from "@/lib/i18n";
import { markdownToHtml } from "@/lib/markdown"; import { markdownToHtml } from "@/lib/markdown";
import { imageMap } from "@/lib/image-map.generated"; import { imageMap } from "@/lib/image-map.generated";
@@ -95,7 +95,7 @@ function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string)
} }
async function readNewsList(locale: Locale): Promise<NewsItem[]> { async function readNewsList(locale: Locale): Promise<NewsItem[]> {
const directory = path.join(process.cwd(), "src", "data", "news", "posts", locale); const directory = path.join(process.cwd(), "src", "data", "news", "posts", localeContentDirectory[locale]);
try { try {
const files = (await fs.readdir(directory)) const files = (await fs.readdir(directory))
@@ -123,15 +123,15 @@ async function readNewsList(locale: Locale): Promise<NewsItem[]> {
} }
} }
export async function getNewsList(locale: Locale = "zh-CN"): Promise<NewsItem[]> { export async function getNewsList(locale: Locale = "zh-Hans"): Promise<NewsItem[]> {
const items = await readNewsList(locale); const items = await readNewsList(locale);
return locale === "zh-CN" || items.length > 0 ? items : readNewsList("zh-CN"); return locale === "zh-Hans" || items.length > 0 ? items : readNewsList("zh-Hans");
} }
export async function getNewsCatalog(): Promise<Record<Locale, NewsItem[]>> { export async function getNewsCatalog(): Promise<Record<Locale, NewsItem[]>> {
const source = await readNewsList("zh-CN"); const source = await readNewsList("zh-Hans");
const entries = await Promise.all((["zh-CN", "zh-TW", "en", "ja"] as Locale[]).map(async (locale) => { const entries = await Promise.all((["zh-Hans", "zh-Hant", "en", "jp"] as Locale[]).map(async (locale) => {
if (locale === "zh-CN") return [locale, source] as const; if (locale === "zh-Hans") return [locale, source] as const;
const translated = await readNewsList(locale); const translated = await readNewsList(locale);
const bySlug = new Map(translated.map((item) => [item.slug, item])); const bySlug = new Map(translated.map((item) => [item.slug, item]));
@@ -140,12 +140,12 @@ export async function getNewsCatalog(): Promise<Record<Locale, NewsItem[]>> {
return Object.fromEntries(entries) as Record<Locale, NewsItem[]>; return Object.fromEntries(entries) as Record<Locale, NewsItem[]>;
} }
export async function getNewsBySlug(slug: string, locale: Locale = "zh-CN"): Promise<NewsItem | null> { export async function getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise<NewsItem | null> {
const items = await getNewsList(locale); const items = await getNewsList(locale);
return items.find((item) => item.slug === slug) ?? null; return items.find((item) => item.slug === slug) ?? null;
} }
export async function getLatestNews(locale: Locale = "zh-CN") { export async function getLatestNews(locale: Locale = "zh-Hans") {
const items = await getNewsList(locale); const items = await getNewsList(locale);
return items.slice(0, 3); return items.slice(0, 3);
} }
+5 -5
View File
@@ -1,6 +1,6 @@
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
import type { Locale } from "@/lib/i18n"; import { localeContentDirectory, type Locale } from "@/lib/i18n";
import { parseFrontmatter } from "@/data/news"; import { parseFrontmatter } from "@/data/news";
import { markdownToHtml } from "@/lib/markdown"; import { markdownToHtml } from "@/lib/markdown";
@@ -21,7 +21,7 @@ function isPolicySlug(slug: string): slug is PolicySlug {
} }
async function readPolicy(slug: PolicySlug, locale: Locale): Promise<PolicyDocument | null> { async function readPolicy(slug: PolicySlug, locale: Locale): Promise<PolicyDocument | null> {
const filePath = path.join(process.cwd(), "src", "data", "policy", locale, `${slug}.md`); const filePath = path.join(process.cwd(), "src", "data", "policy", localeContentDirectory[locale], `${slug}.md`);
try { try {
const body = await fs.readFile(filePath, "utf-8"); const body = await fs.readFile(filePath, "utf-8");
@@ -38,7 +38,7 @@ async function readPolicy(slug: PolicySlug, locale: Locale): Promise<PolicyDocum
translationLabel, translationLabel,
body: content, body: content,
html: markdownToHtml(content), html: markdownToHtml(content),
markdownFile: `src/data/policy/${locale}/${slug}.md`, markdownFile: `src/data/policy/${localeContentDirectory[locale]}/${slug}.md`,
}; };
} catch { } catch {
return null; return null;
@@ -49,7 +49,7 @@ export async function getPolicyCatalog(slug: string): Promise<Partial<Record<Loc
if (!isPolicySlug(slug)) return {}; if (!isPolicySlug(slug)) return {};
const entries = await Promise.all( const entries = await Promise.all(
(["zh-CN", "zh-TW", "en", "ja"] as Locale[]).map(async (locale) => [locale, await readPolicy(slug, locale)] as const), (["zh-Hans", "zh-Hant", "en", "jp"] as Locale[]).map(async (locale) => [locale, await readPolicy(slug, locale)] as const),
); );
return Object.fromEntries(entries.filter(([, document]) => document)) as Partial<Record<Locale, PolicyDocument>>; return Object.fromEntries(entries.filter(([, document]) => document)) as Partial<Record<Locale, PolicyDocument>>;
@@ -57,4 +57,4 @@ export async function getPolicyCatalog(slug: string): Promise<Partial<Record<Loc
export function getPolicySlugs() { export function getPolicySlugs() {
return policySlugs.map((slug) => ({ slug })); return policySlugs.map((slug) => ({ slug }));
} }
+74 -20
View File
@@ -3,28 +3,67 @@ import zhTW from "@/locales/zh-TW.json";
import en from "@/locales/en.json"; import en from "@/locales/en.json";
import ja from "@/locales/ja.json"; import ja from "@/locales/ja.json";
export const locales = ["zh-CN", "zh-TW", "en", "ja"] as const; export const locales = ["zh-Hans", "zh-Hant", "en", "jp"] as const;
export type Locale = (typeof locales)[number]; export type Locale = (typeof locales)[number];
// Content storage retains its original names; public URLs use script subtags.
export const localeContentDirectory: Record<Locale, string> = {
"zh-Hans": "zh-CN", "zh-Hant": "zh-TW", en: "en", jp: "ja",
};
export function matchLocale(value: string | null): Locale | null {
const language = value?.toLowerCase().replaceAll("_", "-");
if (!language) return null;
if (/^zh(?:-|$)/.test(language)) {
if (language.includes("-hant")) return "zh-Hant";
if (language.includes("-hans")) return "zh-Hans";
return /^zh-(tw|hk|mo)(-|$)/.test(language) ? "zh-Hant" : "zh-Hans";
}
if (/^en(?:-|$)/.test(language)) return "en";
if (/^(ja|jp)(?:-|$)/.test(language)) return "jp";
return null;
}
export function getPathLocale(pathname: string): Locale | null {
return matchLocale(pathname.split("/")[1]);
}
export function withoutLocale(pathname: string): string {
return getPathLocale(pathname) ? `/${pathname.split("/").slice(2).join("/")}` : pathname;
}
export function isLocale(value: string | null | undefined): value is Locale {
return locales.some((locale) => locale === value);
}
export function withLocale(href: string, locale: Locale): string {
if (!href.startsWith("/") || href.startsWith("//")) return href;
const url = new URL(href, "https://locale.invalid");
const path = withoutLocale(url.pathname).replace(/\/$/, "");
url.pathname = `/${locale}${path}/`;
url.searchParams.delete("lang");
return `${url.pathname}${url.search}${url.hash}`;
}
export const localeLabels: Record<Locale, string> = { export const localeLabels: Record<Locale, string> = {
"zh-CN": "简体中文", "zh-Hans": "简体中文",
"zh-TW": "繁體中文", "zh-Hant": "繁體中文",
en: "English", en: "English",
ja: "日本語", jp: "日本語",
}; };
export const localeShortLabels: Record<Locale, string> = { export const localeShortLabels: Record<Locale, string> = {
"zh-CN": "简", "zh-Hans": "简",
"zh-TW": "繁", "zh-Hant": "繁",
en: "EN", en: "EN",
ja: "JP", jp: "JP",
}; };
export const translations = { export const translations = {
"zh-CN": zhCN, "zh-Hans": zhCN,
"zh-TW": zhTW, "zh-Hant": zhTW,
en, en,
ja, jp: ja,
} as const; } as const;
export function applyLocale(locale: Locale) { export function applyLocale(locale: Locale) {
@@ -32,23 +71,38 @@ export function applyLocale(locale: Locale) {
if (!locales.includes(locale)) return; if (!locales.includes(locale)) return;
window.localStorage.setItem("kr-locale", locale); const href = `${window.location.pathname}${window.location.search}${window.location.hash}`;
const localizedHref = withLocale(href, locale);
try {
window.localStorage.setItem("kr-locale", locale);
} catch { /* The URL remains the source of truth when storage is unavailable. */ }
if (href !== localizedHref) {
window.location.replace(localizedHref);
return;
}
document.documentElement.lang = locale; document.documentElement.lang = locale;
document.documentElement.setAttribute("data-locale", locale); document.documentElement.setAttribute("data-locale", locale);
window.dispatchEvent(new CustomEvent("kr-locale-change", { detail: { locale } })); window.dispatchEvent(new CustomEvent("kr-locale-change", { detail: { locale } }));
} }
export function getInitialLocale(): Locale { export function getInitialLocale(): Locale {
if (typeof window === "undefined") return "zh-CN"; if (typeof window === "undefined") return "zh-Hans";
const saved = window.localStorage.getItem("kr-locale") as Locale | null; const pathLocale = getPathLocale(window.location.pathname);
if (saved && locales.includes(saved)) return saved; if (pathLocale) return pathLocale;
const urlLocale = matchLocale(new URLSearchParams(window.location.search).get("lang"));
if (urlLocale) return urlLocale;
const browserLanguage = navigator.language.toLowerCase(); try {
if (browserLanguage.startsWith("zh-tw") || browserLanguage.startsWith("zh-hk")) return "zh-TW"; const saved = matchLocale(window.localStorage.getItem("kr-locale"));
if (browserLanguage.startsWith("en")) return "en"; if (saved) return saved;
if (browserLanguage.startsWith("ja")) return "ja"; } catch { /* Fall back to the browser language. */ }
return "zh-CN";
for (const language of navigator.languages?.length ? navigator.languages : [navigator.language]) {
const locale = matchLocale(language);
if (locale) return locale;
}
return "en";
} }
export function getLocaleMessages(locale: Locale) { export function getLocaleMessages(locale: Locale) {
@@ -56,7 +110,7 @@ export function getLocaleMessages(locale: Locale) {
} }
export function getAiTranslationNotice(locale: Locale) { export function getAiTranslationNotice(locale: Locale) {
if (locale === "zh-TW") { if (locale === "zh-Hant") {
return "此文章由 AI 從簡體中文翻譯而成,如有任何歧義,以簡體中文版本為準。"; return "此文章由 AI 從簡體中文翻譯而成,如有任何歧義,以簡體中文版本為準。";
} }