Skip to content

Commit 19048c0

Browse files
committed
fix(i18n): sidebar 按 locale 筛去翻译变体 + 切语言后立即 refresh
之前两个 bug 叠加: 1. Fumadocs sidebar 直接展示 app/docs/ 下所有 .mdx(157 个),包括 foo.mdx / foo.en.mdx / foo.zh.mdx 三份文件都作为独立 entry。结果 zh 模式下能看到 "Learning Toolkit"(来自 .en.mdx)和 "杂项工具"(来自原文) 并列,中英混杂且数量爆炸 2. /settings 切语言按钮只写 document.cookie 就结束,没触发 router.refresh(), 所以 server component(docs sidebar / 文章正文)不会重渲染,用户得手动 F5 才能看到效果 改造: app/docs/layout.tsx - 新增 stripLocaleSuffix / pickVariantsByLocale / chooseVariant:按 canonical slug(去掉 .en/.zh 后缀)分组,每组挑一个最符合当前 locale 的变体 - 优先级:显式 .{locale}.mdx → 原文且 frontmatter.lang === locale → 原文兜底(未翻译的文档不消失,仍以原语言显示) - filterTreeByLocale 剪掉未被选中的变体,并把 URL 改写成 canonical (不带后缀),让点击 sidebar 永远跳 /docs/foo,由 getPageWithLocale 负责根据 cookie 选正文 - locale 从 cookie 读取,方式与根 layout.tsx 保持一致 app/settings/SettingsForm.tsx - 保存偏好 / 按钮直接切语言两处,写 cookie 后追加 router.refresh() - 让 RSC 立即用新 locale 重渲染,不再需要用户手动刷新
1 parent dd7f6c4 commit 19048c0

2 files changed

Lines changed: 147 additions & 1 deletion

File tree

app/docs/layout.tsx

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,140 @@ import { DocsRouteFlag } from "@/app/components/RouteFlags";
66
import type { PageTree } from "fumadocs-core/server";
77
import { CopyTracking } from "@/app/components/CopyTracking";
88
import { DocsPageViewTracker } from "@/app/components/DocsPageViewTracker";
9+
import { cookies } from "next/headers";
10+
11+
type Locale = "zh" | "en";
12+
type SourcePage = ReturnType<typeof source.getPages>[number];
13+
14+
/**
15+
* 从 slug 数组末尾剥离语言后缀(.en / .zh),返回 canonical slug + 识别出的后缀。
16+
*
17+
* 例:["ai", "rl.en"] → { canonical: ["ai", "rl"], suffix: "en" }
18+
* ["ai", "rl"] → { canonical: ["ai", "rl"], suffix: null }
19+
*/
20+
function stripLocaleSuffix(slugs: readonly string[]): {
21+
canonical: string[];
22+
suffix: Locale | null;
23+
} {
24+
const last = slugs[slugs.length - 1] ?? "";
25+
const m = /^(.+)\.(en|zh)$/.exec(last);
26+
if (m) {
27+
return {
28+
canonical: [...slugs.slice(0, -1), m[1]],
29+
suffix: m[2] as Locale,
30+
};
31+
}
32+
return { canonical: [...slugs], suffix: null };
33+
}
34+
35+
function slugsToUrl(slugs: string[]): string {
36+
return slugs.length === 0 ? "/docs" : `/docs/${slugs.join("/")}`;
37+
}
38+
39+
/**
40+
* 把所有 page 按 canonical URL 分组,每组按优先级挑一个最适合当前 locale 的变体:
41+
* 1. 显式 .{locale}.mdx(例 foo.en.mdx 匹配 locale=en)
42+
* 2. frontmatter.lang 与 locale 相同的原文(没后缀但 lang 字段对得上)
43+
* 3. 无 locale 标记的原文(兜底,保证每组至少保留一个)
44+
*
45+
* 返回 rawUrl(page 原始 URL,含 .en/.zh 后缀)→ canonicalUrl 的映射。
46+
* 只有被选中的变体会出现在 Map 里;其它变体在 sidebar 里要被剪掉。
47+
*/
48+
function pickVariantsByLocale(
49+
pages: SourcePage[],
50+
locale: Locale,
51+
): Map<string, string> {
52+
const groups = new Map<string, SourcePage[]>();
53+
for (const p of pages) {
54+
const { canonical } = stripLocaleSuffix(p.slugs);
55+
const key = slugsToUrl(canonical);
56+
const arr = groups.get(key);
57+
if (arr) arr.push(p);
58+
else groups.set(key, [p]);
59+
}
60+
61+
const result = new Map<string, string>();
62+
for (const [canonicalUrl, variants] of groups) {
63+
const chosen = chooseVariant(variants, locale);
64+
result.set(chosen.url, canonicalUrl);
65+
}
66+
return result;
67+
}
68+
69+
function chooseVariant(variants: SourcePage[], locale: Locale): SourcePage {
70+
// 1. 显式 .{locale} 后缀的翻译版
71+
const explicit = variants.find(
72+
(p) => stripLocaleSuffix(p.slugs).suffix === locale,
73+
);
74+
if (explicit) return explicit;
75+
76+
// 2. 原文(无后缀)且 frontmatter.lang === locale
77+
const originalMatching = variants.find((p) => {
78+
const { suffix } = stripLocaleSuffix(p.slugs);
79+
if (suffix !== null) return false;
80+
const lang = (p.data as { lang?: string }).lang;
81+
return lang === locale;
82+
});
83+
if (originalMatching) return originalMatching;
84+
85+
// 3. 原文兜底(显示原语言,避免某篇文档因为没翻译就从 sidebar 消失)
86+
const original = variants.find(
87+
(p) => stripLocaleSuffix(p.slugs).suffix === null,
88+
);
89+
if (original) return original;
90+
91+
// 4. 实在没原文(理论上不会到这,除非两个 .en/.zh 并存没原文),随便返回一个
92+
return variants[0];
93+
}
94+
95+
/**
96+
* 按选中的变体集合剪 PageTree:
97+
* - 页节点:URL 不在 chosen 里 → 删除;在 chosen 里 → 改写 url 成 canonical
98+
* - 文件夹:递归处理子节点,子节点全空就连同文件夹一起删
99+
* - index 页同样判定 + 改写
100+
*/
101+
function filterTreeByLocale(
102+
root: PageTree.Root,
103+
chosen: Map<string, string>,
104+
): PageTree.Root {
105+
const transformIndex = (
106+
index: PageTree.Folder["index"],
107+
): PageTree.Folder["index"] => {
108+
if (!index) return undefined;
109+
const canonicalUrl = chosen.get(index.url);
110+
if (canonicalUrl === undefined) return undefined;
111+
return { ...index, url: canonicalUrl };
112+
};
113+
114+
const transformNode = (node: PageTree.Node): PageTree.Node | null => {
115+
if (node.type === "folder") {
116+
const children = node.children
117+
.map(transformNode)
118+
.filter((c): c is PageTree.Node => c !== null);
119+
const index = transformIndex(node.index);
120+
if (!index && children.length === 0) return null;
121+
return { ...node, index, children };
122+
}
123+
if (node.type === "separator") return { ...node };
124+
// page
125+
const canonicalUrl = chosen.get(node.url);
126+
if (canonicalUrl === undefined) return null;
127+
return { ...node, url: canonicalUrl };
128+
};
129+
130+
const transformRoot = (r: PageTree.Root): PageTree.Root => {
131+
const children = r.children
132+
.map(transformNode)
133+
.filter((c): c is PageTree.Node => c !== null);
134+
return {
135+
...r,
136+
children,
137+
fallback: r.fallback ? transformRoot(r.fallback) : undefined,
138+
};
139+
};
140+
141+
return transformRoot(root);
142+
}
9143

10144
function pruneEmptyFolders(root: PageTree.Root): PageTree.Root {
11145
const transformNode = (node: PageTree.Node): PageTree.Node | null => {
@@ -65,7 +199,15 @@ function pruneEmptyFolders(root: PageTree.Root): PageTree.Root {
65199
}
66200

67201
export default async function Layout({ children }: { children: ReactNode }) {
68-
const tree = pruneEmptyFolders(source.pageTree);
202+
// 根据 locale cookie 为 sidebar 挑选文档变体;与根 layout.tsx 的 locale 读取方式保持一致
203+
const cookieStore = await cookies();
204+
const locale: Locale =
205+
cookieStore.get("locale")?.value === "en" ? "en" : "zh";
206+
207+
// 先按 locale 筛掉重复的翻译变体,剩下每组只留一个;再剪空文件夹
208+
const chosen = pickVariantsByLocale(source.getPages(), locale);
209+
const localizedTree = filterTreeByLocale(source.pageTree, chosen);
210+
const tree = pruneEmptyFolders(localizedTree);
69211
const options = await baseOptions();
70212
return (
71213
<>

app/settings/SettingsForm.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,8 @@ export function SettingsForm() {
168168
setTheme(merged.theme);
169169
// 语言变化写回 cookie,供文档页 Server Component 读取
170170
document.cookie = `locale=${merged.language};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
171+
// Server Component(文档 sidebar / 正文)读 cookie,必须刷新 RSC 才能反映新 locale
172+
router.refresh();
171173
}
172174
showToast("success", t("toast.saveSuccess"));
173175
} catch {
@@ -267,6 +269,8 @@ export function SettingsForm() {
267269
setPrefs((p) => ({ ...p, language: value }));
268270
// 写 cookie 覆盖 middleware 的 IP 判断,让文档页 Server Component 读取
269271
document.cookie = `locale=${value};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
272+
// 立即刷新 RSC,让 sidebar / 正文按新 locale 重渲染(不刷会看起来没生效)
273+
router.refresh();
270274
}}
271275
className={`flex-1 py-2 px-4 font-mono text-sm uppercase transition-colors ${
272276
prefs.language === value

0 commit comments

Comments
 (0)