Primate

I18N

Internationalization (i18n) localizes your app.

Primate exposes a tiny, typed translator t and a locale controller. The active locale persists across reloads.

Setup

Enable i18n by creating config/i18n.ts and importing your catalogs.

TypeScriptconfig/i18n.ts
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

In this 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.

English Germanlocales/en-US.tslocales/de-DE.ts
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}",
});

Key style is up to you (flat, dotted, underscored, nested). Flat keys with underscores make object access ergonomic in TS.

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:

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:

// 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:

// 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

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

Components

FAQ / notes

API reference

t(key, params?) => string

t.locale.get(): string

t.locale.set(locale: string): void

t.subscribe(run: (t) => void): () => void

t.onChange((locale) => void): () => void

t.loading: boolean

Previous
Stores
Next
Intro