优化页面数据加载并完善多语言 SEO、新闻回退、主题首屏及自动化回归测试

This commit is contained in:
2026-10-22 19:01:02 +08:00
parent 7eb97cc5e3
commit 873da8b77c
53 changed files with 774 additions and 362 deletions
+2
View File
@@ -1,5 +1,7 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export const metadata = { title: "403", robots: { index: false, follow: false } };
export default function ForbiddenPage() {
return <HttpStatusPage code={403} />;
}
+2
View File
@@ -1,5 +1,7 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export const metadata = { title: "500", robots: { index: false, follow: false } };
export default function InternalServerErrorPage() {
return <HttpStatusPage code={500} />;
}
+2
View File
@@ -1,5 +1,7 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export const metadata = { title: "502", robots: { index: false, follow: false } };
export default function BadGatewayPage() {
return <HttpStatusPage code={502} />;
}
+2
View File
@@ -1,5 +1,7 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export const metadata = { title: "503", robots: { index: false, follow: false } };
export default function ServiceUnavailablePage() {
return <HttpStatusPage code={503} />;
}
+2
View File
@@ -1,5 +1,7 @@
import { HttpStatusPage } from "@/components/status/http-status-page";
export const metadata = { title: "504", robots: { index: false, follow: false } };
export default function GatewayTimeoutPage() {
return <HttpStatusPage code={504} />;
}
+5 -4
View File
@@ -1,8 +1,9 @@
import { HomePageContent } from "@/components/home/home-page-content";
import { getNewsCatalog } from "@/data/news";
import { getLatestNews } from "@/data/news";
import { pageMetadata } from "@/lib/seo";
export const metadata = pageMetadata("zh-Hans", "/");
export default async function Home() {
const newsByLocale = await getNewsCatalog();
return <HomePageContent newsByLocale={newsByLocale} />;
return <HomePageContent locale="zh-Hans" news={await getLatestNews("zh-Hans")} />;
}
+1 -1
View File
@@ -1 +1 @@
export { default } from "@/app/(legacy)/403/page";
export { default, metadata } from "@/app/(legacy)/403/page";
+1 -1
View File
@@ -1 +1 @@
export { default } from "@/app/(legacy)/500/page";
export { default, metadata } from "@/app/(legacy)/500/page";
+1 -1
View File
@@ -1 +1 @@
export { default } from "@/app/(legacy)/502/page";
export { default, metadata } from "@/app/(legacy)/502/page";
+1 -1
View File
@@ -1 +1 @@
export { default } from "@/app/(legacy)/503/page";
export { default, metadata } from "@/app/(legacy)/503/page";
+1 -1
View File
@@ -1 +1 @@
export { default } from "@/app/(legacy)/504/page";
export { default, metadata } from "@/app/(legacy)/504/page";
+2 -1
View File
@@ -1,3 +1,4 @@
import { pageMetadata } from "@/lib/seo";
import { notFound } from "next/navigation";
import { AboutPage } from "@/components/about/about-page";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return { title: getLocaleMessages(locale).footer.columns[1].links[0] };
return pageMetadata(locale, "/about", { title: getLocaleMessages(locale).footer.columns[1].links[0] });
}
export default async function AboutRoute({ params }: { params: Promise<{ locale: string }> }) {
+2 -1
View File
@@ -1,3 +1,4 @@
import { pageMetadata } from "@/lib/seo";
import { notFound } from "next/navigation";
import { SimpleTitlePage } from "@/components/pages/simple-title-page";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return { title: getLocaleMessages(locale).header.nav[3] };
return pageMetadata(locale, "/city", { title: getLocaleMessages(locale).header.nav[3] });
}
export default async function CityPage({ params }: { params: Promise<{ locale: string }> }) {
+2 -1
View File
@@ -1,3 +1,4 @@
import { pageMetadata } from "@/lib/seo";
import { notFound } from "next/navigation";
import { SimpleTitlePage } from "@/components/pages/simple-title-page";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return { title: getLocaleMessages(locale).footer.columns[1].links[2] };
return pageMetadata(locale, "/join-us", { title: getLocaleMessages(locale).footer.columns[1].links[2] });
}
export default async function JoinUsPage({ params }: { params: Promise<{ locale: string }> }) {
+5 -7
View File
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation";
import { SiteShell } from "@/components/layout/site-shell";
import { getLocaleMessages, locales, matchLocale } from "@/lib/i18n";
import { getLocaleMessages, locales, isLocale } from "@/lib/i18n";
export const dynamicParams = false;
@@ -10,9 +10,8 @@ export function generateStaticParams() {
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;
if (!isLocale(locale)) notFound();
const title = getLocaleMessages(locale).common.siteTitle;
return { title: { default: title, template: `%s | ${title}` }, icons: { icon: "/icon/icon.svg" } };
}
@@ -21,7 +20,6 @@ export default async function LocaleLayout({ children, params }: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const canonicalLocale = matchLocale(locale);
if (!canonicalLocale) notFound();
return <SiteShell locale={canonicalLocale}>{children}</SiteShell>;
if (!isLocale(locale)) notFound();
return <SiteShell locale={locale}>{children}</SiteShell>;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { pageMetadata } from "@/lib/seo";
import { notFound } from "next/navigation";
import { FriendlyLinksPage } from "@/components/links/friendly-links-page";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return { title: getLocaleMessages(locale).footer.legal[2] };
return pageMetadata(locale, "/links", { title: getLocaleMessages(locale).footer.legal[2] });
}
export default async function LinksPage({ params }: { params: Promise<{ locale: string }> }) {
+2 -1
View File
@@ -1,3 +1,4 @@
import { pageMetadata } from "@/lib/seo";
import { notFound } from "next/navigation";
import { MembersPage } from "@/components/members/members-page";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return { title: getLocaleMessages(locale).footer.columns[1].links[1] };
return pageMetadata(locale, "/members", { title: getLocaleMessages(locale).footer.columns[1].links[1] });
}
export default async function MembersRoute({ params }: { params: Promise<{ locale: string }> }) {
+6 -2
View File
@@ -2,6 +2,7 @@ import { notFound } from "next/navigation";
import { getNewsBySlug, getNewsList } from "@/data/news";
import { NewsDetailClient } from "@/components/news/news-detail-client";
import { isLocale } from "@/lib/i18n";
import { descriptionFromMarkdown, pageMetadata } from "@/lib/seo";
export async function generateStaticParams() {
return (await getNewsList()).map((item) => ({ slug: item.slug }));
@@ -11,11 +12,14 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
const { slug, locale } = await params;
if (!isLocale(locale)) notFound();
const item = await getNewsBySlug(slug, locale);
return { title: item?.title ?? "新闻" };
if (!item) notFound();
return pageMetadata(locale, item.href, { title: item.title, description: descriptionFromMarkdown(item.body), article: true });
}
export default async function NewsDetailPage({ params }: { params: Promise<{ slug: string; locale: string }> }) {
const { slug, locale } = await params;
if (!isLocale(locale)) notFound();
return <NewsDetailClient item={await getNewsBySlug(slug, locale)} locale={locale} />;
const item = await getNewsBySlug(slug, locale);
if (!item) notFound();
return <NewsDetailClient item={item} locale={locale} />;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { pageMetadata } from "@/lib/seo";
import { notFound } from "next/navigation";
import { getNewsList } from "@/data/news";
import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
@@ -8,7 +9,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return { title: getLocaleMessages(locale).common.news };
return pageMetadata(locale, "/news", { title: getLocaleMessages(locale).common.news });
}
export default async function NewsPage({ params }: { params: Promise<{ locale: string }> }) {
+17 -1
View File
@@ -1 +1,17 @@
export { default } from "@/app/(legacy)/page";
import { notFound } from "next/navigation";
import { HomePageContent } from "@/components/home/home-page-content";
import { getLatestNews } from "@/data/news";
import { isLocale } from "@/lib/i18n";
import { pageMetadata } from "@/lib/seo";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return pageMetadata(locale, "/");
}
export default async function Home({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return <HomePageContent locale={locale} news={await getLatestNews(locale)} />;
}
+8 -6
View File
@@ -1,8 +1,9 @@
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 { getPolicyDocument, getPolicySlugs } from "@/data/policy";
import { isLocale } from "@/lib/i18n";
import { descriptionFromMarkdown, pageMetadata } from "@/lib/seo";
export const dynamicParams = false;
@@ -13,14 +14,15 @@ export function generateStaticParams() {
export async function generateMetadata({ params }: { params: Promise<{ slug: string; locale: string }> }): Promise<Metadata> {
const { slug, locale } = await params;
if (!isLocale(locale)) notFound();
const catalog = await getPolicyCatalog(slug);
return { title: catalog[locale]?.title ?? "法律信息" };
const document = await getPolicyDocument(slug, locale);
if (!document) notFound();
return pageMetadata(locale, `/policy/${slug}`, { title: document.title, description: descriptionFromMarkdown(document.body) });
}
export default async function PolicyPage({ params }: { params: Promise<{ slug: string; locale: string }> }) {
const { slug, locale } = await params;
if (!isLocale(locale)) notFound();
const catalog = await getPolicyCatalog(slug);
if (!catalog["zh-Hans"]) notFound();
return <PolicyPageContent catalog={catalog} />;
const document = await getPolicyDocument(slug, locale);
if (!document) notFound();
return <PolicyPageContent document={document} locale={locale} />;
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { pageMetadata } from "@/lib/seo";
import { notFound } from "next/navigation";
import { SimpleTitlePage } from "@/components/pages/simple-title-page";
import { getLocaleMessages, isLocale } from "@/lib/i18n";
@@ -5,7 +6,7 @@ import { getLocaleMessages, isLocale } from "@/lib/i18n";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
if (!isLocale(locale)) notFound();
return { title: getLocaleMessages(locale).header.nav[2] };
return pageMetadata(locale, "/railway", { title: getLocaleMessages(locale).header.nav[2] });
}
export default async function RailwayPage({ params }: { params: Promise<{ locale: string }> }) {
+8
View File
@@ -0,0 +1,8 @@
import type { MetadataRoute } from "next";
import { siteOrigin } from "@/lib/seo";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return { rules: { userAgent: "*", allow: "/" }, sitemap: `${siteOrigin}/sitemap.xml` };
}
+18
View File
@@ -0,0 +1,18 @@
import type { MetadataRoute } from "next";
import { getNewsList } from "@/data/news";
import { getPolicySlugs } from "@/data/policy";
import { locales } from "@/lib/i18n";
import { languageAlternates, localizedUrl } from "@/lib/seo";
export const dynamic = "force-static";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const paths = ["/", "/news", "/about", "/railway", "/city", "/join-us", "/members", "/links",
...getPolicySlugs().map(({ slug }) => `/policy/${slug}`),
...(await getNewsList()).map(({ href }) => href),
];
return paths.flatMap((href) => locales.map((locale) => ({
url: localizedUrl(href, locale),
alternates: { languages: languageAlternates(href) },
})));
}
+4 -40
View File
@@ -1,12 +1,8 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { PageActionCards } from "@/components/layout/page-action-cards";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { NewsItem } from "@/data/news";
import { applyLocale, getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import type { NewsSummary } from "@/data/news";
import { withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
const homeFeatureCopy: Record<Locale, {
railway: { title: [string, string]; description: [string, string] };
@@ -30,40 +26,8 @@ const homeFeatureCopy: Record<Locale, {
},
};
export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale, NewsItem[]> }) {
const [locale, setLocale] = useState<Locale>(useRouteLocale());
useEffect(() => {
const syncLocale = (nextLocale: Locale) => setLocale(nextLocale);
const currentLocale = getInitialLocale();
syncLocale(currentLocale);
applyLocale(currentLocale);
const onLocaleChange = (event: Event) => {
const detail = (event as CustomEvent<{ locale?: Locale }>).detail;
if (detail?.locale) {
setLocale(detail.locale);
}
};
const handleStorage = () => {
const nextLocale = getInitialLocale();
syncLocale(nextLocale);
applyLocale(nextLocale);
};
window.addEventListener("kr-locale-change", onLocaleChange);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("kr-locale-change", onLocaleChange);
window.removeEventListener("storage", handleStorage);
};
}, []);
export function HomePageContent({ news, locale }: { news: NewsSummary[]; locale: Locale }) {
const t = getLocaleMessages(locale);
const visibleNews = newsByLocale[locale] ?? newsByLocale["zh-Hans"] ?? [];
const features = homeFeatureCopy[locale];
return (
@@ -116,7 +80,7 @@ export function HomePageContent({ newsByLocale }: { newsByLocale: Record<Locale,
</div>
<div className="news-grid" aria-label={t.home.latest}>
{visibleNews.slice(0, 3).map((post) => (
{news.map((post) => (
<article key={post.id} className="news-card">
<Link href={withLocale(post.href, locale)} className="news-card-link" aria-label={post.title}>
<div className="news-visual" style={{ backgroundImage: `url(${post.cover})` }} />
+3 -1
View File
@@ -1,11 +1,13 @@
import type { Metadata, Viewport } from "next";
import { siteOrigin } from "@/lib/seo";
export const metadata: Metadata = {
metadataBase: new URL(siteOrigin),
title: {
default: "基沃托斯铁道公团 KivotosRailway",
template: "%s | 基沃托斯铁道公团 KivotosRailway",
},
description: "Kivotos Railway website",
description: "基沃托斯铁道公团官方网站,提供 Minecraft 铁路与城市建设、新闻、团队和加入信息。",
icons: { icon: "/icon/icon.svg" },
};
+2 -40
View File
@@ -5,8 +5,8 @@ import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { applyLocale, getInitialLocale, withLocale, withoutLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
import { useState } from "react";
import { withLocale, getLocaleMessages, localeLabels, locales, localeShortLabels, type Locale } from "@/lib/i18n";
export function SiteHeader() {
const locale = useRouteLocale();
@@ -22,44 +22,6 @@ export function SiteHeader() {
router.replace(withLocale(href, nextLocale));
};
useEffect(() => {
const updateDocumentTitle = (nextLocale: Locale) => {
const messages = getLocaleMessages(nextLocale);
const pathname = withoutLocale(window.location.pathname).replace(/\/$/, "") || "/";
const currentTitle = document.title.split(" | ")[0];
const pageLabel = pathname === "/"
? ""
: pathname === "/news"
? messages.common.news
: currentTitle;
document.title = pageLabel ? `${pageLabel} | ${messages.common.siteTitle}` : messages.common.siteTitle;
};
const syncLocale = (nextLocale: Locale) => updateDocumentTitle(nextLocale);
const handleLocaleChange = (event: Event) => {
const nextLocale = (event as CustomEvent<{ locale?: Locale }>).detail?.locale ?? getInitialLocale();
syncLocale(nextLocale);
};
const handleStorage = () => {
syncLocale(getInitialLocale());
};
const currentLocale = getInitialLocale();
syncLocale(currentLocale);
applyLocale(currentLocale);
window.addEventListener("kr-locale-change", handleLocaleChange);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener("kr-locale-change", handleLocaleChange);
window.removeEventListener("storage", handleStorage);
};
}, []);
const messages = getLocaleMessages(locale);
return (
+4 -38
View File
@@ -3,47 +3,13 @@
import { useRouteLocale } from "@/components/layout/locale-context";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { applyTheme, getInitialThemePreference, type ThemePreference } from "@/lib/theme";
import { useEffect, useState } from "react";
import { getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
type ThemePreference = "light" | "system" | "dark";
type ResolvedTheme = "light" | "dark";
const themeCookieName = "kr-theme";
function getThemeCookie(): ThemePreference | null {
const value = document.cookie
.split(";")
.map((entry) => entry.trim())
.find((entry) => entry.startsWith(`${themeCookieName}=`))
?.slice(themeCookieName.length + 1);
return value === "light" || value === "system" || value === "dark" ? value : null;
}
function getInitialThemePreference(): ThemePreference {
if (typeof window === "undefined") return "system";
const cookieTheme = getThemeCookie();
if (cookieTheme) return cookieTheme;
const savedTheme = window.localStorage.getItem("kr-theme");
return savedTheme === "light" || savedTheme === "system" || savedTheme === "dark" ? savedTheme : "system";
}
function resolveTheme(preference: ThemePreference): ResolvedTheme {
if (preference !== "system") return preference;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(preference: ThemePreference) {
const theme = resolveTheme(preference);
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
window.localStorage.setItem("kr-theme", preference);
document.cookie = `${themeCookieName}=${preference}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
export function ThemeToggle() {
const pathname = usePathname();
const [themePreference, setThemePreference] = useState<ThemePreference>("system");
const [locale, setLocale] = useState<Locale>(useRouteLocale());
@@ -70,7 +36,7 @@ export function ThemeToggle() {
window.removeEventListener("storage", syncTheme);
mediaQuery.removeEventListener("change", syncSystemTheme);
};
}, []);
}, [pathname]);
const messages = getLocaleMessages(locale);
const options: { theme: ThemePreference; label: string; icon: string }[] = [
+2 -6
View File
@@ -5,13 +5,9 @@ import { markdownToHtml } from "@/lib/markdown";
import { PageBreadcrumb } from "@/components/layout/page-breadcrumb";
import { NewsShare } from "@/components/news/news-share";
export function NewsDetailClient({ item, locale }: { item: NewsItem | null; locale: Locale }) {
export function NewsDetailClient({ item, locale }: { item: NewsItem; locale: Locale }) {
const messages = getLocaleMessages(locale);
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">
@@ -20,7 +16,7 @@ export function NewsDetailClient({ item, locale }: { item: NewsItem | null; loca
<p className="news-detail-tag">{item.category}</p>
<h1>{item.title}</h1>
<time>{item.date}</time>
{locale !== "zh-Hans" && <div className="news-detail-ai-banner" aria-label="AI translated content">{getAiTranslationNotice(locale)}</div>}
{item.contentLocale !== "zh-Hans" && <div className="news-detail-ai-banner" aria-label="AI translated content">{getAiTranslationNotice(locale)}</div>}
<div className="news-detail-cover" style={{ backgroundImage: `url(${item.cover})` }} />
<div className="news-detail-body" dangerouslySetInnerHTML={{ __html: markdownToHtml(item.body) }} />
<NewsShare title={item.title} />
+2 -2
View File
@@ -4,12 +4,12 @@ import { useRouteLocale } from "@/components/layout/locale-context";
import Link from "next/link";
import { useEffect, useState } from "react";
import type { NewsItem } from "@/data/news";
import type { NewsSummary } from "@/data/news";
import { getInitialLocale, withLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
const ITEMS_PER_PAGE = 12;
export function NewsList({ news }: { news: NewsItem[] }) {
export function NewsList({ news }: { news: NewsSummary[] }) {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const [currentPage, setCurrentPage] = useState(1);
+3 -25
View File
@@ -1,34 +1,12 @@
"use client";
import { useRouteLocale } from "@/components/layout/locale-context";
import { useEffect, useState } from "react";
import { getAiTranslationNotice, getInitialLocale, getLocaleMessages, type Locale } from "@/lib/i18n";
import { getAiTranslationNotice, getLocaleMessages, type Locale } from "@/lib/i18n";
import type { PolicyDocument } from "@/data/policy";
import { PageLocation } from "@/components/layout/page-location";
import { PageTitleHero } from "@/components/layout/page-title-hero";
type PolicyCatalog = Partial<Record<Locale, PolicyDocument>>;
export function PolicyPageContent({ catalog }: { catalog: PolicyCatalog }) {
const [locale, setLocale] = useState<Locale>(useRouteLocale());
const document = catalog[locale] ?? catalog["zh-Hans"];
const isTranslated = locale !== "zh-Hans" && Boolean(catalog[locale]);
export function PolicyPageContent({ document, locale }: { document: PolicyDocument; locale: Locale }) {
const isTranslated = document.contentLocale !== "zh-Hans";
const messages = getLocaleMessages(locale);
useEffect(() => {
const syncLocale = () => setLocale(getInitialLocale());
syncLocale();
window.addEventListener("kr-locale-change", syncLocale);
window.addEventListener("storage", syncLocale);
return () => {
window.removeEventListener("kr-locale-change", syncLocale);
window.removeEventListener("storage", syncLocale);
};
}, []);
if (!document) return null;
return (
<>
<main className="title-page policy-page">
+40 -29
View File
@@ -14,8 +14,16 @@ export type NewsItem = {
tags: string[];
markdownFile: string;
body: string;
contentLocale: Locale;
};
export type NewsSummary = Pick<NewsItem, "id" | "slug" | "title" | "date" | "cover" | "href" | "category">;
function summarize(item: NewsItem): NewsSummary {
const { id, slug, title, date, cover, href, category } = item;
return { id, slug, title, date, cover, href, category };
}
type NewsFrontmatter = {
title?: string;
translationLabel?: string;
@@ -66,7 +74,7 @@ function stripFrontmatter(markdown: string): string {
const DEFAULT_NEWS_COVER = "https://assets.kivotos.cc/image/2025/05/01/681397ba4e763.png";
function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string): NewsItem | null {
function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string, locale: Locale): NewsItem | null {
const title = raw.title?.trim();
const date = raw.date?.trim() || "未注明日期";
const category = (Array.isArray(raw.category) ? raw.category[0] : raw.category)?.trim() || "未分类";
@@ -90,11 +98,12 @@ function normalizeNewsItem(fileName: string, raw: NewsFrontmatter, body: string)
tags,
markdownFile: `src/data/news/posts/${fileName}`,
body,
contentLocale: locale,
};
}
async function readNewsList(locale: Locale): Promise<NewsItem[]> {
const directory = path.join(process.cwd(), "src", "data", "news", "posts", localeContentDirectory[locale]);
async function readNewsList(root: string, locale: Locale): Promise<NewsItem[]> {
const directory = path.join(root, localeContentDirectory[locale]);
try {
const files = (await fs.readdir(directory))
@@ -107,7 +116,7 @@ async function readNewsList(locale: Locale): Promise<NewsItem[]> {
const content = await fs.readFile(filePath, "utf-8");
const frontmatter = parseFrontmatter(content);
const body = stripFrontmatter(content);
return normalizeNewsItem(fileName, frontmatter, body);
return normalizeNewsItem(fileName, frontmatter, body, locale);
}),
);
@@ -117,34 +126,36 @@ async function readNewsList(locale: Locale): Promise<NewsItem[]> {
const firstTime = Date.parse(first.date);
return (Number.isNaN(secondTime) ? 0 : secondTime) - (Number.isNaN(firstTime) ? 0 : firstTime);
});
} catch {
return [];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
export async function getNewsList(locale: Locale = "zh-Hans"): Promise<NewsItem[]> {
const items = await readNewsList(locale);
return locale === "zh-Hans" || items.length > 0 ? items : readNewsList("zh-Hans");
}
export async function getNewsCatalog(): Promise<Record<Locale, NewsItem[]>> {
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);
// The source catalog defines public slugs; a missing translation falls back per article.
export function createNewsReader(root: string) {
async function readLocalizedNews(locale: Locale) {
const [source, translated] = await Promise.all([
readNewsList(root, "zh-Hans"),
locale === "zh-Hans" ? Promise.resolve([]) : readNewsList(root, locale),
]);
const bySlug = new Map(translated.map((item) => [item.slug, item]));
return [locale, source.map((item) => bySlug.get(item.slug) ?? item)] as const;
}));
return Object.fromEntries(entries) as Record<Locale, NewsItem[]>;
return source.map((item) => bySlug.get(item.slug) ?? item);
}
return {
async getNewsList(locale: Locale = "zh-Hans"): Promise<NewsSummary[]> {
return (await readLocalizedNews(locale)).map(summarize);
},
async getNewsBySlug(slug: string, locale: Locale = "zh-Hans"): Promise<NewsItem | null> {
return (await readLocalizedNews(locale)).find((item) => item.slug === slug) ?? null;
},
async getLatestNews(locale: Locale = "zh-Hans"): Promise<NewsSummary[]> {
return (await readLocalizedNews(locale)).slice(0, 3).map(summarize);
},
};
}
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-Hans") {
const items = await getNewsList(locale);
return items.slice(0, 3);
}
export const { getNewsList, getNewsBySlug, getLatestNews } = createNewsReader(
path.join(process.cwd(), "src", "data", "news", "posts"),
);
+9 -10
View File
@@ -14,6 +14,7 @@ export type PolicyDocument = {
body: string;
html: string;
markdownFile: string;
contentLocale: Locale;
};
function isPolicySlug(slug: string): slug is PolicySlug {
@@ -39,20 +40,18 @@ async function readPolicy(slug: PolicySlug, locale: Locale): Promise<PolicyDocum
body: content,
html: markdownToHtml(content),
markdownFile: `src/data/policy/${localeContentDirectory[locale]}/${slug}.md`,
contentLocale: locale,
};
} catch {
return null;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
export async function getPolicyCatalog(slug: string): Promise<Partial<Record<Locale, PolicyDocument>>> {
if (!isPolicySlug(slug)) return {};
const entries = await Promise.all(
(["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>>;
export async function getPolicyDocument(slug: string, locale: Locale): Promise<PolicyDocument | null> {
if (!isPolicySlug(slug)) return null;
const document = await readPolicy(slug, locale);
return document ?? (locale === "zh-Hans" ? null : readPolicy(slug, "zh-Hans"));
}
export function getPolicySlugs() {
+6 -1
View File
@@ -6,6 +6,11 @@ import ja from "@/locales/ja.json";
export const locales = ["zh-Hans", "zh-Hant", "en", "jp"] as const;
export type Locale = (typeof locales)[number];
// Public route /jp/ is retained; HTML and hreflang use the BCP 47 language code.
export const localeLanguageTags: Record<Locale, string> = {
"zh-Hans": "zh-Hans", "zh-Hant": "zh-Hant", en: "en", jp: "ja",
};
// 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",
@@ -80,7 +85,7 @@ export function applyLocale(locale: Locale) {
window.location.replace(localizedHref);
return;
}
document.documentElement.lang = locale;
document.documentElement.lang = localeLanguageTags[locale];
document.documentElement.setAttribute("data-locale", locale);
window.dispatchEvent(new CustomEvent("kr-locale-change", { detail: { locale } }));
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next";
import { getLocaleMessages, localeLanguageTags, locales, withLocale, type Locale } from "@/lib/i18n";
export const siteOrigin = new URL(process.env.NEXT_PUBLIC_SITE_URL || "https://www.kivotos.cc").origin;
const descriptions: Record<Locale, string> = {
"zh-Hans": "基沃托斯铁道公团官方网站。了解我们的 Minecraft 铁路与城市建设,阅读最新新闻,认识团队并获取加入信息。",
"zh-Hant": "基沃托斯鐵道公團官方網站。了解我們的 Minecraft 鐵路與城市建設,閱讀最新消息,認識團隊並取得加入資訊。",
en: "The official KivotosRailway website. Explore our Minecraft railways and cities, read the latest news, meet the team, and find information about joining us.",
jp: "キヴォトス鉄道公団の公式サイト。Minecraft の鉄道・都市開発、最新ニュース、メンバー紹介、参加に関する情報をお届けします。",
};
const openGraphLocales: Record<Locale, string> = {
"zh-Hans": "zh_CN", "zh-Hant": "zh_TW", en: "en_US", jp: "ja_JP",
};
export function localizedUrl(href: string, locale: Locale) {
return new URL(withLocale(href, locale), siteOrigin).href;
}
export function languageAlternates(href: string) {
return Object.fromEntries(locales.map((locale) => [localeLanguageTags[locale], localizedUrl(href, locale)]));
}
export function descriptionFromMarkdown(markdown: string) {
return markdown
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/[#*_`>|]/g, "")
.replace(/\s+/g, " ").trim().slice(0, 160);
}
export function pageMetadata(locale: Locale, href: string, options: {
title?: string;
description?: string;
article?: boolean;
} = {}): Metadata {
const siteTitle = getLocaleMessages(locale).common.siteTitle;
const title = options.title ? `${options.title} | ${siteTitle}` : siteTitle;
const description = options.description || (options.title ? `${options.title} — ${descriptions[locale]}` : descriptions[locale]);
const url = localizedUrl(href, locale);
const image = new URL("/home-hero-scene.webp", siteOrigin).href;
return {
title: { absolute: title },
description,
alternates: { canonical: url, languages: { ...languageAlternates(href), "x-default": localizedUrl(href, "zh-Hans") } },
openGraph: {
type: options.article ? "article" : "website",
title, description, url, siteName: siteTitle,
locale: openGraphLocales[locale],
alternateLocale: locales.filter((item) => item !== locale).map((item) => openGraphLocales[item]),
images: [{ url: image, width: 1440, height: 743, alt: siteTitle }],
},
twitter: { card: "summary_large_image", title, description, images: [image] },
};
}
+31
View File
@@ -0,0 +1,31 @@
export type ThemePreference = "light" | "system" | "dark";
function isTheme(value: string | null | undefined): value is ThemePreference {
return value === "light" || value === "dark" || value === "system";
}
export function getInitialThemePreference(): ThemePreference {
try {
const cookie = document.cookie.split(";").map((entry) => entry.trim())
.find((entry) => entry.startsWith("kr-theme="))?.slice("kr-theme=".length);
if (isTheme(cookie)) return cookie;
} catch { /* Storage may be disabled independently of cookies. */ }
try {
const saved = window.localStorage.getItem("kr-theme");
if (isTheme(saved)) return saved;
} catch { /* System preference works without browser storage. */ }
return "system";
}
export function applyTheme(preference: ThemePreference) {
const theme = preference === "system"
? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
: preference;
const color = theme === "dark" ? "#10151d" : "#ffffff";
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document.documentElement.style.backgroundColor = color;
document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => meta.setAttribute("content", color));
try { window.localStorage.setItem("kr-theme", preference); } catch { /* Keep the current theme usable. */ }
try { document.cookie = `kr-theme=${preference}; Path=/; Max-Age=31536000; SameSite=Lax`; } catch { /* Optional persistence. */ }
}