diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index fff2ec4292b7..94a42fa617dd 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -5,7 +5,7 @@ on: branches: - dev paths: - - packages/web/src/content/docs/*.mdx + - packages/web/src/content/docs/en/*.mdx jobs: sync-locales: @@ -34,7 +34,7 @@ jobs: - name: Compute changed English docs id: changes run: | - FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true) + FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/en/*.mdx' || true) if [ -z "$FILES" ]; then echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "No English docs changed in push range" @@ -82,7 +82,7 @@ jobs: 5. Use only the minimum tools needed for this task (read/glob, file edits, and translator Task). Do not use shell, web, search, or GitHub tools for translation work. 6. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update. 7. Keep locale docs structure aligned with their corresponding English pages. - 8. Do not modify English source docs in packages/web/src/content/docs/*.mdx. + 8. Do not modify English source docs in packages/web/src/content/docs/en/*.mdx. 9. If no locale updates are needed, make no changes. EOF diff --git a/.github/workflows/docs-update.yml b/.github/workflows/docs-update.yml index 900ad2b0c586..c7dcdbe1674a 100644 --- a/.github/workflows/docs-update.yml +++ b/.github/workflows/docs-update.yml @@ -59,9 +59,9 @@ jobs: Steps: 1. For each commit that looks like a new feature or significant change: - Read the changed files to understand what was added - - Check if the feature is already documented in packages/web/src/content/docs/* + - Check if the feature is already documented in packages/web/src/content/docs/en/*.mdx 2. If you find undocumented features: - - Update the relevant documentation files in packages/web/src/content/docs/* + - Update the relevant documentation files in packages/web/src/content/docs/en/*.mdx - Follow the existing documentation style and structure - Make sure to document the feature clearly with examples where appropriate 3. If all new features are already documented, report that no updates are needed diff --git a/.opencode/agent/docs.md b/.opencode/agent/docs.md index 21cfc6a16e04..2f68a408cc32 100644 --- a/.opencode/agent/docs.md +++ b/.opencode/agent/docs.md @@ -26,7 +26,7 @@ The section titles should not repeat the term used in the page title, for example, if the page title is "Models", avoid using a section title like "Add new models". This might be unavoidable in some cases, but try to avoid it. -Check out the /packages/web/src/content/docs/docs/index.mdx as an example. +Check out the /packages/web/src/content/docs/en/index.mdx as an example. For JS or TS code snippets remove trailing semicolons and any trailing commas that might not be needed. diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index a987d01927b6..530a3018941c 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -37,7 +37,7 @@ Locale guidance does not override code/command preservation rules or the global # Do-Not-Translate Terms (OpenCode Docs) -Generated from: `packages/web/src/content/docs/*.mdx` (default English docs) +Generated from: `packages/web/src/content/docs/en/*.mdx` (default English docs) Generated on: 2026-02-10 Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation). diff --git a/packages/console/app/src/lib/docs.ts b/packages/console/app/src/lib/docs.ts new file mode 100644 index 000000000000..98bda11bd184 --- /dev/null +++ b/packages/console/app/src/lib/docs.ts @@ -0,0 +1,38 @@ +import type { APIEvent } from "@solidjs/start/server" +import { Resource } from "@opencode-ai/console-resource" +import { type Locale, cookie, docs, localeFromRequest, tag } from "~/lib/language" + +function redirect(url: URL, path: string, locale: Locale) { + const next = new URL(url) + next.pathname = path + return new Response(null, { + status: 302, + headers: { + Location: next.toString(), + "Set-Cookie": cookie(locale), + }, + }) +} + +export async function docsHandler(evt: APIEvent) { + const req = evt.request.clone() + const url = new URL(req.url) + const locale = localeFromRequest(req) + const path = docs(locale, url.pathname) + if (path !== url.pathname) return redirect(url, path, locale) + + const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai" + const target = `https://${host}${path}${url.search}` + + const headers = new Headers(req.headers) + headers.set("accept-language", tag(locale)) + + const response = await fetch(target, { + method: req.method, + headers, + body: req.body, + }) + const next = new Response(response.body, response) + next.headers.append("set-cookie", cookie(locale)) + return next +} diff --git a/packages/console/app/src/lib/language.ts b/packages/console/app/src/lib/language.ts index 5e80179e4774..fabef116b8c0 100644 --- a/packages/console/app/src/lib/language.ts +++ b/packages/console/app/src/lib/language.ts @@ -69,7 +69,7 @@ const TAG = { } satisfies Record const DOCS = { - en: "root", + en: "en", zh: "zh-cn", zht: "zh-tw", ko: "ko", @@ -88,26 +88,6 @@ const DOCS = { tr: "tr", } satisfies Record -const DOCS_SEGMENT = new Set([ - "ar", - "bs", - "da", - "de", - "es", - "fr", - "it", - "ja", - "ko", - "nb", - "pl", - "pt-br", - "ru", - "th", - "tr", - "zh-cn", - "zh-tw", -]) - const DOCS_LOCALE = { ar: "ar", da: "da", @@ -144,29 +124,28 @@ function suffix(pathname: string) { } export function docs(locale: Locale, pathname: string) { - const value = DOCS[locale] const next = suffix(pathname) if (next.path !== "/docs" && next.path !== "/docs/" && !next.path.startsWith("/docs/")) { return `${next.path}${next.suffix}` } - if (value === "root") { - if (next.path === "/docs/en") return `/docs${next.suffix}` - if (next.path === "/docs/en/") return `/docs/${next.suffix}` - if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}` - return `${next.path}${next.suffix}` - } - - if (next.path === "/docs") return `/docs/${value}${next.suffix}` - if (next.path === "/docs/") return `/docs/${value}/${next.suffix}` + const rest = next.path.slice("/docs".length) + if (!rest || rest === "/") return `/docs/${DOCS[locale]}/${next.suffix}` - const head = next.path.slice("/docs/".length).split("/")[0] ?? "" - if (!head) return `/docs/${value}/${next.suffix}` - if (DOCS_SEGMENT.has(head)) return `${next.path}${next.suffix}` + const value = rest.slice(1) + const index = value.indexOf("/") + const head = (index === -1 ? value : value.slice(0, index)).toLowerCase() + const tail = index === -1 ? "" : value.slice(index + 1) + if (!head) return `/docs/${DOCS[locale]}/${next.suffix}` if (head.startsWith("_")) return `${next.path}${next.suffix}` if (head.includes(".")) return `${next.path}${next.suffix}` - return `/docs/${value}${next.path.slice("/docs".length)}${next.suffix}` + if (head in DOCS_LOCALE) { + if (head === "root") return `/docs/en/${tail}${next.suffix}` + return `${next.path}${next.suffix}` + } + + return `/docs/${DOCS[locale]}/${value}${next.suffix}` } export function parseLocale(value: unknown): Locale | null { @@ -292,11 +271,13 @@ export function localeFromCookieHeader(header: string | null) { const raw = header .split(";") .map((x) => x.trim()) - .find((x) => x.startsWith(`${LOCALE_COOKIE}=`)) + .filter((x) => x.startsWith(`${LOCALE_COOKIE}=`)) + .at(-1) ?.slice(`${LOCALE_COOKIE}=`.length) if (!raw) return null - return parseLocale(decodeURIComponent(raw)) + if (raw.startsWith('"') && raw.endsWith('"')) return parseLocale(raw.slice(1, -1)) + return parseLocale(raw) } export function localeFromRequest(request: Request) { diff --git a/packages/console/app/src/routes/docs/[...path].ts b/packages/console/app/src/routes/docs/[...path].ts index 164bd2872e36..f3221b5d0e18 100644 --- a/packages/console/app/src/routes/docs/[...path].ts +++ b/packages/console/app/src/routes/docs/[...path].ts @@ -1,30 +1,8 @@ -import type { APIEvent } from "@solidjs/start/server" -import { Resource } from "@opencode-ai/console-resource" -import { cookie, docs, localeFromRequest, tag } from "~/lib/language" - -async function handler(evt: APIEvent) { - const req = evt.request.clone() - const url = new URL(req.url) - const locale = localeFromRequest(req) - const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai" - const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}` - - const headers = new Headers(req.headers) - headers.set("accept-language", tag(locale)) - - const response = await fetch(targetUrl, { - method: req.method, - headers, - body: req.body, - }) - const next = new Response(response.body, response) - next.headers.append("set-cookie", cookie(locale)) - return next -} - -export const GET = handler -export const POST = handler -export const PUT = handler -export const DELETE = handler -export const OPTIONS = handler -export const PATCH = handler +import { docsHandler } from "~/lib/docs" + +export const GET = docsHandler +export const POST = docsHandler +export const PUT = docsHandler +export const DELETE = docsHandler +export const OPTIONS = docsHandler +export const PATCH = docsHandler diff --git a/packages/console/app/src/routes/docs/index.ts b/packages/console/app/src/routes/docs/index.ts index 164bd2872e36..f3221b5d0e18 100644 --- a/packages/console/app/src/routes/docs/index.ts +++ b/packages/console/app/src/routes/docs/index.ts @@ -1,30 +1,8 @@ -import type { APIEvent } from "@solidjs/start/server" -import { Resource } from "@opencode-ai/console-resource" -import { cookie, docs, localeFromRequest, tag } from "~/lib/language" - -async function handler(evt: APIEvent) { - const req = evt.request.clone() - const url = new URL(req.url) - const locale = localeFromRequest(req) - const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai" - const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}` - - const headers = new Headers(req.headers) - headers.set("accept-language", tag(locale)) - - const response = await fetch(targetUrl, { - method: req.method, - headers, - body: req.body, - }) - const next = new Response(response.body, response) - next.headers.append("set-cookie", cookie(locale)) - return next -} - -export const GET = handler -export const POST = handler -export const PUT = handler -export const DELETE = handler -export const OPTIONS = handler -export const PATCH = handler +import { docsHandler } from "~/lib/docs" + +export const GET = docsHandler +export const POST = docsHandler +export const PUT = docsHandler +export const DELETE = docsHandler +export const OPTIONS = docsHandler +export const PATCH = docsHandler diff --git a/packages/console/app/test/language.test.ts b/packages/console/app/test/language.test.ts new file mode 100644 index 000000000000..227f68083551 --- /dev/null +++ b/packages/console/app/test/language.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { docs, localeFromCookieHeader } from "../src/lib/language" + +describe("docs", () => { + test("redirects bare docs paths to the requested locale", () => { + expect(docs("en", "/docs")).toBe("/docs/en/") + expect(docs("fr", "/docs/agents")).toBe("/docs/fr/agents") + }) + + test("keeps explicit docs locales authoritative", () => { + expect(docs("en", "/docs/fr/agents")).toBe("/docs/fr/agents") + expect(docs("fr", "/docs/en/agents")).toBe("/docs/en/agents") + }) + + test("normalizes the legacy root docs alias", () => { + expect(docs("fr", "/docs/root/agents")).toBe("/docs/en/agents") + }) + + test("parses locale cookie from the latest value", () => { + expect(localeFromCookieHeader("oc_locale=en; foo=1; oc_locale=fr")).toBe("fr") + expect(localeFromCookieHeader('foo=1; oc_locale="fr"')).toBe("fr") + }) +}) diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index 110c8ce9198d..efecbacf9d4c 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -32,9 +32,9 @@ export default defineConfig({ solidJs(), starlight({ title: "OpenCode", - defaultLocale: "root", + defaultLocale: "en", locales: { - root: { + en: { label: "English", lang: "en", dir: "ltr", diff --git a/packages/web/src/components/Header.astro b/packages/web/src/components/Header.astro index bb13c91175a6..80da715a0e21 100644 --- a/packages/web/src/components/Header.astro +++ b/packages/web/src/components/Header.astro @@ -7,7 +7,7 @@ import Default from "toolbeam-docs-theme/overrides/Header.astro" import SiteTitle from "@astrojs/starlight/components/SiteTitle.astro" const path = Astro.url.pathname -const locale = Astro.currentLocale || "root" +const locale = Astro.currentLocale || "en" const route = Astro.locals.starlightRoute const t = Astro.locals.t as (key: string) => string const links = astroConfig.social || [] diff --git a/packages/web/src/components/Lander.astro b/packages/web/src/components/Lander.astro index 9713cfff602c..55a17bab2c83 100644 --- a/packages/web/src/components/Lander.astro +++ b/packages/web/src/components/Lander.astro @@ -21,7 +21,7 @@ const imageAttrs = { const github = (config.social || []).filter(s => s.icon === 'github')[0]; const discord = (config.social || []).filter(s => s.icon === 'discord')[0]; -const locale = Astro.currentLocale || 'root'; +const locale = Astro.currentLocale || 'en'; const t = Astro.locals.t as (key: string) => string; const docsHref = getRelativeLocaleUrl(locale, "") const docsCliHref = getRelativeLocaleUrl(locale, "cli") diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/en/acp.mdx similarity index 100% rename from packages/web/src/content/docs/acp.mdx rename to packages/web/src/content/docs/en/acp.mdx diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/en/agents.mdx similarity index 100% rename from packages/web/src/content/docs/agents.mdx rename to packages/web/src/content/docs/en/agents.mdx diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/en/cli.mdx similarity index 100% rename from packages/web/src/content/docs/cli.mdx rename to packages/web/src/content/docs/en/cli.mdx diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/en/commands.mdx similarity index 100% rename from packages/web/src/content/docs/commands.mdx rename to packages/web/src/content/docs/en/commands.mdx diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/en/config.mdx similarity index 100% rename from packages/web/src/content/docs/config.mdx rename to packages/web/src/content/docs/en/config.mdx diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/en/custom-tools.mdx similarity index 100% rename from packages/web/src/content/docs/custom-tools.mdx rename to packages/web/src/content/docs/en/custom-tools.mdx diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/en/ecosystem.mdx similarity index 100% rename from packages/web/src/content/docs/ecosystem.mdx rename to packages/web/src/content/docs/en/ecosystem.mdx diff --git a/packages/web/src/content/docs/enterprise.mdx b/packages/web/src/content/docs/en/enterprise.mdx similarity index 99% rename from packages/web/src/content/docs/enterprise.mdx rename to packages/web/src/content/docs/en/enterprise.mdx index 6bfc9b7a93b3..da30feeec85e 100644 --- a/packages/web/src/content/docs/enterprise.mdx +++ b/packages/web/src/content/docs/en/enterprise.mdx @@ -3,7 +3,7 @@ title: Enterprise description: Using OpenCode securely in your organization. --- -import config from "../../../config.mjs" +import config from "../../../../config.mjs" export const email = `mailto:${config.email}` OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves their infrastructure. It can do this by using a centralized config that integrates with your SSO and internal AI gateway. diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/en/formatters.mdx similarity index 100% rename from packages/web/src/content/docs/formatters.mdx rename to packages/web/src/content/docs/en/formatters.mdx diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/en/github.mdx similarity index 100% rename from packages/web/src/content/docs/github.mdx rename to packages/web/src/content/docs/en/github.mdx diff --git a/packages/web/src/content/docs/gitlab.mdx b/packages/web/src/content/docs/en/gitlab.mdx similarity index 100% rename from packages/web/src/content/docs/gitlab.mdx rename to packages/web/src/content/docs/en/gitlab.mdx diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/en/go.mdx similarity index 99% rename from packages/web/src/content/docs/go.mdx rename to packages/web/src/content/docs/en/go.mdx index b634791a6af8..7b1825e5a770 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/en/go.mdx @@ -3,7 +3,7 @@ title: Go description: Low cost subscription for open coding models. --- -import config from "../../../config.mjs" +import config from "../../../../config.mjs" export const console = config.console export const email = `mailto:${config.email}` diff --git a/packages/web/src/content/docs/ide.mdx b/packages/web/src/content/docs/en/ide.mdx similarity index 100% rename from packages/web/src/content/docs/ide.mdx rename to packages/web/src/content/docs/en/ide.mdx diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/en/index.mdx similarity index 98% rename from packages/web/src/content/docs/index.mdx rename to packages/web/src/content/docs/en/index.mdx index 90e7eafb2f31..fe45a6cb3431 100644 --- a/packages/web/src/content/docs/index.mdx +++ b/packages/web/src/content/docs/en/index.mdx @@ -4,12 +4,12 @@ description: Get started with OpenCode. --- import { Tabs, TabItem } from "@astrojs/starlight/components" -import config from "../../../config.mjs" +import config from "../../../../config.mjs" export const console = config.console [**OpenCode**](/) is an open source AI coding agent. It's available as a terminal-based interface, desktop app, or IDE extension. -![OpenCode TUI with the opencode theme](../../assets/lander/screenshot.png) +![OpenCode TUI with the opencode theme](../../../assets/lander/screenshot.png) Let's get started. diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/en/keybinds.mdx similarity index 100% rename from packages/web/src/content/docs/keybinds.mdx rename to packages/web/src/content/docs/en/keybinds.mdx diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/en/lsp.mdx similarity index 100% rename from packages/web/src/content/docs/lsp.mdx rename to packages/web/src/content/docs/en/lsp.mdx diff --git a/packages/web/src/content/docs/mcp-servers.mdx b/packages/web/src/content/docs/en/mcp-servers.mdx similarity index 100% rename from packages/web/src/content/docs/mcp-servers.mdx rename to packages/web/src/content/docs/en/mcp-servers.mdx diff --git a/packages/web/src/content/docs/models.mdx b/packages/web/src/content/docs/en/models.mdx similarity index 100% rename from packages/web/src/content/docs/models.mdx rename to packages/web/src/content/docs/en/models.mdx diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/en/modes.mdx similarity index 100% rename from packages/web/src/content/docs/modes.mdx rename to packages/web/src/content/docs/en/modes.mdx diff --git a/packages/web/src/content/docs/network.mdx b/packages/web/src/content/docs/en/network.mdx similarity index 100% rename from packages/web/src/content/docs/network.mdx rename to packages/web/src/content/docs/en/network.mdx diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/en/permissions.mdx similarity index 100% rename from packages/web/src/content/docs/permissions.mdx rename to packages/web/src/content/docs/en/permissions.mdx diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/en/plugins.mdx similarity index 100% rename from packages/web/src/content/docs/plugins.mdx rename to packages/web/src/content/docs/en/plugins.mdx diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/en/providers.mdx similarity index 99% rename from packages/web/src/content/docs/providers.mdx rename to packages/web/src/content/docs/en/providers.mdx index b14c8ab10a8e..007b7f439a15 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/en/providers.mdx @@ -3,7 +3,7 @@ title: Providers description: Using any LLM provider in OpenCode. --- -import config from "../../../config.mjs" +import config from "../../../../config.mjs" export const console = config.console OpenCode uses the [AI SDK](https://ai-sdk.dev/) and [Models.dev](https://models.dev) to support **75+ LLM providers** and it supports running local models. diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/en/rules.mdx similarity index 100% rename from packages/web/src/content/docs/rules.mdx rename to packages/web/src/content/docs/en/rules.mdx diff --git a/packages/web/src/content/docs/sdk.mdx b/packages/web/src/content/docs/en/sdk.mdx similarity index 99% rename from packages/web/src/content/docs/sdk.mdx rename to packages/web/src/content/docs/en/sdk.mdx index 24546213be51..f2782d9d45d7 100644 --- a/packages/web/src/content/docs/sdk.mdx +++ b/packages/web/src/content/docs/en/sdk.mdx @@ -3,7 +3,7 @@ title: SDK description: Type-safe JS client for opencode server. --- -import config from "../../../config.mjs" +import config from "../../../../config.mjs" export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` The opencode JS/TS SDK provides a type-safe client for interacting with the server. diff --git a/packages/web/src/content/docs/server.mdx b/packages/web/src/content/docs/en/server.mdx similarity index 99% rename from packages/web/src/content/docs/server.mdx rename to packages/web/src/content/docs/en/server.mdx index 4510bd4981fe..d312668255f0 100644 --- a/packages/web/src/content/docs/server.mdx +++ b/packages/web/src/content/docs/en/server.mdx @@ -3,7 +3,7 @@ title: Server description: Interact with opencode server over HTTP. --- -import config from "../../../config.mjs" +import config from "../../../../config.mjs" export const typesUrl = `${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts` The `opencode serve` command runs a headless HTTP server that exposes an OpenAPI endpoint that an opencode client can use. diff --git a/packages/web/src/content/docs/share.mdx b/packages/web/src/content/docs/en/share.mdx similarity index 100% rename from packages/web/src/content/docs/share.mdx rename to packages/web/src/content/docs/en/share.mdx diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/en/skills.mdx similarity index 100% rename from packages/web/src/content/docs/skills.mdx rename to packages/web/src/content/docs/en/skills.mdx diff --git a/packages/web/src/content/docs/themes.mdx b/packages/web/src/content/docs/en/themes.mdx similarity index 100% rename from packages/web/src/content/docs/themes.mdx rename to packages/web/src/content/docs/en/themes.mdx diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/en/tools.mdx similarity index 100% rename from packages/web/src/content/docs/tools.mdx rename to packages/web/src/content/docs/en/tools.mdx diff --git a/packages/web/src/content/docs/troubleshooting.mdx b/packages/web/src/content/docs/en/troubleshooting.mdx similarity index 100% rename from packages/web/src/content/docs/troubleshooting.mdx rename to packages/web/src/content/docs/en/troubleshooting.mdx diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/en/tui.mdx similarity index 100% rename from packages/web/src/content/docs/tui.mdx rename to packages/web/src/content/docs/en/tui.mdx diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/en/web.mdx similarity index 92% rename from packages/web/src/content/docs/web.mdx rename to packages/web/src/content/docs/en/web.mdx index 52b97460c49f..9931835aa0ae 100644 --- a/packages/web/src/content/docs/web.mdx +++ b/packages/web/src/content/docs/en/web.mdx @@ -5,7 +5,7 @@ description: Using OpenCode in your browser. OpenCode can run as a web application in your browser, providing the same powerful AI coding experience without needing a terminal. -![OpenCode Web - New Session](../../assets/web/web-homepage-new-session.png) +![OpenCode Web - New Session](../../../assets/web/web-homepage-new-session.png) ## Getting Started @@ -98,13 +98,13 @@ Once started, the web interface provides access to your OpenCode sessions. View and manage your sessions from the homepage. You can see active sessions and start new ones. -![OpenCode Web - Active Session](../../assets/web/web-homepage-active-session.png) +![OpenCode Web - Active Session](../../../assets/web/web-homepage-active-session.png) ### Server Status Click "See Servers" to view connected servers and their status. -![OpenCode Web - See Servers](../../assets/web/web-homepage-see-servers.png) +![OpenCode Web - See Servers](../../../assets/web/web-homepage-see-servers.png) --- diff --git a/packages/web/src/content/docs/windows-wsl.mdx b/packages/web/src/content/docs/en/windows-wsl.mdx similarity index 100% rename from packages/web/src/content/docs/windows-wsl.mdx rename to packages/web/src/content/docs/en/windows-wsl.mdx diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/en/zen.mdx similarity index 99% rename from packages/web/src/content/docs/zen.mdx rename to packages/web/src/content/docs/en/zen.mdx index 3dd1ef7fb8e7..1b4f3487ccfd 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/en/zen.mdx @@ -3,7 +3,7 @@ title: Zen description: Curated list of models provided by OpenCode. --- -import config from "../../../config.mjs" +import config from "../../../../config.mjs" export const console = config.console export const email = `mailto:${config.email}` diff --git a/packages/web/src/i18n/locales.ts b/packages/web/src/i18n/locales.ts index 67e36dfe1489..aed7883fe730 100644 --- a/packages/web/src/i18n/locales.ts +++ b/packages/web/src/i18n/locales.ts @@ -20,7 +20,7 @@ export const docsLocale = [ export type DocsLocale = (typeof docsLocale)[number] -export const locale = ["root", ...docsLocale] as const +export const locale = ["en", ...docsLocale] as const export type Locale = (typeof locale)[number] @@ -30,7 +30,7 @@ export const localeAlias = { bs: "bs", da: "da", de: "de", - en: "root", + en: "en", es: "es", fr: "fr", it: "it", @@ -42,7 +42,6 @@ export const localeAlias = { pl: "pl", pt: "pt-br", "pt-br": "pt-br", - root: "root", ru: "ru", th: "th", tr: "tr", @@ -66,7 +65,7 @@ const starts = [ ["ar", "ar"], ["th", "th"], ["tr", "tr"], - ["en", "root"], + ["en", "en"], ] as const function parse(input: string) { diff --git a/packages/web/src/lib/docs-locale.ts b/packages/web/src/lib/docs-locale.ts new file mode 100644 index 000000000000..10420188a6bd --- /dev/null +++ b/packages/web/src/lib/docs-locale.ts @@ -0,0 +1,103 @@ +import { exactLocale, matchLocale } from "../i18n/locales" + +export 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 = tail ? `/docs/${locale}${tail}` : `/docs/${locale}/` + if (next === pathname) return null + return { + path: next, + locale, + } +} + +export function docsRouteLocale(pathname: string) { + if (pathname === "/docs" || pathname === "/docs/") return "en" + + const hit = /^\/docs\/([^/]+)(\/.*)?$/.exec(pathname) + if (!hit) return null + + const value = hit[1] ?? "" + if (!value || value.startsWith("_") || value.includes(".")) return null + + return exactLocale(value) ?? "en" +} + +export function docsRedirect(pathname: string, locale: string) { + if (pathname === "/docs" || pathname === "/docs/") return `/docs/${locale}/` + + const hit = /^\/docs\/([^/]+)(\/.*)?$/.exec(pathname) + if (!hit) return null + + const value = hit[1] ?? "" + const tail = hit[2] ?? "" + if (!value || value.startsWith("_") || value.includes(".")) return null + if (exactLocale(value)) return null + return `/docs/${locale}/${value}${tail}` +} + +export function cookie(locale: string) { + return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +export function redirect(url: URL, path: string, locale?: string) { + const next = new URL(url.toString()) + next.pathname = path + const headers = new Headers({ + Location: next.toString(), + }) + if (locale) headers.set("Set-Cookie", cookie(locale)) + return new Response(null, { + status: 302, + headers, + }) +} + +export function localeFromCookie(header: string | null) { + if (!header) return null + const raw = header + .split(";") + .map((x) => x.trim()) + .filter((x) => x.startsWith("oc_locale=")) + .at(-1) + ?.slice("oc_locale=".length) + if (!raw) return null + if (raw.startsWith('"') && raw.endsWith('"')) return matchLocale(raw.slice(1, -1)) + return matchLocale(raw) +} + +export function localeFromAcceptLanguage(header: string | null) { + if (!header) return "en" + + 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 ?? "en" +} diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index cf9f97b0b131..15b0707701fd 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -1,94 +1,18 @@ import { defineMiddleware } from "astro:middleware" -import { exactLocale, matchLocale } from "./i18n/locales" +import { docsAlias, docsRedirect, localeFromAcceptLanguage, localeFromCookie, redirect } from "./lib/docs-locale" -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) { - const next = new URL(url.toString()) - next.pathname = path - const headers = new Headers({ - Location: next.toString(), - }) - if (locale) headers.set("Set-Cookie", cookie(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" -} - -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) } - if (ctx.url.pathname !== "/docs" && ctx.url.pathname !== "/docs/") return next() - const locale = localeFromCookie(ctx.request.headers.get("cookie")) ?? localeFromAcceptLanguage(ctx.request.headers.get("accept-language")) - if (!locale || locale === "root") return next() - return redirect(ctx.url, `/docs/${locale}/`) + const path = docsRedirect(ctx.url.pathname, locale) + if (path) return redirect(ctx.url, path, locale) + + return next() }) diff --git a/packages/web/src/pages/s/[id].astro b/packages/web/src/pages/s/[id].astro index 7ea42f69168c..be6749fb60a0 100644 --- a/packages/web/src/pages/s/[id].astro +++ b/packages/web/src/pages/s/[id].astro @@ -10,8 +10,8 @@ const ta = Astro.locals.t as ((key: string) => string) & { all?: () => Record } const all = typeof ta.all === "function" ? ta.all() : {} -const locale = Astro.currentLocale || Astro.locals.starlightRoute.locale || "root" -const formatLocale = locale === "root" ? "en" : locale +const locale = Astro.currentLocale || Astro.locals.starlightRoute.locale || "en" +const formatLocale = locale const t = ta function tx(key: string) { diff --git a/packages/web/test/middleware.test.ts b/packages/web/test/middleware.test.ts new file mode 100644 index 000000000000..08ca04eae448 --- /dev/null +++ b/packages/web/test/middleware.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { + cookie, + docsAlias, + docsRedirect, + docsRouteLocale, + localeFromAcceptLanguage, + localeFromCookie, + redirect, +} from "../src/lib/docs-locale" + +describe("docs middleware", () => { + test("redirects bare docs aliases to explicit english paths", () => { + expect(docsRedirect("/docs", "en")).toBe("/docs/en/") + expect(docsRedirect("/docs/agents", "en")).toBe("/docs/en/agents") + }) + + test("redirects bare docs aliases to cookie locale paths", () => { + expect(docsRedirect("/docs", "fr")).toBe("/docs/fr/") + expect(docsRedirect("/docs/agents", "fr")).toBe("/docs/fr/agents") + }) + + test("keeps explicit locale routes authoritative", () => { + expect(docsRedirect("/docs/en/agents", "fr")).toBeNull() + expect(docsRedirect("/docs/fr/agents", "en")).toBeNull() + }) + + test("treats unknown locale-looking segments as bare docs aliases", () => { + expect(docsRouteLocale("/docs/xx/agents")).toBe("en") + expect(docsRedirect("/docs/xx/agents", "fr")).toBe("/docs/fr/xx/agents") + expect(docsRedirect("/docs/xx/agents", "en")).toBe("/docs/en/xx/agents") + }) + + test("leaves unknown locale aliases alone", () => { + expect(docsAlias("/docs/root/agents")).toBeNull() + expect(docsAlias("/docs/root")).toBeNull() + }) + + test("parses locale from cookie and accept-language", () => { + expect(localeFromCookie("foo=1; oc_locale=fr; bar=2")).toBe("fr") + expect(localeFromCookie("oc_locale=en; foo=1; oc_locale=fr")).toBe("fr") + expect(localeFromCookie('oc_locale="fr"; foo=1')).toBe("fr") + expect(localeFromAcceptLanguage("fr-CA,fr;q=0.9,en;q=0.8")).toBe("fr") + expect(localeFromAcceptLanguage(null)).toBe("en") + }) + + test("builds redirect responses with locale cookie", () => { + const response = redirect(new URL("https://example.test/docs/agents"), "/docs/en/agents", "en") + expect(response.status).toBe(302) + expect(response.headers.get("location")).toBe("https://example.test/docs/en/agents") + expect(response.headers.get("set-cookie")).toBe(cookie("en")) + }) +})