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
+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}`);
}