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
@@ -0,0 +1 @@
export { default, metadata, viewport } from "@/components/layout/site-document";
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { getNewsBySlug, getNewsCatalog, getNewsList } from "@/data/news";
import { NewsDetailClient } from "@/components/news/news-detail-client";
import { isLocale } from "@/lib/i18n";
export async function generateStaticParams() {
const news = await getNewsList();
@@ -11,10 +12,10 @@ export async function generateStaticParams() {
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
params: Promise<{ slug: string; locale?: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const item = await getNewsBySlug(slug);
const { slug, locale } = await params;
const item = await getNewsBySlug(slug, isLocale(locale) ? locale : "zh-Hans");
return {
title: item?.title ?? "新闻",
@@ -3,12 +3,16 @@ import { getNewsCatalog } from "@/data/news";
import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
import { NewsList } from "@/components/news/news-list";
import { NewsPageHeading } from "@/components/news/news-page-heading";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
export const metadata: Metadata = {
title: "新闻",
};
export async function generateMetadata({ params }: { params: Promise<{ locale?: string }> }): Promise<Metadata> {
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();
return (
@@ -22,7 +26,7 @@ export default async function NewsPage() {
</div>
<div className="page-bottom-nav">
<div className="page-bottom-nav-inner">
<PageBreadcrumb items={[{ label: "首页", href: "/" }, { label: "新闻" }]} />
<PageBreadcrumb items={[{ label: messages.common.home, href: "/" }, { label: messages.common.news }]} />
</div>
</div>
</main>
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PolicyPageContent } from "@/components/policy/policy-page-content";
import { getPolicyCatalog, getPolicySlugs } from "@/data/policy";
import { isLocale } from "@/lib/i18n";
export const dynamicParams = false;
@@ -9,16 +10,16 @@ export function generateStaticParams() {
return getPolicySlugs();
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
export async function generateMetadata({ params }: { params: Promise<{ slug: string; locale?: string }> }): Promise<Metadata> {
const { slug, locale } = await params;
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 }> }) {
const { slug } = await params;
const catalog = await getPolicyCatalog(slug);
if (!catalog["zh-CN"]) notFound();
if (!catalog["zh-Hans"]) notFound();
return (
<main className="policy-page">
@@ -27,4 +28,4 @@ export default async function PolicyPage({ params }: { params: Promise<{ slug: s
</div>
</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";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import { useEffect, useState } from "react";
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[]> }) {
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => {
const syncLocale = (nextLocale: Locale) => setLocale(nextLocale);
@@ -38,7 +40,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale,
}, []);
const t = getLocaleMessages(locale);
const visibleNews = newsByLocale[locale] ?? newsByLocale["zh-CN"] ?? [];
const visibleNews = newsByLocale[locale] ?? newsByLocale["zh-Hans"] ?? [];
return (
<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">
<div className="news-header">
<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>
</Link>
</div>
@@ -64,7 +66,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale,
<div className="news-grid" aria-label={t.home.latest}>
{visibleNews.slice(0, 3).map((post) => (
<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})` }} />
<h3>{post.title}</h3>
<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 { useEffect, useState } from "react";
import { getInitialLocale, withLocale, type Locale } from "@/lib/i18n";
export function PageBreadcrumb({
items,
@@ -7,12 +13,21 @@ export function PageBreadcrumb({
items: Array<{ label: string; href?: 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 (
<nav className="page-breadcrumb" aria-label={ariaLabel}>
{items.map((item, index) => (
<span className="page-breadcrumb-item" key={`${item.label}-${index}`}>
{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>
))}
</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";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image";
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";
const footerColumnHrefs = [
@@ -18,7 +20,7 @@ function isIcpVisibleHost(hostname: string) {
}
export function SiteFooter() {
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [showIcp, setShowIcp] = useState(false);
useEffect(() => {
@@ -77,7 +79,7 @@ export function SiteFooter() {
<h3>{group.title}</h3>
<ul>
{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>
</div>
@@ -100,7 +102,7 @@ export function SiteFooter() {
<div className="site-footer-meta">
<div className="site-footer-legal-row">
{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>
<p className="site-footer-description">{messages.footer.description}</p>
+7 -5
View File
@@ -1,18 +1,20 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import Image from "next/image";
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() {
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
const updateDocumentTitle = (nextLocale: Locale) => {
const messages = getLocaleMessages(nextLocale);
const pathname = window.location.pathname;
const pathname = withoutLocale(window.location.pathname).replace(/\/$/, "") || "/";
const currentTitle = document.title.split(" | ")[0];
const pageLabel = pathname === "/"
? ""
@@ -54,7 +56,7 @@ export function SiteHeader() {
return (
<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
className="site-logo-image"
src="/icon/kivotosrailway.svg"
@@ -68,7 +70,7 @@ export function SiteHeader() {
{messages.header.nav.map((label, index) => {
const href = ["/", "/news", "/railway", "/about", "/members"][index] ?? "/";
return (
<Link key={href} href={href} onClick={() => setMenuOpen(false)}>
<Link key={href} href={withLocale(href, locale)} onClick={() => setMenuOpen(false)}>
{label}
</Link>
);
+3 -1
View File
@@ -1,5 +1,7 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image";
import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
@@ -43,7 +45,7 @@ function applyTheme(preference: ThemePreference) {
export function ThemeToggle() {
const [themePreference, setThemePreference] = useState<ThemePreference>("system");
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => {
const syncTheme = () => {
+8 -6
View File
@@ -1,17 +1,19 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import { useEffect, useState } from "react";
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 { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
import { NewsShare } from "@/components/news/news-share";
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 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(() => {
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">
<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">
<p className="news-detail-tag">{item.category}</p>
<h1>{item.title}</h1>
<time>{item.date}</time>
{locale !== "zh-CN" && (
{locale !== "zh-Hans" && (
<div className="news-detail-ai-banner" aria-label="AI translated content">
{getAiTranslationNotice(locale)}
</div>
+5 -3
View File
@@ -1,15 +1,17 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import { useEffect, useState } from "react";
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;
export function NewsList({ catalog }: { catalog: Record<Locale, NewsItem[]> }) {
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 messages = getLocaleMessages(locale);
const news = catalog[locale];
@@ -88,7 +90,7 @@ export function NewsList({ catalog }: { catalog: Record<Locale, NewsItem[]> }) {
<div className="news-list-grid">
{paginatedNews.map((item) => (
<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-meta">
<span className="news-list-tag">{item.category}</span>
+3 -1
View File
@@ -1,10 +1,12 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
export function NewsPageHeading() {
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => {
const sync = () => setLocale(getInitialLocale());
sync();
+3 -1
View File
@@ -1,11 +1,13 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
export function NewsShare({ title }: { title: string }) {
const [copied, setCopied] = useState(false);
const [locale, setLocale] = useState<Locale>("zh-CN");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const messages = getLocaleMessages(locale);
useEffect(() => {
@@ -1,5 +1,7 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { useEffect, useState } from "react";
import { getAiTranslationNotice, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import type { PolicyDocument } from "@/data/policy";
@@ -8,9 +10,9 @@ import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
type PolicyCatalog = Partial<Record<Locale, PolicyDocument>>;
export function PolicyPageContent({ catalog }: { catalog: PolicyCatalog }) {
const [locale, setLocale] = useState<Locale>("zh-CN");
const document = catalog[locale] ?? catalog["zh-CN"];
const isTranslated = locale !== "zh-CN" && Boolean(catalog[locale]);
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const document = catalog[locale] ?? catalog["zh-Hans"];
const isTranslated = locale !== "zh-Hans" && Boolean(catalog[locale]);
const messages = getLocaleMessages(locale);
useEffect(() => {
+13 -11
View File
@@ -1,13 +1,15 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image";
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;
const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
"zh-CN": {
"zh-Hans": {
403: "你没有权限访问此页面",
404: "人与人的悲欢不尽相同",
500: "服务器遇到了一点问题",
@@ -15,7 +17,7 @@ const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
503: "服务暂时不可用",
504: "服务响应超时",
},
"zh-TW": {
"zh-Hant": {
403: "你沒有權限存取此頁面",
404: "人與人的悲歡並不相同",
500: "伺服器遇到了一點問題",
@@ -31,7 +33,7 @@ const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
503: "The service is temporarily unavailable",
504: "The service took too long to respond",
},
ja: {
jp: {
403: "このページにアクセスする権限がありません",
404: "人それぞれ、喜びも悲しみも違う",
500: "サーバーで問題が発生しました",
@@ -42,10 +44,10 @@ const statusMessages: Record<Locale, Record<HttpStatusCode, string>> = {
};
const backLabels: Record<Locale, string> = {
"zh-CN": "回到上一页",
"zh-TW": "回到上一頁",
"zh-Hans": "回到上一页",
"zh-Hant": "回到上一頁",
en: "Go back",
ja: "前のページに戻る",
jp: "前のページに戻る",
};
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;
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) {
locale = localeMatch[1] as Locale;
messages[locale] = [];
@@ -68,7 +70,7 @@ function parseLocalizedMessages(source: string): Partial<Record<Locale, string[]
}
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 [randomMessage, setRandomMessage] = useState<string | null>(null);
@@ -93,7 +95,7 @@ export function HttpStatusPage({ code }: { code: HttpStatusCode }) {
}, [code]);
useEffect(() => {
const messages = randomMessages[locale] ?? randomMessages["zh-CN"];
const messages = randomMessages[locale] ?? randomMessages["zh-Hans"];
if (!messages?.length) return;
setRandomMessage(messages[Math.floor(Math.random() * messages.length)]);
}, [locale, randomMessages]);
@@ -103,7 +105,7 @@ export function HttpStatusPage({ code }: { code: HttpStatusCode }) {
window.history.back();
return;
}
window.location.assign("/");
window.location.assign(withLocale("/", locale));
};
return (
+9 -9
View File
@@ -1,6 +1,6 @@
import fs from "fs/promises";
import path from "path";
import type { Locale } from "@/lib/i18n";
import { localeContentDirectory, type Locale } from "@/lib/i18n";
import { markdownToHtml } from "@/lib/markdown";
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[]> {
const directory = path.join(process.cwd(), "src", "data", "news", "posts", locale);
const directory = path.join(process.cwd(), "src", "data", "news", "posts", localeContentDirectory[locale]);
try {
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);
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[]>> {
const source = await readNewsList("zh-CN");
const entries = await Promise.all((["zh-CN", "zh-TW", "en", "ja"] as Locale[]).map(async (locale) => {
if (locale === "zh-CN") return [locale, source] as const;
const source = await readNewsList("zh-Hans");
const entries = await Promise.all((["zh-Hans", "zh-Hant", "en", "jp"] as Locale[]).map(async (locale) => {
if (locale === "zh-Hans") return [locale, source] as const;
const translated = await readNewsList(locale);
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[]>;
}
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);
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);
return items.slice(0, 3);
}
+5 -5
View File
@@ -1,6 +1,6 @@
import fs from "fs/promises";
import path from "path";
import type { Locale } from "@/lib/i18n";
import { localeContentDirectory, type Locale } from "@/lib/i18n";
import { parseFrontmatter } from "@/data/news";
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> {
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 {
const body = await fs.readFile(filePath, "utf-8");
@@ -38,7 +38,7 @@ async function readPolicy(slug: PolicySlug, locale: Locale): Promise<PolicyDocum
translationLabel,
body: content,
html: markdownToHtml(content),
markdownFile: `src/data/policy/${locale}/${slug}.md`,
markdownFile: `src/data/policy/${localeContentDirectory[locale]}/${slug}.md`,
};
} catch {
return null;
@@ -49,7 +49,7 @@ export async function getPolicyCatalog(slug: string): Promise<Partial<Record<Loc
if (!isPolicySlug(slug)) return {};
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>>;
@@ -57,4 +57,4 @@ export async function getPolicyCatalog(slug: string): Promise<Partial<Record<Loc
export function getPolicySlugs() {
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 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];
// 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> = {
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"zh-Hans": "简体中文",
"zh-Hant": "繁體中文",
en: "English",
ja: "日本語",
jp: "日本語",
};
export const localeShortLabels: Record<Locale, string> = {
"zh-CN": "简",
"zh-TW": "繁",
"zh-Hans": "简",
"zh-Hant": "繁",
en: "EN",
ja: "JP",
jp: "JP",
};
export const translations = {
"zh-CN": zhCN,
"zh-TW": zhTW,
"zh-Hans": zhCN,
"zh-Hant": zhTW,
en,
ja,
jp: ja,
} as const;
export function applyLocale(locale: Locale) {
@@ -32,23 +71,38 @@ export function applyLocale(locale: Locale) {
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.setAttribute("data-locale", locale);
window.dispatchEvent(new CustomEvent("kr-locale-change", { detail: { 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;
if (saved && locales.includes(saved)) return saved;
const pathLocale = getPathLocale(window.location.pathname);
if (pathLocale) return pathLocale;
const urlLocale = matchLocale(new URLSearchParams(window.location.search).get("lang"));
if (urlLocale) return urlLocale;
const browserLanguage = navigator.language.toLowerCase();
if (browserLanguage.startsWith("zh-tw") || browserLanguage.startsWith("zh-hk")) return "zh-TW";
if (browserLanguage.startsWith("en")) return "en";
if (browserLanguage.startsWith("ja")) return "ja";
return "zh-CN";
try {
const saved = matchLocale(window.localStorage.getItem("kr-locale"));
if (saved) return saved;
} catch { /* Fall back to the browser language. */ }
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) {
@@ -56,7 +110,7 @@ export function getLocaleMessages(locale: Locale) {
}
export function getAiTranslationNotice(locale: Locale) {
if (locale === "zh-TW") {
if (locale === "zh-Hant") {
return "此文章由 AI 從簡體中文翻譯而成,如有任何歧義,以簡體中文版本為準。";
}