From 91daf9ecd68534ac8ae8e7963f006a9f14dec200 Mon Sep 17 00:00:00 2001 From: Hyu Date: Mon, 7 Sep 2026 00:15:05 +0800 Subject: [PATCH] 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 <185672915+dadachann@users.noreply.github.com> --- package.json | 6 ++-- press.config.tsx | 11 +++++++ scripts/generate-locale-entry.mjs | 23 ++++++++++++++ scripts/prepare-fumapress.mjs | 3 +- src/locale-preference-provider.tsx | 25 ++++++++++++++++ src/locale-preference.d.mts | 7 +++++ src/locale-preference.mjs | 48 ++++++++++++++++++++++++++++++ tests/fumapress-migration.test.mjs | 8 ++--- tests/locale-preference.test.mjs | 46 ++++++++++++++++++++++++++++ tests/locale-static-build.test.mjs | 40 +++++++++++++++++++++++++ 10 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 scripts/generate-locale-entry.mjs create mode 100644 src/locale-preference-provider.tsx create mode 100644 src/locale-preference.d.mts create mode 100644 src/locale-preference.mjs create mode 100644 tests/locale-preference.test.mjs create mode 100644 tests/locale-static-build.test.mjs diff --git a/package.json b/package.json index 74ae513ab..cc8c06860 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/press.config.tsx b/press.config.tsx index 1011d87f8..a68713a02 100644 --- a/press.config.tsx +++ b/press.config.tsx @@ -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 = {props.children}; + return props; + }); + }, + }, flexsearchPlugin({ async buildIndex(page) { const title = page.data.title ?? page.path; diff --git a/scripts/generate-locale-entry.mjs b/scripts/generate-locale-entry.mjs new file mode 100644 index 000000000..d16881087 --- /dev/null +++ b/scripts/generate-locale-entry.mjs @@ -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 = ` + +LangBot Docs +

LangBot Docs

+ +
+`; + 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)"); +} diff --git a/scripts/prepare-fumapress.mjs b/scripts/prepare-fumapress.mjs index e7b2ce3bb..94123b461 100644 --- a/scripts/prepare-fumapress.mjs +++ b/scripts/prepare-fumapress.mjs @@ -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, }; diff --git a/src/locale-preference-provider.tsx b/src/locale-preference-provider.tsx new file mode 100644 index 000000000..6564092d6 --- /dev/null +++ b/src/locale-preference-provider.tsx @@ -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 { + if (!LOCALES.includes(locale)) return; + saveLocale(locale); + const alternates = Array.from(document.querySelectorAll('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}; +} diff --git a/src/locale-preference.d.mts b/src/locale-preference.d.mts new file mode 100644 index 000000000..73fe91e51 --- /dev/null +++ b/src/locale-preference.d.mts @@ -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; diff --git a/src/locale-preference.mjs b/src/locale-preference.mjs new file mode 100644 index 000000000..e430e0fc7 --- /dev/null +++ b/src/locale-preference.mjs @@ -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}`); +} diff --git a/tests/fumapress-migration.test.mjs b/tests/fumapress-migration.test.mjs index 74b530ad5..47ec0ad60 100644 --- a/tests/fumapress-migration.test.mjs +++ b/tests/fumapress-migration.test.mjs @@ -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"); diff --git a/tests/locale-preference.test.mjs b/tests/locale-preference.test.mjs new file mode 100644 index 000000000..731229689 --- /dev/null +++ b/tests/locale-preference.test.mjs @@ -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"); +}); diff --git a/tests/locale-static-build.test.mjs b/tests/locale-static-build.test.mjs new file mode 100644 index 000000000..7c37b386a --- /dev/null +++ b/tests/locale-static-build.test.mjs @@ -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("