Skip to content

Commit 24cd84c

Browse files
authored
feat(www): locale specific urls (anomalyco#12508)
1 parent 8069197 commit 24cd84c

33 files changed

Lines changed: 279 additions & 134 deletions

packages/console/app/script/generate-sitemap.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { readdir, writeFile } from "fs/promises"
33
import { join, dirname } from "path"
44
import { fileURLToPath } from "url"
55
import { config } from "../src/config.js"
6+
import { LOCALES, route } from "../src/lib/language.js"
67

78
const __dirname = dirname(fileURLToPath(import.meta.url))
89
const BASE_URL = config.baseUrl
@@ -27,12 +28,14 @@ async function getMainRoutes(): Promise<SitemapEntry[]> {
2728
{ path: "/zen", priority: 0.8, changefreq: "weekly" },
2829
]
2930

30-
for (const route of staticRoutes) {
31-
routes.push({
32-
url: `${BASE_URL}${route.path}`,
33-
priority: route.priority,
34-
changefreq: route.changefreq,
35-
})
31+
for (const item of staticRoutes) {
32+
for (const locale of LOCALES) {
33+
routes.push({
34+
url: `${BASE_URL}${route(locale, item.path)}`,
35+
priority: item.priority,
36+
changefreq: item.changefreq,
37+
})
38+
}
3639
}
3740

3841
return routes
@@ -50,11 +53,13 @@ async function getDocsRoutes(): Promise<SitemapEntry[]> {
5053
const slug = file.replace(".mdx", "")
5154
const path = slug === "index" ? "/docs/" : `/docs/${slug}`
5255

53-
routes.push({
54-
url: `${BASE_URL}${path}`,
55-
priority: slug === "index" ? 0.9 : 0.7,
56-
changefreq: "weekly",
57-
})
56+
for (const locale of LOCALES) {
57+
routes.push({
58+
url: `${BASE_URL}${route(locale, path)}`,
59+
priority: slug === "index" ? 0.9 : 0.7,
60+
changefreq: "weekly",
61+
})
62+
}
5863
}
5964
} catch (error) {
6065
console.error("Error reading docs directory:", error)

packages/console/app/src/app.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import "@ibm/plex/css/ibm-plex.css"
88
import "./app.css"
99
import { LanguageProvider } from "~/context/language"
1010
import { I18nProvider } from "~/context/i18n"
11+
import { strip } from "~/lib/language"
1112

1213
export default function App() {
1314
return (
1415
<Router
1516
explicitLinks={true}
17+
transformUrl={strip}
1618
root={(props) => (
1719
<LanguageProvider>
1820
<I18nProvider>

packages/console/app/src/component/footer.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ export function Footer() {
2626
</a>
2727
</div>
2828
<div data-slot="cell">
29-
<a href="/docs">{i18n.t("footer.docs")}</a>
29+
<a href={language.route("/docs")}>{i18n.t("footer.docs")}</a>
3030
</div>
3131
<div data-slot="cell">
32-
<a href="/changelog">{i18n.t("footer.changelog")}</a>
32+
<a href={language.route("/changelog")}>{i18n.t("footer.changelog")}</a>
3333
</div>
3434
<div data-slot="cell">
35-
<a href="/discord">{i18n.t("footer.discord")}</a>
35+
<a href={language.route("/discord")}>{i18n.t("footer.discord")}</a>
3636
</div>
3737
<div data-slot="cell">
3838
<a href={config.social.twitter}>{i18n.t("footer.x")}</a>

packages/console/app/src/component/header.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { github } from "~/lib/github"
2020
import { createEffect, onCleanup } from "solid-js"
2121
import { config } from "~/config"
2222
import { useI18n } from "~/context/i18n"
23+
import { useLanguage } from "~/context/language"
2324
import "./header-context-menu.css"
2425

2526
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
@@ -38,6 +39,7 @@ const fetchSvgContent = async (svgPath: string): Promise<string> => {
3839
export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
3940
const navigate = useNavigate()
4041
const i18n = useI18n()
42+
const language = useLanguage()
4143
const githubData = createAsync(() => github())
4244
const starCount = createMemo(() =>
4345
githubData()?.stars
@@ -121,7 +123,7 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
121123
return (
122124
<section data-component="top">
123125
<div onContextMenu={handleLogoContextMenu}>
124-
<A href="/">
126+
<A href={language.route("/")}>
125127
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="189" height="34" />
126128
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="189" height="34" />
127129
</A>
@@ -142,7 +144,7 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
142144
<img data-slot="copy dark" src={copyWordmarkDark} alt="" />
143145
{i18n.t("nav.context.copyWordmark")}
144146
</button>
145-
<button class="context-menu-item" onClick={() => navigate("/brand")}>
147+
<button class="context-menu-item" onClick={() => navigate(language.route("/brand"))}>
146148
<img data-slot="copy light" src={copyBrandAssetsLight} alt="" />
147149
<img data-slot="copy dark" src={copyBrandAssetsDark} alt="" />
148150
{i18n.t("nav.context.brandAssets")}
@@ -157,24 +159,24 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
157159
</a>
158160
</li>
159161
<li>
160-
<a href="/docs">{i18n.t("nav.docs")}</a>
162+
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
161163
</li>
162164
<li>
163-
<A href="/enterprise">{i18n.t("nav.enterprise")}</A>
165+
<A href={language.route("/enterprise")}>{i18n.t("nav.enterprise")}</A>
164166
</li>
165167
<li>
166168
<Switch>
167169
<Match when={props.zen}>
168-
<a href="/auth">{i18n.t("nav.login")}</a>
170+
<a href={language.route("/auth")}>{i18n.t("nav.login")}</a>
169171
</Match>
170172
<Match when={!props.zen}>
171-
<A href="/zen">{i18n.t("nav.zen")}</A>
173+
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
172174
</Match>
173175
</Switch>
174176
</li>
175177
<Show when={!props.hideGetStarted}>
176178
<li>
177-
<A href="/download" data-slot="cta-button">
179+
<A href={language.route("/download")} data-slot="cta-button">
178180
<svg
179181
width="18"
180182
height="18"
@@ -245,32 +247,32 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
245247
<nav data-component="nav-mobile-menu-list">
246248
<ul>
247249
<li>
248-
<A href="/">{i18n.t("nav.home")}</A>
250+
<A href={language.route("/")}>{i18n.t("nav.home")}</A>
249251
</li>
250252
<li>
251253
<a href={config.github.repoUrl} target="_blank" style="white-space: nowrap;">
252254
{i18n.t("nav.github")} <span>[{starCount()}]</span>
253255
</a>
254256
</li>
255257
<li>
256-
<a href="/docs">{i18n.t("nav.docs")}</a>
258+
<a href={language.route("/docs")}>{i18n.t("nav.docs")}</a>
257259
</li>
258260
<li>
259-
<A href="/enterprise">{i18n.t("nav.enterprise")}</A>
261+
<A href={language.route("/enterprise")}>{i18n.t("nav.enterprise")}</A>
260262
</li>
261263
<li>
262264
<Switch>
263265
<Match when={props.zen}>
264-
<a href="/auth">{i18n.t("nav.login")}</a>
266+
<a href={language.route("/auth")}>{i18n.t("nav.login")}</a>
265267
</Match>
266268
<Match when={!props.zen}>
267-
<A href="/zen">{i18n.t("nav.zen")}</A>
269+
<A href={language.route("/zen")}>{i18n.t("nav.zen")}</A>
268270
</Match>
269271
</Switch>
270272
</li>
271273
<Show when={!props.hideGetStarted}>
272274
<li>
273-
<A href="/download" data-slot="cta-button">
275+
<A href={language.route("/download")} data-slot="cta-button">
274276
{i18n.t("nav.getStartedFree")}
275277
</A>
276278
</li>

packages/console/app/src/component/language-picker.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { For, createSignal } from "solid-js"
2+
import { useLocation, useNavigate } from "@solidjs/router"
23
import { Dropdown, DropdownItem } from "~/component/dropdown"
34
import { useLanguage } from "~/context/language"
5+
import { route, strip } from "~/lib/language"
46
import "./language-picker.css"
57

68
export function LanguagePicker(props: { align?: "left" | "right" } = {}) {
79
const language = useLanguage()
10+
const navigate = useNavigate()
11+
const location = useLocation()
812
const [open, setOpen] = createSignal(false)
913

1014
return (
@@ -21,6 +25,8 @@ export function LanguagePicker(props: { align?: "left" | "right" } = {}) {
2125
selected={locale === language.locale()}
2226
onClick={() => {
2327
language.setLocale(locale)
28+
const href = `${route(locale, strip(location.pathname))}${location.search}${location.hash}`
29+
if (href !== `${location.pathname}${location.search}${location.hash}`) navigate(href)
2430
setOpen(false)
2531
}}
2632
>

packages/console/app/src/component/legal.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
11
import { A } from "@solidjs/router"
22
import { LanguagePicker } from "~/component/language-picker"
33
import { useI18n } from "~/context/i18n"
4+
import { useLanguage } from "~/context/language"
45

56
export function Legal() {
67
const i18n = useI18n()
8+
const language = useLanguage()
79
return (
810
<div data-component="legal">
911
<span>
1012
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
1113
</span>
1214
<span>
13-
<A href="/brand">{i18n.t("legal.brand")}</A>
15+
<A href={language.route("/brand")}>{i18n.t("legal.brand")}</A>
1416
</span>
1517
<span>
16-
<A href="/legal/privacy-policy">{i18n.t("legal.privacy")}</A>
18+
<A href={language.route("/legal/privacy-policy")}>{i18n.t("legal.privacy")}</A>
1719
</span>
1820
<span>
19-
<A href="/legal/terms-of-service">{i18n.t("legal.terms")}</A>
21+
<A href={language.route("/legal/terms-of-service")}>{i18n.t("legal.terms")}</A>
2022
</span>
2123
<span>
2224
<LanguagePicker align="right" />
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Link } from "@solidjs/meta"
2+
import { For } from "solid-js"
3+
import { getRequestEvent } from "solid-js/web"
4+
import { config } from "~/config"
5+
import { useLanguage } from "~/context/language"
6+
import { LOCALES, route, tag } from "~/lib/language"
7+
8+
function skip(path: string) {
9+
const evt = getRequestEvent()
10+
if (!evt) return false
11+
12+
const key = "__locale_links_seen"
13+
const locals = evt.locals as Record<string, unknown>
14+
const seen = locals[key] instanceof Set ? (locals[key] as Set<string>) : new Set<string>()
15+
locals[key] = seen
16+
if (seen.has(path)) return true
17+
seen.add(path)
18+
return false
19+
}
20+
21+
export function LocaleLinks(props: { path: string }) {
22+
const language = useLanguage()
23+
if (skip(props.path)) return null
24+
25+
return (
26+
<>
27+
<Link rel="canonical" href={`${config.baseUrl}${route(language.locale(), props.path)}`} />
28+
<For each={LOCALES}>
29+
{(locale) => (
30+
<Link rel="alternate" hreflang={tag(locale)} href={`${config.baseUrl}${route(locale, props.path)}`} />
31+
)}
32+
</For>
33+
<Link rel="alternate" hreflang="x-default" href={`${config.baseUrl}${props.path}`} />
34+
</>
35+
)
36+
}

packages/console/app/src/context/language.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
localeFromCookieHeader,
1414
localeFromRequest,
1515
parseLocale,
16+
route as localeRoute,
1617
tag as localeTag,
1718
} from "~/lib/language"
1819

@@ -54,6 +55,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
5455
label: localeLabel,
5556
tag: localeTag,
5657
dir: localeDir,
58+
route(pathname: string) {
59+
return localeRoute(store.locale, pathname)
60+
},
5761
setLocale(next: Locale) {
5862
setStore("locale", next)
5963
if (typeof document !== "object") return

packages/console/app/src/lib/language.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ export const LOCALES = [
2121
export type Locale = (typeof LOCALES)[number]
2222

2323
export const LOCALE_COOKIE = "oc_locale" as const
24+
export const LOCALE_HEADER = "x-opencode-locale" as const
25+
26+
function fix(pathname: string) {
27+
if (pathname.startsWith("/")) return pathname
28+
return `/${pathname}`
29+
}
2430

2531
const LABEL = {
2632
en: "English",
@@ -68,6 +74,28 @@ export function parseLocale(value: unknown): Locale | null {
6874
return null
6975
}
7076

77+
export function fromPathname(pathname: string) {
78+
return parseLocale(fix(pathname).split("/")[1])
79+
}
80+
81+
export function strip(pathname: string) {
82+
const locale = fromPathname(pathname)
83+
if (!locale) return fix(pathname)
84+
85+
const next = fix(pathname).slice(locale.length + 1)
86+
if (!next) return "/"
87+
if (next.startsWith("/")) return next
88+
return `/${next}`
89+
}
90+
91+
export function route(locale: Locale, pathname: string) {
92+
const next = strip(pathname)
93+
if (next.startsWith("/docs")) return next
94+
if (locale === "en") return next
95+
if (next === "/") return `/${locale}`
96+
return `/${locale}${next}`
97+
}
98+
7199
export function label(locale: Locale) {
72100
return LABEL[locale]
73101
}
@@ -160,6 +188,12 @@ export function localeFromCookieHeader(header: string | null) {
160188
}
161189

162190
export function localeFromRequest(request: Request) {
191+
const fromHeader = parseLocale(request.headers.get(LOCALE_HEADER))
192+
if (fromHeader) return fromHeader
193+
194+
const fromPath = fromPathname(new URL(request.url).pathname)
195+
if (fromPath) return fromPath
196+
163197
return (
164198
localeFromCookieHeader(request.headers.get("cookie")) ??
165199
detectFromAcceptLanguage(request.headers.get("accept-language"))
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
import { createMiddleware } from "@solidjs/start/middleware"
2+
import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language"
23

34
export default createMiddleware({
4-
onBeforeResponse() {},
5+
onRequest(event) {
6+
const url = new URL(event.request.url)
7+
const locale = fromPathname(url.pathname)
8+
if (!locale) return
9+
10+
event.request.headers.set(LOCALE_HEADER, locale)
11+
event.response.headers.append("set-cookie", cookie(locale))
12+
13+
url.pathname = strip(url.pathname)
14+
event.request = new Request(url, event.request)
15+
},
516
})

0 commit comments

Comments
 (0)