fix: locale loading for ui + moderation nags (#5235)

* fix: locale loading

* fix: locale problems

* fix: lint
This commit is contained in:
Calum H.
2026-01-28 19:41:03 +00:00
committed by GitHub
parent 16ac2aae6b
commit 4c14339b4b
7 changed files with 138 additions and 17 deletions

View File

@@ -13,7 +13,7 @@ import {
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { locale, setLocale } = injectI18n() const { locale, setLocale } = injectI18n()
const platform = formatMessage(languageSelectorMessages.platformSite) const platform = computed(() => formatMessage(languageSelectorMessages.platformSite))
const $isChanging = ref(false) const $isChanging = ref(false)

View File

@@ -1,35 +1,102 @@
import { moderationLocaleModules } from '@modrinth/moderation'
import { import {
type CrowdinMessages, type CrowdinMessages,
I18N_INJECTION_KEY, I18N_INJECTION_KEY,
type I18nContext, type I18nContext,
LOCALES, LOCALES,
transformCrowdinMessages, transformCrowdinMessages,
uiLocaleModules,
useDebugLogger,
} from '@modrinth/ui' } from '@modrinth/ui'
import IntlMessageFormat from 'intl-messageformat' import IntlMessageFormat from 'intl-messageformat'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
const debug = useDebugLogger('i18n')
const DEFAULT_LOCALE = 'en-US' const DEFAULT_LOCALE = 'en-US'
const localeModules = import.meta.glob<{ default: CrowdinMessages }>('../locales/*/index.json', { const frontendLocaleModules = import.meta.glob<{ default: CrowdinMessages }>(
eager: false, '../locales/*/index.json',
}) { eager: false },
)
const messageCache = new LRUCache<string, Record<string, string>>({ max: 10 }) const messageCache = new LRUCache<string, Record<string, string>>({ max: 10 })
const formatterCache = new LRUCache<string, IntlMessageFormat>({ max: 1000 }) const formatterCache = new LRUCache<string, IntlMessageFormat>({ max: 1000 })
const loadingPromises = new Map<string, Promise<void>>() // Dedupe concurrent loads const loadingPromises = new Map<string, Promise<void>>() // Dedupe concurrent loads
type LocaleModules = Record<string, () => 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<void> { async function loadLocale(code: string): Promise<void> {
if (messageCache.has(code)) return if (messageCache.has(code)) {
debug('loadLocale: already cached', code)
return
}
// Dedupe concurrent requests for the same locale // Dedupe concurrent requests for the same locale
const existing = loadingPromises.get(code) 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 promise = (async () => {
const loader = localeModules[`../locales/${code}/index.json`] const frontendLoader = findLocaleLoader(frontendLocaleModules, code)
if (!loader) return const uiLoader = findLocaleLoader(uiLocaleModules, code)
const raw = await loader() const moderationLoader = findLocaleLoader(moderationLocaleModules, code)
messageCache.set(code, transformCrowdinMessages(raw.default))
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<string, string> = {}
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) loadingPromises.set(code, promise)
@@ -65,8 +132,20 @@ export default defineNuxtPlugin({
function t(key: string, values?: Record<string, unknown>): string { function t(key: string, values?: Record<string, unknown>): string {
const currentLocale = locale.value const currentLocale = locale.value
const msg = messageCache.get(currentLocale)?.[key] ?? messageCache.get(DEFAULT_LOCALE)?.[key] const localeMessages = messageCache.get(currentLocale)
if (!msg) return key 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 if (!values || Object.keys(values).length === 0) return msg
const cacheKey = `${currentLocale}:${msg}` const cacheKey = `${currentLocale}:${msg}`
@@ -78,7 +157,7 @@ export default defineNuxtPlugin({
try { try {
const result = formatter.format(values) as string const result = formatter.format(values) as string
if (import.meta.dev && typeof result !== '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 return result
} catch { } catch {
@@ -87,8 +166,23 @@ export default defineNuxtPlugin({
} }
async function setLocale(newLocale: string): Promise<void> { async function setLocale(newLocale: string): Promise<void> {
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) 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 locale.value = newLocale
useCookie('locale', { maxAge: 31536000, path: '/' }).value = newLocale useCookie('locale', { maxAge: 31536000, path: '/' }).value = newLocale
} }
@@ -98,18 +192,24 @@ export default defineNuxtPlugin({
let detectedLocale = DEFAULT_LOCALE let detectedLocale = DEFAULT_LOCALE
if (cookieLocale && LOCALES.some((l) => l.code === cookieLocale)) { if (cookieLocale && LOCALES.some((l) => l.code === cookieLocale)) {
detectedLocale = cookieLocale detectedLocale = cookieLocale
debug('init: locale from cookie', detectedLocale)
} else if (import.meta.server) { } else if (import.meta.server) {
const acceptLang = useRequestHeaders(['accept-language'])['accept-language'] const acceptLang = useRequestHeaders(['accept-language'])['accept-language']
if (acceptLang) { if (acceptLang) {
detectedLocale = parseAcceptLanguage(acceptLang) ?? DEFAULT_LOCALE 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) // Load locales (hits cache after first request)
await loadLocale(DEFAULT_LOCALE) await loadLocale(DEFAULT_LOCALE)
if (detectedLocale !== DEFAULT_LOCALE) await loadLocale(detectedLocale) if (detectedLocale !== DEFAULT_LOCALE) await loadLocale(detectedLocale)
locale.value = detectedLocale locale.value = detectedLocale
debug('init: complete', { locale: locale.value })
const context: I18nContext = { locale, t, setLocale } const context: I18nContext = { locale, t, setLocale }
nuxtApp.vueApp.provide(I18N_INJECTION_KEY, context) nuxtApp.vueApp.provide(I18N_INJECTION_KEY, context)
}, },

View File

@@ -8,6 +8,7 @@ export {
type TechReviewContext, type TechReviewContext,
default as techReviewQuickReplies, default as techReviewQuickReplies,
} from './data/tech-review-quick-replies' } from './data/tech-review-quick-replies'
export * from './locales'
export * from './types/actions' export * from './types/actions'
export * from './types/keybinds' export * from './types/keybinds'
export * from './types/messages' export * from './types/messages'

View File

@@ -0,0 +1,6 @@
import type { CrowdinMessages } from '@modrinth/ui'
export const moderationLocaleModules = import.meta.glob<{ default: CrowdinMessages }>(
'./locales/*/index.json',
{ eager: false },
)

View File

@@ -1,5 +1,6 @@
export * from './src/components' export * from './src/components'
export * from './src/composables' export * from './src/composables'
export * from './src/locales'
export * from './src/pages' export * from './src/pages'
export * from './src/providers' export * from './src/providers'
export * from './src/utils' export * from './src/utils'

View File

@@ -12,7 +12,7 @@ export interface MessageDescriptor {
export type MessageDescriptorMap<K extends string> = Record<K, MessageDescriptor> export type MessageDescriptorMap<K extends string> = Record<K, MessageDescriptor>
export type CrowdinMessages = Record<string, { message: string } | string> export type CrowdinMessages = Record<string, { message?: string; defaultMessage?: string } | string>
export function defineMessage<T extends MessageDescriptor>(descriptor: T): T { export function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor return descriptor
@@ -101,8 +101,11 @@ export function transformCrowdinMessages(messages: CrowdinMessages): Record<stri
for (const [key, value] of Object.entries(messages)) { for (const [key, value] of Object.entries(messages)) {
if (typeof value === 'string') { if (typeof value === 'string') {
result[key] = value result[key] = value
} else if (typeof value === 'object' && value !== null && 'message' in value) { } else if (typeof value === 'object' && value !== null) {
result[key] = value.message const msg = value.message ?? value.defaultMessage
if (msg) {
result[key] = msg
}
} }
} }
return result return result
@@ -177,6 +180,10 @@ export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
const { t, locale } = injectI18n() const { t, locale } = injectI18n()
function formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string { function formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): 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 key = descriptor.id
const translation = t(key, values ?? {}) const translation = t(key, values ?? {})

View File

@@ -0,0 +1,6 @@
import type { CrowdinMessages } from './composables/i18n'
export const uiLocaleModules = import.meta.glob<{ default: CrowdinMessages }>(
'./locales/*/index.json',
{ eager: false },
)