From 4c14339b4bd37604c010b2e0f70bbbaeaaf34936 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 28 Jan 2026 19:41:03 +0000 Subject: [PATCH] fix: locale loading for ui + moderation nags (#5235) * fix: locale loading * fix: locale problems * fix: lint --- apps/frontend/src/pages/settings/language.vue | 2 +- apps/frontend/src/plugins/i18n.ts | 126 ++++++++++++++++-- packages/moderation/src/index.ts | 1 + packages/moderation/src/locales.ts | 6 + packages/ui/index.ts | 1 + packages/ui/src/composables/i18n.ts | 13 +- packages/ui/src/locales.ts | 6 + 7 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 packages/moderation/src/locales.ts create mode 100644 packages/ui/src/locales.ts diff --git a/apps/frontend/src/pages/settings/language.vue b/apps/frontend/src/pages/settings/language.vue index c9629be42..612b76f86 100644 --- a/apps/frontend/src/pages/settings/language.vue +++ b/apps/frontend/src/pages/settings/language.vue @@ -13,7 +13,7 @@ import { const { formatMessage } = useVIntl() const { locale, setLocale } = injectI18n() -const platform = formatMessage(languageSelectorMessages.platformSite) +const platform = computed(() => formatMessage(languageSelectorMessages.platformSite)) const $isChanging = ref(false) diff --git a/apps/frontend/src/plugins/i18n.ts b/apps/frontend/src/plugins/i18n.ts index fc62bea10..86ce55da0 100644 --- a/apps/frontend/src/plugins/i18n.ts +++ b/apps/frontend/src/plugins/i18n.ts @@ -1,35 +1,102 @@ +import { moderationLocaleModules } from '@modrinth/moderation' import { type CrowdinMessages, I18N_INJECTION_KEY, type I18nContext, LOCALES, transformCrowdinMessages, + uiLocaleModules, + useDebugLogger, } from '@modrinth/ui' import IntlMessageFormat from 'intl-messageformat' import { LRUCache } from 'lru-cache' +const debug = useDebugLogger('i18n') const DEFAULT_LOCALE = 'en-US' -const localeModules = import.meta.glob<{ default: CrowdinMessages }>('../locales/*/index.json', { - eager: false, -}) +const frontendLocaleModules = import.meta.glob<{ default: CrowdinMessages }>( + '../locales/*/index.json', + { eager: false }, +) const messageCache = new LRUCache>({ max: 10 }) const formatterCache = new LRUCache({ max: 1000 }) const loadingPromises = new Map>() // Dedupe concurrent loads +type LocaleModules = Record Promise<{ default: CrowdinMessages }>> + +// Find the loader for a locale code in a glob result (paths end with /{code}/index.json) +function findLocaleLoader(modules: LocaleModules, code: string) { + for (const [path, loader] of Object.entries(modules)) { + if (path.endsWith(`/${code}/index.json`)) { + return loader + } + } + return undefined +} + async function loadLocale(code: string): Promise { - if (messageCache.has(code)) return + if (messageCache.has(code)) { + debug('loadLocale: already cached', code) + return + } // Dedupe concurrent requests for the same locale const existing = loadingPromises.get(code) - if (existing) return existing + if (existing) { + debug('loadLocale: already loading', code) + return existing + } + + debug('loadLocale: starting', code) const promise = (async () => { - const loader = localeModules[`../locales/${code}/index.json`] - if (!loader) return - const raw = await loader() - messageCache.set(code, transformCrowdinMessages(raw.default)) + const frontendLoader = findLocaleLoader(frontendLocaleModules, code) + const uiLoader = findLocaleLoader(uiLocaleModules, code) + const moderationLoader = findLocaleLoader(moderationLocaleModules, code) + + debug('loadLocale: loaders found', { + code, + frontend: !!frontendLoader, + ui: !!uiLoader, + moderation: !!moderationLoader, + }) + + // Load all sources in parallel + const [uiData, moderationData, frontendData] = await Promise.all([ + uiLoader?.().catch((e) => { + debug('loadLocale: ui loader failed', code, e) + return null + }), + moderationLoader?.().catch((e) => { + debug('loadLocale: moderation loader failed', code, e) + return null + }), + frontendLoader?.().catch((e) => { + debug('loadLocale: frontend loader failed', code, e) + return null + }), + ]) + + debug('loadLocale: data loaded', { + code, + uiKeys: uiData ? Object.keys(uiData.default).length : 0, + moderationKeys: moderationData ? Object.keys(moderationData.default).length : 0, + frontendKeys: frontendData ? Object.keys(frontendData.default).length : 0, + }) + + // Merge: UI (base) → moderation → frontend (highest priority) + const mergedMessages: Record = {} + if (uiData) Object.assign(mergedMessages, transformCrowdinMessages(uiData.default)) + if (moderationData) + Object.assign(mergedMessages, transformCrowdinMessages(moderationData.default)) + if (frontendData) Object.assign(mergedMessages, transformCrowdinMessages(frontendData.default)) + + debug('loadLocale: merged', code, 'total keys:', Object.keys(mergedMessages).length) + + if (Object.keys(mergedMessages).length > 0) { + messageCache.set(code, mergedMessages) + } })() loadingPromises.set(code, promise) @@ -65,8 +132,20 @@ export default defineNuxtPlugin({ function t(key: string, values?: Record): string { const currentLocale = locale.value - const msg = messageCache.get(currentLocale)?.[key] ?? messageCache.get(DEFAULT_LOCALE)?.[key] - if (!msg) return key + const localeMessages = messageCache.get(currentLocale) + const fallbackMessages = messageCache.get(DEFAULT_LOCALE) + const msg = localeMessages?.[key] ?? fallbackMessages?.[key] + + if (!msg) { + debug('t: key not found', { + key, + locale: currentLocale, + hasLocaleMessages: !!localeMessages, + hasFallbackMessages: !!fallbackMessages, + }) + return key + } + if (!values || Object.keys(values).length === 0) return msg const cacheKey = `${currentLocale}:${msg}` @@ -78,7 +157,7 @@ export default defineNuxtPlugin({ try { const result = formatter.format(values) as string if (import.meta.dev && typeof result !== 'string') { - console.error('[i18n] t() returned non-string:', typeof result) + debug('t: format returned non-string', key, typeof result) } return result } catch { @@ -87,8 +166,23 @@ export default defineNuxtPlugin({ } async function setLocale(newLocale: string): Promise { - if (!LOCALES.some((l) => l.code === newLocale)) return + debug('setLocale: called', { newLocale, currentLocale: locale.value }) + + if (!LOCALES.some((l) => l.code === newLocale)) { + debug('setLocale: invalid locale', newLocale) + return + } + await loadLocale(newLocale) + + debug('setLocale: loaded', { + newLocale, + cacheHas: messageCache.has(newLocale), + cacheKeys: messageCache.get(newLocale) + ? Object.keys(messageCache.get(newLocale)!).length + : 0, + }) + locale.value = newLocale useCookie('locale', { maxAge: 31536000, path: '/' }).value = newLocale } @@ -98,18 +192,24 @@ export default defineNuxtPlugin({ let detectedLocale = DEFAULT_LOCALE if (cookieLocale && LOCALES.some((l) => l.code === cookieLocale)) { detectedLocale = cookieLocale + debug('init: locale from cookie', detectedLocale) } else if (import.meta.server) { const acceptLang = useRequestHeaders(['accept-language'])['accept-language'] if (acceptLang) { detectedLocale = parseAcceptLanguage(acceptLang) ?? DEFAULT_LOCALE + debug('init: locale from Accept-Language', detectedLocale) } } + debug('init: detected locale', { detectedLocale, cookieLocale, isServer: import.meta.server }) + // Load locales (hits cache after first request) await loadLocale(DEFAULT_LOCALE) if (detectedLocale !== DEFAULT_LOCALE) await loadLocale(detectedLocale) locale.value = detectedLocale + debug('init: complete', { locale: locale.value }) + const context: I18nContext = { locale, t, setLocale } nuxtApp.vueApp.provide(I18N_INJECTION_KEY, context) }, diff --git a/packages/moderation/src/index.ts b/packages/moderation/src/index.ts index 312751a37..2df2ecfe6 100644 --- a/packages/moderation/src/index.ts +++ b/packages/moderation/src/index.ts @@ -8,6 +8,7 @@ export { type TechReviewContext, default as techReviewQuickReplies, } from './data/tech-review-quick-replies' +export * from './locales' export * from './types/actions' export * from './types/keybinds' export * from './types/messages' diff --git a/packages/moderation/src/locales.ts b/packages/moderation/src/locales.ts new file mode 100644 index 000000000..7a8c916ef --- /dev/null +++ b/packages/moderation/src/locales.ts @@ -0,0 +1,6 @@ +import type { CrowdinMessages } from '@modrinth/ui' + +export const moderationLocaleModules = import.meta.glob<{ default: CrowdinMessages }>( + './locales/*/index.json', + { eager: false }, +) diff --git a/packages/ui/index.ts b/packages/ui/index.ts index 55871f13d..0ec278532 100644 --- a/packages/ui/index.ts +++ b/packages/ui/index.ts @@ -1,5 +1,6 @@ export * from './src/components' export * from './src/composables' +export * from './src/locales' export * from './src/pages' export * from './src/providers' export * from './src/utils' diff --git a/packages/ui/src/composables/i18n.ts b/packages/ui/src/composables/i18n.ts index 1448624d4..e220828ac 100644 --- a/packages/ui/src/composables/i18n.ts +++ b/packages/ui/src/composables/i18n.ts @@ -12,7 +12,7 @@ export interface MessageDescriptor { export type MessageDescriptorMap = Record -export type CrowdinMessages = Record +export type CrowdinMessages = Record export function defineMessage(descriptor: T): T { return descriptor @@ -101,8 +101,11 @@ export function transformCrowdinMessages(messages: CrowdinMessages): Record } { const { t, locale } = injectI18n() function formatMessage(descriptor: MessageDescriptor, values?: Record): string { + // Read locale.value to ensure Vue tracks this as a reactive dependency + // when formatMessage is called during component render + void locale.value + const key = descriptor.id const translation = t(key, values ?? {}) diff --git a/packages/ui/src/locales.ts b/packages/ui/src/locales.ts new file mode 100644 index 000000000..ab7a727b8 --- /dev/null +++ b/packages/ui/src/locales.ts @@ -0,0 +1,6 @@ +import type { CrowdinMessages } from './composables/i18n' + +export const uiLocaleModules = import.meta.glob<{ default: CrowdinMessages }>( + './locales/*/index.json', + { eager: false }, +)