Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/web/src/middleware-locale.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
81 changes: 81 additions & 0 deletions packages/web/src/middleware-locale.ts
Original file line number Diff line number Diff line change
@@ -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
}
94 changes: 28 additions & 66 deletions packages/web/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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<Response>, 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 =
Expand Down
Loading