language.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import * as i18n from "@solid-primitives/i18n"
  2. import { createEffect, createMemo, createResource } from "solid-js"
  3. import { createStore } from "solid-js/store"
  4. import { createSimpleContext } from "@opencode-ai/ui/context"
  5. import { Persist, persisted } from "@/utils/persist"
  6. import { dict as en } from "@/i18n/en"
  7. import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
  8. export type Locale =
  9. | "en"
  10. | "zh"
  11. | "zht"
  12. | "ko"
  13. | "de"
  14. | "es"
  15. | "fr"
  16. | "da"
  17. | "ja"
  18. | "pl"
  19. | "ru"
  20. | "uk"
  21. | "ar"
  22. | "no"
  23. | "br"
  24. | "th"
  25. | "bs"
  26. | "tr"
  27. type RawDictionary = typeof en & typeof uiEn
  28. type Dictionary = i18n.Flatten<RawDictionary>
  29. type Source = { dict: Record<string, string> }
  30. function cookie(locale: Locale) {
  31. return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
  32. }
  33. const LOCALES: readonly Locale[] = [
  34. "en",
  35. "zh",
  36. "zht",
  37. "ko",
  38. "de",
  39. "es",
  40. "fr",
  41. "da",
  42. "ja",
  43. "pl",
  44. "ru",
  45. "uk",
  46. "bs",
  47. "ar",
  48. "no",
  49. "br",
  50. "th",
  51. "tr",
  52. ]
  53. const INTL: Record<Locale, string> = {
  54. en: "en",
  55. zh: "zh-Hans",
  56. zht: "zh-Hant",
  57. ko: "ko",
  58. de: "de",
  59. es: "es",
  60. fr: "fr",
  61. da: "da",
  62. ja: "ja",
  63. pl: "pl",
  64. ru: "ru",
  65. uk: "uk",
  66. ar: "ar",
  67. no: "nb-NO",
  68. br: "pt-BR",
  69. th: "th",
  70. bs: "bs",
  71. tr: "tr",
  72. }
  73. const LABEL_KEY: Record<Locale, keyof Dictionary> = {
  74. en: "language.en",
  75. zh: "language.zh",
  76. zht: "language.zht",
  77. ko: "language.ko",
  78. de: "language.de",
  79. es: "language.es",
  80. fr: "language.fr",
  81. da: "language.da",
  82. ja: "language.ja",
  83. pl: "language.pl",
  84. ru: "language.ru",
  85. uk: "language.uk",
  86. ar: "language.ar",
  87. no: "language.no",
  88. br: "language.br",
  89. th: "language.th",
  90. bs: "language.bs",
  91. tr: "language.tr",
  92. }
  93. const base = i18n.flatten({ ...en, ...uiEn })
  94. const dicts = new Map<Locale, Dictionary>([["en", base]])
  95. const merge = (app: Promise<Source>, ui: Promise<Source>) =>
  96. Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
  97. const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
  98. zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
  99. zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
  100. ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
  101. de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
  102. es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
  103. fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
  104. da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
  105. ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
  106. pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
  107. ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
  108. uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
  109. ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
  110. no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
  111. br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
  112. th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
  113. bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
  114. tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
  115. }
  116. function loadDict(locale: Locale) {
  117. const hit = dicts.get(locale)
  118. if (hit) return Promise.resolve(hit)
  119. if (locale === "en") return Promise.resolve(base)
  120. const load = loaders[locale]
  121. return load().then((next: Dictionary) => {
  122. dicts.set(locale, next)
  123. return next
  124. })
  125. }
  126. export function loadLocaleDict(locale: Locale) {
  127. return loadDict(locale).then(() => undefined)
  128. }
  129. const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
  130. { locale: "en", match: (language) => language.startsWith("en") },
  131. { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
  132. { locale: "zh", match: (language) => language.startsWith("zh") },
  133. { locale: "ko", match: (language) => language.startsWith("ko") },
  134. { locale: "de", match: (language) => language.startsWith("de") },
  135. { locale: "es", match: (language) => language.startsWith("es") },
  136. { locale: "fr", match: (language) => language.startsWith("fr") },
  137. { locale: "da", match: (language) => language.startsWith("da") },
  138. { locale: "ja", match: (language) => language.startsWith("ja") },
  139. { locale: "pl", match: (language) => language.startsWith("pl") },
  140. { locale: "ru", match: (language) => language.startsWith("ru") },
  141. { locale: "uk", match: (language) => language.startsWith("uk") },
  142. { locale: "ar", match: (language) => language.startsWith("ar") },
  143. {
  144. locale: "no",
  145. match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
  146. },
  147. { locale: "br", match: (language) => language.startsWith("pt") },
  148. { locale: "th", match: (language) => language.startsWith("th") },
  149. { locale: "bs", match: (language) => language.startsWith("bs") },
  150. { locale: "tr", match: (language) => language.startsWith("tr") },
  151. ]
  152. function detectLocale(): Locale {
  153. if (typeof navigator !== "object") return "en"
  154. const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
  155. for (const language of languages) {
  156. if (!language) continue
  157. const normalized = language.toLowerCase()
  158. const match = localeMatchers.find((entry) => entry.match(normalized))
  159. if (match) return match.locale
  160. }
  161. return "en"
  162. }
  163. export function normalizeLocale(value: string): Locale {
  164. return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
  165. }
  166. function readStoredLocale() {
  167. if (typeof localStorage !== "object") return
  168. try {
  169. const raw = localStorage.getItem("opencode.global.dat:language")
  170. if (!raw) return
  171. const next = JSON.parse(raw) as { locale?: string }
  172. if (typeof next?.locale !== "string") return
  173. return normalizeLocale(next.locale)
  174. } catch {
  175. return
  176. }
  177. }
  178. const warm = readStoredLocale() ?? detectLocale()
  179. if (warm !== "en") void loadDict(warm)
  180. export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
  181. name: "Language",
  182. gate: false,
  183. init: (props: { locale?: Locale }) => {
  184. const initial = props.locale ?? readStoredLocale() ?? detectLocale()
  185. const [store, setStore, _, ready] = persisted(
  186. Persist.global("language", ["language.v1"]),
  187. createStore({
  188. locale: initial,
  189. }),
  190. )
  191. const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
  192. const intl = createMemo(() => INTL[locale()])
  193. const [dict] = createResource(locale, loadDict, {
  194. initialValue: dicts.get(initial) ?? base,
  195. })
  196. const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
  197. key: keyof Dictionary,
  198. params?: Record<string, string | number | boolean>,
  199. ) => string
  200. const label = (value: Locale) => t(LABEL_KEY[value])
  201. createEffect(() => {
  202. if (typeof document !== "object") return
  203. document.documentElement.lang = locale()
  204. document.cookie = cookie(locale())
  205. })
  206. return {
  207. ready,
  208. locale,
  209. intl,
  210. locales: LOCALES,
  211. label,
  212. t,
  213. setLocale(next: Locale) {
  214. setStore("locale", normalizeLocale(next))
  215. },
  216. }
  217. },
  218. })