Internacionalização
@basaltkit/i18n resolve o locale a partir do contexto do request (por utilizador, depois por tenant), traduz catálogos de mensagens tipados com interpolação e plurais CLDR, e formata números, moeda, datas, tempo relativo e listas através das APIs nativas Intl. Zero dependências.
Setup
Define um catálogo por locale com defineMessages (fixa os tipos de chave/valor para que t() complete automaticamente), depois regista-os com i18nPlugin:
// src/i18n.ts
import { defineMessages } from '@basaltkit/i18n'
export const en = defineMessages({
greeting: 'Hi {name}',
notes: { one: '{count} note', other: '{count} notes' },
})
export const pt = defineMessages({
greeting: 'Olá {name}',
notes: { one: '{count} nota', other: '{count} notas' },
})// src/app.ts
import { createApp } from '@basaltkit/core'
import { I18n, I18N, i18nPlugin } from '@basaltkit/i18n'
import { en, pt } from './i18n.js'
const app = await createApp({
plugins: [i18nPlugin({ locales: { en, pt }, defaultLocale: 'en' })],
}).boot()
// The I18N token erases the catalog type; cast to keep key autocompletion.
export const i18n = app.container.get(I18N) as I18n<typeof en>Dica
Por defeito o locale do request é ctx().user.locale, depois ctx().tenant.locale. Para o resolver de outra forma, passa resolveLocale. Preenche tu mesmo uma chave locale no contexto do request (ex.: a partir de um header Accept-Language num enricher) — RequestContext tem uma index signature aberta — e depois lê-a aqui:
import { ctx } from '@basaltkit/core'
i18nPlugin({
locales: { en, pt },
defaultLocale: 'en',
resolveLocale: () => ctx().locale as string | undefined,
})O locale resolvido é dado controlado pelo cliente e é validado antes de chegar ao Intl: uma tag BCP-47 inválida num registo de utilizador (en_US, !!) cai para o defaultLocale em vez de transformar cada página formatada num 500.
Traduzir
i18n.in('pt').t('greeting', { name: 'Ada' }) // 'Olá Ada'
i18n.in('en').t('notes', { count: 3 }) // '3 notes' (plural via Intl.PluralRules)Dentro de um request, i18n.t(...) usa o locale do contexto automaticamente — ctx().user.locale, depois ctx().tenant.locale (guarda um campo locale nesses registos, ou passa um resolveLocale personalizado). Um locale regional negoceia para baixo até um catálogo disponível (pt-BR → pt), depois recorre a defaultLocale, e por fim à própria chave.
Formatar
Cada tradutor transporta formatadores baseados em Intl no mesmo locale:
const l = i18n.in('en')
l.n(1234.5) // '1,234.5'
l.currency(9.9, 'USD') // '$9.90'
l.date(new Date(), { dateStyle: 'long' })
l.relativeTime(-1, 'day') // '1 day ago'
l.list(['a', 'b', 'c']) // 'a, b, and c'Num request
Dentro de um handler, chama i18n.t / os formatadores diretamente — usam o locale do contexto sem plumbing por chamada:
import { z } from 'zod'
import { route } from '@basaltkit/fastify'
import { i18n } from './app.js' // the module-scoped instance resolved above
export const summary = route({
method: 'GET',
url: '/summary',
query: z.object({ count: z.coerce.number() }),
handler({ query }) {
return {
title: i18n.t('greeting', { name: 'Ada' }), // context locale
line: i18n.t('notes', { count: query.count }), // '1 note' / '3 notes'
when: i18n.relativeTime(-1, 'day'), // '1 day ago' / 'há 1 dia'
}
},
})Combina-o com @basaltkit/mailer / @basaltkit/notifications para renderizar conteúdo de saída no locale do destinatário.