diff --git a/packages/ui/src/lib/i18n/index.tsx b/packages/ui/src/lib/i18n/index.tsx index a63e53f1..3a50885c 100644 --- a/packages/ui/src/lib/i18n/index.tsx +++ b/packages/ui/src/lib/i18n/index.tsx @@ -2,11 +2,6 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou import type { ParentComponent } from "solid-js" import { useConfig } from "../../stores/preferences" import { enMessages } from "./messages/en" -import { esMessages } from "./messages/es" -import { frMessages } from "./messages/fr" -import { ruMessages } from "./messages/ru" -import { jaMessages } from "./messages/ja" -import { zhHansMessages } from "./messages/zh-Hans" type Messages = Record @@ -15,14 +10,18 @@ export type TranslateParams = Record export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const +const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])) -const messagesByLocale: Record = { - en: enMessages, - es: esMessages, - fr: frMessages, - ru: ruMessages, - ja: jaMessages, - "zh-Hans": zhHansMessages, +const localeMessagesCache = new Map([["en", enMessages]]) +const localeMessagesPromises = new Map>() + +const localeLoaders: Record Promise> = { + en: async () => enMessages, + es: async () => (await import("./messages/es")).esMessages, + fr: async () => (await import("./messages/fr")).frMessages, + ru: async () => (await import("./messages/ru")).ruMessages, + ja: async () => (await import("./messages/ja")).jaMessages, + "zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages, } function normalizeLocaleTag(value: string): string { @@ -34,8 +33,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null { const normalized = normalizeLocaleTag(value) const lower = normalized.toLowerCase() - const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])) - const exact = supportedLower.get(lower) + const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower) if (exact) return exact const parts = lower.split("-") @@ -43,11 +41,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null { if (!base) return null if (base === "zh") { - const zhHans = supportedLower.get("zh-hans") + const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans") return zhHans ?? null } - const baseMatch = supportedLower.get(base) + const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base) return baseMatch ?? null } @@ -84,8 +82,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams } const [globalRevision, setGlobalRevision] = createSignal(0) -const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en" -let globalMessages: Messages = messagesByLocale[initialGlobalLocale] +let globalMessages: Messages = enMessages +let globalLocale: Locale = "en" + +function getMessagesForLocale(locale: Locale): Messages { + return localeMessagesCache.get(locale) ?? enMessages +} + +async function loadLocaleMessages(locale: Locale): Promise { + const cached = localeMessagesCache.get(locale) + if (cached) { + return cached + } + + const pending = localeMessagesPromises.get(locale) + if (pending) { + return pending + } + + const loader = localeLoaders[locale] + const promise = loader() + .then((messages) => { + localeMessagesCache.set(locale, messages) + localeMessagesPromises.delete(locale) + return messages + }) + .catch((error) => { + localeMessagesPromises.delete(locale) + throw error + }) + + localeMessagesPromises.set(locale, promise) + return promise +} + +export async function preloadLocaleMessages(preferredLocale?: string | null): Promise { + const resolvedLocale = matchSupportedLocale(preferredLocale ?? undefined) ?? detectNavigatorLocale() ?? "en" + try { + globalMessages = await loadLocaleMessages(resolvedLocale) + globalLocale = resolvedLocale + setGlobalRevision((value) => value + 1) + return resolvedLocale + } catch { + globalMessages = enMessages + globalLocale = "en" + setGlobalRevision((value) => value + 1) + return "en" + } +} export function tGlobal(key: string, params?: TranslateParams): string { globalRevision() @@ -101,9 +145,10 @@ const I18nContext = createContext() export const I18nProvider: ParentComponent = (props) => { const { preferences } = useConfig() - const [detectedLocale, setDetectedLocale] = createSignal("en") - - const previousMessages = globalMessages + const [detectedLocale, setDetectedLocale] = createSignal(globalLocale) + const [resolvedLocale, setResolvedLocale] = createSignal(globalLocale) + const previousGlobalMessages = globalMessages + const previousGlobalLocale = globalLocale onMount(() => { const detected = detectNavigatorLocale() @@ -115,19 +160,44 @@ export const I18nProvider: ParentComponent = (props) => { return configured ?? detectedLocale() ?? "en" }) - const messages = createMemo(() => messagesByLocale[locale()]) + const messages = createMemo(() => getMessagesForLocale(resolvedLocale())) function t(key: string, params?: TranslateParams): string { return translateFrom(messages(), key, params) } createEffect(() => { - globalMessages = messages() - setGlobalRevision((value) => value + 1) + const nextLocale = locale() + let cancelled = false + + void loadLocaleMessages(nextLocale) + .then((loadedMessages) => { + if (cancelled) { + return + } + setResolvedLocale(nextLocale) + globalLocale = nextLocale + globalMessages = loadedMessages + setGlobalRevision((value) => value + 1) + }) + .catch(() => { + if (cancelled) { + return + } + setResolvedLocale("en") + globalMessages = enMessages + globalLocale = "en" + setGlobalRevision((value) => value + 1) + }) + + onCleanup(() => { + cancelled = true + }) }) onCleanup(() => { - globalMessages = previousMessages + globalMessages = previousGlobalMessages + globalLocale = previousGlobalLocale setGlobalRevision((value) => value + 1) }) diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index c0a1b4ff..4be1fc57 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -4,7 +4,7 @@ import { ThemeProvider } from "./lib/theme" import { ConfigProvider } from "./stores/preferences" import { InstanceConfigProvider } from "./stores/instance-config" import { runtimeEnv } from "./lib/runtime-env" -import { I18nProvider } from "./lib/i18n" +import { I18nProvider, preloadLocaleMessages } from "./lib/i18n" import { storage } from "./lib/storage" import "./index.css" import "@git-diff-view/solid/styles/diff-view-pure.css" @@ -31,15 +31,19 @@ async function bootstrap() { try { const uiConfig = await storage.loadConfigOwner("ui") - const theme = (uiConfig as any)?.theme ?? "system" + const theme = (uiConfig as any)?.theme + const locale = typeof (uiConfig as any)?.settings?.locale === "string" ? (uiConfig as any).settings.locale : undefined - if (theme === "system") { - document.documentElement.removeAttribute("data-theme") - } else { + if (theme === "light" || theme === "dark") { document.documentElement.setAttribute("data-theme", theme) + } else { + document.documentElement.removeAttribute("data-theme") } + + await preloadLocaleMessages(locale) } catch { // If config fails to load, fall back to CSS defaults. + await preloadLocaleMessages() } }