| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- import * as i18n from "@solid-primitives/i18n"
- import { createEffect, createMemo, createResource } from "solid-js"
- import { createStore } from "solid-js/store"
- import { createSimpleContext } from "@opencode-ai/ui/context"
- import { Persist, persisted } from "@/utils/persist"
- import { dict as en } from "@/i18n/en"
- import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
- export type Locale =
- | "en"
- | "zh"
- | "zht"
- | "ko"
- | "de"
- | "es"
- | "fr"
- | "da"
- | "ja"
- | "pl"
- | "ru"
- | "uk"
- | "ar"
- | "no"
- | "br"
- | "th"
- | "bs"
- | "tr"
- type RawDictionary = typeof en & typeof uiEn
- type Dictionary = i18n.Flatten<RawDictionary>
- type Source = { dict: Record<string, string> }
- function cookie(locale: Locale) {
- return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
- }
- const LOCALES: readonly Locale[] = [
- "en",
- "zh",
- "zht",
- "ko",
- "de",
- "es",
- "fr",
- "da",
- "ja",
- "pl",
- "ru",
- "uk",
- "bs",
- "ar",
- "no",
- "br",
- "th",
- "tr",
- ]
- const INTL: Record<Locale, string> = {
- en: "en",
- zh: "zh-Hans",
- zht: "zh-Hant",
- ko: "ko",
- de: "de",
- es: "es",
- fr: "fr",
- da: "da",
- ja: "ja",
- pl: "pl",
- ru: "ru",
- uk: "uk",
- ar: "ar",
- no: "nb-NO",
- br: "pt-BR",
- th: "th",
- bs: "bs",
- tr: "tr",
- }
- const LABEL_KEY: Record<Locale, keyof Dictionary> = {
- en: "language.en",
- zh: "language.zh",
- zht: "language.zht",
- ko: "language.ko",
- de: "language.de",
- es: "language.es",
- fr: "language.fr",
- da: "language.da",
- ja: "language.ja",
- pl: "language.pl",
- ru: "language.ru",
- uk: "language.uk",
- ar: "language.ar",
- no: "language.no",
- br: "language.br",
- th: "language.th",
- bs: "language.bs",
- tr: "language.tr",
- }
- const base = i18n.flatten({ ...en, ...uiEn })
- const dicts = new Map<Locale, Dictionary>([["en", base]])
- const merge = (app: Promise<Source>, ui: Promise<Source>) =>
- Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
- const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
- zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
- zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
- ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
- de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
- es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
- fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
- da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
- ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
- pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
- ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
- uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
- ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
- no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
- br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
- th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
- bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
- tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
- }
- function loadDict(locale: Locale) {
- const hit = dicts.get(locale)
- if (hit) return Promise.resolve(hit)
- if (locale === "en") return Promise.resolve(base)
- const load = loaders[locale]
- return load().then((next: Dictionary) => {
- dicts.set(locale, next)
- return next
- })
- }
- export function loadLocaleDict(locale: Locale) {
- return loadDict(locale).then(() => undefined)
- }
- const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
- { locale: "en", match: (language) => language.startsWith("en") },
- { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
- { locale: "zh", match: (language) => language.startsWith("zh") },
- { locale: "ko", match: (language) => language.startsWith("ko") },
- { locale: "de", match: (language) => language.startsWith("de") },
- { locale: "es", match: (language) => language.startsWith("es") },
- { locale: "fr", match: (language) => language.startsWith("fr") },
- { locale: "da", match: (language) => language.startsWith("da") },
- { locale: "ja", match: (language) => language.startsWith("ja") },
- { locale: "pl", match: (language) => language.startsWith("pl") },
- { locale: "ru", match: (language) => language.startsWith("ru") },
- { locale: "uk", match: (language) => language.startsWith("uk") },
- { locale: "ar", match: (language) => language.startsWith("ar") },
- {
- locale: "no",
- match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
- },
- { locale: "br", match: (language) => language.startsWith("pt") },
- { locale: "th", match: (language) => language.startsWith("th") },
- { locale: "bs", match: (language) => language.startsWith("bs") },
- { locale: "tr", match: (language) => language.startsWith("tr") },
- ]
- function detectLocale(): Locale {
- if (typeof navigator !== "object") return "en"
- const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
- for (const language of languages) {
- if (!language) continue
- const normalized = language.toLowerCase()
- const match = localeMatchers.find((entry) => entry.match(normalized))
- if (match) return match.locale
- }
- return "en"
- }
- export function normalizeLocale(value: string): Locale {
- return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
- }
- function readStoredLocale() {
- if (typeof localStorage !== "object") return
- try {
- const raw = localStorage.getItem("opencode.global.dat:language")
- if (!raw) return
- const next = JSON.parse(raw) as { locale?: string }
- if (typeof next?.locale !== "string") return
- return normalizeLocale(next.locale)
- } catch {
- return
- }
- }
- const warm = readStoredLocale() ?? detectLocale()
- if (warm !== "en") void loadDict(warm)
- export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
- name: "Language",
- gate: false,
- init: (props: { locale?: Locale }) => {
- const initial = props.locale ?? readStoredLocale() ?? detectLocale()
- const [store, setStore, _, ready] = persisted(
- Persist.global("language", ["language.v1"]),
- createStore({
- locale: initial,
- }),
- )
- const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
- const intl = createMemo(() => INTL[locale()])
- const [dict] = createResource(locale, loadDict, {
- initialValue: dicts.get(initial) ?? base,
- })
- const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
- key: keyof Dictionary,
- params?: Record<string, string | number | boolean>,
- ) => string
- const label = (value: Locale) => t(LABEL_KEY[value])
- createEffect(() => {
- if (typeof document !== "object") return
- document.documentElement.lang = locale()
- document.cookie = cookie(locale())
- })
- return {
- ready,
- locale,
- intl,
- locales: LOCALES,
- label,
- t,
- setLocale(next: Locale) {
- setStore("locale", normalizeLocale(next))
- },
- }
- },
- })
|