i18n - config
Locale files for a Psychic app live in src/conf/locales/. By default, an English translation file is set up for you; add a locale file for each other language you wish to support.
// src/conf/locales/en.ts
export default {
labels: {
nutrition: {
calories: 'calories',
},
},
}
// src/conf/locales/es.ts
export default {
labels: {
nutrition: {
calories: 'calorias',
},
},
}
Within each translation file, you will want to make sure to provide the same payload shape, so that translations can be correctly resolved in all locales.
Register each locale file in src/conf/locales/index.ts, keyed by its language:
// src/conf/locales/index.ts
import en from '#conf/locales/en.js'
import es from '#conf/locales/es.js'
export default {
en,
es,
}
Tie supported locales to the database enum
Resist the urge to hand-write a union type for your supported locales (type SupportedLocales = 'en' | 'es') alongside this file. Instead, back your locale column with a database enum in a migration, and let the generated LocalesEnum / LocalesEnumValues types (in @src/types/db.js) be the single source of truth. That way the list of supported locales is defined in exactly one place — the migration — and flows through the type system from there, instead of drifting out of sync with a manually maintained type.
Then point the i18n utility, src/utils/i18n.ts, at your supported locales. It exposes them for picking the locale a request asks for (from its Accept-Language header), and builds your i18n function:
// src/utils/i18n.ts
import locales from '#conf/locales/index.js'
import { I18nProvider } from '@rvoh/psychic/system'
import { LocalesEnumValues } from '#src/types/db.js'
export function supportedLocales() {
return LocalesEnumValues
}
// the locale a request gets when its Accept-Language header is missing, is `*`,
// or accepts none of the supported locales. LocalesEnumValues is alphabetical,
// so the default is named rather than taken from the list
export const DEFAULT_LOCALE: ReturnType<typeof supportedLocales>[number] = 'en-US'
// the base locale: i18n falls back to it for a key the requested locale is missing
export const BASE_LOCALE = 'en'
export default I18nProvider.provide(locales, BASE_LOCALE)
See usage for how I18nProvider.provide uses these locale files, and for how a controller picks the locale a request asks for.
Catch missing translations in a spec
At runtime, a key missing from a locale quietly falls back to the base locale's text, so a missing translation would otherwise only show up as untranslated text in front of a user. I18nProvider.localeDifferences compares every locale with the base locale and returns one readable line per difference: a key the locale is missing, a key the base locale doesn't have, a string where the base has nested translations (or the reverse), or different %{…} placeholders than the base's text for that key. Assert that the list is empty:
// spec/unit/conf/locales.spec.ts
import locales from '#conf/locales/index.js'
import { BASE_LOCALE } from '#src/utils/i18n.js'
import { I18nProvider } from '@rvoh/psychic/system'
describe('locales', () => {
it('match the base locale key for key and placeholder for placeholder', () => {
expect(I18nProvider.localeDifferences(locales, BASE_LOCALE)).toEqual([])
})
})
How a locale resolves to a file
A locale is matched to a locale file by its language prefix alone: en-US, en-GB and en-AU all resolve to the en file, and es-ES to es. A locale whose language has no file uses the base locale's file.