From 1746cfd51556c15096b3a27d890f7bb3c3b395b0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 2 Nov 2026 17:09:59 +0000 Subject: [PATCH] docs(chord): i18n proposal, t.message and one file per host --- packages/chord/docs/next/i18n/book.md | 29 ++++++++++++++++++++++-- packages/chord/docs/next/i18n/index.html | 22 ++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/chord/docs/next/i18n/book.md b/packages/chord/docs/next/i18n/book.md index 32a268354..b431cb030 100644 --- a/packages/chord/docs/next/i18n/book.md +++ b/packages/chord/docs/next/i18n/book.md @@ -117,6 +117,24 @@ export default defineFacet((_host, self) => { - A plugin's own catalogs are its built-in contributions; a translation plugin can also override one of them (a better German), contributed explicitly with `override: true`. +## One file, every host + +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: + +```ts +// plugins/french/fr.ts: the catalogs, written once +export const notesFr: MessagesOf = { 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. + # Choosing a Language {#locale lede="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 @@ -148,9 +166,10 @@ 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 +// 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 = { namespace: "notes", key: "saved", params: [note.title, note.at] }; + (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 @@ -158,6 +177,12 @@ 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. diff --git a/packages/chord/docs/next/i18n/index.html b/packages/chord/docs/next/i18n/index.html index b568aa8a7..7a2fb4888 100644 --- a/packages/chord/docs/next/i18n/index.html +++ b/packages/chord/docs/next/i18n/index.html @@ -216,6 +216,18 @@ registry rule for conflicts).
  • A plugin's own catalogs are its built-in contributions; a translation plugin can also override one of them (a better German), contributed explicitly with override: true.
  • +

    One file, every host

    +

    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.

    III

    Choosing a Language

    Per viewer, defaulted from the device, switched live.

    @@ -242,14 +254,20 @@ both look at the same object state.

    // 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
    +// 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 = { namespace: "notes", key: "saved", params: [note.title, note.at] };
    +	(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