@luwiostack/translations
v0.2.0
Published
Lingui-powered translations for React — a language provider, catalog (pre)loading, and a t() helper.
Downloads
289
Readme
@luwiostack/translations
Lingui-powered translations for React. Just translations:
- Create a
Translationsobject withcreateTranslations(catalogRegistry). - Hand it to
<Translations translations={translations}>— like<RouterProvider router={…} />. - Reach it with
useTranslations()toactivate()a language ort()to translate.
Catalogs come from two places: up front with createCatalog() in *.catalog files (discovered
by the luwioTranslations Vite plugin and pre-loaded by
createTranslations(catalogRegistry)), or at runtime with translations.add() (cached, deduped) — an import, an
API, inline messages. Each language must be a valid @luwiostack/language ILanguage,
so it's valid by construction. It knows nothing about routing.
Built on Lingui, which is bundled in — you don't install or import
@lingui/coreor@lingui/reactdirectly. Catalogs are plain objects you manage yourself (see below), not extracted/compiled via the Lingui CLI.
Install
npm install @luwiostack/translationsCreate & provide
// translations.ts
import { catalogRegistry, createTranslations } from '@luwiostack/translations'
export const translations = createTranslations(catalogRegistry) // from the registry; add more at runtime// App.tsx
import { Translations } from '@luwiostack/translations'
import { translations } from './translations'
function App() {
return (
<Translations translations={translations}>
<Site />
</Translations>
)
}Add a catalog — from anywhere
add / activate / isLoaded take an ILanguage (from @luwiostack/language), so a language is
always valid. The source accepts messages directly, a dynamic import, an API call, or a promise.
It's awaitable, cached and deduped — a language is fetched at most once and never re-loads on switch.
import { Language } from '@luwiostack/language'
const nl = Language.new({ code: 'nl' })
const fr = Language.new({ code: 'fr' })
await translations.add(nl, { greeting: 'Hallo' }) // inline messages
await translations.add(fr, () => import('./locales/fr').then((m) => m.messages)) // file import
await translations.add(fr, () => fetch(`/api/i18n/${fr.code}`).then((r) => r.json())) // API
translations.activate(nl) // switch to a loaded languageCombine multiple files
add() takes a single CatalogSource per language — to combine several files (or requests) into
one catalog, merge them yourself inside it before returning. A second add() for an
already-loaded (or in-flight) language is a no-op, not a merge, so this has to happen in one call:
// Split across files
await translations.add(nl, async () => {
const [common, page] = await Promise.all([
import('./locales/nl/common').then((m) => m.messages),
import('./locales/nl/page').then((m) => m.messages),
])
return { ...common, ...page } // Catalog is a plain object — later keys win
})// Split across API endpoints
await translations.add(nl, async () => {
const [common, page] = await Promise.all([
fetch(`/api/i18n/${nl.code}/common`).then((r) => r.json()),
fetch(`/api/i18n/${nl.code}/page`).then((r) => r.json()),
])
return { ...common, ...page }
})Preload
Preloading is just add() without activate() — warm the languages a user is likely to switch to
(after first paint, on idle, or on hover) so the switch itself is synchronous, with no fetch and no
loading state.
const nl = Language.new({ code: 'nl' })
const fr = Language.new({ code: 'fr' })
// Warm the other languages in the background…
void Promise.all([
translations.add(nl, () => fetch(`/api/i18n/${nl.code}`).then((r) => r.json())),
translations.add(fr, () => fetch(`/api/i18n/${fr.code}`).then((r) => r.json())),
])
// …later the switch is instant — already cached:
translations.activate(fr)Define catalogs up front (registry + Vite)
The runtime add() above is one of two ways to supply a catalog. The other is to define it up
front in a *.catalog file with createCatalog — the translations counterpart to createRoute /
createApi. createTranslations(catalogRegistry) pre-loads every registered catalog, so a language shipped with the
app is ready with no runtime add().
// app/en.catalog.ts
import { Language } from '@luwiostack/language'
import { createCatalog } from '@luwiostack/translations'
import { messages } from './messages'
export const en = createCatalog({ language: Language.new({ code: 'en' }), source: messages.en })Discover those files automatically with the Vite plugin (like luwioRouter / luwioApi) — no glob
in your app:
// vite.config.ts
import { luwioTranslations } from '@luwiostack/translations/vite'
export default defineConfig({ plugins: [luwioTranslations()] }) // scans src for *.catalog.{ts,tsx,…}// translations.ts
import 'virtual:@luwiostack/translations/catalogs' // every *.catalog file is now registered
import { catalogRegistry, createTranslations } from '@luwiostack/translations'
export const translations = createTranslations(catalogRegistry) // pre-loads the registered catalogsReference @luwiostack/translations/vite-client in your vite-env.d.ts for the virtual module's
type. Up-front and runtime coexist: ship the default language as a catalog, and still
translations.add(language, () => fetch(…)) the rest on switch. Opt out of the shared registry with
a different registry to createTranslations (or add an explicit { catalogs: [...] }).
useTranslations
import { useTranslations } from '@luwiostack/translations'
function Greeting() {
const { t } = useTranslations() // t is pulled out for convenience; re-renders on activate
return (
<>
<h1>{t('greeting')}</h1>
<p>{t({ key: 'welcome', defaultValue: 'Welcome, {name}' }, { name: 'Gert' })}</p>
</>
)
}useTranslations() returns { translations, t } — destructure t for the common translate case, or
reach the whole translations store when you need add() / activate().
With a route (@luwiostack/router)
@luwiostack/translations is routing-agnostic — this is just how an app wires it. @luwiostack/router puts the
resolved locale in route context, so a layout route adds + activates the catalog in beforeLoad.
Because add is awaited, the route waits for translations; because it's cached, moving between pages
in the same language never re-fetches.
import { createRoute } from '@luwiostack/router'
import { Shell } from '../components/Shell'
import { translations } from '../translations'
export default createRoute({
id: 'shell',
layout: true,
component: Shell,
beforeLoad: async ({ context }) => {
const language = context.locale.language() // an ILanguage from @luwiostack/language
await translations.add(language, () => fetch(`/api/i18n/${language.code}`).then((r) => r.json()))
translations.activate(language)
},
})API
createTranslations(catalogRegistry, { catalogs? })→ITranslations— create from a catalog registry (likecreateRouter(routeRegistry, …)); pre-loads the registered catalogs, plus any passed viaoptions.catalogsadd(language, source)— add a catalog for anILanguagefrom aCatalogSource(messages ·() => import(…)·() => fetch(…)· promise); cached, deduped, awaitableactivate(language)— make anILanguageactiveisLoaded(language)— whether anILanguage's catalog is loadedlanguages— theILanguage[]added so fart(id, values?)— runtime translate against the active languagefor(language)→{ t }— a translator bound to a specific language, independent of the active one (translate several languages in one pass, e.g. SSR-ing every locale or a locale-aware router building<head>tags per locale)
- Up-front catalogs (registry):
createCatalog({ language, source })→CatalogEntry(self-registers),catalogRegistry(add/get/all/clear),registerModules(modules)— all on@luwiostack/registry - Vite:
luwioTranslations({ dir?, suffix?, virtualId? })(@luwiostack/translations/vite) — scans*.catalogfiles intovirtual:@luwiostack/translations/catalogs; ambient type at@luwiostack/translations/vite-client - React:
Translations(provider,{ translations }),useTranslations()→{ translations, t } - Types:
ITranslations,Translator,CreateTranslationsOptions,CatalogEntry,CatalogSource,Messages
