feat(docs): persist browser and manual locale preferences (#123)

* feat(docs): persist browser and manual locale preferences

* test(docs): include localized 404 pages in provider coverage

---------

Co-authored-by: dadachann <[email protected]>
This commit is contained in:
Hyu
2026-09-07 00:15:05 +08:00
committed by GitHub
co-authored by dadachann
parent 070ef2cff9
commit 91daf9ecd6
10 changed files with 208 additions and 9 deletions
+3 -3
View File
@@ -11,15 +11,15 @@
"prepare:fumapress": "node scripts/prepare-fumapress.mjs",
"build": "fumapress build",
"prebuild": "npm run prepare:fumapress",
"postbuild": "npm run finalize:base-path && npm run generate:sitemap-alternates && npm run normalize:static-paths && npm run generate:agent-docs && npm run test:static",
"postbuild": "npm run finalize:base-path && npm run generate:sitemap-alternates && npm run normalize:static-paths && npm run generate:agent-docs && node scripts/generate-locale-entry.mjs && npm run test:static",
"generate:agent-docs": "node scripts/generate-agent-docs.mjs",
"typecheck": "tsc --noEmit",
"normalize:static-paths": "node scripts/normalize-static-paths.mjs",
"test:static": "node --test tests/fumapress-static-build.test.mjs tests/agent-static-build.test.mjs",
"test:static": "node --test tests/fumapress-static-build.test.mjs tests/agent-static-build.test.mjs tests/locale-static-build.test.mjs",
"generate:sitemap-alternates": "node scripts/generate-alternate-sitemap.mjs",
"sync:articles": "node scripts/sync-blog-articles.mjs",
"check:articles": "node scripts/sync-blog-articles.mjs --check",
"test": "node --test tests/agent-docs.test.mjs tests/blog-articles.test.mjs tests/fumapress-migration.test.mjs tests/seo.test.mjs && python3 -m unittest discover -s tests -p 'test_*.py'",
"test": "node --test tests/locale-preference.test.mjs tests/agent-docs.test.mjs tests/blog-articles.test.mjs tests/fumapress-migration.test.mjs tests/seo.test.mjs && python3 -m unittest discover -s tests -p 'test_*.py'",
"finalize:base-path": "node scripts/finalize-base-path.mjs"
},
"dependencies": {
+11
View File
@@ -12,6 +12,7 @@ import { createOpenAPI, type OpenAPIServer } from "fumadocs-openapi/server";
import { Blocks, Bot, Cloud, ExternalLink, Map as MapIcon, Newspaper } from "lucide-react";
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { LocalePreferenceProvider } from "./src/locale-preference-provider";
const SITE_URL = "https://langbot.app/docs";
const LOCALES = ["en", "zh", "ja"] as const;
@@ -403,6 +404,16 @@ export default defineConfig({
// One registration is intentional: its adapter and loader plugin handle all
// OpenAPI virtual pages, regardless of which content source generated them.
.plugins(
{
name: "langbot:locale-preference",
init() {
const data = (this.data["core:provider"] ??= {});
(data.transformers ??= []).push((props) => {
if (props.i18n) props.children = <LocalePreferenceProvider i18n={props.i18n}>{props.children}</LocalePreferenceProvider>;
return props;
});
},
},
flexsearchPlugin({
async buildIndex(page) {
const title = page.data.title ?? page.path;
+23
View File
@@ -0,0 +1,23 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(import.meta.dirname, "..");
export async function generateLocaleEntry(publicRoot = path.join(root, "dist/public")) {
const helper = await readFile(path.join(root, "src/locale-preference.mjs"), "utf8");
// Inline a classic script: negotiation runs before rendering or module fetches.
const script = helper.replace(/^export /gm, "") + "\nenterDocs();";
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>LangBot Docs</title><script>${script}</script></head>
<body><main><h1>LangBot Docs</h1><noscript><p>Please choose a language / 请选择语言 / 言語を選択してください</p></noscript>
<ul><li><a lang="en" href="/docs/en/insight/guide">English</a></li><li><a lang="zh" href="/docs/zh/insight/guide">简体中文</a></li><li><a lang="ja" href="/docs/ja/insight/guide">日本語</a></li></ul>
</main></body></html>
`;
await writeFile(path.join(publicRoot, "index.html"), html);
return html;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
await generateLocaleEntry();
console.log("Generated browser-negotiated /docs/ entry (langbot-docs-locale)");
}
+1 -2
View File
@@ -133,7 +133,6 @@ function cloudflarePattern(value, destination = false) {
export function renderCloudflareRedirects(docs) {
const lines = [
"/ /en/insight/guide 302",
"/zh/develop/adapter/discord /zh/develop/adapter/discord/README 308",
"/scripts/README-blog-articles /en/articles 308",
];
@@ -206,7 +205,7 @@ export async function prepareFumapress({ root = ROOT, outRoot = root } = {}) {
documents: documents.length,
fallbackDefaults: 0,
localeOnlyDocuments,
redirects: (docs.redirects?.length ?? 0) + 3,
redirects: (docs.redirects?.length ?? 0) + 2,
locales: [...LOCALES],
openapi,
};
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { I18nProvider, type I18nProviderProps } from "fumadocs-ui/contexts/i18n";
import { useRouter } from "fumapress/client";
import { preferredLocale, saveLocale, localeDestination, LOCALES } from "./locale-preference.mjs";
/** Reuse both stock selectors via their native onLocaleChange callback. */
export function LocalePreferenceProvider({ i18n, children }: {
i18n: I18nProviderProps;
children: ReactNode;
}) {
const router = useRouter();
useEffect(() => { preferredLocale(); }, []);
return <I18nProvider {...i18n} onLocaleChange={(locale) => {
if (!LOCALES.includes(locale)) return;
saveLocale(locale);
const alternates = Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="alternate"][hreflang]'))
.map((link) => ({ language: link.hreflang, href: link.href }));
// Fumapress adds /docs itself. Use canonical hreflang routes for API pages
// whose translated slugs differ, and a guide when no translation exists.
const target = localeDestination(locale, alternates, location.origin);
router.push(target.slice("/docs".length) + location.search + location.hash);
}}>{children}</I18nProvider>;
}
+7
View File
@@ -0,0 +1,7 @@
export const STORAGE_KEY: string;
export const LOCALES: readonly string[];
export function detectLocale(saved: unknown, languages?: readonly unknown[]): string;
export function saveLocale(locale: string, browser?: Window): void;
export function preferredLocale(browser?: Window): string;
export function localeDestination(locale: string, alternates: {language: string; href: string}[], origin: string): string;
export function enterDocs(browser?: Window): void;
+48
View File
@@ -0,0 +1,48 @@
export const STORAGE_KEY = "langbot-docs-locale";
export const LOCALES = ["en", "zh", "ja"];
export function detectLocale(saved, languages = []) {
if (LOCALES.includes(saved)) return saved;
for (const language of languages) {
if (typeof language !== "string") continue;
const base = language.toLowerCase().split("-")[0];
if (LOCALES.includes(base)) return base;
}
return "en";
}
export function saveLocale(locale, browser = window) {
if (!LOCALES.includes(locale)) return;
try { browser.localStorage.setItem(STORAGE_KEY, locale); } catch { /* Private/blocked storage. */ }
}
export function preferredLocale(browser = window) {
let saved;
try { saved = browser.localStorage.getItem(STORAGE_KEY); } catch { /* Storage may throw on access. */ }
const locale = detectLocale(saved, browser.navigator.languages?.length
? browser.navigator.languages : [browser.navigator.language]);
// Initialize from the browser, never from an explicit shared page's locale.
if (saved !== locale) saveLocale(locale, browser);
return locale;
}
export function localeDestination(locale, alternates, origin) {
if (!LOCALES.includes(locale)) locale = "en";
for (const alternate of alternates) {
if (alternate.language.toLowerCase().split("-")[0] !== locale) continue;
try {
const url = new URL(alternate.href, origin);
// Exported canonicals point to production even on previews. Only accept
// our canonical host or the current origin, and an exact locale prefix.
if ((url.origin === origin || url.origin === "https://langbot.app") &&
url.pathname.startsWith(`/docs/${locale}/`)) return url.pathname;
} catch { /* Ignore malformed alternate metadata. */ }
}
return `/docs/${locale}/insight/guide`;
}
export function enterDocs(browser = window) {
if (!/^\/docs\/?$/.test(browser.location.pathname)) return;
const locale = preferredLocale(browser);
browser.location.replace(`/docs/${locale}/insight/guide${browser.location.search}${browser.location.hash}`);
}
+4 -4
View File
@@ -179,11 +179,11 @@ test("canonical MDX and nested OpenAPI sources are discovered", async () => {
]);
});
test("Cloudflare redirects preserve all Mintlify routes and add root", async () => {
test("Cloudflare redirects preserve localized routes without overriding negotiated root", async () => {
const docs = JSON.parse(await readFile(path.join(root, "docs.json"), "utf8"));
const lines = renderCloudflareRedirects(docs).trim().split("\n");
assert.equal(lines.length, docs.redirects.length + 3);
assert.equal(lines[0], "/ /en/insight/guide 302");
assert.equal(lines.length, docs.redirects.length + 2);
assert.ok(!lines.some((line) => line.startsWith("/ ")));
assert.ok(lines.includes("/README_EN /en/insight/guide 308"));
assert.ok(lines.includes("/zh/develop/adapter/discord /zh/develop/adapter/discord/README 308"));
assert.ok(lines.includes("/scripts/README-blog-articles /en/articles 308"));
@@ -219,7 +219,7 @@ test("prebuild is deterministic and preserves local assets and SEO files", async
assert.equal(first.documents, 302);
assert.equal(first.fallbackDefaults, 0);
assert.equal(first.localeOnlyDocuments, 14);
assert.equal(first.redirects, 104);
assert.equal(first.redirects, 103);
assert.deepEqual(first.locales, ["en", "zh", "ja"]);
await readFile(path.join(temp, "content/docs/insight/guide.mdx"), "utf8");
await readFile(path.join(temp, "content/docs/insight/guide.zh.mdx"), "utf8");
+46
View File
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { detectLocale, preferredLocale, saveLocale, enterDocs, localeDestination, STORAGE_KEY } from "../src/locale-preference.mjs";
export function browser(saved, languages = ["ja-JP"], pathname = "/docs/") {
const values = new Map(saved === undefined ? [] : [[STORAGE_KEY, saved]]);
return { values, navigator: { languages }, localStorage: {
getItem: (key) => values.get(key), setItem: (key, value) => values.set(key, value),
}, location: { pathname, search: "?next=https://evil.example/&q=中文", hash: "#part", replace(value) { this.destination = value; } } };
}
test("saved locale wins; regional browser languages negotiate in order", () => {
for (const [saved, languages, expected] of [
["en", ["zh-TW"], "en"], [null, ["fr", "zh-TW", "ja-JP"], "zh"],
[null, ["ja-JP", "en-US"], "ja"], ["invalid", ["de"], "en"],
["//evil.example", ["ZH-Hant-TW"], "zh"], [null, [], "en"],
]) assert.equal(detectLocale(saved, languages), expected);
});
test("entry preserves query/hash with a fixed local destination", () => {
for (const path of ["/docs", "/docs/"]) {
const b = browser(undefined, ["zh-TW"], path); enterDocs(b);
assert.equal(b.location.destination, `/docs/zh/insight/guide${b.location.search}${b.location.hash}`);
assert.equal(b.values.get(STORAGE_KEY), "zh");
}
});
test("explicit links never redirect or overwrite a preference", () => {
const b = browser("ja", ["en"], "/docs/zh/usage/platforms/discord");
enterDocs(b); preferredLocale(b);
assert.equal(b.location.destination, undefined); assert.equal(b.values.get(STORAGE_KEY), "ja");
const first = browser(undefined, ["en"], b.location.pathname);
preferredLocale(first); assert.equal(first.values.get(STORAGE_KEY), "en");
});
test("manual selection persists; invalid and blocked storage remain safe", () => {
const b = browser("invalid"); assert.equal(preferredLocale(b), "ja");
saveLocale("zh", b); enterDocs(b); assert.match(b.location.destination, /^\/docs\/zh\//);
saveLocale("evil", b); assert.equal(b.values.get(STORAGE_KEY), "zh");
Object.defineProperty(b, "localStorage", { get() { throw new Error("blocked"); } });
assert.doesNotThrow(() => { enterDocs(b); saveLocale("en", b); });
assert.match(b.location.destination, /^\/docs\/ja\//);
});
test("language changes use translated API alternates and safe missing-locale fallback", () => {
const links = [{language: "zh-CN", href: "https://langbot.app/docs/zh/api-reference/系统/获取系统信息"}];
assert.equal(localeDestination("zh", links, "http://localhost"), "/docs/zh/api-reference/%E7%B3%BB%E7%BB%9F/%E8%8E%B7%E5%8F%96%E7%B3%BB%E7%BB%9F%E4%BF%A1%E6%81%AF");
assert.equal(localeDestination("en", links, "http://localhost"), "/docs/en/insight/guide");
for (const href of ["https://evil.example/docs/en/attack", "/docs/zh/attack", "javascript:alert(1)"])
assert.equal(localeDestination("en", [{language: "en", href}], "https://langbot.app"), "/docs/en/insight/guide");
});
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import test from "node:test";
import vm from "node:vm";
import { browser } from "./locale-preference.test.mjs";
const root = path.resolve(import.meta.dirname, "../dist/public");
test("actual exported entry executes negotiation before body and exposes noscript links", async () => {
const html = await readFile(path.join(root, "index.html"), "utf8");
assert.ok(html.indexOf("<script>") < html.indexOf("<body>"));
assert.match(html, /<noscript>/);
for (const locale of ["en", "zh", "ja"]) assert.ok(html.includes(`href="/docs/${locale}/insight/guide"`));
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
for (const [saved, languages, expected] of [["en", ["zh-TW"], "en"], ["bad", ["ja-JP"], "ja"], [null, ["fr", "zh-TW"], "zh"], [null, ["de"], "en"]]) {
const window = browser(saved, languages); vm.runInNewContext(script, {window});
assert.equal(window.location.destination, `/docs/${expected}/insight/guide${window.location.search}${window.location.hash}`);
}
const window = browser(); Object.defineProperty(window, "localStorage", {get() {throw Error("blocked");}});
vm.runInNewContext(script, {window}); assert.match(window.location.destination, /^\/docs\/ja\//);
const redirects = await readFile(path.join(root, "_redirects"), "utf8");
assert.doesNotMatch(redirects, /^\/docs\/?\s/m);
});
test("every localized HTML payload includes the preference client provider", async () => {
let count = 0;
async function visit(dir) {
for (const entry of await readdir(dir, {withFileTypes: true})) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) await visit(file);
else if (entry.name === "index.html") {
const html = await readFile(file, "utf8");
assert.match(html, /LocalePreferenceProvider|locale-preference-provider/, file); count++;
}
}
}
for (const locale of ["en", "zh", "ja"]) await visit(path.join(root, locale));
// 479 canonical content pages plus one framework 404 page per locale.
assert.equal(count, 482);
});