How an app and its plugins write their texts once, in code, and show them in each viewer's language
A proposal by the redesign session, asked for by the owner on 2026-11-02 ("i18n also needs to be part of the app
kit"; "need code example"). It starts from mini-lit's src/i18n.ts (typed catalogs in code) and adapts it to
plugins that are built separately, reload live and run on several hosts. Accepted 2026-11-03 and built after slice
11, with three refinements (a contribution is a list, each rendering facet contributes its plugin's own catalogs,
t is bound per host): design.md §23 "Messages and languages" is the current statement.
Lena uses p3 in German; her colleague Tom, a guest in her session, reads English. The notes plugin shows "New note" to Tom and "Neue Notiz" to Lena, on the same page served by the same server. A French translator later adds French to the app and to every plugin, without touching any of them. And when Lena switches to French, nothing reloads: the text on her screen changes, the half-written message stays.
mini-lit already does the first part well: messages are TypeScript objects, keyed and type-checked, so a missing
German text is a compile error. Three things do not carry over: its messages are one global table extended by
declaration merging (which separately built plugins cannot share), the language lives in localStorage and switching
reloads the page, and every text is formatted where it is shown, which breaks when the server formats a message for
two viewers with two languages.
defineMessages("notes", { en: {...}, de: {...} }) returns a typed t.Intl.Translations registry adds a language to any plugin, the app included.A message is one text with a key (newNote). A catalog is the messages of one plugin in one language. A
namespace is the plugin a catalog belongs to. A locale is a language tag (de, pt-BR).
Each plugin defines its own messages and gets a typed function back.
// plugins/notes/messages.ts
import { defineMessages } from "@p3/app/v1";
export const t = defineMessages("notes", {
en: {
newNote: "New note",
empty: "No notes yet",
count: (n: number) => (n === 1 ? "1 note" : `${n} notes`),
saved: (title: string, at: Date) => `Saved "${title}" at ${formatTime(at)}`,
},
de: {
newNote: "Neue Notiz",
empty: "Noch keine Notizen",
count: (n) => (n === 1 ? "1 Notiz" : `${n} Notizen`),
saved: (title, at) => `„${title}“ um ${formatTime(at)} gespeichert`,
},
});
// plugins/notes/web.tsx
import { t } from "./messages.ts";
function NotesHeader({ count }: { count: number }) {
return <h2>{t("count", count)} <button>{t("newNote")}</button></h2>;
}
The first catalog (en) defines the keys and their argument types; every other catalog must have the same keys with
the same signatures, so a missing German text, a typo in a key or a wrong argument is a type error, not a blank in
production. t("count", "3") does not compile. The namespace ("notes") keeps two plugins' empty apart.
Functions cover what plain strings cannot: plurals (count), parameters in the right word order (saved puts the
title first in English and German alike, but another language may not), and gender or case where a language needs it.
Plural rules beyond one and many use Intl.PluralRules; dates, times and numbers use Intl.DateTimeFormat and
Intl.NumberFormat with the viewer's locale, through two small helpers (formatTime, formatNumber) that read the
current locale like t.
Adding French to the app and every plugin without editing them.
A plugin ships the languages its author speaks. Everything else can come from anyone, as a contribution:
// plugins/french/web.ts (a translation plugin)
import { defineFacet, Translations } from "@p3/app/v1";
import type { MessagesOf } from "@p3/app/v1";
import type { t as notes } from "../notes/messages.ts"; // the notes plugin's keys, for the type check
const notesFr: MessagesOf<typeof notes> = {
newNote: "Nouvelle note",
empty: "Aucune note",
count: (n) => (n <= 1 ? `${n} note` : `${n} notes`),
saved: (title, at) => `« ${title} » enregistrée à ${formatTime(at)}`,
};
export default defineFacet((_host, self) => {
self.contribute(Translations, { namespace: "notes", locale: "fr", messages: notesFr });
self.contribute(Translations, { namespace: "app", locale: "fr", messages: appFr });
});
MessagesOf<typeof t> gives a translation plugin the exact keys and signatures to fill, from the plugin it
translates (an imported type, erased at build).pt-BR to
pt), then to the first catalog (en), key by key. A fallback is reported once in the logs, so gaps are visible.override: true.Registries are per host: a contribution lives on the host whose facet made it. A French catalog contributed from the
web facet exists in browser pages only. So a plugin keeps its messages in one shared file, messages.ts, which every
facet that renders text imports (the web and TUI facets; the worker's too when it builds messages with t.message),
and a translation plugin keeps its catalog in one shared file too and contributes it from each facet that renders:
// plugins/french/fr.ts: the catalogs, written once
export const notesFr: MessagesOf<typeof notes> = { newNote: "Nouvelle note", /* ... */ };
// plugins/french/web.ts and plugins/french/tui.ts: the same two lines each
export default defineFacet((_host, self) => self.contribute(Translations, { namespace: "notes", locale: "fr", messages: notesFr }));
The server and workers need catalogs only when they produce final text themselves (an email, a push notification); then their facets contribute them too, and the text is rendered in the recipient's language from their preference. Catalogs never travel over links: they contain functions.
Per viewer, defaulted from the device, switched live.
The language is a user preference, kept with the user's other preferences (the kit's per-principal document, like the theme choice), so it follows a person across devices:
navigator.languages on the web, the system locale in the TUI, matched against the
locales any catalog offers.t reads the current locale from a watchable; React components re-render through a hook, the TUI
redraws through its scope. Nothing reloads.// any web component
const { t, locale } = useMessages(notesMessages); // re-renders when the locale or a translation changes
When a translation plugin is edited and rolled out, the registry recomposes and the open pages show the new text, as with themes.
Why the server sends keys, not sentences.
The server and the workers also produce text people read: a notice ("Worker failed to start"), a tool card's summary, a status line, an error. If the worker formatted "Notiz gespeichert" for Lena, Tom would read German too: both look at the same object state.
So objects carry messages as data, and each client renders them:
// plugins/notes/contract.ts: a member of the Notes object's state holds a message as data
lastSaved: opt(obj({ namespace: str(), key: str(), params: arr(unknown()) })),
// plugins/notes/worker.ts: the object writes the key and its parameters, never a sentence; the same `t` builds it
import { t } from "./messages.ts";
await self.tx(async (tx) => {
(await tx.draft(status)).lastSaved = t.message("saved", note.title, note.at); // typed: key and arguments checked
}, ctx);
// plugins/notes/web.tsx: each viewer renders it in its own language
const notes = useHold(Notes, { session });
const text = notes.lastSaved && t.render(notes.lastSaved); // typed against the notes catalog
t.message returns a Message<"notes">, plain data { namespace, key, params }, typed against the first catalog:
t.message("saved", 42) and t.message("svaed", ...) are type errors. t.render takes only Message<"notes">. Across a
link the contract's member is a generic message schema, so t.render checks the namespace and key at runtime and
shows the key for an unknown one. The writer and the reader are type-checked; the hop between them is checked at
runtime.
The same holds for anything else that reaches people: an app's notices object stores { namespace, key, params }
per notice, a tool card's summary carries one, an error shown to a user carries a code and parameters.
Parameters are plain data (strings, numbers, ISO dates), so they cross links like any state. What is never translated: the model's prompts and the model's own answers (the conversation's language is the user's business with the model), log lines (for developers, in English), error codes. Error messages shown to people follow the same rule as notices: a code plus parameters, rendered by the client.
To decide.
"Copy"), which reads well and needs no key
names; named keys (newNote) survive rewording the English. Recommendation: named keys, with the English catalog
as the source of truth.dir from the locale; the app's CSS uses logical properties
(margin-inline-start). Recommendation: the kit sets lang and dir on the root; components are the app's.| Piece | Where | What |
|---|---|---|
defineMessages(namespace, catalogs) |
the app's API (shared) | typed t(key, ...args), t.render(message) |
MessagesOf<typeof t> |
the app's API (type) | the keys and signatures a translation must fill |
Translations |
the app's API (shared registry) | { namespace, locale, messages, override? } |
useMessages(t) |
the app's web module | the hook re-rendering on locale or translation changes |
formatTime, formatNumber |
the app's API | Intl helpers in the viewer's locale |
| the locale preference | the kit's per-principal preferences | default from the device, switched live |
{ namespace, key, params } |
object state and notices | a message as data, rendered by each client |
Speaking Every Language · proposal · 2026-11-02