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("