Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
success: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}</>;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
success: boolean;
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,8 @@ function AdminUsersInner() {
用户管理
</h1>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed max-w-2xl">
勾选 admin 即赋予管理员角色;取消即撤销。superadmin 角色不允许在这里改,
只能走 DB。
勾选 admin 即赋予管理员角色;取消即撤销。superadmin
角色不允许在这里改, 只能走 DB。
</p>
</div>
<div className="flex flex-col gap-1.5 min-w-[240px]">
Expand Down
138 changes: 60 additions & 78 deletions app/docs/[...slug]/page.tsx → app/[locale]/docs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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 = {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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<Metadata> {
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:告诉搜索引擎该文档有哪些语言版本。
// 翻译版文件命名是 `<slug>.en.mdx` / `<slug>.zh.mdx`,URL 靠 cookie 切换,
// 两种语言走同一 canonical URL,因此 hreflang 都指向自己。
const languages: Record<string, string> = {
"zh-CN": canonical,
"en-US": canonical,
"x-default": canonical,
};
const canonical = slugPath
? `/${locale}/docs/${slugPath}`
: `/${locale}/docs`;

// hreflang:告诉 Google 同一文档的另一语言 URL 在哪。
const langs: Record<string, string> = {};
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,
Expand Down
106 changes: 106 additions & 0 deletions app/[locale]/docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<CopyTracking />
<DocsPageViewTracker />
<DocsRouteFlag />
<DocsLayout
tree={tree}
{...options}
sidebar={{
// Only show top-level items on first load
defaultOpenLevel: 0,
}}
>
{children}
</DocsLayout>
</>
);
}
Loading
Loading