fix: locale loading for ui + moderation nags (#5235)
* fix: locale loading * fix: locale problems * fix: lint
This commit is contained in:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
6
packages/moderation/src/locales.ts
Normal file
6
packages/moderation/src/locales.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { CrowdinMessages } from '@modrinth/ui'
|
||||||
|
|
||||||
|
export const moderationLocaleModules = import.meta.glob<{ default: CrowdinMessages }>(
|
||||||
|
'./locales/*/index.json',
|
||||||
|
{ eager: false },
|
||||||
|
)
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 ?? {})
|
||||||
|
|
||||||
|
|||||||
6
packages/ui/src/locales.ts
Normal file
6
packages/ui/src/locales.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { CrowdinMessages } from './composables/i18n'
|
||||||
|
|
||||||
|
export const uiLocaleModules = import.meta.glob<{ default: CrowdinMessages }>(
|
||||||
|
'./locales/*/index.json',
|
||||||
|
{ eager: false },
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user