Files
Modrinth-plus/packages/ui/src/composables/i18n.ts
Jerozgen e8665f43ca Filipino compact number plural rule (#5516)
Use `one` for compact numbers in Filipino

Signed-off-by: Jerozgen <jerozgen@gmail.com>
Co-authored-by: Calum H. <calum@modrinth.com>
2026-05-09 09:20:05 +00:00

345 lines
9.5 KiB
TypeScript

import IntlMessageFormat from 'intl-messageformat'
import type { Ref } from 'vue'
import type { CompileError, MessageCompiler, MessageContext } from 'vue-i18n'
import { injectI18n } from '../providers/i18n'
import { injectI18nDebug } from './i18n-debug'
export interface MessageDescriptor {
id: string
defaultMessage?: string
description?: string
}
export type MessageDescriptorMap<K extends string> = Record<K, MessageDescriptor>
export type CrowdinMessages = Record<string, { message?: string; defaultMessage?: string } | string>
export function defineMessage<T extends MessageDescriptor>(descriptor: T): T {
return descriptor
}
export function defineMessages<K extends string, T extends MessageDescriptorMap<K>>(
descriptors: T,
): T {
return descriptors
}
export interface LocaleDefinition {
code: string
name: string
translatedName: MessageDescriptor
numeric?: Intl.RelativeTimeFormatNumeric
compactNumberPlural?: number
dir?: 'ltr' | 'rtl'
serverLanguageCode?: string
}
export const LOCALES: LocaleDefinition[] = [
// Commented out as it's RTL - will enable when we have better RTL support
// {
// code: 'ar-SA',
// name: 'العربية (السعودية)',
// translatedName: defineMessage({ id: 'locale.ar-SA', defaultMessage: 'Arabic' }),
// dir: 'rtl',
// },
{
code: 'cs-CZ',
name: 'Čeština',
translatedName: defineMessage({ id: 'locale.cs-CZ', defaultMessage: 'Czech' }),
},
{
code: 'da-DK',
name: 'Dansk',
translatedName: defineMessage({ id: 'locale.da-DK', defaultMessage: 'Danish' }),
},
{
code: 'de-CH',
name: 'Deutsch (Schweiz)',
translatedName: defineMessage({ id: 'locale.de-CH', defaultMessage: 'German (Switzerland)' }),
},
{
code: 'de-DE',
name: 'Deutsch (Deutschland)',
translatedName: defineMessage({ id: 'locale.de-DE', defaultMessage: 'German (Germany)' }),
},
{
code: 'en-US',
name: 'English (United States)',
translatedName: defineMessage({
id: 'locale.en-US',
defaultMessage: 'English (United States)',
}),
},
{
code: 'es-419',
name: 'Español (Latinoamérica)',
translatedName: defineMessage({
id: 'locale.es-419',
defaultMessage: 'Spanish (Latin America)',
}),
},
{
code: 'es-ES',
name: 'Español (España)',
translatedName: defineMessage({ id: 'locale.es-ES', defaultMessage: 'Spanish (Spain)' }),
},
{
code: 'fi-FI',
name: 'Suomi',
translatedName: defineMessage({ id: 'locale.fi-FI', defaultMessage: 'Finnish' }),
},
{
code: 'fil-PH',
name: 'Filipino',
translatedName: defineMessage({ id: 'locale.fil-PH', defaultMessage: 'Filipino' }),
compactNumberPlural: 1,
serverLanguageCode: 'tl',
},
{
code: 'fr-FR',
name: 'Français',
translatedName: defineMessage({ id: 'locale.fr-FR', defaultMessage: 'French' }),
},
{
code: 'he-IL',
name: 'עברית',
translatedName: defineMessage({ id: 'locale.he-IL', defaultMessage: 'Hebrew' }),
dir: 'rtl',
},
{
code: 'hu-HU',
name: 'Magyar',
translatedName: defineMessage({ id: 'locale.hu-HU', defaultMessage: 'Hungarian' }),
},
{
code: 'id-ID',
name: 'Bahasa Indonesia',
translatedName: defineMessage({ id: 'locale.id-ID', defaultMessage: 'Indonesian' }),
},
{
code: 'it-IT',
name: 'Italiano (Italia)',
translatedName: defineMessage({ id: 'locale.it-IT', defaultMessage: 'Italian (Italy)' }),
numeric: 'always',
},
{
code: 'ja-JP',
name: '日本語',
translatedName: defineMessage({ id: 'locale.ja-JP', defaultMessage: 'Japanese' }),
},
{
code: 'ko-KR',
name: '한국어',
translatedName: defineMessage({ id: 'locale.ko-KR', defaultMessage: 'Korean' }),
},
{
code: 'ms-MY',
name: 'Bahasa Melayu',
translatedName: defineMessage({ id: 'locale.ms-MY', defaultMessage: 'Malay' }),
},
{
code: 'nl-NL',
name: 'Nederlands',
translatedName: defineMessage({ id: 'locale.nl-NL', defaultMessage: 'Dutch' }),
},
{
code: 'no-NO',
name: 'Norsk (Bokmål)',
translatedName: defineMessage({ id: 'locale.no-NO', defaultMessage: 'Norwegian Bokmål' }),
},
{
code: 'pl-PL',
name: 'Polski',
translatedName: defineMessage({ id: 'locale.pl-PL', defaultMessage: 'Polish' }),
},
{
code: 'pt-BR',
name: 'Português (Brasil)',
translatedName: defineMessage({ id: 'locale.pt-BR', defaultMessage: 'Portuguese (Brazil)' }),
},
{
code: 'pt-PT',
name: 'Português (Portugal)',
translatedName: defineMessage({ id: 'locale.pt-PT', defaultMessage: 'Portuguese (Portugal)' }),
},
{
code: 'ro-RO',
name: 'Română',
translatedName: defineMessage({ id: 'locale.ro-RO', defaultMessage: 'Romanian' }),
},
{
code: 'ru-RU',
name: 'Русский',
translatedName: defineMessage({ id: 'locale.ru-RU', defaultMessage: 'Russian' }),
numeric: 'always',
},
{
code: 'sr-CS',
name: 'Srpski (latinica)',
translatedName: defineMessage({ id: 'locale.sr-CS', defaultMessage: 'Serbian (Latin)' }),
},
{
code: 'sv-SE',
name: 'Svenska',
translatedName: defineMessage({ id: 'locale.sv-SE', defaultMessage: 'Swedish' }),
},
{
code: 'th-TH',
name: 'ไทย',
translatedName: defineMessage({ id: 'locale.th-TH', defaultMessage: 'Thai' }),
},
{
code: 'tr-TR',
name: 'Türkçe',
translatedName: defineMessage({ id: 'locale.tr-TR', defaultMessage: 'Turkish' }),
},
{
code: 'uk-UA',
name: 'Українська',
translatedName: defineMessage({ id: 'locale.uk-UA', defaultMessage: 'Ukrainian' }),
},
{
code: 'vi-VN',
name: 'Tiếng Việt',
translatedName: defineMessage({ id: 'locale.vi-VN', defaultMessage: 'Vietnamese' }),
},
{
code: 'zh-CN',
name: '简体中文',
translatedName: defineMessage({ id: 'locale.zh-CN', defaultMessage: 'Chinese (Simplified)' }),
},
{
code: 'zh-TW',
name: '繁體中文',
translatedName: defineMessage({ id: 'locale.zh-TW', defaultMessage: 'Chinese (Traditional)' }),
},
]
export function transformCrowdinMessages(messages: CrowdinMessages): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(messages)) {
if (typeof value === 'string') {
result[key] = value
} else if (typeof value === 'object' && value !== null) {
const msg = value.message ?? value.defaultMessage
if (msg) {
result[key] = msg
}
}
}
return result
}
const LOCALE_CODES = new Set(LOCALES.map((l) => l.code))
/**
* Builds locale messages from glob-imported modules.
* Only includes locales that are defined in the LOCALES array.
* Usage: buildLocaleMessages(import.meta.glob('./locales/* /index.json', { eager: true }))
*/
export function buildLocaleMessages(
...allModules: Record<string, { default: CrowdinMessages }>[]
): Record<string, Record<string, string>> {
const messages: Record<string, Record<string, string>> = {}
for (const modules of allModules) {
for (const [path, module] of Object.entries(modules)) {
// Extract locale code from path like './locales/en-US/index.json', './src/locales/en-US/index.json' or './locales/en-US/meta.json'
const match = path.match(/\/([^/]+)\/(index|meta)\.json$/)
if (match) {
const locale = match[1]
// Only include locales that are in our LOCALES list
if (LOCALE_CODES.has(locale)) {
const mergedMessages = messages[locale] ?? {}
messages[locale] = Object.assign(mergedMessages, transformCrowdinMessages(module.default))
}
}
}
}
return messages
}
/**
* Creates a vue-i18n message compiler that uses IntlMessageFormat for ICU syntax support.
* This enables pluralization, select, and other ICU message features.
*/
export function createMessageCompiler(): MessageCompiler {
return (msg, { locale, key, onError }) => {
let messageString: string
if (typeof msg === 'string') {
messageString = msg
} else if (typeof msg === 'object' && msg !== null && 'message' in msg) {
messageString = (msg as { message: string }).message
} else {
onError?.(new Error('Invalid message format') as CompileError)
return () => key
}
try {
const formatter = new IntlMessageFormat(messageString, locale)
return (ctx: MessageContext) => {
try {
return formatter.format(ctx.values as Record<string, unknown>) as string
} catch {
return messageString
}
}
} catch (e) {
onError?.(e as CompileError)
return () => key
}
}
}
export interface VIntlFormatters {
formatMessage(descriptor: MessageDescriptor, values?: Record<string, unknown>): string
}
/**
* Composable that provides formatMessage() with the same API as @vintl/vintl.
* Uses the injected I18nContext from the provider.
*/
export function useVIntl(): VIntlFormatters & { locale: Ref<string> } {
const { t, locale } = injectI18n()
const debugContext = injectI18nDebug()
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 translation = t(key, values ?? {})
let result: string
if (translation && translation !== key) {
result = translation as string
} else {
// Fallback to defaultMessage if key not found
const defaultMsg = descriptor.defaultMessage ?? key
try {
const formatter = new IntlMessageFormat(defaultMsg, locale.value)
result = formatter.format(values ?? {}) as string
} catch {
result = defaultMsg
}
}
if (debugContext?.enabled.value) {
debugContext.registry.set(key, {
key,
value: result,
defaultMessage: descriptor.defaultMessage,
timestamp: Date.now(),
})
if (debugContext.keyReveal.value) {
return `\u300C${key}\u300D`
}
}
return result
}
return { formatMessage, locale }
}