diff --git a/app/admin/community/layout.tsx b/app/[locale]/admin/community/layout.tsx similarity index 100% rename from app/admin/community/layout.tsx rename to app/[locale]/admin/community/layout.tsx diff --git a/app/admin/community/lib.ts b/app/[locale]/admin/community/lib.ts similarity index 96% rename from app/admin/community/lib.ts rename to app/[locale]/admin/community/lib.ts index 463a0dc3..445b95fe 100644 --- a/app/admin/community/lib.ts +++ b/app/[locale]/admin/community/lib.ts @@ -10,7 +10,7 @@ * 对应后端:/api/admin/community/* (走 @SaCheckRole("admin")) */ -import type { SharedLinkView } from "@/app/feed/types"; +import type { SharedLinkView } from "@/app/[locale]/feed/types"; interface ApiResponse { success: boolean; diff --git a/app/admin/community/page.tsx b/app/[locale]/admin/community/page.tsx similarity index 98% rename from app/admin/community/page.tsx rename to app/[locale]/admin/community/page.tsx index 04d4bc86..9415a604 100644 --- a/app/admin/community/page.tsx +++ b/app/[locale]/admin/community/page.tsx @@ -12,8 +12,8 @@ */ import { useEffect, useState } from "react"; -import { AdminGuard } from "@/app/admin/events/AdminGuard"; -import type { SharedLinkView } from "@/app/feed/types"; +import { AdminGuard } from "@/app/[locale]/admin/events/AdminGuard"; +import type { SharedLinkView } from "@/app/[locale]/feed/types"; import { sanitizeExternalUrl, sanitizeMediaUrl } from "@/lib/url-safety"; import { approveLink, listPendingLinks, rejectLink } from "./lib"; diff --git a/app/admin/database/page.tsx b/app/[locale]/admin/database/page.tsx similarity index 100% rename from app/admin/database/page.tsx rename to app/[locale]/admin/database/page.tsx diff --git a/app/admin/events/AdminGuard.tsx b/app/[locale]/admin/events/AdminGuard.tsx similarity index 100% rename from app/admin/events/AdminGuard.tsx rename to app/[locale]/admin/events/AdminGuard.tsx diff --git a/app/admin/events/EventForm.tsx b/app/[locale]/admin/events/EventForm.tsx similarity index 98% rename from app/admin/events/EventForm.tsx rename to app/[locale]/admin/events/EventForm.tsx index b1c4f63a..2e199606 100644 --- a/app/admin/events/EventForm.tsx +++ b/app/[locale]/admin/events/EventForm.tsx @@ -14,7 +14,11 @@ import { useState, useRef, type FormEvent } from "react"; import { useRouter } from "next/navigation"; -import type { EventRequest, EventView, EventStatus } from "@/app/events/types"; +import type { + EventRequest, + EventView, + EventStatus, +} from "@/app/[locale]/events/types"; import { createEvent, updateEvent } from "./lib"; interface Props { diff --git a/app/admin/events/[id]/edit/page.tsx b/app/[locale]/admin/events/[id]/edit/page.tsx similarity index 98% rename from app/admin/events/[id]/edit/page.tsx rename to app/[locale]/admin/events/[id]/edit/page.tsx index cd09da75..378d4629 100644 --- a/app/admin/events/[id]/edit/page.tsx +++ b/app/[locale]/admin/events/[id]/edit/page.tsx @@ -9,7 +9,7 @@ import { use, useEffect, useState } from "react"; import Link from "next/link"; -import type { EventView } from "@/app/events/types"; +import type { EventView } from "@/app/[locale]/events/types"; import { AdminGuard } from "../../AdminGuard"; import { EventForm } from "../../EventForm"; import { getAdminEvent } from "../../lib"; diff --git a/app/admin/events/layout.tsx b/app/[locale]/admin/events/layout.tsx similarity index 78% rename from app/admin/events/layout.tsx rename to app/[locale]/admin/events/layout.tsx index 30c8f8b2..ebe9d607 100644 --- a/app/admin/events/layout.tsx +++ b/app/[locale]/admin/events/layout.tsx @@ -6,6 +6,10 @@ import type { ReactNode } from "react"; * 之前这里单独挂 Header / Footer 是因为当时还没有 /admin/layout.tsx。现在根 admin * 已经有共享 layout,这层只是透传,保留文件是为了 Next 路由分段还能命中。 */ -export default function AdminEventsLayout({ children }: { children: ReactNode }) { +export default function AdminEventsLayout({ + children, +}: { + children: ReactNode; +}) { return <>{children}; } diff --git a/app/admin/events/lib.ts b/app/[locale]/admin/events/lib.ts similarity index 96% rename from app/admin/events/lib.ts rename to app/[locale]/admin/events/lib.ts index 81b2f6a6..a7f5798b 100644 --- a/app/admin/events/lib.ts +++ b/app/[locale]/admin/events/lib.ts @@ -9,7 +9,7 @@ * - 避免 SSR 缓存污染 admin 视角的数据 */ -import type { EventRequest, EventView } from "@/app/events/types"; +import type { EventRequest, EventView } from "@/app/[locale]/events/types"; interface ApiResponse { success: boolean; diff --git a/app/admin/events/new/page.tsx b/app/[locale]/admin/events/new/page.tsx similarity index 100% rename from app/admin/events/new/page.tsx rename to app/[locale]/admin/events/new/page.tsx diff --git a/app/admin/events/page.tsx b/app/[locale]/admin/events/page.tsx similarity index 99% rename from app/admin/events/page.tsx rename to app/[locale]/admin/events/page.tsx index 6dca1c28..f55085fb 100644 --- a/app/admin/events/page.tsx +++ b/app/[locale]/admin/events/page.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import type { EventView } from "@/app/events/types"; +import type { EventView } from "@/app/[locale]/events/types"; import { AdminGuard } from "./AdminGuard"; import { deleteEvent, listAdminEvents } from "./lib"; diff --git a/app/admin/layout.tsx b/app/[locale]/admin/layout.tsx similarity index 100% rename from app/admin/layout.tsx rename to app/[locale]/admin/layout.tsx diff --git a/app/admin/page.tsx b/app/[locale]/admin/page.tsx similarity index 100% rename from app/admin/page.tsx rename to app/[locale]/admin/page.tsx diff --git a/app/admin/users/lib.ts b/app/[locale]/admin/users/lib.ts similarity index 100% rename from app/admin/users/lib.ts rename to app/[locale]/admin/users/lib.ts diff --git a/app/admin/users/page.tsx b/app/[locale]/admin/users/page.tsx similarity index 99% rename from app/admin/users/page.tsx rename to app/[locale]/admin/users/page.tsx index 8a5161b5..9be70d29 100644 --- a/app/admin/users/page.tsx +++ b/app/[locale]/admin/users/page.tsx @@ -107,8 +107,8 @@ function AdminUsersInner() { 用户管理

- 勾选 admin 即赋予管理员角色;取消即撤销。superadmin 角色不允许在这里改, - 只能走 DB。 + 勾选 admin 即赋予管理员角色;取消即撤销。superadmin + 角色不允许在这里改, 只能走 DB。

diff --git a/app/docs/[...slug]/page.tsx b/app/[locale]/docs/[...slug]/page.tsx similarity index 57% rename from app/docs/[...slug]/page.tsx rename to app/[locale]/docs/[...slug]/page.tsx index ab699a1c..2a13a164 100644 --- a/app/docs/[...slug]/page.tsx +++ b/app/[locale]/docs/[...slug]/page.tsx @@ -3,6 +3,8 @@ import { SITE_URL } from "@/lib/site-url"; import { DocsPage, DocsBody } from "fumadocs-ui/page"; import { notFound } from "next/navigation"; import type { Metadata } from "next"; +import { setRequestLocale } from "next-intl/server"; +import { hasLocale } from "next-intl"; import { getMDXComponents } from "@/mdx-components"; import { GiscusComments } from "@/app/components/GiscusComments"; import { EditOnGithub } from "@/app/components/EditOnGithub"; @@ -17,71 +19,35 @@ import { LicenseNotice } from "@/app/components/LicenseNotice"; import { PageFeedback } from "@/app/components/PageFeedback"; import { DocHistoryPanel } from "@/app/components/DocHistoryPanel"; import { DocShareButton } from "@/app/components/DocShareButton"; -import { cookies } from "next/headers"; +import { routing } from "@/i18n/routing"; import { type PageData } from "@/app/types/doc"; -// Extract clean text content from MDX - no longer used on client/page side -// content fetching moved to API route for performance interface Param { params: Promise<{ + locale: string; slug?: string[]; }>; } -/** 从 cookie 读取用户语言偏好,未设置时返回 null */ -async function getLocaleFromCookie(): Promise<"zh" | "en" | null> { - const cookieStore = await cookies(); - const val = cookieStore.get("locale")?.value; - if (val === "zh" || val === "en") return val; - return null; -} - -/** - * 根据 locale 尝试加载对应语言版本的文档。 - * 翻译文件命名规则:原文 slug 最后一段加上语言后缀,例如 - * slug = ["ai", "rl"] → 英文版尝试 ["ai", "rl.en"] - * - * 若对应翻译版不存在,fallback 到原文。 - */ -function getPageWithLocale( - slug: string[] | undefined, - locale: "zh" | "en" | null, -) { - const originalPage = source.getPage(slug); - if (!locale || !slug || slug.length === 0) - return { page: originalPage, isFallback: false }; - - const originalLang = - (originalPage?.data as PageData | undefined)?.lang ?? null; - - // 已经是目标语言,直接返回 - if (originalLang === locale) return { page: originalPage, isFallback: false }; - - // 尝试加载翻译版:slug 末尾加语言后缀 - const lastSegment = slug[slug.length - 1]; - const translatedSlug = [...slug.slice(0, -1), `${lastSegment}.${locale}`]; - const translatedPage = source.getPage(translatedSlug); - - if (translatedPage) { - return { page: translatedPage, isFallback: false }; - } - - // 翻译版不存在,fallback 到原文 - return { page: originalPage, isFallback: true }; -} +// 显式声明 force-static:让 Next.js 严格按 generateStaticParams 预渲染 +// 所有 (locale, slug) 组合,未列出的不允许动态生成。 +// 没有这条时,build 表里 ƒ Dynamic 标签会让 docs 走运行时渲染(即使加了 +// setRequestLocale 也不一定 prerender)。 +export const dynamic = "force-static"; export default async function DocPage({ params }: Param) { - const { slug } = await params; - const locale = await getLocaleFromCookie(); - const { page } = getPageWithLocale(slug, locale); - + const { locale, slug } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + // 启用 SSG(让 next-intl 不去 await cookies/headers) + setRequestLocale(locale); + + // fumadocs i18n 接口:传 locale 后会按 .en / .zh 后缀加载对应文件, + // 找不到时按 source.ts 配的 fallbackLanguage='zh' 回退到原文。 + const page = source.getPage(slug, locale); if (page == null) { notFound(); } - // 静默 fallback:翻译版不存在时直接展示原文,不再显示"暂无英文版"横幅 - // 原因:中文为默认语言,大多数文档本身就是中文;显示 banner 反而让 UI 碍眼 - // 统一通过工具函数生成 Edit 链接,内部已处理中文目录编码 const editUrl = buildDocsEditUrl(page.path); const data = page.data as PageData; @@ -92,10 +58,11 @@ export default async function DocPage({ params }: Param) { getDocContributorsByDocId(docIdFromPage); const Mdx = page.data.body; - // SEO 结构化数据 - const siteUrl = SITE_URL; + // SEO 结构化数据:URL 含 locale 前缀 const slugPath = (slug ?? []).join("/"); - const docUrl = slugPath ? `${siteUrl}/docs/${slugPath}` : `${siteUrl}/docs`; + const docUrl = slugPath + ? `${SITE_URL}/${locale}/docs/${slugPath}` + : `${SITE_URL}/${locale}/docs`; // TechArticle: 让 docs 在 Google 搜索结果上更可能展示为技术文章卡片 const articleJsonLd = { @@ -108,17 +75,17 @@ export default async function DocPage({ params }: Param) { publisher: { "@type": "Organization", name: "Involution Hell", - url: siteUrl, + url: SITE_URL, }, }; - // BreadcrumbList: 按 slug 层级生成面包屑(Google 搜索结果里的那种层级链接) + // BreadcrumbList: 按 slug 层级生成面包屑 const breadcrumbItems = [ - { name: "Involution Hell", url: siteUrl }, - { name: "Docs", url: `${siteUrl}/docs` }, + { name: "Involution Hell", url: `${SITE_URL}/${locale}` }, + { name: "Docs", url: `${SITE_URL}/${locale}/docs` }, ...(slug ?? []).map((seg, idx) => ({ name: decodeURIComponent(seg), - url: `${siteUrl}/docs/${slug!.slice(0, idx + 1).join("/")}`, + url: `${SITE_URL}/${locale}/docs/${slug!.slice(0, idx + 1).join("/")}`, })), ]; const breadcrumbJsonLd = { @@ -178,39 +145,54 @@ export default async function DocPage({ params }: Param) { ); } +/** + * generateStaticParams: 给每个 base slug × 每个 locale 出一份预渲染参数。 + * + * fumadocs 的 source.generateParams('slug', 'lang') 会自动产出这种结构, + * 但我们的 i18n 段名是 'locale'(next-intl 约定),所以 mapping 一下。 + * + * 双语预渲染规模:约 318 base × 2 = 636 页 SSG。fallbackLanguage='zh' + * 让翻译版缺失的 en 页面也能预渲染(直接拿原文)。 + */ export async function generateStaticParams() { - return source.getPages().map((page) => ({ - slug: page.slugs, + return source.generateParams("slug", "lang").map((p) => ({ + locale: p.lang as string, + slug: p.slug as string[], })); } export async function generateMetadata({ params }: Param): Promise { - const { slug } = await params; - const locale = await getLocaleFromCookie(); - // metadata 需与页面主体同语言,避免英文页显示中文 title/desc 造成 SEO 错乱 - const { page } = getPageWithLocale(slug, locale); + const { locale, slug } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + setRequestLocale(locale); + + const page = source.getPage(slug, locale); if (page == null) { notFound(); } - // 规范化 slug → canonical 路径。用户访问 /docs/learn/ai/rl(原文)或 /docs/learn/ai/rl.en(翻译版) - // 都统一指向原始 slug,避免两个 URL 竞争同一份内容的 PageRank。 + // canonical: 当前 locale 的本语言 URL(每个语言独立 canonical,避免 zh/en + // 互相竞争 PageRank)。 const slugPath = (slug ?? []).join("/"); - const canonical = slugPath ? `/docs/${slugPath}` : "/docs"; - - // hreflang:告诉搜索引擎该文档有哪些语言版本。 - // 翻译版文件命名是 `.en.mdx` / `.zh.mdx`,URL 靠 cookie 切换, - // 两种语言走同一 canonical URL,因此 hreflang 都指向自己。 - const languages: Record = { - "zh-CN": canonical, - "en-US": canonical, - "x-default": canonical, - }; + const canonical = slugPath + ? `/${locale}/docs/${slugPath}` + : `/${locale}/docs`; + + // hreflang:告诉 Google 同一文档的另一语言 URL 在哪。 + const langs: Record = {}; + for (const l of routing.locales) { + const url = slugPath ? `/${l}/docs/${slugPath}` : `/${l}/docs`; + langs[l === "en" ? "en-US" : "zh-CN"] = url; + } + langs["x-default"] = `/${routing.defaultLocale}/docs/${slugPath}`.replace( + /\/$/, + "", + ); return { title: page.data.title, description: page.data.description, - alternates: { canonical, languages }, + alternates: { canonical, languages: langs }, openGraph: { type: "article", title: page.data.title, diff --git a/app/[locale]/docs/layout.tsx b/app/[locale]/docs/layout.tsx new file mode 100644 index 00000000..00c88103 --- /dev/null +++ b/app/[locale]/docs/layout.tsx @@ -0,0 +1,106 @@ +import { source } from "@/lib/source"; +import { DocsLayout } from "fumadocs-ui/layouts/docs"; +import { baseOptions } from "@/lib/layout.shared"; +import type { ReactNode } from "react"; +import { DocsRouteFlag } from "@/app/components/RouteFlags"; +import type { PageTree } from "fumadocs-core/server"; +import { CopyTracking } from "@/app/components/CopyTracking"; +import { DocsPageViewTracker } from "@/app/components/DocsPageViewTracker"; +import { setRequestLocale } from "next-intl/server"; +import { hasLocale } from "next-intl"; +import { notFound } from "next/navigation"; +import { routing } from "@/i18n/routing"; + +/** + * 单 child 文件夹的 hoist 规则。 + * + * 历史背景:learn/ai/ 下有些 folder 只挂了一篇文章(例如某个细分主题只 + * 写了一篇),sidebar 里展开折叠没意义。把这种 folder 替换成它的唯一 + * child page,让 sidebar 更紧凑。 + * + * 限定 learn/ai/ 是因为这是社区里最多"独苗 folder"的子树,其它分区不 + * 强行 hoist 避免误压平正常的层级结构。 + */ +function pruneEmptyFolders(root: PageTree.Root): PageTree.Root { + const transformNode = (node: PageTree.Node): PageTree.Node | null => { + if (node.type === "folder") { + const transformedChildren = node.children + .map(transformNode) + .filter((child): child is PageTree.Node => child !== null); + + const index = node.index ? { ...node.index } : undefined; + + if (transformedChildren.length === 0) { + if (index) return { ...index }; + return null; + } + + if (!index && transformedChildren.length === 1) { + const [onlyChild] = transformedChildren; + if ( + onlyChild.type === "page" && + onlyChild.url.startsWith("/docs/learn/ai/") + ) { + return { ...onlyChild }; + } + } + + return { ...node, index, children: transformedChildren }; + } + if (node.type === "separator") return { ...node }; + return { ...node }; + }; + + const transformRoot = (node: PageTree.Root): PageTree.Root => { + const children = node.children + .map(transformNode) + .filter((child): child is PageTree.Node => child !== null); + return { + ...node, + children, + fallback: node.fallback ? transformRoot(node.fallback) : undefined, + }; + }; + + return transformRoot(root); +} + +interface Props { + children: ReactNode; + params: Promise<{ locale: string }>; +} + +/** + * Docs 子树共享 layout。 + * + * 关键变化(i18n URL 段化): + * 旧版手写 pickVariantsByLocale / filterTreeByLocale,按 cookie 把 + * pageTree 里的 .en / .zh 变体筛成单语 tree。fumadocs i18n 接入后 + * `source.getPageTree(locale)` 已经原生返回单 locale 的 tree,整段 + * 过滤逻辑直接删除,只保留 learn/ai/ 单 child folder 的 hoist 规则。 + */ +export default async function Layout({ children, params }: Props) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + setRequestLocale(locale); + + const tree = pruneEmptyFolders(source.getPageTree(locale)); + const options = await baseOptions(); + return ( + <> + + + + + {children} + + + ); +} diff --git a/app/docs/page.tsx b/app/[locale]/docs/page.tsx similarity index 55% rename from app/docs/page.tsx rename to app/[locale]/docs/page.tsx index cddd1069..0a150ab8 100644 --- a/app/docs/page.tsx +++ b/app/[locale]/docs/page.tsx @@ -1,24 +1,30 @@ import { DocsPage, DocsBody } from "fumadocs-ui/page"; import type { Metadata } from "next"; -import { cookies } from "next/headers"; +import { setRequestLocale } from "next-intl/server"; +import { hasLocale } from "next-intl"; +import { notFound } from "next/navigation"; import { SectionIndex } from "@/app/components/docs/SectionIndex"; +import { routing } from "@/i18n/routing"; /** - * /docs 根路由的 landing。Header 导航的 "文档 / Docs" 直接指向 /docs,但原本 - * app/docs/ 下只有 layout.tsx + [...slug]/page.tsx(catch-all 不匹配空 slug), - * 所以 /docs 本身 404。这个文件提供兜底 landing,复用已挂好的 DocsLayout。 + * /[locale]/docs 根路由的 landing。Header 的 "文档 / Docs" 链接指到 /docs, + * 但 docs/[...slug] catch-all 不匹配空 slug,所以 /docs 本身 404。这个文件 + * 提供兜底 landing,复用已挂好的 DocsLayout。 * - * 内容交给 ``(root 不传 → 渲染 pageTree 顶层分区)。所有渲染 - * 逻辑和 community / career/interview-prep/leetcode 两处共用同一个组件,避免 drift。 + * 内容交给 ``(root 不传 → 渲染 pageTree 顶层分区)。所有 + * 渲染逻辑和 community / career/interview-prep/leetcode 两处共用同一个组 + * 件,避免 drift。 */ -async function getLocaleFromCookie(): Promise<"zh" | "en"> { - const cookieStore = await cookies(); - return cookieStore.get("locale")?.value === "en" ? "en" : "zh"; +interface Props { + params: Promise<{ locale: string }>; } -export default async function DocsRootPage() { - const locale = await getLocaleFromCookie(); +export default async function DocsRootPage({ params }: Props) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + setRequestLocale(locale); + const heading = locale === "en" ? "Knowledge Base" : "文档总览"; const intro = locale === "en" @@ -38,8 +44,11 @@ export default async function DocsRootPage() { ); } -export async function generateMetadata(): Promise { - const locale = await getLocaleFromCookie(); +export async function generateMetadata({ params }: Props): Promise { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) notFound(); + setRequestLocale(locale); + return { title: locale === "en" ? "Docs" : "文档", description: diff --git a/app/editor/EditorPageClient.tsx b/app/[locale]/editor/EditorPageClient.tsx similarity index 100% rename from app/editor/EditorPageClient.tsx rename to app/[locale]/editor/EditorPageClient.tsx diff --git a/app/editor/page.tsx b/app/[locale]/editor/page.tsx similarity index 100% rename from app/editor/page.tsx rename to app/[locale]/editor/page.tsx diff --git a/app/events/[id]/InterestButton.tsx b/app/[locale]/events/[id]/InterestButton.tsx similarity index 100% rename from app/events/[id]/InterestButton.tsx rename to app/[locale]/events/[id]/InterestButton.tsx diff --git a/app/events/[id]/page.tsx b/app/[locale]/events/[id]/page.tsx similarity index 100% rename from app/events/[id]/page.tsx rename to app/[locale]/events/[id]/page.tsx diff --git a/app/events/page.tsx b/app/[locale]/events/page.tsx similarity index 100% rename from app/events/page.tsx rename to app/[locale]/events/page.tsx diff --git a/app/events/types.ts b/app/[locale]/events/types.ts similarity index 100% rename from app/events/types.ts rename to app/[locale]/events/types.ts diff --git a/app/feed/components/CategoryTabs.tsx b/app/[locale]/feed/components/CategoryTabs.tsx similarity index 96% rename from app/feed/components/CategoryTabs.tsx rename to app/[locale]/feed/components/CategoryTabs.tsx index 34f42098..762b7a64 100644 --- a/app/feed/components/CategoryTabs.tsx +++ b/app/[locale]/feed/components/CategoryTabs.tsx @@ -8,7 +8,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { type CategorySlug, CATEGORY_SLUGS } from "@/app/feed/types"; +import { type CategorySlug, CATEGORY_SLUGS } from "@/app/[locale]/feed/types"; import { cn } from "@/lib/utils"; export function CategoryTabs() { diff --git a/app/feed/components/FeedAuthWrapper.tsx b/app/[locale]/feed/components/FeedAuthWrapper.tsx similarity index 90% rename from app/feed/components/FeedAuthWrapper.tsx rename to app/[locale]/feed/components/FeedAuthWrapper.tsx index 27cee6f6..fb38bbb7 100644 --- a/app/feed/components/FeedAuthWrapper.tsx +++ b/app/[locale]/feed/components/FeedAuthWrapper.tsx @@ -13,8 +13,8 @@ */ import { useAuth } from "@/lib/use-auth"; -import { LinkCard } from "@/app/feed/components/LinkCard"; -import type { SharedLinkView, CategorySlug } from "@/app/feed/types"; +import { LinkCard } from "@/app/[locale]/feed/components/LinkCard"; +import type { SharedLinkView, CategorySlug } from "@/app/[locale]/feed/types"; interface FeedAuthWrapperProps { links: SharedLinkView[]; diff --git a/app/feed/components/LinkCard.tsx b/app/[locale]/feed/components/LinkCard.tsx similarity index 97% rename from app/feed/components/LinkCard.tsx rename to app/[locale]/feed/components/LinkCard.tsx index cd1c6392..5851d24a 100644 --- a/app/feed/components/LinkCard.tsx +++ b/app/[locale]/feed/components/LinkCard.tsx @@ -7,8 +7,8 @@ */ import { useTranslations } from "next-intl"; -import type { SharedLinkView } from "@/app/feed/types"; -import { ReportButton } from "@/app/feed/components/ReportButton"; +import type { SharedLinkView } from "@/app/[locale]/feed/types"; +import { ReportButton } from "@/app/[locale]/feed/components/ReportButton"; import { Badge } from "@/components/ui/badge"; import { sanitizeMediaUrl } from "@/lib/url-safety"; diff --git a/app/feed/components/ReportButton.tsx b/app/[locale]/feed/components/ReportButton.tsx similarity index 100% rename from app/feed/components/ReportButton.tsx rename to app/[locale]/feed/components/ReportButton.tsx diff --git a/app/feed/page.tsx b/app/[locale]/feed/page.tsx similarity index 95% rename from app/feed/page.tsx rename to app/[locale]/feed/page.tsx index 0c3356fe..6689a45e 100644 --- a/app/feed/page.tsx +++ b/app/[locale]/feed/page.tsx @@ -14,10 +14,10 @@ import { getTranslations } from "next-intl/server"; import { Header } from "@/app/components/Header"; import { Footer } from "@/app/components/Footer"; import { Suspense } from "react"; -import { CategoryTabs } from "@/app/feed/components/CategoryTabs"; -import { FeedAuthWrapper } from "@/app/feed/components/FeedAuthWrapper"; -import type { SharedLinkView, CategorySlug } from "@/app/feed/types"; -import type { ApiResponse } from "@/app/feed/types"; +import { CategoryTabs } from "@/app/[locale]/feed/components/CategoryTabs"; +import { FeedAuthWrapper } from "@/app/[locale]/feed/components/FeedAuthWrapper"; +import type { SharedLinkView, CategorySlug } from "@/app/[locale]/feed/types"; +import type { ApiResponse } from "@/app/[locale]/feed/types"; import Link from "next/link"; export const revalidate = 120; @@ -141,7 +141,7 @@ export default async function FeedPage({ searchParams }: FeedPageProps) { // Server 端预计算 slug → 中文显示名 map。传给 FeedAuthWrapper(client) // 时必须是纯数据(函数 prop 在 Next 16 会报 "Functions cannot be passed to // Client Components")。8 个 slug 一次翻译完毕,零额外开销。 - const { CATEGORY_SLUGS } = await import("@/app/feed/types"); + const { CATEGORY_SLUGS } = await import("@/app/[locale]/feed/types"); const categoryLabels: Partial> = {}; for (const slug of CATEGORY_SLUGS) { try { diff --git a/app/feed/submit/layout.tsx b/app/[locale]/feed/submit/layout.tsx similarity index 100% rename from app/feed/submit/layout.tsx rename to app/[locale]/feed/submit/layout.tsx diff --git a/app/feed/submit/page.tsx b/app/[locale]/feed/submit/page.tsx similarity index 100% rename from app/feed/submit/page.tsx rename to app/[locale]/feed/submit/page.tsx diff --git a/app/feed/types.ts b/app/[locale]/feed/types.ts similarity index 100% rename from app/feed/types.ts rename to app/[locale]/feed/types.ts diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 00000000..f3e76e41 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,91 @@ +import { setRequestLocale, getMessages } from "next-intl/server"; +import { hasLocale, NextIntlClientProvider } from "next-intl"; +import { notFound } from "next/navigation"; +import { RootProvider } from "fumadocs-ui/provider"; +import { ThemeProvider } from "@/app/components/ThemeProvider"; +import { AuthProvider } from "@/lib/use-auth"; +import { CustomSearchDialog } from "@/app/components/CustomSearchDialog"; +import { UmamiIdentity } from "@/app/components/UmamiIdentity"; +import { routing } from "@/i18n/routing"; + +/** + * locale 段 layout:所有 user-facing 路由(含 admin)的最外层包装。 + * + * 关键作用(启用 SSG): + * setRequestLocale(locale) 必须在第一行调用(在任何 next-intl hook 之前)。 + * 它把 locale 写进 next-intl 的 RequestStore,让所有嵌套 RSC 拿到 locale, + * 而不需要再调 cookies() / headers() —— 这是让全树 SSG 的关键开关。 + * + * 缺这一行的话,next-intl 会回退到从 cookies()/headers() 推断 locale, + * 整棵 RSC 树重新变 dynamic,绕了一圈又回到老问题。 + * + * generateStaticParams 双倍出货 zh + en,build 时 Next.js 会按 + * [locale] × 嵌套 generateStaticParams 笛卡尔积预渲染所有页面。 + * + * 这里包了 root layout 移过来的全部 locale-bound provider: + * - NextIntlClientProvider (locale + messages) + * - ThemeProvider / AuthProvider / RootProvider (fumadocs,search api 按 locale 选分片) + * - 主体 main 容器 + * + * inline script: 把 documentElement.lang 改成当前 locale。root layout 写死 + * lang="zh-CN" 作为 SSR fallback,client 端这条脚本会立刻覆盖,确保 a11y + * 工具和 SEO 拿到正确语言标记。 + */ +type Props = { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}; + +export default async function LocaleLayout({ children, params }: Props) { + const { locale } = await params; + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + // 关键:启用 SSG 的开关。必须在调任何 next-intl hook 之前。 + setRequestLocale(locale); + + const messages = await getMessages(); + const htmlLang = locale === "en" ? "en" : "zh-CN"; + // 搜索索引按 locale 分片(规避 Vercel 单页 ISR 19.07MB 上限) + const searchApi = `/search.${locale}.json`; + + return ( + <> + {/* + SSR 时 root layout 的 html lang 是写死的 "zh-CN"。 + 在客户端立即覆盖为当前 locale,让屏幕阅读器、Google Translate 等 + 立刻拿到正确语言标记。早于 hydration —— 通过同步 inline script 实现。 + */} + - {/* Umami Analytics */} - +