From 721b90a5582f12cce796c6e49cccc0cc1680707a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Tue, 26 May 2026 16:19:48 +0800 Subject: [PATCH] fix(web): persist docs English locale selection --- packages/web/src/middleware-locale.test.ts | 42 ++++++++++ packages/web/src/middleware-locale.ts | 81 +++++++++++++++++++ packages/web/src/middleware.ts | 94 +++++++--------------- 3 files changed, 151 insertions(+), 66 deletions(-) create mode 100644 packages/web/src/middleware-locale.test.ts create mode 100644 packages/web/src/middleware-locale.ts diff --git a/packages/web/src/middleware-locale.test.ts b/packages/web/src/middleware-locale.test.ts new file mode 100644 index 000000000000..3d1f5cb27105 --- /dev/null +++ b/packages/web/src/middleware-locale.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test" +import { + defaultDocsLocale, + docsAlias, + localeCookie, + localeFromAcceptLanguage, + localeFromCookie, +} from "./middleware-locale" + +describe("docs locale middleware helpers", () => { + test("redirects locale aliases to canonical docs paths", () => { + expect(docsAlias("/docs/en/config/")).toEqual({ path: "/docs/config/", locale: "root" }) + expect(docsAlias("/docs/root/config/")).toEqual({ path: "/docs/config/", locale: "root" }) + expect(docsAlias("/docs/de/config/")).toBeNull() + }) + + test("persists English when visiting an unprefixed docs page", () => { + expect(defaultDocsLocale("/docs/config/", null, "text/html")).toBe("root") + expect(defaultDocsLocale("/docs/config/", null, "text/css")).toBeNull() + expect(defaultDocsLocale("/docs/de/config/", null, "text/html")).toBeNull() + expect(defaultDocsLocale("/blog", null, "text/html")).toBeNull() + }) + + test("persists English when switching the docs root from another locale", () => { + expect(defaultDocsLocale("/docs/", "https://opencode.ai/docs/de/", "text/html")).toBe("root") + expect(defaultDocsLocale("/docs/", "https://opencode.ai/docs/de/config/", "text/html")).toBe("root") + expect(defaultDocsLocale("/docs/", "https://opencode.ai/docs/config/", "text/html")).toBeNull() + expect(defaultDocsLocale("/docs/", "https://opencode.ai/docs/de/", "text/css")).toBeNull() + expect(defaultDocsLocale("/docs/", null, "text/html")).toBeNull() + }) + + test("formats and reads locale cookies", () => { + expect(localeCookie("root")).toContain("oc_locale=en") + expect(localeFromCookie("theme=dark; oc_locale=de")).toBe("de") + expect(localeFromCookie("oc_locale=en")).toBe("root") + }) + + test("uses accept-language quality order", () => { + expect(localeFromAcceptLanguage("de-DE,de;q=0.9,en;q=0.8")).toBe("de") + expect(localeFromAcceptLanguage("fr;q=0.4,en-US;q=0.8")).toBe("root") + }) +}) diff --git a/packages/web/src/middleware-locale.ts b/packages/web/src/middleware-locale.ts new file mode 100644 index 000000000000..4150c5afa9c0 --- /dev/null +++ b/packages/web/src/middleware-locale.ts @@ -0,0 +1,81 @@ +import { exactLocale, matchLocale, type Locale } from "./i18n/locales" + +export function docsAlias(pathname: string): { path: string; locale: Locale } | null { + const hit = /^\/docs\/([^/]+)(\/.*)?$/.exec(pathname) + if (!hit) return null + + const value = hit[1] ?? "" + const tail = hit[2] ?? "" + const locale = exactLocale(value) + if (!locale) return null + + const next = locale === "root" ? `/docs${tail}` : `/docs/${locale}${tail}` + if (next === pathname) return null + return { + path: next, + locale, + } +} + +export function localeCookie(locale: Locale) { + const value = locale === "root" ? "en" : locale + return `oc_locale=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +export function localeFromCookie(header: string | null): Locale | null { + if (!header) return null + const raw = header + .split(";") + .map((x) => x.trim()) + .find((x) => x.startsWith("oc_locale=")) + ?.slice("oc_locale=".length) + if (!raw) return null + return matchLocale(raw) +} + +export function localeFromAcceptLanguage(header: string | null): Locale { + if (!header) return "root" + + const items = header + .split(",") + .map((raw) => raw.trim()) + .filter(Boolean) + .map((raw) => { + const parts = raw.split(";").map((x) => x.trim()) + const lang = parts[0] ?? "" + const q = parts + .slice(1) + .find((x) => x.startsWith("q=")) + ?.slice(2) + return { + lang, + q: q ? Number.parseFloat(q) : 1, + } + }) + .sort((a, b) => b.q - a.q) + + const locale = items + .map((item) => item.lang) + .filter((lang) => lang && lang !== "*") + .map((lang) => matchLocale(lang)) + .find((lang) => lang) + + return locale ?? "root" +} + +export function defaultDocsLocale(pathname: string, referer?: string | null, accept?: string | null): Locale | null { + if (!accept?.includes("text/html")) return null + if (pathname !== "/docs" && pathname !== "/docs/" && !pathname.startsWith("/docs/")) return null + const segment = pathname.split("/")[2] ?? "" + if (segment && exactLocale(segment)) return null + if (pathname !== "/docs" && pathname !== "/docs/") return "root" + const ref = refererPathname(referer) + if (!ref) return null + const refLocale = exactLocale(ref.split("/")[2] ?? "") + return refLocale && refLocale !== "root" ? "root" : null +} + +function refererPathname(referer?: string | null) { + if (!referer || !URL.canParse(referer)) return null + return new URL(referer).pathname +} diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index cf9f97b0b131..6351ce28ecf6 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -1,88 +1,50 @@ import { defineMiddleware } from "astro:middleware" -import { exactLocale, matchLocale } from "./i18n/locales" - -function docsAlias(pathname: string) { - const hit = /^\/docs\/([^/]+)(\/.*)?$/.exec(pathname) - if (!hit) return null - - const value = hit[1] ?? "" - const tail = hit[2] ?? "" - const locale = exactLocale(value) - if (!locale) return null - - const next = locale === "root" ? `/docs${tail}` : `/docs/${locale}${tail}` - if (next === pathname) return null - return { - path: next, - locale, - } -} - -function cookie(locale: string) { - const value = locale === "root" ? "en" : locale - return `oc_locale=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax` -} - -function redirect(url: URL, path: string, locale?: string) { +import type { Locale } from "./i18n/locales" +import { + defaultDocsLocale, + docsAlias, + localeCookie, + localeFromAcceptLanguage, + localeFromCookie, +} from "./middleware-locale" + +function redirect(url: URL, path: string, locale?: Locale) { const next = new URL(url.toString()) next.pathname = path const headers = new Headers({ Location: next.toString(), }) - if (locale) headers.set("Set-Cookie", cookie(locale)) + if (locale) headers.set("Set-Cookie", localeCookie(locale)) return new Response(null, { status: 302, headers, }) } -function localeFromCookie(header: string | null) { - if (!header) return null - const raw = header - .split(";") - .map((x) => x.trim()) - .find((x) => x.startsWith("oc_locale=")) - ?.slice("oc_locale=".length) - if (!raw) return null - return matchLocale(raw) -} - -function localeFromAcceptLanguage(header: string | null) { - if (!header) return "root" - - const items = header - .split(",") - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => { - const parts = raw.split(";").map((x) => x.trim()) - const lang = parts[0] ?? "" - const q = parts - .slice(1) - .find((x) => x.startsWith("q=")) - ?.slice(2) - return { - lang, - q: q ? Number.parseFloat(q) : 1, - } - }) - .sort((a, b) => b.q - a.q) - - const locale = items - .map((item) => item.lang) - .filter((lang) => lang && lang !== "*") - .map((lang) => matchLocale(lang)) - .find((lang) => lang) - - return locale ?? "root" +async function withCookie(next: () => Response | Promise, locale: Locale) { + const response = await next() + const headers = new Headers(response.headers) + headers.append("Set-Cookie", localeCookie(locale)) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) } -export const onRequest = defineMiddleware((ctx, next) => { +export const onRequest = defineMiddleware(async (ctx, next) => { const alias = docsAlias(ctx.url.pathname) if (alias) { return redirect(ctx.url, alias.path, alias.locale) } + const selected = defaultDocsLocale( + ctx.url.pathname, + ctx.request.headers.get("referer"), + ctx.request.headers.get("accept"), + ) + if (selected) return withCookie(next, selected) + if (ctx.url.pathname !== "/docs" && ctx.url.pathname !== "/docs/") return next() const locale =