diff --git a/packages/chord/docs/next/i18n/book.md b/packages/chord/docs/next/i18n/book.md new file mode 100644 index 000000000..bcc275cc2 --- /dev/null +++ b/packages/chord/docs/next/i18n/book.md @@ -0,0 +1,182 @@ +--- +title: Speaking Every Language: i18n in the app kit +cover: i18n / in the App Kit +kicker: A proposal · Chord v2 app kit · 2026-11-02 +subtitle: How an app and its plugins write their texts once, in code, and show them in each viewer's language +stats: + - 1 | function a plugin calls: t(key, ...args) + - 0 | message files outside the code + - 0 | page reloads to switch a language +note: | + 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. Not built; it would land in the app kit + with slice 11. +colophon: Speaking Every Language · proposal · 2026-11-02 +--- + +# Before we begin {.preface} + +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. + +## Five things to know + +:::findings +1. **Messages stay in code,** per plugin: `defineMessages("notes", { en: {...}, de: {...} })` returns a typed `t`. +2. **Values may be functions,** for plurals and parameters; dates and numbers go through `Intl`. +3. **Languages are contributions:** a `Translations` registry adds a language to any plugin, the app included. +4. **The language is a user preference,** from the browser or the system locale by default; switching re-renders live. +5. **Servers and workers never format text for people:** they send a key and its parameters, and each viewer's client + renders them in its own language. +::: + +## Words used throughout + +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`). + +# Messages in Code {#messages lede="Each plugin defines its own messages and gets a typed function back."} + +```ts +// 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`, + }, +}); +``` + +```tsx +// plugins/notes/web.tsx +import { t } from "./messages.ts"; + +function NotesHeader({ count }: { count: number }) { + return
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. Not built; it would land in the app kit
+with slice 11.
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.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/worker.ts: state and notices carry a key and its parameters
+self.notify({ message: { namespace: "notes", key: "saved", params: [note.title, new Date(note.at)] } });
+
+// plugins/notes/web.tsx: rendered in the viewer's language
+const text = t.render(notice.message); // typed against the notes catalog
+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
+