mirror of
https://github.com/KivotosRailway/website.git
synced 2026-11-03 11:06:32 +09:00
i18n 代码进入URL路由,优化重定向规则
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
- 人それぞれ、喜びも悲しみも違う
|
- 人それぞれ、喜びも悲しみも違う
|
||||||
- ここを通る列車はありません
|
- ここを通る列車はありません
|
||||||
- この路線はまだ開通していません
|
- この路線はまだ開通していません
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/(legacy)/403/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/(legacy)/500/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/(legacy)/502/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/(legacy)/503/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/(legacy)/504/page";
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, generateMetadata, generateStaticParams } from "@/app/(legacy)/news/[slug]/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, generateMetadata } from "@/app/(legacy)/news/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/(legacy)/not-found";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "@/app/(legacy)/page";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, generateMetadata, generateStaticParams } from "@/app/(legacy)/policy/[slug]/page";
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>>;
|
||||||
|
|||||||
+74
-20
@@ -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 從簡體中文翻譯而成,如有任何歧義,以簡體中文版本為準。";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user