152 行
5.5 KiB
TypeScript
152 行
5.5 KiB
TypeScript
import { createInstance } from 'i18next'
|
|
import { initReactI18next } from 'react-i18next'
|
|
|
|
import type { i18n as I18n, TOptions } from 'i18next'
|
|
|
|
import type { LocaleCode } from '@/lib/locales'
|
|
|
|
// Keep Japanese placeholders editable without reporting them as completed translations.
|
|
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
|
|
const untranslated = new Set (
|
|
Array.isArray (resource._untranslated) ? resource._untranslated : [])
|
|
if (untranslated.has ('*'))
|
|
return { }
|
|
const select = (entries: Record<string, unknown>, prefix = ''): Record<string, unknown> =>
|
|
Object.fromEntries (Object.entries (entries).flatMap (([key, value]) => {
|
|
const path = prefix ? `${ prefix }.${ key }` : key
|
|
if (key === '_untranslated' || untranslated.has (path))
|
|
return []
|
|
const translated =
|
|
(value != null && typeof value === 'object' && !(Array.isArray (value)))
|
|
? select (value as Record<string, unknown>, path)
|
|
: value
|
|
return [[key, translated]]
|
|
}))
|
|
return select (resource)
|
|
}
|
|
|
|
|
|
const dictionaries = import.meta.glob<Record<string, unknown>> ('../locales/*/*.json', {
|
|
eager: true, import: 'default' })
|
|
const resources: Record<string, Record<string, Record<string, unknown>>> = { }
|
|
for (const [path, dictionary] of Object.entries (dictionaries))
|
|
{
|
|
const [, , locale, filename] = path.split ('/')
|
|
resources[locale] ??= { }
|
|
resources[locale][filename.slice (0, -5)] =
|
|
locale === 'ain-Kana' ? translatedResource (dictionary) : dictionary
|
|
}
|
|
const namespaces = Object.keys (resources.ja ?? { })
|
|
|
|
|
|
export const createUII18n = (locale: string): I18n => {
|
|
const instance = createInstance ()
|
|
void instance.init ({
|
|
lng: locale,
|
|
fallbackLng: 'ja',
|
|
supportedLngs: ['ja', 'ain-Kana'],
|
|
load: 'currentOnly',
|
|
initAsync: false,
|
|
defaultNS: 'navigation',
|
|
ns: namespaces,
|
|
resources,
|
|
interpolation: { escapeValue: false },
|
|
returnNull: false,
|
|
returnEmptyString: false,
|
|
saveMissing: import.meta.env.DEV,
|
|
missingKeyHandler: (languages, namespace, key) => {
|
|
console.warn ('[i18n] missing key', { languages, namespace, key })
|
|
},
|
|
react: { useSuspense: false } })
|
|
return instance
|
|
}
|
|
|
|
// This instance is used only by legacy helpers with an explicit lng option.
|
|
export const i18n = createUII18n ('ja')
|
|
initReactI18next.init (i18n)
|
|
|
|
|
|
export const translateUIMessage = (
|
|
key: string,
|
|
namespace: string,
|
|
locale: string,
|
|
values: Record<string, string | number> = { },
|
|
instance: I18n = i18n,
|
|
): { text: string; language: string } => {
|
|
const languages = new Set<string> ()
|
|
const visiting = new Set<string> ()
|
|
const collectLanguages = (reference: string, options: TOptions): string => {
|
|
const details = instance.t (reference, { ...options,
|
|
returnDetails: true, returnObjects: false, nest: false, skipInterpolation: true })
|
|
const identity = `${details.usedLng}:${details.usedNS}:${details.exactUsedKey}`
|
|
if (visiting.has (identity) || typeof details.res !== 'string')
|
|
return details.usedLng
|
|
visiting.add (identity)
|
|
const nestingOptions = { ...instance.options.interpolation, ...options,
|
|
ns: details.usedNS, lng: details.usedLng }
|
|
const remainder = instance.services.interpolator.nest (details.res,
|
|
(nestedKey: string, nestedOptions: TOptions) => {
|
|
collectLanguages (nestedKey, nestedOptions)
|
|
return ' '
|
|
}, nestingOptions)
|
|
// References, punctuation and supplied values do not define the wrapper's language.
|
|
if (/\p{L}/u.test (remainder.replace (/\{\{[^}]*\}\}/g, '')))
|
|
languages.add (details.usedLng)
|
|
visiting.delete (identity)
|
|
return details.usedLng
|
|
}
|
|
const options = { ...values, ns: namespace, lng: locale }
|
|
const resolvedLanguage = collectLanguages (key, options)
|
|
// A single lang attribute cannot describe mixed fallback text: use the full Japanese message.
|
|
const mixed = languages.size > 1
|
|
const language = mixed ? 'ja' : languages.values ().next ().value ?? resolvedLanguage
|
|
const text = instance.t (key, { ...options, lng: mixed ? 'ja' : resolvedLanguage,
|
|
returnDetails: false, returnObjects: false })
|
|
if (typeof text !== 'string')
|
|
{
|
|
console.warn ('[i18n] UI message must be a string', { key, namespace, locale })
|
|
return { text: key, language }
|
|
}
|
|
return { text, language }
|
|
}
|
|
|
|
|
|
export const languageUIMessage = (
|
|
code: string, locale: string, instance: I18n = i18n,
|
|
): { text: string; language: string } => {
|
|
const key = `languageNames.${code}`
|
|
return instance.exists (key, { ns: 'common', lng: locale })
|
|
? translateUIMessage (key, 'common', locale, { }, instance)
|
|
: { text: code, language: 'und' }
|
|
}
|
|
|
|
|
|
export const untranslatedKeys = (
|
|
locale: LocaleCode,
|
|
namespace = 'navigation',
|
|
): string[] => {
|
|
const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
|
|
Object.entries (resource).flatMap (([key, value]) => {
|
|
const path = prefix ? `${ prefix }.${ key }` : key
|
|
return (value != null && typeof value === 'object')
|
|
? collect (value as Record<string, unknown>, path)
|
|
: [path]
|
|
})
|
|
const baseline = i18n.getResourceBundle ('ja', namespace) ?? { }
|
|
return collect (baseline).filter (key => {
|
|
const value = i18n.getResource (locale, namespace, key)
|
|
return typeof value !== 'string' || value.length === 0
|
|
})
|
|
}
|
|
|
|
|
|
if (import.meta.env.DEV)
|
|
{
|
|
for (const namespace of namespaces)
|
|
{
|
|
const missing = untranslatedKeys ('ain-Kana', namespace)
|
|
if (missing.length > 0)
|
|
console.warn (`[i18n] ain-Kana: untranslated ${ namespace } keys`, missing)
|
|
}
|
|
}
|