I18N
Internationalization (i18n) localizes your app.
Primate exposes a tiny, typed translator t
and a locale
controller.
The active locale persists across reloads.
- No hooks — call
t("key", params?)
anywhere - Typed keys / params — inferred from your default locale file
- SSR-safe / reactive across all major frontends
- Persistence — cookie (default), localStorage, sessionStorage, or none
- Intl-backed — numbers, dates, currency, lists, relative time, units
Setup
Enable i18n by creating config/i18n.ts
and importing your catalogs.
import de from "#locale/de-DE";
import en from "#locale/en-US";
import i18n from "primate/config/i18n";
export default i18n({
defaultLocale: "en-US",
locales: {
"en-US": en,
"de-DE": de,
},
});
Locales
Locales are TypeScript modules in the locales
directory that export a catalog
via locale(...)
.
File | Purpose |
---|---|
<default>.ts |
catalog matching defaultLocale (drives types) |
<locale>.ts |
any other catalog mapping keys -> translated strings |
Example
defaultLocale
is "en-US"
, so en-US.ts
is the default
catalog. If you set defaultLocale: "de-DE"
, then de-DE.ts
becomes the
default and drives type inference.import locale from "primate/i18n/locale";
export default locale({
switch_language: "Switch language",
english: "English",
german: "German",
all_posts: "All posts",
counter: "Counter: {count:n}",
title: "Title",
greet_user: "Hello, {name}",
added_n_items: "{n:n|one item added|{n} items added}",
price_line: "Total: {amount:currency}",
last_seen: "Last seen {secs:ago}",
when: "It happened on {d:date}",
distance: "Distance: {km:unit(kilometer)}",
list_example: "Tags: {tags:list}",
});
import locale from "primate/i18n/locale";
export default locale({
switch_language: "Sprache wechseln",
english: "Englisch",
german: "Deutsch",
all_posts: "Alle Beiträge",
counter: "Zähler: {count:n}",
title: "Titel",
greet_user: "Hallo, {name}",
added_n_items: "{n:n|ein Eintrag hinzugefügt|{n} Einträge hinzugefügt}",
price_line: "Summe: {amount:currency}",
last_seen: "Zuletzt gesehen {secs:ago}",
when: "Es geschah am {d:date}",
distance: "Entfernung: {km:unit(kilometer)}",
list_example: "Stichwörter: {tags:list}",
});
Using the translator
Front adapters expose a translator t
and a locale setter. The translator is a
function and a reactive store.
Frontend | Translator | Locale getter | Locale setter | Notes |
---|---|---|---|---|
React | t |
t.locale.get() |
t.locale.set(l) |
Call t() anywhere |
Angular | t |
t.locale.get() |
t.locale.set(l) |
Injectable or import as needed |
Vue | t |
t.locale.get() |
t.locale.set(l) |
Works in setup/options |
Svelte | $t |
t.locale.get() |
t.locale.set(l) |
t is a store; use $t in markup |
Solid | t |
t.locale.get() |
t.locale.set(l) |
Signal-like; t.subscribe() |
Type safety — keys and placeholder types are inferred from the catalog whose
filename matches defaultLocale
. Unknown keys are compile-time errors; missing
params are flagged with proper types.
// typed: "greeting" requires { name: string }
t("greeting", { name: "Ada" });
// optional: messages with no params
t("simple");
// missing key -> TS error
// t("greetnig");
// missing param -> TS error
// t("greeting");
Message syntax
Messages are simple strings with {placeholders}
. Each placeholder can specify
a format spec after a colon.
Spec | Meaning | Param type | Example |
---|---|---|---|
(no spec) | string | string |
{name} |
n or number |
numbers | number |
{count:n} |
d or date |
date | Date | number |
{created:d} |
c or currency |
currency | number |
{total:c} |
o or ordinal |
ordinals | number |
{rank:o} |
a or ago |
relative time | number |
{delta:a} |
l or list |
item list | string[] |
{tags:l} |
u(<u>) or unit(<u>) |
units | number |
{size:u(MB)} |
t("cart_summary", {
count: 3, // {count:n}
total: 99.99, // {total:c}
eta: 3600, // {eta:a}
items: ["A","B"], // {items:l}
speed: 100, // {speed:u(km/h)}
});
Plural selection
{n:n|...}
optionally takes 2 or 3 choices:
n|one|other
— uses the locale's plural rulesn|zero|one|other
— adds an explicit zero branch
Inside choices you can backreference the formatted number as using the same key.
// en: "You have 1 item" / "You have 3 items"
"You have {count:n|{count} item|{count} items}";
// de: "Du hast keine Artikel" / "Du hast 1 Artikel" / "Du hast 7 Artikel"
"Du hast {num:n|keine Artikel|{num} Artikel|{num} Artikel}";
Units
u(...)
accepts many aliases mapped to Intl units (length, area, volume, mass,
temperature, speed, time, digital, energy, power, pressure, angle, frequency,
concentration, electric, force, luminous). Examples:
Alias | Normalized Intl unit |
---|---|
km/h kph kmh |
kilometer-per-hour |
MB mb |
megabyte /megabit |
°C celsius |
celsius |
kWh |
kilowatt-hour |
psi |
pound-force-per-square-inch |
See full mapping in code (toIntlUnit
).
t("stats", { dist: 5, speed: 80, temp: 25, size: 2 });
// "Distance: {dist:u(km)} Speed: {speed:u(km/h)} Temperature: {temp:u(celsius)} Size: {size:u(GB)}"
Locale switching
Use t.locale.set(locale)
to switch. The active locale is reactive; subscribers
are notified, and UI updates.
// read current
const current = t.locale.get();
// switch and persist
t.locale.set("de-DE");
Persistence
Persistence is client-side and controlled by config.persist
.
Mode | Behavior |
---|---|
"cookie" (default) |
Sends a fetch("/") and persists in cookie |
"localStorage" |
Writes __primate_locale |
"sessionStorage" |
Writes __primate_locale |
false |
No persistence |
Notes:
- Persistence only runs in the browser.
t.loading
istrue
while a persistence request (cookie mode) is inflight.
// config/i18n.ts
export default i18n({
defaultLocale: "en-US",
currency: "USD",
persist: "cookie", // "localStorage" | "sessionStorage" | false
locales: { /* ... */ },
});
// runtime
if (t.loading) {
// show a tiny spinner if you care about cookie write
}
Reactivity & store API
t
is both a function and a store:
t.subscribe(run)
— Svelte-style;run
is called immediately and on changest.loading
— boolean gettert.locale.get()
/t.locale.set(l)
// Svelte
<script lang="ts">
import { t } from "$lib/i18n";
// $t is the live translator function
</script>
<h1>{$t("greeting", { name: "Ada" })}</h1>
// React (manual subscribe if you need it)
useEffect(() => t.subscribe(() => forceUpdate()), []);
Fallbacks & edge cases
- Missing key -> returns the key name as a string (e.g.
"nonexistent"
). - Missing param -> replaced with an empty string (
{name}
->""
). - Invalid date -> falls back to
Date.toString()
(locale-dependent). - NaN / ±∞ ->
Intl.NumberFormat
best effort; tests expect"NaN"
and"∞"
. - Multiple choices mismatch -> if you pass 2 choices,
zero
usesother
; 3 choices give you a separate zero branch.
Frontend usage
React
import { t } from "@/i18n";
export function Profile({ user }: { user: { name: string; since: number } }) {
return (
<section>
<h2>{t("profile_title")}</h2>
<p>{t("greeting", { name: user.name })}</p>
<p>{t("member_since", { date: user.since, /* d */ })}</p>
<button onClick={() => t.locale.set("de-DE")}>
{t("switch_to_german")}
</button>
</section>
);
}
Svelte
<script lang="ts">
import { t } from "$lib/i18n";
const change = () => t.locale.set("en-US");
</script>
<h1>{$t("dashboard_title")}</h1>
<p>{$t("notifications", { count: 3 })}</p>
<button on:click={change}>{$t("switch_to_english")}</button>
Vue
<script setup lang="ts">
import { t } from "@/i18n";
const switchLocale = () => t.locale.set("fr-FR");
</script>
<template>
<h1>{{ t("home_title") }}</h1>
<p>{{ t("items", { count: 1 }) }}</p>
<button @click="switchLocale">{{ t("switch_to_french") }}</button>
</template>
Formatting reference
This table summarizes the placeholder specs supported by the formatter.
Spec | Output example (en-US) | Input type | Notes | |||
---|---|---|---|---|---|---|
{n:n} |
1,234 |
number |
Uses Intl.NumberFormat |
|||
`{n:n | one | other}` | one / other |
number |
Locale plural rules | |
`{n:n | zero | one | other}` | zero / one / other |
number |
Explicit zero branch |
{d:d} |
6/12/2023 |
Date or number |
number treated as epoch |
|||
{price:c} |
$99.99 |
number |
Uses config.currency |
|||
{pos:o} |
1st , 2nd , 3rd , 11th |
number |
Ordinal via plural rules | |||
{time:a} |
in 1 hour , 3 days ago |
number (seconds) |
Rounds to s/min/h/day | |||
{list:l} |
a, b, and c |
string[] |
Intl.ListFormat |
|||
{val:u(km/h)} |
100 km/h |
number |
Intl unit mapping |
Type inference (compile-time)
Keys and parameter types come from the default catalog:
// locales/en-US.ts (default)
export default locale({
greeting: "Hello {name}",
added: "Added {n:n|{n} item|{n} items}",
created_at: "Created on {date:d}",
});
t("greeting", { name: "Ada" }); // ok
t("added", { n: 3 }); // ok
t("created_at", { date: Date.now() }); // ok (number as epoch)
// t("greeting", {}); // TS error: missing "name"
// t("created_at", { date: "2024-01-01" });// TS error: wrong type
Server rendering / hydration
t
is safe to call on the server (no globals required).- Persistence is client-only; on SSR,
t.locale.set()
won't attempt to write storage/cookies. - The initial active locale is the one you pass in config; you can hydrate from a cookie or storage on the client as needed.
Components
FAQ / notes
- Where do the plural categories come from?
Intl.PluralRules
per active locale. When you pass 2 options, categories collapse toone
vsother
. - Can I lazy-load catalogs? Yes—provide
config.locales
with modules you control (the i18n core doesn't mandate how you import them). - What if I mistype a key at runtime? At runtime, an unknown key returns the key name. At compile time, TypeScript should catch it in app code that sees the inferred types.
API reference
t(key, params?) => string
key
— string literal from default catalog (typed)params
— inferred object; placeholder names -> types- Returns rendered string
t.locale.get(): string
- Returns the active locale (reactive read)
t.locale.set(locale: string): void
- Switches active locale; notifies subscribers
- Triggers persistence in the browser (per config)
t.subscribe(run: (t) => void): () => void
- Svelte-style store subscription (immediate call + on-change)
- Returns an unsubscribe function
t.onChange((locale) => void): () => void
- Low-level locale change listener
- Returns an unsubscribe function
t.loading: boolean
true
while an async persistence write (cookie mode) is inflight