mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(chord): messages and languages in the app kit (i18n)
defineMessages with typed t, t.message/t.render for messages as data, the Translations registry with fallbacks and conflicts, a locale preference per principal with the device as default and a live switch, useMessages and scope.messages, formatTime/formatNumber. The tutorial gains a chapter on languages.
This commit is contained in:
parent
2439933cf2
commit
3d11c3510b
70 changed files with 2641 additions and 320 deletions
|
|
@ -19,6 +19,7 @@
|
|||
- Chord v2's React bindings gain `usePageState(key, initial)`, `useState`'s pair with the value kept by the page's host under `key` instead of the component: a component of a reloaded web plugin is a new function and remounts (its `useState` starts over), and page state is what survives (a half-typed message, a scroll position); any value, never on the wire, gone with the page. `TuiScope` gains `pageState(key, initial)` on the same per-host store, its changes calling the scope's `changed`.
|
||||
- Chord v2 reports mistakes that waited silently through `onError` (decision "Mistakes that wait are reported at once"): `unbound_long { waitingFor, error, since }`, a request waiting unbound (a type or registry not served here, not at its version, a registry whose first `compose` threw) once after 5 s, on one timer per host (it keeps waiting until its `readyTimeout`); `registry_undeclared { registry, plugins }`, a registry with contributions and no provider at a publication (`load`, a reload's commit) or when a host-lifetime object's contributions enter, naming the contributing plugins, again only once they change; `transaction_cancelled { object, error }`, a document change or transaction that did not commit because the context it was started with ended first (a root whose `fn` returned, a caller that cancelled, its object's end), besides rejecting.
|
||||
- Chord v2 gains live queries: `self.query(table, q, limit)` in the factory of a table's owner is a watchable of the query's rows (at most `limit`, in its order), usable as a state member and a derive source. Chord runs it before the object goes live and again inside the commit step of every commit of the object that writes the table, publishing only the diff in that commit's batch; a commit writing no table with a live query runs nothing (`chord.query.runs` counts the runs). Only the object's own tables; the limit is required (`invalid/args` at declaration). A run that fails (a row the code cannot read) is a failed member, reported once as the new `HostError` `query_failed { object, table, error }`, and heals at the next good run. A live query is no native, so an object showing table rows live swaps on reload instead of restarting, as reading them in `self.open` made it. Each touching commit runs the query once (100 rows: about 60 µs, 80 µs watched): not for tables written hundreds of times a second.
|
||||
- Chord v2 gains messages and languages in the app kit (design §23): `defineMessages(namespace, { en: {...}, de: {...} })` returns a typed `t(key, ...args)` (the first catalog types the others: a missing key, an unknown one or a wrong argument is a type error; values are strings or functions), `t.message(key, ...args)` builds a message as data (`MessageData`, `{ namespace, key, params }`, for state members typed with `MessageSchema`), `t.render(message)` renders one (a foreign namespace or an unknown key from a link shows the key), `t.translations` lists its catalogs, and `MessagesOf<typeof t>` types a translation. The `Translations` registry takes a list of `{ namespace, locale, messages, override? }` per contributor (one catalog per namespace and locale: the first by plugin name wins, reported; `override: true` wins); a missing key falls back down the locale's chain (`pt-BR`, `pt`) to the first catalog, reported once. `LocaleChoice` keeps one locale per principal on the server (the theme choice's store, now shared as one preference); a client follows it with `followActiveLanguage`, defaulting to `navigator.languages` on a page and the system locale in the terminal (`startTerminal`'s `locales`), matched against the catalogs, switched live. `useMessages(t)` (`/react`) and `TuiScope.messages(t)` (`/tui`) render in the host's language and re-render or redraw on a change; `startPage` sets `lang` and `dir` on the page (`followLanguage`, `/web`); `formatTime` and `formatNumber` use `Intl` in the language being rendered. The kit serves `Translations` on the server, workers, pages and terminals and `LocaleChoice` on the server; client errors gain `{ kind: "messages", report }`.
|
||||
|
||||
### Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -15,15 +15,15 @@ The design, the rule book for all of this, is [docs/design.md](docs/design.md).
|
|||
|
||||
| Import | Contents | Runs in |
|
||||
|--------|----------|---------|
|
||||
| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, the schema builders), errors (`ChordError`, `isChordError`), hosts and facets (`createHost`, `defineFacet`), documents (`defineState`), derives and natives (`constant`, `mapShared`), routines and jobs (`defineJob`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`), registries (`defineRegistry`), the store (`openStore`, `defineTable`, queries, export and import), and the client surfaces' shared part (`Themes`, `ThemeChoice`, `WebScripts`, `TuiScripts`). | Node and browsers |
|
||||
| `@earendil-works/chord` | Contracts (`local`, `remote`, `method`, the schema builders), errors (`ChordError`, `isChordError`), hosts and facets (`createHost`, `defineFacet`), documents (`defineState`), derives and natives (`constant`, `mapShared`), routines and jobs (`defineJob`), links (`memoryLinkPair`, `ipcLink`, `webSocketLink`), registries (`defineRegistry`), the store (`openStore`, `defineTable`, queries, export and import), and the client surfaces' shared part (`Themes`, `ThemeChoice`, `WebScripts`, `TuiScripts`, messages and languages: `defineMessages`, `Translations`, `LocaleChoice`, `formatTime`, `formatNumber`). | Node and browsers |
|
||||
| `@earendil-works/chord/http` | HTTP: a Web-standard `fetch` handler (`createHttp`) built from the routes, wraps and sockets plugins contribute to the registry `Http`, authenticators (`Authenticators`), responses as descriptions, a security baseline on by default, Chord's Node listener and `nodeRequest`, the links endpoint. | Node |
|
||||
| `@earendil-works/chord/workers` | Workers: `createWorkers` runs a forked worker per address of a host kind, started on demand and stopped when idle; `workerMain` is a worker's entry; `processLock`. | Node |
|
||||
| `@earendil-works/chord/plugins` | Plugins: discovery, builds with esbuild (`buildFacet`), evaluation, the watcher, rollouts, `createPlugins`, `installPlugin`, `testPlugin`, `pluginFile`. | Node |
|
||||
| `@earendil-works/chord/plugins/runtime` | `createPluginRuntime`: loads built plugins without esbuild (a worker's side). | Node |
|
||||
| `@earendil-works/chord/app` | The app kit: `startServer`, `startWorker`, the start-data objects, sessions and the owner's sign-in, device pairing, files, export and import per address, optional plugins (users, invites, ephemeral login). | Node |
|
||||
| `@earendil-works/chord/surfaces` | The client surfaces' server side: `webSurface` (base bundle, plugin scripts, the page) and `tuiSurface` (the terminal surface). | Node |
|
||||
| `@earendil-works/chord/web` | The web surface's browser side: `startPage` in the app's web entry, `followThemes`. | Browsers |
|
||||
| `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `usePageState`, `watchStore`. | Browsers |
|
||||
| `@earendil-works/chord/web` | The web surface's browser side: `startPage` in the app's web entry, `followThemes`, `followLanguage`. | Browsers |
|
||||
| `@earendil-works/chord/react` | Optional React bindings: `HostProvider`, `useHold`, `useWatch`, `useRefState`, `useRegistry`, `usePageState`, `useMessages`, `watchStore`. | Browsers |
|
||||
| `@earendil-works/chord/tui` | The terminal surface's sandbox side: `startTerminalRuntime` in the app's terminal entry, `BridgeTerminal`, `TerminalTheme`, `TuiScope`. | Node (sandboxed) |
|
||||
| `@earendil-works/chord/tui/launcher` | The terminal launcher, the trusted side of a terminal client: `startTerminal`, `requireSandboxNode`. | Node |
|
||||
| `@earendil-works/chord/context` | Explicit cancellation, telemetry, and invocation values: `BACKGROUND_CONTEXT`, `withCancel`, `withAbortSignal`, `withoutAbortSignal`, `withTelemetry`, `traceIn`, `withContextValue`, `awaitWithContext`. | Node and browsers |
|
||||
|
|
|
|||
|
|
@ -2419,7 +2419,21 @@ validation and copies the v2 kernel added around Delta.
|
|||
state, notices, tool summaries and errors shown to people (a code plus parameters); each client renders it with
|
||||
`t.render` in its own language (checked at runtime across a link: an unknown key shows the key). Never translated:
|
||||
the model's prompts and answers, log lines, error codes. No translator file format now; an export/import tool to JSON
|
||||
when someone needs it. Built after slice 11 (carry-over "After slice 11").
|
||||
when someone needs it. Built after slice 11 (carry-over "After slice 11"). **Refined in the build (2026-11-03;
|
||||
asked by the build, accepted by the main session):** (1) a `Translations` contribution is a list
|
||||
(`readonly Translation[]`): a contributor contributes once per registry, and the proposal's French plugin
|
||||
contributes `notes/fr` and `app/fr` from one facet. (2) A plugin's own catalogs are its built-in contributions
|
||||
literally: each facet that renders contributes them (`self.contribute(Translations, notes.translations)`), because
|
||||
`defineMessages` runs at module evaluation, tied to no host, and a host must know every locale its catalogs offer
|
||||
(the device default, a settings list); conflicts and `override` as decided (`override: true` wins over a catalog
|
||||
without it), and `t` still renders its own catalogs if a facet forgot the contribution. (3) `t` is per host:
|
||||
`useMessages(t)` returns `{ t, locale }` bound to the `HostProvider`'s host and its language, `scope.messages(t)`
|
||||
likewise (a change redraws); the plain `t(key, ...args)` imported from `messages.ts` renders in the language of the
|
||||
render under way (a synchronous render scope: nested `t` calls, `formatTime` in a message function), outside one in
|
||||
the first catalog (servers, tests, logs); no module-level current locale, since one process may run many hosts and a
|
||||
server renders per recipient. Also from the build: the typed message is `MessageData<N, K>` (the link protocol owns
|
||||
`Message`), `t.message` takes only keys whose arguments are JSON scalars, and the theme choice and the locale choice
|
||||
share one per-principal preference store (`src/surfaces/preference.ts`).
|
||||
- **Chord v2 becomes the package's root (owner, 2026-11-03: "src/old", "subpaths", "yes", "yes", "no, chord is
|
||||
wip"; for merging pi-private into pi this week; trial `/home/badlogic/chord-root-move/report.md`):** `src/next` is
|
||||
the package's `src` (its main export), the Node parts `src/next-node` are `src/node` with short subpaths (`/http`,
|
||||
|
|
|
|||
|
|
@ -2388,20 +2388,71 @@ Decision "Slice 11's points", "Slice 11, P3" to "P9", and "Tutorial gaps" (1) (p
|
|||
own prompt); a component scope (`hold`, `watch`, `registry`, released on dispose, one `changed` callback for the
|
||||
app's redraw; `pageState(key, initial)`, the host's page state shared with `usePageState`), no pi-tui dependency.
|
||||
|
||||
### Messages and languages (after slice 11)
|
||||
### Messages and languages (built after slice 11)
|
||||
|
||||
Decision "Messages and languages in the app kit". Text people read is rendered per viewer, never by a server.
|
||||
Decision "Messages and languages in the app kit", refined in the build (2026-11-03). Text people read is rendered per
|
||||
viewer, never by a server. The shared parts are in Chord's root (`src/surfaces/`, portable), `useMessages` in
|
||||
`chord/react`, `scope.messages` in `chord/tui`, `lang` and `dir` in `chord/web`; the kit serves `Translations` on the
|
||||
server, workers, pages and terminals and `LocaleChoice` on the server. Apps re-export what their plugins use.
|
||||
|
||||
- **Messages in code:** `defineMessages(namespace, { en, de, ... })` per plugin, in a shared `messages.ts` every
|
||||
rendering facet imports; typed `t(key, ...args)`, keys and signatures from the first catalog; functions for plurals
|
||||
and word order; `formatTime`, `formatNumber` through `Intl` in the viewer's locale.
|
||||
- **Translations** registry (`{ namespace, locale, messages, override? }`), per host: a translation plugin contributes
|
||||
its shared catalog file from each facet that renders. Fallback key by key: the locale's base, then the first catalog
|
||||
(reported once); conflicts: the first by plugin name wins. Catalogs never cross links.
|
||||
- **The locale** is a per-principal preference (device default, live switch, `lang` and `dir` on the page).
|
||||
- **Messages as data:** `t.message(key, ...args)` gives `{ namespace, key, params }` for state, notices and errors;
|
||||
each client renders it with `t.render` (checked at runtime across a link). Prompts, model output, logs and error
|
||||
codes are never translated.
|
||||
```ts
|
||||
// plugins/notes/messages.ts: one shared file every facet that renders imports
|
||||
export const messages = defineMessages("notes", {
|
||||
en: { newNote: "New note", count: (n: number) => (n === 1 ? "1 note" : `${n} notes`), saved: (title: string, at: string) => `Saved "${title}" at ${formatTime(at)}` },
|
||||
de: { newNote: "Neue Notiz", count: (n) => `${n} Notizen`, saved: (title, at) => `„${title}“ um ${formatTime(at)} gespeichert` },
|
||||
});
|
||||
// plugins/notes/web.tsx
|
||||
const { t, locale } = useMessages(messages); // re-renders on a locale or translation change
|
||||
self.contribute(Translations, messages.translations); // in the facet: the plugin's own catalogs
|
||||
// plugins/notes/worker.ts: a message as data (state member `notice: union([MessageSchema, nul()])`)
|
||||
draft.notice = messages.message("saved", note.title, note.at); // { namespace: "notes", key: "saved", params }
|
||||
// any client: t.render(notes.notice.value)
|
||||
```
|
||||
|
||||
- **Messages in code:** `defineMessages(namespace, catalogs)` (a namespace is a name like a type's; catalog keys are
|
||||
language tags, canonicalized) returns `t`, typed by the first catalog: the others take their functions' parameter
|
||||
types from it, and a missing key, an unknown one or a wrong argument is a type error (when only strings differ,
|
||||
TypeScript may report a missing key at the first catalog's line). Values are strings or functions; `formatTime(at,
|
||||
options?)` (a `Date`, milliseconds or an ISO string; `timeStyle: "short"` by default) and `formatNumber(value,
|
||||
options?)` use `Intl` with the locale being rendered, the platform's outside a render (a language's default
|
||||
formatters are made once, kept in a `WeakMap` by the language).
|
||||
- **`t` is per host** (refinement (3)): `useMessages(t)` returns `{ t, locale }` bound to the `HostProvider`'s host and
|
||||
its language; `scope.messages(t)` returns a `t` reading the host's language at each call, its change calling
|
||||
`changed`; `t.in(language)` binds to any `Language`. The plain `t(key, ...args)` renders in the language of the
|
||||
render under way (a **render scope**: synchronous, set while a bound `t` calls a message function, so a nested plain
|
||||
`t` or `formatTime` follows it), and outside one in the first catalog (servers, tests, logs). No module-level current
|
||||
language: one process runs many hosts, and a server renders per recipient.
|
||||
- **Rendering a key:** down the language's chain (`pt-BR`, `pt`): the host's `Translations` catalog for the namespace
|
||||
and locale, else the plugin's own; else the first catalog. Each key found further down is reported (`fallback`), a
|
||||
contributed message that throws is reported (`failed`, naming its plugin) and the chain goes on (use sites guard
|
||||
contributions, §17).
|
||||
- **Translations** (refinements (1), (2)): a registry (`chord.translations`, version 1) whose contribution is a list of
|
||||
`{ namespace, locale, messages, override? }` (a contributor contributes once per registry, and one translation
|
||||
plugin translates several namespaces). A plugin's own catalogs are its built-in contributions literally: each
|
||||
rendering facet contributes `t.translations`, so the registry knows every locale offered (for the device default and
|
||||
a settings list); `t` still renders its own catalogs if a facet forgot. The composed `TranslationIndex` keeps one
|
||||
catalog per namespace and canonical locale: the first by plugin name wins and the other is reported
|
||||
(`registry_conflict`), `override: true` wins over a catalog without it; an invalid locale is left out, reported;
|
||||
`locales` lists every locale offered. Catalogs may be partial (`MessagesOf<typeof t>` is the full shape a translation
|
||||
fills); contributions are per host, so a translation plugin keeps them in a shared file and contributes them from
|
||||
each facet that renders. Catalogs never cross links.
|
||||
- **The locale:** `LocaleChoice` (remote on the server, params `{}`, per principal: `pick`, a language tag of at most 35
|
||||
characters or `null`, and `choose`), kept like the theme choice: both are a **preference** (`preferenceContract`,
|
||||
`preferenceFacet`: one persisted document of picks by principal key, the last 10,000 choosers). A client's
|
||||
`followActiveLanguage(host, preferred, changed, report, ctx)` resolves the locale: the pick when a catalog offers it
|
||||
(or a base or another region of its language), else the first of the device's languages offered (`navigator.languages`
|
||||
on a page, followed on `languagechange`; the system locale in the terminal: the launcher's `locales`, by default
|
||||
`LANGUAGE`, `LC_ALL`, `LC_MESSAGES`, `LANG`, then `Intl`'s, passed in the bridge's start), else English (the source
|
||||
of truth), else the first offered. The result, a `Language` (`{ locale, dir, chain, translations, report }`; `dir`
|
||||
from the locale's likely script), is the host's page state under `chord.language`, which `useMessages` and
|
||||
`scope.messages` follow; it changes only when the locale or the index does, nothing reloads. A page writes `lang`
|
||||
and `dir` on its root element. Reports go to the client's `onError` once each (`{ kind: "messages", report }`).
|
||||
- **Messages as data:** `t.message(key, ...args)` returns `MessageData<"notes", "saved">` (`{ namespace, key, params }`;
|
||||
`Message` is the link protocol's), only for keys whose arguments are JSON scalars (`MessageParam`), so a `Date`
|
||||
argument does not compile. A contract member uses `MessageSchema` (any namespace and key). `t.render(message)`
|
||||
refuses another literal namespace at the type level and checks at runtime what crossed a link: another namespace, a
|
||||
key the first catalog lacks or params that are no list show the key (reported `unknown`), a message that throws on
|
||||
its params shows the key (reported `failed`). Prompts, model output, logs and error codes are never translated.
|
||||
|
||||
### Channels
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ note: |
|
|||
`slice-10.md`, `slice-12.md`, `slice-13.md`, the decision log and the code in `packages/chord/src`. API names
|
||||
are the current ones. The early chapters wire hosts by hand to show the parts; the last chapter shows the app kit
|
||||
(`chord/app`), which does that wiring for an app.
|
||||
colophon: Hosts and Plugins · third edition · 2026-11-02 · Chord v2 slices 1-10, 12 and 13
|
||||
colophon: Hosts and Plugins · third edition · 2026-11-03 · Chord v2 slices 1-10, 12 and 13, messages and languages
|
||||
---
|
||||
|
||||
# Before we begin {.preface}
|
||||
|
|
@ -390,6 +390,21 @@ gets the current value in one frame. The UI shows a banner from `ref.state`, not
|
|||
type, be routed to, or keep data others rely on. Data that must outlive a tab belongs in an object on the server or a
|
||||
worker.
|
||||
|
||||
**Text in the viewer's language** is a client's job too, because two people looking at the same object may read
|
||||
different languages. A plugin writes its texts once, in a shared `messages.ts`; a client renders them in its host's
|
||||
language (the user's pick, else the device's), and objects store messages as data, never sentences (design §23,
|
||||
"Messages and languages"):
|
||||
|
||||
```ts
|
||||
// plugins/notes/messages.ts: the first catalog types the others
|
||||
export const messages = defineMessages("notes", { en: { empty: "No notes yet" }, de: { empty: "Noch keine Notizen" } });
|
||||
// plugins/notes/web.tsx: the facet contributes its catalogs; a component renders in the page's language
|
||||
self.contribute(Translations, messages.translations);
|
||||
const { t } = useMessages(messages); // re-renders when the user picks another language
|
||||
// plugins/notes/worker.ts: a notice as data; every client renders it with t.render(notice)
|
||||
draft.notice = messages.message("empty");
|
||||
```
|
||||
|
||||
# Plugins {#plugins lede="Move the code into a directory that can be added, edited and removed while the app runs."}
|
||||
|
||||
So far the facets were part of the app's own code. A **plugin** is the same code in its own directory:
|
||||
|
|
|
|||
|
|
@ -397,6 +397,17 @@ gets the current value in one frame. The UI shows a banner from <code>ref.state<
|
|||
<code>tui</code> facets, rendering with whatever terminal library the app chose. <strong>What a client never does</strong>: serve a <code>remote</code>
|
||||
type, be routed to, or keep data others rely on. Data that must outlive a tab belongs in an object on the server or a
|
||||
worker.</p>
|
||||
<p><strong>Text in the viewer's language</strong> is a client's job too, because two people looking at the same object may read
|
||||
different languages. A plugin writes its texts once, in a shared <code>messages.ts</code>; a client renders them in its host's
|
||||
language (the user's pick, else the device's), and objects store messages as data, never sentences (design §23,
|
||||
"Messages and languages"):</p>
|
||||
<pre><code><span class="tk-c">// plugins/notes/messages.ts: the first catalog types the others</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> messages = <span class="tk-f">defineMessages</span>(<span class="tk-s">"notes"</span>, { en: { empty: <span class="tk-s">"No notes yet"</span> }, de: { empty: <span class="tk-s">"Noch keine Notizen"</span> } });
|
||||
<span class="tk-c">// plugins/notes/web.tsx: the facet contributes its catalogs; a component renders in the page's language</span>
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, messages.translations);
|
||||
<span class="tk-k">const</span> { t } = <span class="tk-f">useMessages</span>(messages); <span class="tk-c">// re-renders when the user picks another language</span>
|
||||
<span class="tk-c">// plugins/notes/worker.ts: a notice as data; every client renders it with t.render(notice)</span>
|
||||
draft.notice = messages.<span class="tk-f">message</span>(<span class="tk-s">"empty"</span>);</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="plugins"><header class="opener"><div class="numeral">V</div><h2>Plugins</h2><p class="lede">Move the code into a directory that can be added, edited and removed while the app runs.</p></header>
|
||||
|
|
@ -787,7 +798,7 @@ refuses one that already has data (example 55).</p>
|
|||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<p class="colophon">Hosts and Plugins · third edition · 2026-11-02 · Chord v2 slices 1-10, 12 and 13</p>
|
||||
<p class="colophon">Hosts and Plugins · third edition · 2026-11-03 · Chord v2 slices 1-10, 12 and 13, messages and languages</p>
|
||||
</div></div>
|
||||
<script>(function activate(root) {
|
||||
const replay = (f) => {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ stats:
|
|||
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.
|
||||
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.
|
||||
colophon: Speaking Every Language · proposal · 2026-11-02
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -115,8 +115,9 @@ table{max-width:100%}
|
|||
<div class="cover-stats"><div class="stat"><b>1</b><span>function a plugin calls: t(key, ...args)</span></div><div class="stat"><b>0</b><span>message files outside the code</span></div><div class="stat"><b>0</b><span>page reloads to switch a language</span></div></div>
|
||||
<div class="cover-note"><p>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 <code>src/i18n.ts</code> (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.</p>
|
||||
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,
|
||||
<code>t</code> is bound per host): design.md §23 "Messages and languages" is the current statement.</p>
|
||||
</div>
|
||||
</section><div class="inner">
|
||||
<nav class="toc"><h2>Contents</h2><ol>
|
||||
|
|
|
|||
|
|
@ -183,6 +183,37 @@ or keeps its old code (at a reload), reported with the error.
|
|||
- A theme is data: contribute a `Theme` (`{ id, name, mode, colors, web?, tui? }`) to the kit's `Themes` registry,
|
||||
from a web facet and a terminal facet to reach both. Users pick one dark and one light theme.
|
||||
|
||||
## Messages
|
||||
|
||||
Text people read is written once, in code, and rendered in each viewer's language (design §23, "Messages and
|
||||
languages"). From the app's API:
|
||||
|
||||
```ts
|
||||
// plugins/stats/messages.ts: imported by every facet that renders, and by the worker when it builds messages as data
|
||||
export const messages = defineMessages("bob.stats", {
|
||||
en: { title: "Stats", words: (n: number) => (n === 1 ? "1 word" : `${formatNumber(n)} words`), done: (by: string) => `${by} finished` },
|
||||
de: { title: "Statistik", words: (n) => (n === 1 ? "1 Wort" : `${formatNumber(n)} Wörter`), done: (by) => `${by} ist fertig` },
|
||||
});
|
||||
```
|
||||
|
||||
- The namespace is the plugin's name; keys are names (`title`), never English text; the English catalog comes first and
|
||||
annotates its functions' parameters, the others take them from it. A missing key, an unknown one or a wrong argument
|
||||
is a type error. Plurals and word order are functions; dates, times and numbers go through `formatTime` and
|
||||
`formatNumber` inside them (they follow the language being rendered).
|
||||
- Every facet that renders contributes the plugin's own catalogs: `self.contribute(Translations, messages.translations)`
|
||||
(registries are per host; the host learns the languages offered from them).
|
||||
- Render in the host's language: `const { t } = useMessages(messages)` in a component, `const t =
|
||||
scope.messages(messages)` in a terminal facet. The plain `messages(key, ...)` has no language of its own (outside a
|
||||
render: the first catalog); never use it in UI code.
|
||||
- Objects never store sentences: a member typed `union([MessageSchema, nul()])` holds `messages.message("done", by)`
|
||||
(keys whose arguments are strings, numbers, booleans or `null`; a date as an ISO string), and each client renders it
|
||||
with `t.render(message)`, which shows the key of a message it does not know.
|
||||
- A translation is a plugin: its catalogs typed `MessagesOf<typeof messages>` (a type-only import of the other plugin's
|
||||
`messages.ts`), partial allowed, in one shared file contributed as a list from each facet that renders:
|
||||
`self.contribute(Translations, [{ namespace: "bob.stats", locale: "fr", messages: statsFr }])`; `override: true`
|
||||
replaces the plugin's own catalog of that locale.
|
||||
- Never translated: the model's prompts and answers, log lines, error codes.
|
||||
|
||||
## Installing
|
||||
|
||||
- `plugins.installPlugin({ git: "https://github.com/bob/stats.git", ref: "v1.2.0" }, root, ctx)`: a shallow clone of
|
||||
|
|
@ -233,3 +264,5 @@ of plugins it has no files of, it declares again, as `Notes` above.
|
|||
5. Live rows of a table through `self.query(table, q, limit)`, never a read in `self.open`.
|
||||
6. Files of one kind named `*.<kind>.ts(x)`.
|
||||
7. A test with `testPlugin` and stubs for every name `t.unserved()` lists.
|
||||
8. Texts people read in `messages.ts` (`defineMessages`), its catalogs contributed from each facet that renders, state
|
||||
holding messages as data.
|
||||
|
|
|
|||
|
|
@ -204,11 +204,6 @@ deleted (the slice's spec and code are the record). Moved here from `carry-over.
|
|||
|
||||
### After slice 11
|
||||
|
||||
- **Messages and languages in the app kit** (decision "Messages and languages in the app kit", proposal
|
||||
`docs/i18n/`): `defineMessages`, `t.message`/`t.render`, `MessagesOf`, the `Translations` registry with
|
||||
fallbacks and conflicts, the locale preference per principal (default from the device, live switch, `lang`/`dir` on
|
||||
the page), `useMessages` and the terminal scope's redraw, `formatTime`/`formatNumber`; tests and an example (the
|
||||
notes plugin in English and German, a French translation plugin, a server message rendered per viewer).
|
||||
- **Small examples** (owner, 2026-10-30: the 7c examples are complete but complex): once HTTP, workers, web and TUI
|
||||
exist, a pass of small, easy-to-read examples, each showing one thing a plugin author or app does.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
title: Together: build a shared todo app with Chord
|
||||
cover: Build It / Together
|
||||
kicker: A tutorial · Chord v2 and its app kit · 2026-11-03
|
||||
subtitle: One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme and an edit applied while they are connected
|
||||
subtitle: One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages and an edit applied while they are connected
|
||||
stats:
|
||||
- 9 | steps, one runnable example
|
||||
- 10 | steps, one runnable example
|
||||
- 4 | host kinds one plugin runs on
|
||||
- 1 | worker process per list
|
||||
- 0 | restarts for a new theme or an edit
|
||||
- 0 | restarts for a new theme, a language or an edit
|
||||
note: |
|
||||
The app is `packages/chord/test/examples/61-tutorial/`, runnable with the line in its `main.ts`. Every code block
|
||||
below is quoted from its files, and every console output from what its steps print:
|
||||
|
|
@ -41,7 +41,7 @@ node --conditions=source --experimental-strip-types test/examples/61-tutorial/ma
|
|||
With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one,
|
||||
that step says so and the rest runs.
|
||||
|
||||
## Eight things to know
|
||||
## Nine things to know
|
||||
|
||||
:::findings
|
||||
1. **An app is its API, its plugins and a start script.** `startServer` makes the data directory, the store, the
|
||||
|
|
@ -59,8 +59,10 @@ that step says so and the rest runs.
|
|||
plugins contribute, one plugin (or the entry) composes.
|
||||
7. **Changes apply when you say so.** Saving a file marks its plugin dirty; `applyChanges` builds and rolls out to the
|
||||
server, the workers, the pages and the terminals, in place.
|
||||
8. **Four traps:** load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value.
|
||||
8. **Text is rendered per viewer.** A plugin's messages are typed code, a translation is a plugin, each user picks a
|
||||
language, and servers store keys, never sentences.
|
||||
9. **Five traps:** load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value; the plain `t` has no language of its own.
|
||||
:::
|
||||
|
||||
## Words used throughout
|
||||
|
|
@ -72,7 +74,8 @@ host only, `remote` for any host) names its **params** (which instance) and memb
|
|||
are the member sets different principals get. A **factory** declares an object's parts: **documents** and **tables**
|
||||
(stored data, changed in **transactions**), **live queries** (a table's rows Chord keeps current), **derives**
|
||||
(values computed from others), **natives** (things opened and closed, such as a file or a database cursor). A **reference** is what asking for an object returns. A **facet** is a
|
||||
plugin's code for one host kind; a **registry** is an extension point plugins **contribute** to. A **rollout** applies
|
||||
plugin's code for one host kind; a **registry** is an extension point plugins **contribute** to. A **message** is a
|
||||
text with a key, a **catalog** a plugin's messages in one language, a **locale** a language tag (`de`, `pt-BR`). A **rollout** applies
|
||||
new plugin code to the hosts in order.
|
||||
|
||||
# One Server {#server lede="The API, the principals and a start script: the smallest app the kit runs, and who may come in."}
|
||||
|
|
@ -113,13 +116,18 @@ export {
|
|||
arr,
|
||||
bool,
|
||||
type Ctx,
|
||||
defineMessages,
|
||||
defineState,
|
||||
defineTable,
|
||||
eq,
|
||||
forbidden,
|
||||
int,
|
||||
invalid,
|
||||
local,
|
||||
MessageSchema,
|
||||
type MessagesOf,
|
||||
method,
|
||||
nul,
|
||||
num,
|
||||
obj,
|
||||
PluginError,
|
||||
|
|
@ -129,13 +137,16 @@ export {
|
|||
str,
|
||||
type Theme,
|
||||
Themes,
|
||||
type Translation,
|
||||
Translations,
|
||||
union,
|
||||
} from "../../../../../../src/index.ts";
|
||||
export type { TodoPrincipal } from "../../principals.ts";
|
||||
```
|
||||
|
||||
`invalid` and `forbidden` make Chord's own errors: a plugin refusing arguments throws `invalid("args", message)`,
|
||||
`defineMessages`, `Translations` and the message schema are chapter VIII's. `invalid` and `forbidden` make Chord's own errors: a plugin refusing arguments throws `invalid("args", message)`,
|
||||
one refusing a caller `forbidden(message)`, and the caller gets the same `{ code, reason }` Chord's built-in checks
|
||||
give (chapter VIII). `PluginError` is for codes of the plugin's own.
|
||||
give (chapter IX). `PluginError` is for codes of the plugin's own.
|
||||
|
||||
and a `defineFacet` bound to the principal type, so every factory sees a `TodoPrincipal`:
|
||||
|
||||
|
|
@ -307,7 +318,21 @@ the lists plugin's, in its `contract.ts`, which its facets of every kind import,
|
|||
|
||||
<!-- quote: plugins/lists/contract.ts -->
|
||||
```ts
|
||||
import { arr, bool, int, List, method, num, obj, remote, type Static, str } from "@todo/app/v1";
|
||||
import {
|
||||
arr,
|
||||
bool,
|
||||
int,
|
||||
List,
|
||||
MessageSchema,
|
||||
method,
|
||||
nul,
|
||||
num,
|
||||
obj,
|
||||
remote,
|
||||
type Static,
|
||||
str,
|
||||
union,
|
||||
} from "@todo/app/v1";
|
||||
|
||||
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
|
||||
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
|
||||
|
|
@ -315,9 +340,12 @@ export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: n
|
|||
/** A todo (see `Item`). */
|
||||
export type Item = Static<typeof Item>;
|
||||
|
||||
/** The list's latest notice, as data each viewer renders in its own language; `null` before the first. */
|
||||
export const Notice = union([MessageSchema, nul()]);
|
||||
|
||||
/**
|
||||
* A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
|
||||
* owner also clears the done ones.
|
||||
* A list's todos as one principal sees them: the newest 50 (live) and the list's notice, adding one and ticking one off
|
||||
* (or on again); the owner also clears the done ones.
|
||||
*/
|
||||
export const Todos = remote("todo.todos", {
|
||||
version: 1,
|
||||
|
|
@ -325,18 +353,19 @@ export const Todos = remote("todo.todos", {
|
|||
hostAddress: ({ list }) => List({ id: list }),
|
||||
variants: {
|
||||
member: {
|
||||
state: { items: arr(Item) },
|
||||
state: { items: arr(Item), notice: Notice },
|
||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
|
||||
},
|
||||
owner: {
|
||||
state: { items: arr(Item) },
|
||||
state: { items: arr(Item), notice: Notice },
|
||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`params` select the instance: `Todos { list: "groceries" }` is one object per principal. Nobody who asks names a
|
||||
Its `notice` is a message as data, which chapter VIII renders in each viewer's language. `params` select the
|
||||
instance: `Todos { list: "groceries" }` is one object per principal. Nobody who asks names a
|
||||
host: the caller computes the address from the params and sends the request there; a host that cannot serve it passes
|
||||
it on to its parent or to a route (design §3, "Host kinds and addresses"). The kit's `workers` option is that route:
|
||||
|
||||
|
|
@ -392,9 +421,10 @@ const Store = local("todo.store", {
|
|||
params: {},
|
||||
members: {
|
||||
items: arr(Item),
|
||||
notice: Notice,
|
||||
add: method(obj({ text: str(), by: str() })),
|
||||
tick: method(obj({ id: int(), done: bool() })),
|
||||
clear: method(),
|
||||
clear: method(obj({ by: str() })),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
|
@ -408,9 +438,11 @@ of references. Its factory declares its parts and returns its members:
|
|||
const items = self.table(Items);
|
||||
// The newest todos, live: read before the store goes live, read again after every commit writing the table.
|
||||
const newest = self.query(items, NEWEST, WINDOW);
|
||||
const notices = self.state(Notices);
|
||||
return {
|
||||
// Oldest first, as the list shows them.
|
||||
items: self.derive([newest], ([rows]) => [...rows].reverse()),
|
||||
notice: self.derive([notices], ([value]) => value.notice),
|
||||
```
|
||||
|
||||
A **factory** is synchronous and only declares; Chord activates the object afterwards. `self.table` declares the
|
||||
|
|
@ -457,11 +489,12 @@ contract's schema.
|
|||
const by = principal.kind === "user" ? principal.name : principal.kind;
|
||||
const members = {
|
||||
items: self.derive([store.items], ([items]) => items),
|
||||
notice: self.derive([store.notice], ([notice]) => notice),
|
||||
add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),
|
||||
tick: (tick: { id: number; done: boolean }, ctx: Ctx) => store.tick(tick, ctx),
|
||||
};
|
||||
if (principal.kind !== "owner") return { variant: "member", members };
|
||||
return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear(ctx) } };
|
||||
return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear({ by }, ctx) } };
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -611,17 +644,17 @@ for them:
|
|||
import type { ComponentType } from "react";
|
||||
import { defineRegistry } from "../../../../../../src/index.ts";
|
||||
|
||||
export { useHold, usePageState, useWatch } from "../../../../../../src/react/index.ts";
|
||||
export { useHold, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts";
|
||||
|
||||
/** A panel of the page: its title and its React component. */
|
||||
export type Panel = { readonly title: string; readonly Component: ComponentType };
|
||||
/** A panel of the page: its name (a key, never shown) and its React component, which shows its own title. */
|
||||
export type Panel = { readonly name: string; readonly Component: ComponentType };
|
||||
|
||||
/** The page's panels, one per contributing plugin, in plugin-name order. */
|
||||
export const Panels = defineRegistry<Panel, readonly Panel[]>("todo.panels", { version: 1 });
|
||||
```
|
||||
|
||||
The web entry serves `Panels` (its `compose` keeps the contributions in plugin-name order), renders every panel, and
|
||||
starts the page's connection:
|
||||
A panel shows its own title, in the page's language; its `name` is only a key. The web entry serves `Panels` (its
|
||||
`compose` keeps the contributions in plugin-name order), renders every panel, and starts the page's connection:
|
||||
|
||||
<!-- quote: src/web/main.ts -->
|
||||
```ts
|
||||
|
|
@ -634,7 +667,7 @@ function Page() {
|
|||
"main",
|
||||
null,
|
||||
h("h1", null, "Todo"),
|
||||
...panels.map(({ title, Component }) => h("section", { key: title }, h("h2", null, title), h(Component))),
|
||||
...panels.map(({ name, Component }) => h("section", { key: name }, h(Component))),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -657,9 +690,11 @@ when its items change:
|
|||
<!-- quote: plugins/lists/web.ts -->
|
||||
```ts
|
||||
function TodoList() {
|
||||
const { t } = useMessages(messages);
|
||||
const list = location.hash.slice(1) || "groceries";
|
||||
const todos = useHold(Todos, { list });
|
||||
const items = useWatch(todos?.items) ?? [];
|
||||
const notice = useWatch(todos?.notice);
|
||||
// Kept by the page, not the component: the new code's component finds the half-typed todo.
|
||||
const [text, setText] = usePageState(`lists.draft.${list}`, "");
|
||||
const add = (event: { preventDefault(): void }) => {
|
||||
|
|
@ -671,6 +706,7 @@ function TodoList() {
|
|||
return h(
|
||||
"div",
|
||||
{ className: "lists-panel" },
|
||||
h("h2", null, t("title")),
|
||||
h(
|
||||
"ul",
|
||||
null,
|
||||
|
|
@ -692,22 +728,27 @@ function TodoList() {
|
|||
{ onSubmit: add },
|
||||
h("input", {
|
||||
value: text,
|
||||
placeholder: "Add a todo",
|
||||
placeholder: t("placeholder"),
|
||||
onChange: (event: { target: { value: string } }) => setText(event.target.value),
|
||||
}),
|
||||
),
|
||||
todos?.variant === "owner"
|
||||
? h("button", { type: "button", onClick: () => void todos.clear(context) }, "Clear done")
|
||||
? h("button", { type: "button", onClick: () => void todos.clear(context) }, t("clearDone"))
|
||||
: null,
|
||||
notice === undefined || notice === null ? null : h("p", { className: "lists-notice" }, t.render(notice)),
|
||||
);
|
||||
}
|
||||
|
||||
export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList }));
|
||||
export default defineFacet((_host, self) => {
|
||||
self.contribute(Translations, messages.translations);
|
||||
self.contribute(Panels, { name: "groceries", Component: TodoList });
|
||||
});
|
||||
```
|
||||
|
||||
`useHold` holds a reference while the component is mounted and releases it on unmount; it is `undefined` on the first
|
||||
render. `useWatch` follows a member. `usePageState` is `useState` whose value the page keeps under a key instead of the
|
||||
component: chapter VIII shows why the half-typed todo needs it. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call
|
||||
`useMessages(messages)` gives the plugin's texts in the page's language (chapter VIII; `messages` is the plugin's
|
||||
`messages.ts`, and the facet contributes its catalogs to the page's `Translations`). `useHold` holds a reference while
|
||||
the component is mounted and releases it on unmount; it is `undefined` on the first render. `useWatch` follows a member. `usePageState` is `useState` whose value the page keeps under a key instead of the
|
||||
component: chapter IX shows why the half-typed todo needs it. Clicks call methods with `BACKGROUND_CONTEXT`, which never ends, so a click's call
|
||||
is not cancelled when the handler returns. `todos?.variant === "owner"` narrows the reference: only the owner's has
|
||||
`clear`, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed
|
||||
by the plugin's name (a `*.module.css` file would scope them instead):
|
||||
|
|
@ -787,7 +828,7 @@ The API's terminal module declares the parts of the screen:
|
|||
<!-- quote: src/api/v1/tui.ts -->
|
||||
```ts
|
||||
export type Screen = {
|
||||
readonly title: string;
|
||||
readonly name: string;
|
||||
readonly open: (scope: TuiScope, theme: TerminalTheme) => () => readonly string[];
|
||||
};
|
||||
|
||||
|
|
@ -818,7 +859,7 @@ void startTerminalRuntime({
|
|||
const scope = new TuiScope(runtime.host, () => draw());
|
||||
opened = { screens: current, scope, lines: current.map((screen) => screen.open(scope, theme)) };
|
||||
}
|
||||
const lines = current.flatMap((screen, n) => [theme.fg("accent", screen.title), ...opened!.lines[n]!()]);
|
||||
const lines = opened.lines.flatMap((read) => read());
|
||||
terminal.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`);
|
||||
};
|
||||
theme.onChange(draw);
|
||||
|
|
@ -831,25 +872,37 @@ void startTerminalRuntime({
|
|||
```
|
||||
|
||||
When a reload changes `Screens`, the entry disposes the old scope and opens the screens again. The lists plugin's
|
||||
terminal facet is a few lines:
|
||||
terminal facet draws its title, the todos and the list's notice; `scope.messages(messages)` is the terminal's
|
||||
`useMessages` (chapter VIII):
|
||||
|
||||
<!-- quote: plugins/lists/tui.ts -->
|
||||
```ts
|
||||
export default defineFacet((_host, self) =>
|
||||
export default defineFacet((_host, self) => {
|
||||
self.contribute(Translations, messages.translations);
|
||||
self.contribute(Screens, {
|
||||
title: "Groceries",
|
||||
name: "groceries",
|
||||
open: (scope, theme) => {
|
||||
const items = scope.watch(scope.hold(Todos, { list: "groceries" }).items);
|
||||
return () =>
|
||||
(items() ?? []).map((item) =>
|
||||
item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`,
|
||||
);
|
||||
const t = scope.messages(messages);
|
||||
const todos = scope.hold(Todos, { list: "groceries" });
|
||||
const items = scope.watch(todos.items);
|
||||
const notice = scope.watch(todos.notice);
|
||||
return () => {
|
||||
const shown = notice();
|
||||
return [
|
||||
theme.fg("accent", t("title")),
|
||||
...(items() ?? []).map((item) =>
|
||||
item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`,
|
||||
),
|
||||
...(shown === undefined || shown === null ? [] : [theme.fg("warning", t.render(shown))]),
|
||||
];
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`theme.fg("muted", …)` colours by role, never by a fixed colour. Step 5 runs the launcher on a scripted terminal:
|
||||
`theme.fg("muted", …)` colours by role, never by a fixed colour. Step 5 runs the launcher on a scripted terminal, its
|
||||
languages fixed (by default they are the machine's system locale):
|
||||
|
||||
<!-- quote: main.ts -->
|
||||
```ts
|
||||
|
|
@ -862,6 +915,8 @@ if (sandboxAvailable()) {
|
|||
input: new PassThrough(),
|
||||
output,
|
||||
colors: "truecolor",
|
||||
// The machine's languages (its system locale by default), here fixed for the book's output.
|
||||
locales: ["en-US"],
|
||||
});
|
||||
await until(() => screen(frames).includes("butter"));
|
||||
console.log("5. the terminal shows:");
|
||||
|
|
@ -932,7 +987,7 @@ scripts.release();
|
|||
<!-- output: main -->
|
||||
```text
|
||||
6. harbor added, rolled out to: server, list, web, tui
|
||||
the page's plugins: @todo/harbor, @todo/lists
|
||||
the page's plugins: @todo/french, @todo/harbor, @todo/lists
|
||||
```
|
||||
|
||||
<!-- output: tui -->
|
||||
|
|
@ -1023,10 +1078,209 @@ Ada got the `member` variant; her `clear` is refused twice, by TypeScript (the `
|
|||
(`invalid/member`), because what a reference offers is decided by the provider's variant, not by the caller's code.
|
||||
Her todo is hers: `coffee (ada)`.
|
||||
|
||||
# Languages {#languages lede="Messages in code, a French plugin, a language chosen live, and a notice each viewer reads in its own language."}
|
||||
|
||||
Mara reads German; Ada would rather read French. Both look at the same list, served by the same worker, so its text
|
||||
cannot be formatted once for everyone: **text people read is rendered per viewer, never by a server** (decision
|
||||
"Messages and languages in the app kit"). A plugin writes its texts once, in code; languages are contributions; the
|
||||
language is a preference of each user.
|
||||
|
||||
## Messages in code
|
||||
|
||||
The lists plugin keeps its texts in one file, `messages.ts`, which every facet that renders imports (and the worker,
|
||||
below):
|
||||
|
||||
<!-- quote: plugins/lists/messages.ts -->
|
||||
```ts
|
||||
export const messages = defineMessages("todo.lists", {
|
||||
en: {
|
||||
title: "Groceries",
|
||||
placeholder: "Add a todo",
|
||||
clearDone: "Clear done",
|
||||
open: (n: number) => (n === 1 ? "1 open" : `${n} open`),
|
||||
cleared: (by: string) => `${by} cleared the done todos`,
|
||||
},
|
||||
de: {
|
||||
title: "Einkäufe",
|
||||
placeholder: "Neues Todo",
|
||||
clearDone: "Erledigte entfernen",
|
||||
open: (n) => `${n} offen`,
|
||||
cleared: (by) => `${by} hat die erledigten Todos entfernt`,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The first catalog defines the keys and their argument types, and the English catalog is the source of truth; the
|
||||
German one gets its functions' parameter types from it, and a missing key, an unknown one or a wrong argument
|
||||
(`t("open", "3")`) is a type error, not a blank in production. The namespace (`todo.lists`) keeps two plugins' `title`
|
||||
apart. Functions cover plurals and word order; dates and numbers inside them go through `formatTime` and
|
||||
`formatNumber`, which use the locale being rendered.
|
||||
|
||||
The panel renders with `useMessages(messages)` and the terminal screen with `scope.messages(messages)` (chapters IV
|
||||
and V): both give a `t` bound to their host's language, and re-render or redraw when it changes. Each facet that
|
||||
renders also contributes the plugin's own catalogs to its host's `Translations`:
|
||||
|
||||
<!-- quote: plugins/lists/web.ts -->
|
||||
```ts
|
||||
export default defineFacet((_host, self) => {
|
||||
self.contribute(Translations, messages.translations);
|
||||
self.contribute(Panels, { name: "groceries", Component: TodoList });
|
||||
});
|
||||
```
|
||||
|
||||
Registries are per host, and a host learns from them which languages its plugins offer: the page matches the
|
||||
browser's languages against them before it picks one.
|
||||
|
||||
## A translation plugin
|
||||
|
||||
French comes from a plugin of its own, written by someone who never touches the lists plugin. Its catalog is typed by
|
||||
the lists plugin's messages, a type-only import erased at build:
|
||||
|
||||
<!-- quote: plugins/french/fr.ts -->
|
||||
```ts
|
||||
import type { MessagesOf, Translation } from "@todo/app/v1";
|
||||
import type { messages } from "../lists/messages.ts";
|
||||
|
||||
const lists: MessagesOf<typeof messages> = {
|
||||
title: "Courses",
|
||||
placeholder: "Nouvelle tâche",
|
||||
clearDone: "Retirer les tâches faites",
|
||||
open: (n) => `${n} à faire`,
|
||||
cleared: (by) => `${by} a retiré les tâches faites`,
|
||||
};
|
||||
|
||||
export const french: readonly Translation[] = [{ namespace: "todo.lists", locale: "fr", messages: lists }];
|
||||
```
|
||||
|
||||
and its web and terminal facets contribute that list, the same line in each:
|
||||
|
||||
<!-- quote: plugins/french/web.ts -->
|
||||
```ts
|
||||
export default defineFacet((_host, self) => self.contribute(Translations, french));
|
||||
```
|
||||
|
||||
A contribution is a list, so one plugin can translate the app and every plugin at once. A catalog may be partial: a
|
||||
missing key falls back to the locale's base (`pt-BR` to `pt`), then to the namespace's first catalog, key by key, and
|
||||
each fallback is reported once (the page's `onError`, kind `messages`). Two catalogs for one namespace and locale: the
|
||||
first by plugin name wins and the other is reported; a translation contributed with `override: true` replaces the
|
||||
plugin's own (a better German).
|
||||
|
||||
## Choosing a language
|
||||
|
||||
The language is a preference each user keeps on the server, `LocaleChoice`, one per principal like the theme choice of
|
||||
chapter VI. Until a user chooses, the device decides: the browser's `navigator.languages` on a page, the system locale
|
||||
in a terminal (the launcher's `locales`), matched against the languages the catalogs offer, else English. Step 8 has
|
||||
the owner choose German while the terminal runs:
|
||||
|
||||
<!-- quote: main.ts -->
|
||||
```ts
|
||||
const ownerChooses = (locale: string) =>
|
||||
app.host.root(async (ctx) => (await ctx.get(LocaleChoice, {})).choose(locale, ctx), context, { principal: OWNER });
|
||||
await ownerChooses("de");
|
||||
```
|
||||
|
||||
<!-- output: main -->
|
||||
```text
|
||||
8. the owner chooses German
|
||||
ada's pick: fr
|
||||
```
|
||||
|
||||
<!-- output: tui -->
|
||||
```text
|
||||
the terminal shows:
|
||||
Einkäufe
|
||||
[x] milk
|
||||
[x] eggs
|
||||
[ ] bread server
|
||||
[ ] butter owner
|
||||
[ ] coffee ada
|
||||
```
|
||||
|
||||
Every client of the owner holds the owner's `LocaleChoice`, so the terminal redrew in German at once; nothing
|
||||
restarted. Ada's pick (French) is hers and changes nothing for the owner. A page does the same and also sets `lang`
|
||||
and `dir` on its root element (right to left for Arabic or Hebrew, through the app's logical CSS properties).
|
||||
|
||||
## Text from the server
|
||||
|
||||
Clearing the done todos should tell everyone who cleared them. If the worker wrote "owner hat die erledigten Todos
|
||||
entfernt", Ada would read German too. So the list's state carries a **message as data**, a key and its parameters,
|
||||
declared with the kit's generic schema:
|
||||
|
||||
<!-- quote: plugins/lists/contract.ts -->
|
||||
```ts
|
||||
/** The list's latest notice, as data each viewer renders in its own language; `null` before the first. */
|
||||
export const Notice = union([MessageSchema, nul()]);
|
||||
```
|
||||
|
||||
The store writes it with `messages.message`, typed against the English catalog like `t` (`messages.message("cleared",
|
||||
42)` does not compile), and each viewer renders it with `t.render`:
|
||||
|
||||
<!-- quote: plugins/lists/worker.ts -->
|
||||
```ts
|
||||
clear: async ({ by }, ctx) => {
|
||||
await self.tx(async (tx) => tx.deleteWhere(items, (item) => eq(item.done, true)), ctx);
|
||||
// The key and its parameters, typed against the English catalog: every viewer renders its own text.
|
||||
await notices.change((draft) => {
|
||||
draft.notice = messages.message("cleared", by);
|
||||
}, ctx);
|
||||
return null;
|
||||
},
|
||||
```
|
||||
|
||||
<!-- output: main -->
|
||||
```text
|
||||
the notice: { namespace: 'todo.lists', key: 'cleared', params: [ 'owner' ] }
|
||||
the server renders it: owner cleared the done todos
|
||||
```
|
||||
|
||||
<!-- output: tui -->
|
||||
```text
|
||||
the terminal renders it: owner hat die erledigten Todos entfernt
|
||||
```
|
||||
|
||||
Parameters are plain data (strings, numbers, ISO dates), so a message crosses links like any state. Across a link
|
||||
the member is the generic schema, so `t.render` checks the namespace and the key at runtime: a message of another
|
||||
namespace or a key the catalogs no longer have shows the key, reported. Never translated: the model's prompts and
|
||||
answers, log lines, error codes.
|
||||
|
||||
## Trap: the plain `t`
|
||||
|
||||
The server's `messages.render(…)` printed English. **The plain `t` has no language of its own:** inside a render it
|
||||
uses the language being rendered (a message function calling another plugin's `t`, `formatTime`), outside one the
|
||||
first catalog. That is right for a server, a test or a log line, and wrong in a component: a page or a terminal
|
||||
renders with `useMessages(t)` or `scope.messages(t)`, bound to its host's language. There is no module-wide current
|
||||
language, because one process runs many hosts and a server renders for many people.
|
||||
|
||||
On the page, `screenshots.ts` has the owner choose French and clear the done todos while a todo is half typed:
|
||||
|
||||
<!-- quote: screenshots.ts -->
|
||||
```ts
|
||||
await app.host.root(
|
||||
async (ctx) => {
|
||||
await (await ctx.get(LocaleChoice, {})).choose("fr", ctx);
|
||||
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||
if (todos.variant === "owner") await todos.clear(ctx);
|
||||
},
|
||||
context,
|
||||
{ principal: { kind: "owner" } },
|
||||
);
|
||||
```
|
||||
|
||||
<!-- output: screenshots -->
|
||||
```text
|
||||
wrote docs/tutorial/screens/french.png
|
||||
after choosing French: lang="fr", "oat mi" still typed
|
||||
the page did not reload: true
|
||||
```
|
||||
|
||||
:::figure | The page after the owner chose French: the lists plugin's texts from the French plugin, the notice rendered from its key, the half-typed "oat mi" kept; no reload.
|
||||

|
||||
:::
|
||||
|
||||
# Changing Code {#reload lede="An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate."}
|
||||
|
||||
Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
|
||||
plugin's worker facet: the view trims the text before the store sees it. Step 8 makes the edit in the running app's
|
||||
plugin's worker facet: the view trims the text before the store sees it. Step 9 makes the edit in the running app's
|
||||
copy of the plugins, as an editor or an agent would, and applies it:
|
||||
|
||||
<!-- quote: main.ts -->
|
||||
|
|
@ -1037,7 +1291,7 @@ const workerFile = join(root, "lists", "worker.ts");
|
|||
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
|
||||
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
|
||||
const applied = await app.applyChanges(context);
|
||||
console.log("8. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", "));
|
||||
console.log("9. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", "));
|
||||
for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
|
||||
const fates = "report" in host ? host.report.objects.map(({ object, kind }) => `${object} ${kind}`) : [];
|
||||
console.log(` on ${host.host}:`, [...new Set(fates)].join(", "));
|
||||
|
|
@ -1054,7 +1308,7 @@ console.log(" ada adds ' ':", refused);
|
|||
|
||||
<!-- output: main -->
|
||||
```text
|
||||
8. lists changed, rolled out to: server, list, web, tui
|
||||
9. lists changed, rolled out to: server, list, web, tui
|
||||
on list|groceries: todo.store swapped, todo.todos(groceries) parked
|
||||
ada adds ' tea ': [ ] tea (ada)
|
||||
ada adds ' ': { code: 'invalid', reason: 'args' }
|
||||
|
|
@ -1098,7 +1352,8 @@ shape bumps its own version with a migration. Contract versions protect callers,
|
|||
## On the page
|
||||
|
||||
An edit of a web facet applies in place too: the page fetches the one changed script and reloads its host, with no
|
||||
page reload. `screenshots.ts` adds a count of open todos to the panel while half a todo is typed:
|
||||
page reload. `screenshots.ts` adds a count of open todos to the panel (`t("open", n)`, in the page's language) while half a
|
||||
todo is typed:
|
||||
|
||||
<!-- quote: screenshots.ts -->
|
||||
```ts
|
||||
|
|
@ -1128,14 +1383,14 @@ value, never sent anywhere, and gone with the page.
|
|||
|
||||
# Moving a List {#export lede="Export a worker's data while it runs, import it under another address."}
|
||||
|
||||
A list's data is what its worker created: its store and its files. Step 9 exports the groceries list while its worker
|
||||
A list's data is what its worker created: its store and its files. Step 10 exports the groceries list while its worker
|
||||
runs and imports it as a new list:
|
||||
|
||||
<!-- quote: main.ts -->
|
||||
```ts
|
||||
const archive = join(base, "groceries.tar");
|
||||
await app.exportData(List({ id: "groceries" }), archive, context);
|
||||
console.log("9. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing");
|
||||
console.log("10. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing");
|
||||
await app.importData(archive, List({ id: "picnic" }), context);
|
||||
const picnic = await app.host.get(Todos, { list: "picnic" }, context);
|
||||
console.log(" imported as picnic:", show(picnic.items.value));
|
||||
|
|
@ -1147,8 +1402,8 @@ await app.importData(archive, List({ id: "groceries" }), context).catch((error:
|
|||
|
||||
<!-- output: main -->
|
||||
```text
|
||||
9. exported groceries: an archive
|
||||
imported as picnic: [x] milk (owner), [x] eggs (owner), [ ] bread (server), [ ] butter (owner), [ ] coffee (ada), [ ] tea (ada)
|
||||
10. exported groceries: an archive
|
||||
imported as picnic: [ ] bread (server), [ ] butter (owner), [ ] coffee (ada), [ ] tea (ada)
|
||||
importing over groceries: importData: list|groceries has data (its worker runs)
|
||||
```
|
||||
|
||||
|
|
@ -1197,6 +1452,7 @@ Each test runs code as a principal of its choice with `t.root`:
|
|||
await todos.tick({ id: 1, done: true }, ctx);
|
||||
await todos.clear(ctx);
|
||||
expect(todos.items.value.map(({ text }) => text)).toEqual(["eggs"]);
|
||||
expect(todos.notice.value).toEqual({ namespace: "todo.lists", key: "cleared", params: ["owner"] });
|
||||
},
|
||||
context,
|
||||
{ principal: OWNER },
|
||||
|
|
@ -1360,12 +1616,13 @@ p2 with the owner's Reload button or `npm run reload -- scratchpad`; a new plugi
|
|||
reload. The trap of chapter II applies with p2's names: a server method that must reach a session's worker as the
|
||||
server runs in a root of the server's own (`serverOf(self)().root`), not through its caller's `ctx.get`.
|
||||
|
||||
# Traps and Open Questions {.epilogue lede="The four traps once more, and what in Chord v2 was hard to explain while writing this."}
|
||||
# Traps and Open Questions {.epilogue lede="The five traps once more, and what in Chord v2 was hard to explain while writing this."}
|
||||
|
||||
**The traps.** Load before use: a host binds nothing before `load`; a client with nothing to load calls `load([])`. A
|
||||
reference acts as whoever made it: `ctx.get` as the caller, `host.get` and a local object's `self.get` as the host.
|
||||
Await what you start: a write started with a context that ends is cancelled and reported `transaction_cancelled`. A
|
||||
watch starts at the current value: read `handle.value`, then `start`; the listener gets changes only.
|
||||
watch starts at the current value: read `handle.value`, then `start`; the listener gets changes only. The plain `t`
|
||||
has no language of its own: components and screens render with `useMessages(t)` and `scope.messages(t)`.
|
||||
|
||||
**Hard to explain, found while writing the example.** Each is a candidate for a fix in Chord rather than a paragraph in
|
||||
a tutorial.
|
||||
|
|
@ -1375,10 +1632,10 @@ a tutorial.
|
|||
`tx.get(items, { id })` with a number fails `tsc`. The store here uses `updateWhere` with `eq` instead of
|
||||
`tx.update`.
|
||||
Four more, found the same way, are fixed: a reloaded web plugin lost its own React state (now `usePageState`,
|
||||
chapter VIII), a plugin's `contract.ts` could not be imported outside builds (the API is now a workspace package,
|
||||
chapter IX), a plugin's `contract.ts` could not be imported outside builds (the API is now a workspace package,
|
||||
chapter I), plugins could not throw Chord's `invalid` (Chord exports `invalid` and `forbidden`, chapter II), and a
|
||||
live window over a table needed a one-time read in `self.open`, which made the store restart at every reload of its
|
||||
plugin (now `self.query`, a live query Chord runs, chapters II and VIII).
|
||||
plugin (now `self.query`, a live query Chord runs, chapters II and IX).
|
||||
|
||||
# The App's Files {.appendix}
|
||||
|
||||
|
|
@ -1386,14 +1643,15 @@ plugin (now `self.query`, a live query Chord runs, chapters II and VIII).
|
|||
| --- | --- | --- |
|
||||
| `src/principals.ts` | base | the principal type |
|
||||
| `package.json` | base | the API as the workspace package `@todo/app`, its `exports` |
|
||||
| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part (with `invalid`, `forbidden`), `defineFacet`, `List` |
|
||||
| `src/api/v1/index.ts` | base, every kind | `@todo/app/v1`: Chord's part (with `invalid`, `forbidden`, `defineMessages`, `Translations`), `defineFacet`, `List` |
|
||||
| `src/api/v1/server.ts` | base, server | `Http`, the ephemeral `login` |
|
||||
| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `usePageState`, `useWatch` |
|
||||
| `src/api/v1/web.ts` | base, web | `Panels`, `useHold`, `useMessages`, `usePageState`, `useWatch` |
|
||||
| `src/api/v1/tui.ts` | base, tui | `Screens` |
|
||||
| `start.ts`, `worker.ts` | base | `startServer`, `startWorker` |
|
||||
| `src/web/main.ts`, `src/web/style.css` | base, web entry | `startPage`, the panels |
|
||||
| `src/tui/main.ts` | base, terminal entry | `startTerminalRuntime`, the screens |
|
||||
| `plugins/lists/` | plugin | contract (`Item`, `Todos`), worker (table, store, views), server (routes), web (panel), tui (screen), its test |
|
||||
| `plugins/lists/` | plugin | contract (`Item`, `Notice`, `Todos`), messages (English, German), worker (table, store, views), server (routes), web (panel), tui (screen), its test |
|
||||
| `plugins/french/` | plugin | French for the lists plugin, for pages and terminals |
|
||||
| `plugins/login.server.ts` | single-file plugin | the kit's ephemeral login |
|
||||
| `later/harbor/` | plugin, added in step 6 | the Harbor theme for pages and terminals |
|
||||
| `main.ts`, `screenshots.ts`, `tutorial.test.ts` | example | the steps, the screenshots, the checks |
|
||||
|
|
@ -1408,13 +1666,16 @@ plugin (now `self.query`, a live query Chord runs, chapters II and VIII).
|
|||
| `hostKind(name, params)`, `remote(name, { version, params, hostAddress, variants })`, `local(name, { version, params, members })` | II | host kinds and contracts |
|
||||
| `host.serve(Type, factory)`; factory `self`: `table`, `query`, `derive`, `tx`, `get`, `contribute` | II | an object's parts |
|
||||
| `host.root(fn, ctx, { principal })`, `ctx.get(Type, params)` | II | code outside objects, as a principal |
|
||||
| `invalid(reason, message, details?)`, `forbidden(message, details?)` | II, VIII | Chord's errors, thrown by a plugin |
|
||||
| `invalid(reason, message, details?)`, `forbidden(message, details?)` | II, IX | Chord's errors, thrown by a plugin |
|
||||
| `self.contribute(Http, { routes })` | III | routes, denied by default |
|
||||
| `startPage`, `HostProvider`, `useHold`, `useWatch`, `useRegistry` | IV | the page |
|
||||
| `startTerminalRuntime`, `TuiScope`, `startTerminal` | V | the terminal |
|
||||
| `usePageState(key, initial)`, `scope.pageState(key, initial)` | IV, VIII | state the page keeps across plugin reloads |
|
||||
| `usePageState(key, initial)`, `scope.pageState(key, initial)` | IV, IX | state the page keeps across plugin reloads |
|
||||
| `self.contribute(Themes, theme)` | VI | a theme |
|
||||
| `ephemeralLoginFacet<P>()`, `host.load([], ctx)`, `host.connect(link, { principal }, ctx)` | VII | a second user's client |
|
||||
| `app.plugins.state`, `app.applyChanges(ctx)` | VI, VIII | marks and rollouts |
|
||||
| `app.exportData(address, path, ctx)`, `app.importData(path, address, ctx)` | IX | moving a worker's data |
|
||||
| `testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)` | X | one plugin alone |
|
||||
| `defineMessages(namespace, catalogs)`, `t.translations`, `self.contribute(Translations, list)` | IV, V, VIII | a plugin's messages and its catalogs |
|
||||
| `useMessages(t)`, `scope.messages(t)`, `t.message(key, ...args)`, `t.render(message)`, `MessageSchema` | IV, V, VIII | texts in the viewer's language, messages as data |
|
||||
| `MessagesOf<typeof t>`, `LocaleChoice` | VIII | a translation plugin, the user's language |
|
||||
| `app.plugins.state`, `app.applyChanges(ctx)` | VI, IX | marks and rollouts |
|
||||
| `app.exportData(address, path, ctx)`, `app.importData(path, address, ctx)` | X | moving a worker's data |
|
||||
| `testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)` | XI | one plugin alone |
|
||||
|
|
|
|||
|
|
@ -111,8 +111,8 @@ table{max-width:100%}
|
|||
<section class="cover">
|
||||
<div class="kicker">A tutorial · Chord v2 and its app kit · 2026-11-03</div>
|
||||
<h1>Build It<br>Together</h1>
|
||||
<p class="subtitle">One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme and an edit applied while they are connected</p>
|
||||
<div class="cover-stats"><div class="stat"><b>9</b><span>steps, one runnable example</span></div><div class="stat"><b>4</b><span>host kinds one plugin runs on</span></div><div class="stat"><b>1</b><span>worker process per list</span></div><div class="stat"><b>0</b><span>restarts for a new theme or an edit</span></div></div>
|
||||
<p class="subtitle">One small app, built a step at a time, from an empty server to two people, a page, a terminal, a theme, three languages and an edit applied while they are connected</p>
|
||||
<div class="cover-stats"><div class="stat"><b>10</b><span>steps, one runnable example</span></div><div class="stat"><b>4</b><span>host kinds one plugin runs on</span></div><div class="stat"><b>1</b><span>worker process per list</span></div><div class="stat"><b>0</b><span>restarts for a new theme, a language or an edit</span></div></div>
|
||||
<div class="cover-note"><p>The app is <code>packages/chord/test/examples/61-tutorial/</code>, runnable with the line in its <code>main.ts</code>. Every code block
|
||||
below is quoted from its files, and every console output from what its steps print:
|
||||
<code>test/examples/61-tutorial/tutorial.test.ts</code> fails when a quote or an output drifts. The design is
|
||||
|
|
@ -129,10 +129,11 @@ chapter is about p2, whose code lives outside this repository.</p>
|
|||
<li><a href="#terminal">V. A Terminal</a></li>
|
||||
<li><a href="#theme">VI. A Theme</a></li>
|
||||
<li><a href="#user">VII. A Second User</a></li>
|
||||
<li><a href="#reload">VIII. Changing Code</a></li>
|
||||
<li><a href="#export">IX. Moving a List</a></li>
|
||||
<li><a href="#testing">X. Testing One Plugin</a></li>
|
||||
<li><a href="#p2">XI. Writing a p2 Plugin</a></li>
|
||||
<li><a href="#languages">VIII. Languages</a></li>
|
||||
<li><a href="#reload">IX. Changing Code</a></li>
|
||||
<li><a href="#export">X. Moving a List</a></li>
|
||||
<li><a href="#testing">XI. Testing One Plugin</a></li>
|
||||
<li><a href="#p2">XII. Writing a p2 Plugin</a></li>
|
||||
<li><a href="#traps-and-open-questions">Traps and Open Questions</a></li>
|
||||
<li><a href="#the-app-s-files">Appendix A: The App's Files</a></li>
|
||||
<li><a href="#reference">Appendix B: Reference</a></li>
|
||||
|
|
@ -155,7 +156,7 @@ states the rule behind it. The app is one directory, <code>test/examples/61-tuto
|
|||
<pre><code>node --conditions=source --experimental-strip-types test/examples/<span class="tk-n">61</span>-tutorial/main.ts</code></pre>
|
||||
<p>With Node 26.5.1 or later the terminal client of chapter V runs too (its sandbox needs that Node); with an older one,
|
||||
that step says so and the rest runs.</p>
|
||||
<h3 id="eight-things-to-know">Eight things to know</h3>
|
||||
<h3 id="nine-things-to-know">Nine things to know</h3>
|
||||
<ol class="findings">
|
||||
<li><strong>An app is its API, its plugins and a start script.</strong> <code>startServer</code> makes the data directory, the store, the
|
||||
server host, HTTP with sign-in, the workers and the plugin roots; <code>startWorker</code> does a worker's half.</li>
|
||||
|
|
@ -172,8 +173,10 @@ of host serves and contributes. Plugins import only the app's API.</li>
|
|||
plugins contribute, one plugin (or the entry) composes.</li>
|
||||
<li><strong>Changes apply when you say so.</strong> Saving a file marks its plugin dirty; <code>applyChanges</code> builds and rolls out to the
|
||||
server, the workers, the pages and the terminals, in place.</li>
|
||||
<li><strong>Four traps:</strong> load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value.</li>
|
||||
<li><strong>Text is rendered per viewer.</strong> A plugin's messages are typed code, a translation is a plugin, each user picks a
|
||||
language, and servers store keys, never sentences.</li>
|
||||
<li><strong>Five traps:</strong> load before use; a reference acts as whoever made it; await what you start; a watch starts at the
|
||||
current value; the plain <code>t</code> has no language of its own.</li>
|
||||
</ol>
|
||||
<h3 id="words-used-throughout">Words used throughout</h3>
|
||||
<p>A <strong>host</strong> is one participant of the app, with an <strong>address</strong> if others can reach it (the server, <code>List({ id })</code>) or
|
||||
|
|
@ -183,7 +186,8 @@ host only, <code>remote</code> for any host) names its <strong>params</strong> (
|
|||
are the member sets different principals get. A <strong>factory</strong> declares an object's parts: <strong>documents</strong> and <strong>tables</strong>
|
||||
(stored data, changed in <strong>transactions</strong>), <strong>live queries</strong> (a table's rows Chord keeps current), <strong>derives</strong>
|
||||
(values computed from others), <strong>natives</strong> (things opened and closed, such as a file or a database cursor). A <strong>reference</strong> is what asking for an object returns. A <strong>facet</strong> is a
|
||||
plugin's code for one host kind; a <strong>registry</strong> is an extension point plugins <strong>contribute</strong> to. A <strong>rollout</strong> applies
|
||||
plugin's code for one host kind; a <strong>registry</strong> is an extension point plugins <strong>contribute</strong> to. A <strong>message</strong> is a
|
||||
text with a key, a <strong>catalog</strong> a plugin's messages in one language, a <strong>locale</strong> a language tag (<code>de</code>, <code>pt-BR</code>). A <strong>rollout</strong> applies
|
||||
new plugin code to the hosts in order.</p>
|
||||
</section>
|
||||
|
||||
|
|
@ -214,13 +218,18 @@ stays small: a kind and an id. A user's profile, if the app had one, would b
|
|||
arr,
|
||||
bool,
|
||||
<span class="tk-k">type</span> <span class="tk-t">Ctx</span>,
|
||||
defineMessages,
|
||||
defineState,
|
||||
defineTable,
|
||||
eq,
|
||||
forbidden,
|
||||
int,
|
||||
invalid,
|
||||
local,
|
||||
<span class="tk-t">MessageSchema</span>,
|
||||
<span class="tk-k">type</span> <span class="tk-t">MessagesOf</span>,
|
||||
method,
|
||||
nul,
|
||||
num,
|
||||
obj,
|
||||
<span class="tk-t">PluginError</span>,
|
||||
|
|
@ -230,11 +239,14 @@ stays small: a kind and an id. A user's profile, if the app had one, would b
|
|||
str,
|
||||
<span class="tk-k">type</span> <span class="tk-t">Theme</span>,
|
||||
<span class="tk-t">Themes</span>,
|
||||
<span class="tk-k">type</span> <span class="tk-t">Translation</span>,
|
||||
<span class="tk-t">Translations</span>,
|
||||
union,
|
||||
} <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
|
||||
<span class="tk-k">export</span> <span class="tk-k">type</span> { <span class="tk-t">TodoPrincipal</span> } <span class="tk-k">from</span> <span class="tk-s">"../../principals.ts"</span>;</code></pre>
|
||||
<p><code>invalid</code> and <code>forbidden</code> make Chord's own errors: a plugin refusing arguments throws <code>invalid("args", message)</code>,
|
||||
<p><code>defineMessages</code>, <code>Translations</code> and the message schema are chapter VIII's. <code>invalid</code> and <code>forbidden</code> make Chord's own errors: a plugin refusing arguments throws <code>invalid("args", message)</code>,
|
||||
one refusing a caller <code>forbidden(message)</code>, and the caller gets the same <code>{ code, reason }</code> Chord's built-in checks
|
||||
give (chapter VIII). <code>PluginError</code> is for codes of the plugin's own.</p>
|
||||
give (chapter IX). <code>PluginError</code> is for codes of the plugin's own.</p>
|
||||
<p>and a <code>defineFacet</code> bound to the principal type, so every factory sees a <code>TodoPrincipal</code>:</p>
|
||||
<!-- quote: src/api/v1/index.ts -->
|
||||
<pre><code><span class="tk-c">/** Defines a facet of the todo app: its factories get the app's principals. */</span>
|
||||
|
|
@ -366,7 +378,21 @@ host kinds are base code, in the API:</p>
|
|||
<p>A <code>remote</code> contract says, with <code>hostAddress</code>, at which address its objects live. The contract for a list's todos is
|
||||
the lists plugin's, in its <code>contract.ts</code>, which its facets of every kind import, and so do the steps and its test:</p>
|
||||
<!-- quote: plugins/lists/contract.ts -->
|
||||
<pre><code><span class="tk-k">import</span> { arr, bool, int, <span class="tk-t">List</span>, method, num, obj, remote, <span class="tk-k">type</span> <span class="tk-t">Static</span>, str } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
|
||||
<pre><code><span class="tk-k">import</span> {
|
||||
arr,
|
||||
bool,
|
||||
int,
|
||||
<span class="tk-t">List</span>,
|
||||
<span class="tk-t">MessageSchema</span>,
|
||||
method,
|
||||
nul,
|
||||
num,
|
||||
obj,
|
||||
remote,
|
||||
<span class="tk-k">type</span> <span class="tk-t">Static</span>,
|
||||
str,
|
||||
union,
|
||||
} <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
|
||||
|
||||
<span class="tk-c">/** A todo: its number in the list, its text, whether it is done, who added it and when. */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Item</span> = <span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), text: <span class="tk-f">str</span>(), done: <span class="tk-f">bool</span>(), by: <span class="tk-f">str</span>(), at: <span class="tk-f">num</span>() });
|
||||
|
|
@ -374,9 +400,12 @@ the lists plugin's, in its <code>contract.ts</code>, which its facets of eve
|
|||
<span class="tk-c">/** A todo (see `Item`). */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Item</span> = <span class="tk-t">Static</span><<span class="tk-k">typeof</span> <span class="tk-t">Item</span>>;
|
||||
|
||||
<span class="tk-c">/** The list's latest notice, as data each viewer renders in its own language; `null` before the first. */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notice</span> = <span class="tk-f">union</span>([<span class="tk-t">MessageSchema</span>, <span class="tk-f">nul</span>()]);
|
||||
|
||||
<span class="tk-c">/**
|
||||
* A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
|
||||
* owner also clears the done ones.
|
||||
* A list's todos as one principal sees them: the newest 50 (live) and the list's notice, adding one and ticking one off
|
||||
* (or on again); the owner also clears the done ones.
|
||||
*/</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Todos</span> = <span class="tk-f">remote</span>(<span class="tk-s">"todo.todos"</span>, {
|
||||
version: <span class="tk-n">1</span>,
|
||||
|
|
@ -384,16 +413,17 @@ the lists plugin's, in its <code>contract.ts</code>, which its facets of eve
|
|||
hostAddress: ({ list }) => <span class="tk-t">List</span>({ id: list }),
|
||||
variants: {
|
||||
member: {
|
||||
state: { items: <span class="tk-f">arr</span>(<span class="tk-t">Item</span>) },
|
||||
state: { items: <span class="tk-f">arr</span>(<span class="tk-t">Item</span>), notice: <span class="tk-t">Notice</span> },
|
||||
methods: { add: <span class="tk-f">method</span>(<span class="tk-f">str</span>()), tick: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), done: <span class="tk-f">bool</span>() })) },
|
||||
},
|
||||
owner: {
|
||||
state: { items: <span class="tk-f">arr</span>(<span class="tk-t">Item</span>) },
|
||||
state: { items: <span class="tk-f">arr</span>(<span class="tk-t">Item</span>), notice: <span class="tk-t">Notice</span> },
|
||||
methods: { add: <span class="tk-f">method</span>(<span class="tk-f">str</span>()), tick: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), done: <span class="tk-f">bool</span>() })), clear: <span class="tk-f">method</span>() },
|
||||
},
|
||||
},
|
||||
});</code></pre>
|
||||
<p><code>params</code> select the instance: <code>Todos { list: "groceries" }</code> is one object per principal. Nobody who asks names a
|
||||
<p>Its <code>notice</code> is a message as data, which chapter VIII renders in each viewer's language. <code>params</code> select the
|
||||
instance: <code>Todos { list: "groceries" }</code> is one object per principal. Nobody who asks names a
|
||||
host: the caller computes the address from the params and sends the request there; a host that cannot serve it passes
|
||||
it on to its parent or to a route (design §3, "Host kinds and addresses"). The kit's <code>workers</code> option is that route:</p>
|
||||
<!-- quote: start.ts -->
|
||||
|
|
@ -437,9 +467,10 @@ in <code>local</code> objects with empty params (one per host and type), so its
|
|||
params: {},
|
||||
members: {
|
||||
items: <span class="tk-f">arr</span>(<span class="tk-t">Item</span>),
|
||||
notice: <span class="tk-t">Notice</span>,
|
||||
add: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ text: <span class="tk-f">str</span>(), by: <span class="tk-f">str</span>() })),
|
||||
tick: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ id: <span class="tk-f">int</span>(), done: <span class="tk-f">bool</span>() })),
|
||||
clear: <span class="tk-f">method</span>(),
|
||||
clear: <span class="tk-f">method</span>(<span class="tk-f">obj</span>({ by: <span class="tk-f">str</span>() })),
|
||||
},
|
||||
});</code></pre>
|
||||
<p>A <code>local</code> object with empty params lives as long as its host: it starts with the worker and is never ended by a count
|
||||
|
|
@ -449,9 +480,11 @@ of references. Its factory declares its parts and returns its members:</p>
|
|||
<span class="tk-k">const</span> items = self.<span class="tk-f">table</span>(<span class="tk-t">Items</span>);
|
||||
<span class="tk-c">// The newest todos, live: read before the store goes live, read again after every commit writing the table.</span>
|
||||
<span class="tk-k">const</span> newest = self.<span class="tk-f">query</span>(items, NEWEST, WINDOW);
|
||||
<span class="tk-k">const</span> notices = self.<span class="tk-f">state</span>(<span class="tk-t">Notices</span>);
|
||||
<span class="tk-k">return</span> {
|
||||
<span class="tk-c">// Oldest first, as the list shows them.</span>
|
||||
items: self.<span class="tk-f">derive</span>([newest], ([rows]) => [...rows].<span class="tk-f">reverse</span>()),</code></pre>
|
||||
items: self.<span class="tk-f">derive</span>([newest], ([rows]) => [...rows].<span class="tk-f">reverse</span>()),
|
||||
notice: self.<span class="tk-f">derive</span>([notices], ([value]) => value.notice),</code></pre>
|
||||
<p>A <strong>factory</strong> is synchronous and only declares; Chord activates the object afterwards. <code>self.table</code> declares the
|
||||
table. <code>self.query(items, NEWEST, WINDOW)</code> declares a <strong>live query</strong> over it: the rows of <code>NEWEST</code>, at most 50, as a
|
||||
value the object can expose or derive from. Chord runs it before the store goes live, and again after every commit
|
||||
|
|
@ -486,11 +519,12 @@ contract's schema.</p>
|
|||
<span class="tk-k">const</span> by = principal.kind === <span class="tk-s">"user"</span> ? principal.name : principal.kind;
|
||||
<span class="tk-k">const</span> members = {
|
||||
items: self.<span class="tk-f">derive</span>([store.items], ([items]) => items),
|
||||
notice: self.<span class="tk-f">derive</span>([store.notice], ([notice]) => notice),
|
||||
add: (text: string, ctx: <span class="tk-t">Ctx</span>) => store.<span class="tk-f">add</span>({ text, by }, ctx),
|
||||
tick: (tick: { id: number; done: boolean }, ctx: <span class="tk-t">Ctx</span>) => store.<span class="tk-f">tick</span>(tick, ctx),
|
||||
};
|
||||
<span class="tk-k">if</span> (principal.kind !== <span class="tk-s">"owner"</span>) <span class="tk-k">return</span> { variant: <span class="tk-s">"member"</span>, members };
|
||||
<span class="tk-k">return</span> { variant: <span class="tk-s">"owner"</span>, members: { ...members, clear: (ctx: <span class="tk-t">Ctx</span>) => store.<span class="tk-f">clear</span>(ctx) } };
|
||||
<span class="tk-k">return</span> { variant: <span class="tk-s">"owner"</span>, members: { ...members, clear: (ctx: <span class="tk-t">Ctx</span>) => store.<span class="tk-f">clear</span>({ by }, ctx) } };
|
||||
});</code></pre>
|
||||
<p><code>self.get(Store, {})</code> declares a dependency: the view goes live once the store is, and holds a <strong>reference</strong> to it
|
||||
for its whole life. <code>by</code> comes from the principal. A client could put anything in the arguments of <code>add</code>; it cannot
|
||||
|
|
@ -606,15 +640,15 @@ for them:</p>
|
|||
<pre><code><span class="tk-k">import</span> <span class="tk-k">type</span> { <span class="tk-t">ComponentType</span> } <span class="tk-k">from</span> <span class="tk-s">"react"</span>;
|
||||
<span class="tk-k">import</span> { defineRegistry } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/index.ts"</span>;
|
||||
|
||||
<span class="tk-k">export</span> { useHold, usePageState, useWatch } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/react/index.ts"</span>;
|
||||
<span class="tk-k">export</span> { useHold, useMessages, usePageState, useWatch } <span class="tk-k">from</span> <span class="tk-s">"../../../../../../src/react/index.ts"</span>;
|
||||
|
||||
<span class="tk-c">/** A panel of the page: its title and its React component. */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Panel</span> = { <span class="tk-k">readonly</span> title: string; <span class="tk-k">readonly</span> <span class="tk-t">Component</span>: <span class="tk-t">ComponentType</span> };
|
||||
<span class="tk-c">/** A panel of the page: its name (a key, never shown) and its React component, which shows its own title. */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Panel</span> = { <span class="tk-k">readonly</span> name: string; <span class="tk-k">readonly</span> <span class="tk-t">Component</span>: <span class="tk-t">ComponentType</span> };
|
||||
|
||||
<span class="tk-c">/** The page's panels, one per contributing plugin, in plugin-name order. */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Panels</span> = <span class="tk-f">defineRegistry</span><<span class="tk-t">Panel</span>, <span class="tk-k">readonly</span> <span class="tk-t">Panel</span>[]>(<span class="tk-s">"todo.panels"</span>, { version: <span class="tk-n">1</span> });</code></pre>
|
||||
<p>The web entry serves <code>Panels</code> (its <code>compose</code> keeps the contributions in plugin-name order), renders every panel, and
|
||||
starts the page's connection:</p>
|
||||
<p>A panel shows its own title, in the page's language; its <code>name</code> is only a key. The web entry serves <code>Panels</code> (its
|
||||
<code>compose</code> keeps the contributions in plugin-name order), renders every panel, and starts the page's connection:</p>
|
||||
<!-- quote: src/web/main.ts -->
|
||||
<pre><code><span class="tk-c">/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */</span>
|
||||
<span class="tk-k">const</span> core = <span class="tk-f">defineFacet</span>((host) => host.<span class="tk-f">serve</span>(v1web.<span class="tk-t">Panels</span>, { compose: (table) => table.<span class="tk-f">map</span>(({ value }) => value) }));
|
||||
|
|
@ -625,7 +659,7 @@ starts the page's connection:</p>
|
|||
<span class="tk-s">"main"</span>,
|
||||
<span class="tk-k">null</span>,
|
||||
<span class="tk-f">h</span>(<span class="tk-s">"h1"</span>, <span class="tk-k">null</span>, <span class="tk-s">"Todo"</span>),
|
||||
...panels.<span class="tk-f">map</span>(({ title, <span class="tk-t">Component</span> }) => <span class="tk-f">h</span>(<span class="tk-s">"section"</span>, { key: title }, <span class="tk-f">h</span>(<span class="tk-s">"h2"</span>, <span class="tk-k">null</span>, title), <span class="tk-f">h</span>(<span class="tk-t">Component</span>))),
|
||||
...panels.<span class="tk-f">map</span>(({ name, <span class="tk-t">Component</span> }) => <span class="tk-f">h</span>(<span class="tk-s">"section"</span>, { key: name }, <span class="tk-f">h</span>(<span class="tk-t">Component</span>))),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -642,9 +676,11 @@ scripts with the entry's core facets, and keeps them current. <code>HostProv
|
|||
when its items change:</p>
|
||||
<!-- quote: plugins/lists/web.ts -->
|
||||
<pre><code><span class="tk-k">function</span> <span class="tk-t">TodoList</span>() {
|
||||
<span class="tk-k">const</span> { t } = <span class="tk-f">useMessages</span>(messages);
|
||||
<span class="tk-k">const</span> list = location.hash.<span class="tk-f">slice</span>(<span class="tk-n">1</span>) || <span class="tk-s">"groceries"</span>;
|
||||
<span class="tk-k">const</span> todos = <span class="tk-f">useHold</span>(<span class="tk-t">Todos</span>, { list });
|
||||
<span class="tk-k">const</span> items = <span class="tk-f">useWatch</span>(todos?.items) ?? [];
|
||||
<span class="tk-k">const</span> notice = <span class="tk-f">useWatch</span>(todos?.notice);
|
||||
<span class="tk-c">// Kept by the page, not the component: the new code's component finds the half-typed todo.</span>
|
||||
<span class="tk-k">const</span> [text, setText] = <span class="tk-f">usePageState</span>(<span class="tk-s">`lists.draft.${list}`</span>, <span class="tk-s">""</span>);
|
||||
<span class="tk-k">const</span> add = (event: { <span class="tk-f">preventDefault</span>(): <span class="tk-k">void</span> }) => {
|
||||
|
|
@ -656,6 +692,7 @@ when its items change:</p>
|
|||
<span class="tk-k">return</span> <span class="tk-f">h</span>(
|
||||
<span class="tk-s">"div"</span>,
|
||||
{ className: <span class="tk-s">"lists-panel"</span> },
|
||||
<span class="tk-f">h</span>(<span class="tk-s">"h2"</span>, <span class="tk-k">null</span>, <span class="tk-f">t</span>(<span class="tk-s">"title"</span>)),
|
||||
<span class="tk-f">h</span>(
|
||||
<span class="tk-s">"ul"</span>,
|
||||
<span class="tk-k">null</span>,
|
||||
|
|
@ -677,20 +714,25 @@ when its items change:</p>
|
|||
{ onSubmit: add },
|
||||
<span class="tk-f">h</span>(<span class="tk-s">"input"</span>, {
|
||||
value: text,
|
||||
placeholder: <span class="tk-s">"Add a todo"</span>,
|
||||
placeholder: <span class="tk-f">t</span>(<span class="tk-s">"placeholder"</span>),
|
||||
onChange: (event: { target: { value: string } }) => <span class="tk-f">setText</span>(event.target.value),
|
||||
}),
|
||||
),
|
||||
todos?.variant === <span class="tk-s">"owner"</span>
|
||||
? <span class="tk-f">h</span>(<span class="tk-s">"button"</span>, { <span class="tk-k">type</span>: <span class="tk-s">"button"</span>, onClick: () => <span class="tk-k">void</span> todos.<span class="tk-f">clear</span>(context) }, <span class="tk-s">"Clear done"</span>)
|
||||
? <span class="tk-f">h</span>(<span class="tk-s">"button"</span>, { <span class="tk-k">type</span>: <span class="tk-s">"button"</span>, onClick: () => <span class="tk-k">void</span> todos.<span class="tk-f">clear</span>(context) }, <span class="tk-f">t</span>(<span class="tk-s">"clearDone"</span>))
|
||||
: <span class="tk-k">null</span>,
|
||||
notice === <span class="tk-k">undefined</span> || notice === <span class="tk-k">null</span> ? <span class="tk-k">null</span> : <span class="tk-f">h</span>(<span class="tk-s">"p"</span>, { className: <span class="tk-s">"lists-notice"</span> }, t.<span class="tk-f">render</span>(notice)),
|
||||
);
|
||||
}
|
||||
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { title: <span class="tk-s">"Groceries"</span>, <span class="tk-t">Component</span>: <span class="tk-t">TodoList</span> }));</code></pre>
|
||||
<p><code>useHold</code> holds a reference while the component is mounted and releases it on unmount; it is <code>undefined</code> on the first
|
||||
render. <code>useWatch</code> follows a member. <code>usePageState</code> is <code>useState</code> whose value the page keeps under a key instead of the
|
||||
component: chapter VIII shows why the half-typed todo needs it. Clicks call methods with <code>BACKGROUND_CONTEXT</code>, which never ends, so a click's call
|
||||
<span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, messages.translations);
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { name: <span class="tk-s">"groceries"</span>, <span class="tk-t">Component</span>: <span class="tk-t">TodoList</span> });
|
||||
});</code></pre>
|
||||
<p><code>useMessages(messages)</code> gives the plugin's texts in the page's language (chapter VIII; <code>messages</code> is the plugin's
|
||||
<code>messages.ts</code>, and the facet contributes its catalogs to the page's <code>Translations</code>). <code>useHold</code> holds a reference while
|
||||
the component is mounted and releases it on unmount; it is <code>undefined</code> on the first render. <code>useWatch</code> follows a member. <code>usePageState</code> is <code>useState</code> whose value the page keeps under a key instead of the
|
||||
component: chapter IX shows why the half-typed todo needs it. Clicks call methods with <code>BACKGROUND_CONTEXT</code>, which never ends, so a click's call
|
||||
is not cancelled when the handler returns. <code>todos?.variant === "owner"</code> narrows the reference: only the owner's has
|
||||
<code>clear</code>, and TypeScript knows it. The panel's look reads the active theme's CSS variables, with class names prefixed
|
||||
by the plugin's name (a <code>*.module.css</code> file would scope them instead):</p>
|
||||
|
|
@ -747,7 +789,7 @@ server never touches Mara's machine directly (decision "The terminal
|
|||
<p>The API's terminal module declares the parts of the screen:</p>
|
||||
<!-- quote: src/api/v1/tui.ts -->
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">type</span> <span class="tk-t">Screen</span> = {
|
||||
<span class="tk-k">readonly</span> title: string;
|
||||
<span class="tk-k">readonly</span> name: string;
|
||||
<span class="tk-k">readonly</span> open: (scope: <span class="tk-t">TuiScope</span>, theme: <span class="tk-t">TerminalTheme</span>) => () => <span class="tk-k">readonly</span> string[];
|
||||
};
|
||||
|
||||
|
|
@ -774,7 +816,7 @@ counterpart of the React hooks, for any terminal library:</p>
|
|||
<span class="tk-k">const</span> scope = <span class="tk-k">new</span> <span class="tk-t">TuiScope</span>(runtime.host, () => <span class="tk-f">draw</span>());
|
||||
opened = { screens: current, scope, lines: current.<span class="tk-f">map</span>((screen) => screen.<span class="tk-f">open</span>(scope, theme)) };
|
||||
}
|
||||
<span class="tk-k">const</span> lines = current.<span class="tk-f">flatMap</span>((screen, n) => [theme.<span class="tk-f">fg</span>(<span class="tk-s">"accent"</span>, screen.title), ...opened!.lines[n]!()]);
|
||||
<span class="tk-k">const</span> lines = opened.lines.<span class="tk-f">flatMap</span>((read) => <span class="tk-f">read</span>());
|
||||
terminal.<span class="tk-f">write</span>(<span class="tk-s">`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`</span>);
|
||||
};
|
||||
theme.<span class="tk-f">onChange</span>(draw);
|
||||
|
|
@ -785,21 +827,33 @@ counterpart of the React hooks, for any terminal library:</p>
|
|||
},
|
||||
});</code></pre>
|
||||
<p>When a reload changes <code>Screens</code>, the entry disposes the old scope and opens the screens again. The lists plugin's
|
||||
terminal facet is a few lines:</p>
|
||||
terminal facet draws its title, the todos and the list's notice; <code>scope.messages(messages)</code> is the terminal's
|
||||
<code>useMessages</code> (chapter VIII):</p>
|
||||
<!-- quote: plugins/lists/tui.ts -->
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) =>
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, messages.translations);
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Screens</span>, {
|
||||
title: <span class="tk-s">"Groceries"</span>,
|
||||
name: <span class="tk-s">"groceries"</span>,
|
||||
open: (scope, theme) => {
|
||||
<span class="tk-k">const</span> items = scope.<span class="tk-f">watch</span>(scope.<span class="tk-f">hold</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> }).items);
|
||||
<span class="tk-k">return</span> () =>
|
||||
(<span class="tk-f">items</span>() ?? []).<span class="tk-f">map</span>((item) =>
|
||||
item.done ? theme.<span class="tk-f">fg</span>(<span class="tk-s">"muted"</span>, <span class="tk-s">`[x] ${item.text}`</span>) : <span class="tk-s">`[ ] ${item.text} ${theme.fg("muted", item.by)}`</span>,
|
||||
);
|
||||
<span class="tk-k">const</span> t = scope.<span class="tk-f">messages</span>(messages);
|
||||
<span class="tk-k">const</span> todos = scope.<span class="tk-f">hold</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> });
|
||||
<span class="tk-k">const</span> items = scope.<span class="tk-f">watch</span>(todos.items);
|
||||
<span class="tk-k">const</span> notice = scope.<span class="tk-f">watch</span>(todos.notice);
|
||||
<span class="tk-k">return</span> () => {
|
||||
<span class="tk-k">const</span> shown = <span class="tk-f">notice</span>();
|
||||
<span class="tk-k">return</span> [
|
||||
theme.<span class="tk-f">fg</span>(<span class="tk-s">"accent"</span>, <span class="tk-f">t</span>(<span class="tk-s">"title"</span>)),
|
||||
...(<span class="tk-f">items</span>() ?? []).<span class="tk-f">map</span>((item) =>
|
||||
item.done ? theme.<span class="tk-f">fg</span>(<span class="tk-s">"muted"</span>, <span class="tk-s">`[x] ${item.text}`</span>) : <span class="tk-s">`[ ] ${item.text} ${theme.fg("muted", item.by)}`</span>,
|
||||
),
|
||||
...(shown === <span class="tk-k">undefined</span> || shown === <span class="tk-k">null</span> ? [] : [theme.<span class="tk-f">fg</span>(<span class="tk-s">"warning"</span>, t.<span class="tk-f">render</span>(shown))]),
|
||||
];
|
||||
};
|
||||
},
|
||||
}),
|
||||
);</code></pre>
|
||||
<p><code>theme.fg("muted", …)</code> colours by role, never by a fixed colour. Step 5 runs the launcher on a scripted terminal:</p>
|
||||
});
|
||||
});</code></pre>
|
||||
<p><code>theme.fg("muted", …)</code> colours by role, never by a fixed colour. Step 5 runs the launcher on a scripted terminal, its
|
||||
languages fixed (by default they are the machine's system locale):</p>
|
||||
<!-- quote: main.ts -->
|
||||
<pre><code><span class="tk-k">if</span> (<span class="tk-f">sandboxAvailable</span>()) {
|
||||
<span class="tk-k">const</span> output = <span class="tk-t">Object</span>.<span class="tk-f">assign</span>(<span class="tk-k">new</span> <span class="tk-t">PassThrough</span>(), { columns: <span class="tk-n">60</span>, rows: <span class="tk-n">12</span> });
|
||||
|
|
@ -810,6 +864,8 @@ terminal facet is a few lines:</p>
|
|||
input: <span class="tk-k">new</span> <span class="tk-t">PassThrough</span>(),
|
||||
output,
|
||||
colors: <span class="tk-s">"truecolor"</span>,
|
||||
<span class="tk-c">// The machine's languages (its system locale by default), here fixed for the book's output.</span>
|
||||
locales: [<span class="tk-s">"en-US"</span>],
|
||||
});
|
||||
<span class="tk-k">await</span> <span class="tk-f">until</span>(() => <span class="tk-f">screen</span>(frames).<span class="tk-f">includes</span>(<span class="tk-s">"butter"</span>));
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">"5. the terminal shows:"</span>);
|
||||
|
|
@ -863,7 +919,7 @@ console.<span class="tk-f">log</span>(<span class="tk-s">" the page's plugins:
|
|||
scripts.<span class="tk-f">release</span>();</code></pre>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code>6. harbor added, rolled out to: server, list, web, tui
|
||||
the page's plugins: @todo/harbor, @todo/lists</code></pre>
|
||||
the page's plugins: @todo/french, @todo/harbor, @todo/lists</code></pre>
|
||||
<!-- output: tui -->
|
||||
<pre class="plain"><code> the terminal redraws its title in Harbor's accent: "\u001b[38;2;242;166;90mGroceries"</code></pre>
|
||||
<p>A directory appearing in a plugin root marks the plugin set changed (<code>app.plugins.state.value.set</code>); nothing happens
|
||||
|
|
@ -930,9 +986,147 @@ a host itself must not forget.</p>
|
|||
Her todo is hers: <code>coffee (ada)</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="reload"><header class="opener"><div class="numeral">VIII</div><h2>Changing Code</h2><p class="lede">An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate.</p></header>
|
||||
<section class="chapter" id="languages"><header class="opener"><div class="numeral">VIII</div><h2>Languages</h2><p class="lede">Messages in code, a French plugin, a language chosen live, and a notice each viewer reads in its own language.</p></header>
|
||||
<p class="dropcap">Mara reads German; Ada would rather read French. Both look at the same list, served by the same worker, so its text
|
||||
cannot be formatted once for everyone: <strong>text people read is rendered per viewer, never by a server</strong> (decision
|
||||
"Messages and languages in the app kit"). A plugin writes its texts once, in code; languages are contributions; the
|
||||
language is a preference of each user.</p>
|
||||
<h3 id="messages-in-code">Messages in code</h3>
|
||||
<p>The lists plugin keeps its texts in one file, <code>messages.ts</code>, which every facet that renders imports (and the worker,
|
||||
below):</p>
|
||||
<!-- quote: plugins/lists/messages.ts -->
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">const</span> messages = <span class="tk-f">defineMessages</span>(<span class="tk-s">"todo.lists"</span>, {
|
||||
en: {
|
||||
title: <span class="tk-s">"Groceries"</span>,
|
||||
placeholder: <span class="tk-s">"Add a todo"</span>,
|
||||
clearDone: <span class="tk-s">"Clear done"</span>,
|
||||
open: (n: number) => (n === <span class="tk-n">1</span> ? <span class="tk-s">"1 open"</span> : <span class="tk-s">`${n} open`</span>),
|
||||
cleared: (by: string) => <span class="tk-s">`${by} cleared the done todos`</span>,
|
||||
},
|
||||
de: {
|
||||
title: <span class="tk-s">"Einkäufe"</span>,
|
||||
placeholder: <span class="tk-s">"Neues Todo"</span>,
|
||||
clearDone: <span class="tk-s">"Erledigte entfernen"</span>,
|
||||
open: (n) => <span class="tk-s">`${n} offen`</span>,
|
||||
cleared: (by) => <span class="tk-s">`${by} hat die erledigten Todos entfernt`</span>,
|
||||
},
|
||||
});</code></pre>
|
||||
<p>The first catalog defines the keys and their argument types, and the English catalog is the source of truth; the
|
||||
German one gets its functions' parameter types from it, and a missing key, an unknown one or a wrong argument
|
||||
(<code>t("open", "3")</code>) is a type error, not a blank in production. The namespace (<code>todo.lists</code>) keeps two plugins' <code>title</code>
|
||||
apart. Functions cover plurals and word order; dates and numbers inside them go through <code>formatTime</code> and
|
||||
<code>formatNumber</code>, which use the locale being rendered.</p>
|
||||
<p>The panel renders with <code>useMessages(messages)</code> and the terminal screen with <code>scope.messages(messages)</code> (chapters IV
|
||||
and V): both give a <code>t</code> bound to their host's language, and re-render or redraw when it changes. Each facet that
|
||||
renders also contributes the plugin's own catalogs to its host's <code>Translations</code>:</p>
|
||||
<!-- quote: plugins/lists/web.ts -->
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => {
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, messages.translations);
|
||||
self.<span class="tk-f">contribute</span>(<span class="tk-t">Panels</span>, { name: <span class="tk-s">"groceries"</span>, <span class="tk-t">Component</span>: <span class="tk-t">TodoList</span> });
|
||||
});</code></pre>
|
||||
<p>Registries are per host, and a host learns from them which languages its plugins offer: the page matches the
|
||||
browser's languages against them before it picks one.</p>
|
||||
<h3 id="a-translation-plugin">A translation plugin</h3>
|
||||
<p>French comes from a plugin of its own, written by someone who never touches the lists plugin. Its catalog is typed by
|
||||
the lists plugin's messages, a type-only import erased at build:</p>
|
||||
<!-- quote: plugins/french/fr.ts -->
|
||||
<pre><code><span class="tk-k">import</span> <span class="tk-k">type</span> { <span class="tk-t">MessagesOf</span>, <span class="tk-t">Translation</span> } <span class="tk-k">from</span> <span class="tk-s">"@todo/app/v1"</span>;
|
||||
<span class="tk-k">import</span> <span class="tk-k">type</span> { messages } <span class="tk-k">from</span> <span class="tk-s">"../lists/messages.ts"</span>;
|
||||
|
||||
<span class="tk-k">const</span> lists: <span class="tk-t">MessagesOf</span><<span class="tk-k">typeof</span> messages> = {
|
||||
title: <span class="tk-s">"Courses"</span>,
|
||||
placeholder: <span class="tk-s">"Nouvelle tâche"</span>,
|
||||
clearDone: <span class="tk-s">"Retirer les tâches faites"</span>,
|
||||
open: (n) => <span class="tk-s">`${n} à faire`</span>,
|
||||
cleared: (by) => <span class="tk-s">`${by} a retiré les tâches faites`</span>,
|
||||
};
|
||||
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> french: <span class="tk-k">readonly</span> <span class="tk-t">Translation</span>[] = [{ namespace: <span class="tk-s">"todo.lists"</span>, locale: <span class="tk-s">"fr"</span>, messages: lists }];</code></pre>
|
||||
<p>and its web and terminal facets contribute that list, the same line in each:</p>
|
||||
<!-- quote: plugins/french/web.ts -->
|
||||
<pre><code><span class="tk-k">export</span> <span class="tk-k">default</span> <span class="tk-f">defineFacet</span>((_host, self) => self.<span class="tk-f">contribute</span>(<span class="tk-t">Translations</span>, french));</code></pre>
|
||||
<p>A contribution is a list, so one plugin can translate the app and every plugin at once. A catalog may be partial: a
|
||||
missing key falls back to the locale's base (<code>pt-BR</code> to <code>pt</code>), then to the namespace's first catalog, key by key, and
|
||||
each fallback is reported once (the page's <code>onError</code>, kind <code>messages</code>). Two catalogs for one namespace and locale: the
|
||||
first by plugin name wins and the other is reported; a translation contributed with <code>override: true</code> replaces the
|
||||
plugin's own (a better German).</p>
|
||||
<h3 id="choosing-a-language">Choosing a language</h3>
|
||||
<p>The language is a preference each user keeps on the server, <code>LocaleChoice</code>, one per principal like the theme choice of
|
||||
chapter VI. Until a user chooses, the device decides: the browser's <code>navigator.languages</code> on a page, the system locale
|
||||
in a terminal (the launcher's <code>locales</code>), matched against the languages the catalogs offer, else English. Step 8 has
|
||||
the owner choose German while the terminal runs:</p>
|
||||
<!-- quote: main.ts -->
|
||||
<pre><code><span class="tk-k">const</span> ownerChooses = (locale: string) =>
|
||||
app.host.<span class="tk-f">root</span>(<span class="tk-k">async</span> (ctx) => (<span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">LocaleChoice</span>, {})).<span class="tk-f">choose</span>(locale, ctx), context, { principal: OWNER });
|
||||
<span class="tk-k">await</span> <span class="tk-f">ownerChooses</span>(<span class="tk-s">"de"</span>);</code></pre>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code>8. the owner chooses German
|
||||
ada's pick: fr</code></pre>
|
||||
<!-- output: tui -->
|
||||
<pre class="plain"><code> the terminal shows:
|
||||
Einkäufe
|
||||
[x] milk
|
||||
[x] eggs
|
||||
[ ] bread server
|
||||
[ ] butter owner
|
||||
[ ] coffee ada</code></pre>
|
||||
<p>Every client of the owner holds the owner's <code>LocaleChoice</code>, so the terminal redrew in German at once; nothing
|
||||
restarted. Ada's pick (French) is hers and changes nothing for the owner. A page does the same and also sets <code>lang</code>
|
||||
and <code>dir</code> on its root element (right to left for Arabic or Hebrew, through the app's logical CSS properties).</p>
|
||||
<h3 id="text-from-the-server">Text from the server</h3>
|
||||
<p>Clearing the done todos should tell everyone who cleared them. If the worker wrote "owner hat die erledigten Todos
|
||||
entfernt", Ada would read German too. So the list's state carries a <strong>message as data</strong>, a key and its parameters,
|
||||
declared with the kit's generic schema:</p>
|
||||
<!-- quote: plugins/lists/contract.ts -->
|
||||
<pre><code><span class="tk-c">/** The list's latest notice, as data each viewer renders in its own language; `null` before the first. */</span>
|
||||
<span class="tk-k">export</span> <span class="tk-k">const</span> <span class="tk-t">Notice</span> = <span class="tk-f">union</span>([<span class="tk-t">MessageSchema</span>, <span class="tk-f">nul</span>()]);</code></pre>
|
||||
<p>The store writes it with <code>messages.message</code>, typed against the English catalog like <code>t</code> (<code>messages.message("cleared", 42)</code> does not compile), and each viewer renders it with <code>t.render</code>:</p>
|
||||
<!-- quote: plugins/lists/worker.ts -->
|
||||
<pre><code> clear: <span class="tk-k">async</span> ({ by }, ctx) => {
|
||||
<span class="tk-k">await</span> self.<span class="tk-f">tx</span>(<span class="tk-k">async</span> (tx) => tx.<span class="tk-f">deleteWhere</span>(items, (item) => <span class="tk-f">eq</span>(item.done, <span class="tk-k">true</span>)), ctx);
|
||||
<span class="tk-c">// The key and its parameters, typed against the English catalog: every viewer renders its own text.</span>
|
||||
<span class="tk-k">await</span> notices.<span class="tk-f">change</span>((draft) => {
|
||||
draft.notice = messages.<span class="tk-f">message</span>(<span class="tk-s">"cleared"</span>, by);
|
||||
}, ctx);
|
||||
<span class="tk-k">return</span> <span class="tk-k">null</span>;
|
||||
},</code></pre>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code> the notice: { namespace: 'todo.lists', key: 'cleared', params: [ 'owner' ] }
|
||||
the server renders it: owner cleared the done todos</code></pre>
|
||||
<!-- output: tui -->
|
||||
<pre class="plain"><code> the terminal renders it: owner hat die erledigten Todos entfernt</code></pre>
|
||||
<p>Parameters are plain data (strings, numbers, ISO dates), so a message crosses links like any state. Across a link
|
||||
the member is the generic schema, so <code>t.render</code> checks the namespace and the key at runtime: a message of another
|
||||
namespace or a key the catalogs no longer have shows the key, reported. Never translated: the model's prompts and
|
||||
answers, log lines, error codes.</p>
|
||||
<h3 id="trap-the-plain-t">Trap: the plain <code>t</code></h3>
|
||||
<p>The server's <code>messages.render(…)</code> printed English. <strong>The plain <code>t</code> has no language of its own:</strong> inside a render it
|
||||
uses the language being rendered (a message function calling another plugin's <code>t</code>, <code>formatTime</code>), outside one the
|
||||
first catalog. That is right for a server, a test or a log line, and wrong in a component: a page or a terminal
|
||||
renders with <code>useMessages(t)</code> or <code>scope.messages(t)</code>, bound to its host's language. There is no module-wide current
|
||||
language, because one process runs many hosts and a server renders for many people.</p>
|
||||
<p>On the page, <code>screenshots.ts</code> has the owner choose French and clear the done todos while a todo is half typed:</p>
|
||||
<!-- quote: screenshots.ts -->
|
||||
<pre><code> <span class="tk-k">await</span> app.host.<span class="tk-f">root</span>(
|
||||
<span class="tk-k">async</span> (ctx) => {
|
||||
<span class="tk-k">await</span> (<span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">LocaleChoice</span>, {})).<span class="tk-f">choose</span>(<span class="tk-s">"fr"</span>, ctx);
|
||||
<span class="tk-k">const</span> todos = <span class="tk-k">await</span> ctx.<span class="tk-f">get</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"groceries"</span> });
|
||||
<span class="tk-k">if</span> (todos.variant === <span class="tk-s">"owner"</span>) <span class="tk-k">await</span> todos.<span class="tk-f">clear</span>(ctx);
|
||||
},
|
||||
context,
|
||||
{ principal: { kind: <span class="tk-s">"owner"</span> } },
|
||||
);</code></pre>
|
||||
<!-- output: screenshots -->
|
||||
<pre class="plain"><code>wrote docs/tutorial/screens/french.png
|
||||
after choosing French: lang="fr", "oat mi" still typed
|
||||
the page did not reload: true</code></pre>
|
||||
<figure class="fig"><p><img src="screens/french.png" alt="The page in French"></p>
|
||||
<figcaption><span class="fignum">Figure 3.</span> The page after the owner chose French: the lists plugin's texts from the French plugin, the notice rendered from its key, the half-typed "oat mi" kept; no reload.</figcaption></figure>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="reload"><header class="opener"><div class="numeral">IX</div><h2>Changing Code</h2><p class="lede">An edit while Ada is connected: the watcher marks, applyChanges rolls out, every object takes its fate.</p></header>
|
||||
<p class="dropcap">Ada typed " tea " with spaces, and a blank todo should be refused like an empty one. The fix is one line of the lists
|
||||
plugin's worker facet: the view trims the text before the store sees it. Step 8 makes the edit in the running app's
|
||||
plugin's worker facet: the view trims the text before the store sees it. Step 9 makes the edit in the running app's
|
||||
copy of the plugins, as an editor or an agent would, and applies it:</p>
|
||||
<!-- quote: main.ts -->
|
||||
<pre><code><span class="tk-k">const</span> BEFORE = <span class="tk-s">"add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),"</span>;
|
||||
|
|
@ -941,7 +1135,7 @@ copy of the plugins, as an editor or an agent would, and applies it:</p>
|
|||
<span class="tk-k">await</span> <span class="tk-f">writeFile</span>(workerFile, (<span class="tk-k">await</span> <span class="tk-f">readFile</span>(workerFile, <span class="tk-s">"utf8"</span>)).<span class="tk-f">replace</span>(BEFORE, AFTER));
|
||||
<span class="tk-k">await</span> <span class="tk-f">until</span>(() => app.plugins.state.value.dirty.<span class="tk-f">includes</span>(<span class="tk-s">"@todo/lists"</span>));
|
||||
<span class="tk-k">const</span> applied = <span class="tk-k">await</span> app.<span class="tk-f">applyChanges</span>(context);
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">"8. lists changed, rolled out to:"</span>, applied.steps.<span class="tk-f">map</span>(({ name }) => name).<span class="tk-f">join</span>(<span class="tk-s">", "</span>));
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">"9. lists changed, rolled out to:"</span>, applied.steps.<span class="tk-f">map</span>(({ name }) => name).<span class="tk-f">join</span>(<span class="tk-s">", "</span>));
|
||||
<span class="tk-k">for</span> (<span class="tk-k">const</span> host <span class="tk-k">of</span> applied.steps.<span class="tk-f">find</span>(({ name }) => name === <span class="tk-s">"list"</span>)!.hosts) {
|
||||
<span class="tk-k">const</span> fates = <span class="tk-s">"report"</span> <span class="tk-k">in</span> host ? host.report.objects.<span class="tk-f">map</span>(({ object, kind }) => <span class="tk-s">`${object} ${kind}`</span>) : [];
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">` on ${host.host}:`</span>, [...<span class="tk-k">new</span> <span class="tk-t">Set</span>(fates)].<span class="tk-f">join</span>(<span class="tk-s">", "</span>));
|
||||
|
|
@ -955,7 +1149,7 @@ console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' tea ':
|
|||
);
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">" ada adds ' ':"</span>, refused);</code></pre>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code>8. lists changed, rolled out to: server, list, web, tui
|
||||
<pre class="plain"><code>9. lists changed, rolled out to: server, list, web, tui
|
||||
on list|groceries: todo.store swapped, todo.todos(groceries) parked
|
||||
ada adds ' tea ': [ ] tea (ada)
|
||||
ada adds ' ': { code: 'invalid', reason: 'args' }</code></pre>
|
||||
|
|
@ -1009,7 +1203,8 @@ and the report would name the error.</p>
|
|||
shape bumps its own version with a migration. Contract versions protect callers, storage versions protect data.</p>
|
||||
<h3 id="on-the-page">On the page</h3>
|
||||
<p>An edit of a web facet applies in place too: the page fetches the one changed script and reloads its host, with no
|
||||
page reload. <code>screenshots.ts</code> adds a count of open todos to the panel while half a todo is typed:</p>
|
||||
page reload. <code>screenshots.ts</code> adds a count of open todos to the panel (<code>t("open", n)</code>, in the page's language) while half a
|
||||
todo is typed:</p>
|
||||
<!-- quote: screenshots.ts -->
|
||||
<pre><code><span class="tk-k">const</span> BEFORE = <span class="tk-s">`h(
|
||||
"ul",`</span>;</code></pre>
|
||||
|
|
@ -1020,7 +1215,7 @@ wrote docs/tutorial/screens/edited.png
|
|||
typed "oat mi"; after the edit: "oat mi"
|
||||
the page did not reload: true</code></pre>
|
||||
<figure class="fig"><p><img src="screens/edited.png" alt="The page after the edit"></p>
|
||||
<figcaption><span class="fignum">Figure 3.</span> After the edit of the web facet, applied in place: the count appears, and the half-typed "oat mi" is still in the box: the page kept it under its key while React mounted the new component.</figcaption></figure>
|
||||
<figcaption><span class="fignum">Figure 4.</span> After the edit of the web facet, applied in place: the count appears, and the half-typed "oat mi" is still in the box: the page kept it under its key while React mounted the new component.</figcaption></figure>
|
||||
<p>The page did not reload, and everything outside the changed plugin's components kept its state. The changed panel
|
||||
itself is new code: its component is a new function, which React mounts anew, so its own <code>useState</code> would start over
|
||||
(design §21). What must survive a plugin update (a half-typed todo, a scroll position) is <strong>page state</strong>:
|
||||
|
|
@ -1029,13 +1224,13 @@ component finds it; a terminal's <code>TuiScope</code> has the same store as
|
|||
value, never sent anywhere, and gone with the page.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="export"><header class="opener"><div class="numeral">IX</div><h2>Moving a List</h2><p class="lede">Export a worker's data while it runs, import it under another address.</p></header>
|
||||
<p class="dropcap">A list's data is what its worker created: its store and its files. Step 9 exports the groceries list while its worker
|
||||
<section class="chapter" id="export"><header class="opener"><div class="numeral">X</div><h2>Moving a List</h2><p class="lede">Export a worker's data while it runs, import it under another address.</p></header>
|
||||
<p class="dropcap">A list's data is what its worker created: its store and its files. Step 10 exports the groceries list while its worker
|
||||
runs and imports it as a new list:</p>
|
||||
<!-- quote: main.ts -->
|
||||
<pre><code><span class="tk-k">const</span> archive = <span class="tk-f">join</span>(base, <span class="tk-s">"groceries.tar"</span>);
|
||||
<span class="tk-k">await</span> app.<span class="tk-f">exportData</span>(<span class="tk-t">List</span>({ id: <span class="tk-s">"groceries"</span> }), archive, context);
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">"9. exported groceries:"</span>, (<span class="tk-k">await</span> <span class="tk-f">readFile</span>(archive)).length > <span class="tk-n">0</span> ? <span class="tk-s">"an archive"</span> : <span class="tk-s">"nothing"</span>);
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">"10. exported groceries:"</span>, (<span class="tk-k">await</span> <span class="tk-f">readFile</span>(archive)).length > <span class="tk-n">0</span> ? <span class="tk-s">"an archive"</span> : <span class="tk-s">"nothing"</span>);
|
||||
<span class="tk-k">await</span> app.<span class="tk-f">importData</span>(archive, <span class="tk-t">List</span>({ id: <span class="tk-s">"picnic"</span> }), context);
|
||||
<span class="tk-k">const</span> picnic = <span class="tk-k">await</span> app.host.<span class="tk-f">get</span>(<span class="tk-t">Todos</span>, { list: <span class="tk-s">"picnic"</span> }, context);
|
||||
console.<span class="tk-f">log</span>(<span class="tk-s">" imported as picnic:"</span>, <span class="tk-f">show</span>(picnic.items.value));
|
||||
|
|
@ -1044,8 +1239,8 @@ picnic.<span class="tk-f">release</span>();
|
|||
console.<span class="tk-f">log</span>(<span class="tk-s">" importing over groceries:"</span>, error.message.<span class="tk-f">replace</span>(base, <span class="tk-s">"…"</span>));
|
||||
});</code></pre>
|
||||
<!-- output: main -->
|
||||
<pre class="plain"><code>9. exported groceries: an archive
|
||||
imported as picnic: [x] milk (owner), [x] eggs (owner), [ ] bread (server), [ ] butter (owner), [ ] coffee (ada), [ ] tea (ada)
|
||||
<pre class="plain"><code>10. exported groceries: an archive
|
||||
imported as picnic: [ ] bread (server), [ ] butter (owner), [ ] coffee (ada), [ ] tea (ada)
|
||||
importing over groceries: importData: list|groceries has data (its worker runs)</code></pre>
|
||||
<p><code>exportData</code> stops nothing: the running worker writes its own store at one consistent point (the store's export holds
|
||||
its commit queue while it reads) and its files, as one archive with a manifest naming the app. With no worker running,
|
||||
|
|
@ -1054,7 +1249,7 @@ replacing a list is a delete, then an import (decision "App kit naming and
|
|||
an index of the app's, if it had one, is the app's.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="testing"><header class="opener"><div class="numeral">X</div><h2>Testing One Plugin</h2><p class="lede">testPlugin builds a plugin as production does and loads it alone on a host of its own.</p></header>
|
||||
<section class="chapter" id="testing"><header class="opener"><div class="numeral">XI</div><h2>Testing One Plugin</h2><p class="lede">testPlugin builds a plugin as production does and loads it alone on a host of its own.</p></header>
|
||||
<p class="dropcap">The steps above test the whole app. Most rules are checked faster on one plugin. <code>testPlugin</code> builds the plugin as
|
||||
production builds it (the same esbuild call, the API's imports checked), evaluates it with the app's real API module and
|
||||
loads it on a new host with an in-memory store, here at a list's address so it may serve <code>Todos</code>:</p>
|
||||
|
|
@ -1086,6 +1281,7 @@ loads it on a new host with an in-memory store, here at a list's address so
|
|||
<span class="tk-k">await</span> todos.<span class="tk-f">tick</span>({ id: <span class="tk-n">1</span>, done: <span class="tk-k">true</span> }, ctx);
|
||||
<span class="tk-k">await</span> todos.<span class="tk-f">clear</span>(ctx);
|
||||
<span class="tk-f">expect</span>(todos.items.value.<span class="tk-f">map</span>(({ text }) => text)).<span class="tk-f">toEqual</span>([<span class="tk-s">"eggs"</span>]);
|
||||
<span class="tk-f">expect</span>(todos.notice.value).<span class="tk-f">toEqual</span>({ namespace: <span class="tk-s">"todo.lists"</span>, key: <span class="tk-s">"cleared"</span>, params: [<span class="tk-s">"owner"</span>] });
|
||||
},
|
||||
context,
|
||||
{ principal: OWNER },
|
||||
|
|
@ -1108,7 +1304,7 @@ the refusal of chapter II:</p>
|
|||
<span class="tk-f">expect</span>(refused).<span class="tk-f">toMatchObject</span>({ code: <span class="tk-s">"invalid"</span>, reason: <span class="tk-s">"args"</span> });</code></pre>
|
||||
</section>
|
||||
|
||||
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XI</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
|
||||
<section class="chapter" id="p2"><header class="opener"><div class="numeral">XII</div><h2>Writing a p2 Plugin</h2><p class="lede">What is different in p2, and one small plugin: a tool for the agent and a chip for the people.</p></header>
|
||||
<p class="dropcap">p2, the coding agent's server with its web page and terminal, runs on Chord v2 but not on the app kit. It is older than
|
||||
the kit and keeps its own decisions: its own data directory and SQLite files, its own sign-in, devices and sharing,
|
||||
its own page and terminal bundles. It uses Chord's components directly: <code>createHost</code> with p2's principals,
|
||||
|
|
@ -1251,11 +1447,12 @@ reload. The trap of chapter II applies with p2's names: a server method that
|
|||
server runs in a root of the server's own (<code>serverOf(self)().root</code>), not through its caller's <code>ctx.get</code>.</p>
|
||||
</section>
|
||||
|
||||
<section class="chapter epilogue" id="traps-and-open-questions"><header class="opener"><div class="numeral">∴</div><h2>Traps and Open Questions</h2><p class="lede">The four traps once more, and what in Chord v2 was hard to explain while writing this.</p></header>
|
||||
<section class="chapter epilogue" id="traps-and-open-questions"><header class="opener"><div class="numeral">∴</div><h2>Traps and Open Questions</h2><p class="lede">The five traps once more, and what in Chord v2 was hard to explain while writing this.</p></header>
|
||||
<p class="dropcap"><strong>The traps.</strong> Load before use: a host binds nothing before <code>load</code>; a client with nothing to load calls <code>load([])</code>. A
|
||||
reference acts as whoever made it: <code>ctx.get</code> as the caller, <code>host.get</code> and a local object's <code>self.get</code> as the host.
|
||||
Await what you start: a write started with a context that ends is cancelled and reported <code>transaction_cancelled</code>. A
|
||||
watch starts at the current value: read <code>handle.value</code>, then <code>start</code>; the listener gets changes only.</p>
|
||||
watch starts at the current value: read <code>handle.value</code>, then <code>start</code>; the listener gets changes only. The plain <code>t</code>
|
||||
has no language of its own: components and screens render with <code>useMessages(t)</code> and <code>scope.messages(t)</code>.</p>
|
||||
<p><strong>Hard to explain, found while writing the example.</strong> Each is a candidate for a fix in Chord rather than a paragraph in
|
||||
a tutorial.</p>
|
||||
<ul>
|
||||
|
|
@ -1264,10 +1461,10 @@ a tutorial.</p>
|
|||
<code>tx.get(items, { id })</code> with a number fails <code>tsc</code>. The store here uses <code>updateWhere</code> with <code>eq</code> instead of
|
||||
<code>tx.update</code>.
|
||||
Four more, found the same way, are fixed: a reloaded web plugin lost its own React state (now <code>usePageState</code>,
|
||||
chapter VIII), a plugin's <code>contract.ts</code> could not be imported outside builds (the API is now a workspace package,
|
||||
chapter IX), a plugin's <code>contract.ts</code> could not be imported outside builds (the API is now a workspace package,
|
||||
chapter I), plugins could not throw Chord's <code>invalid</code> (Chord exports <code>invalid</code> and <code>forbidden</code>, chapter II), and a
|
||||
live window over a table needed a one-time read in <code>self.open</code>, which made the store restart at every reload of its
|
||||
plugin (now <code>self.query</code>, a live query Chord runs, chapters II and VIII).</li>
|
||||
plugin (now <code>self.query</code>, a live query Chord runs, chapters II and IX).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
|
@ -1293,7 +1490,7 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and VI
|
|||
<tr>
|
||||
<td><code>src/api/v1/index.ts</code></td>
|
||||
<td>base, every kind</td>
|
||||
<td><code>@todo/app/v1</code>: Chord's part (with <code>invalid</code>, <code>forbidden</code>), <code>defineFacet</code>, <code>List</code></td>
|
||||
<td><code>@todo/app/v1</code>: Chord's part (with <code>invalid</code>, <code>forbidden</code>, <code>defineMessages</code>, <code>Translations</code>), <code>defineFacet</code>, <code>List</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>src/api/v1/server.ts</code></td>
|
||||
|
|
@ -1303,7 +1500,7 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and VI
|
|||
<tr>
|
||||
<td><code>src/api/v1/web.ts</code></td>
|
||||
<td>base, web</td>
|
||||
<td><code>Panels</code>, <code>useHold</code>, <code>usePageState</code>, <code>useWatch</code></td>
|
||||
<td><code>Panels</code>, <code>useHold</code>, <code>useMessages</code>, <code>usePageState</code>, <code>useWatch</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>src/api/v1/tui.ts</code></td>
|
||||
|
|
@ -1328,7 +1525,12 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and VI
|
|||
<tr>
|
||||
<td><code>plugins/lists/</code></td>
|
||||
<td>plugin</td>
|
||||
<td>contract (<code>Item</code>, <code>Todos</code>), worker (table, store, views), server (routes), web (panel), tui (screen), its test</td>
|
||||
<td>contract (<code>Item</code>, <code>Notice</code>, <code>Todos</code>), messages (English, German), worker (table, store, views), server (routes), web (panel), tui (screen), its test</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plugins/french/</code></td>
|
||||
<td>plugin</td>
|
||||
<td>French for the lists plugin, for pages and terminals</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>plugins/login.server.ts</code></td>
|
||||
|
|
@ -1389,7 +1591,7 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and VI
|
|||
</tr>
|
||||
<tr>
|
||||
<td><code>invalid(reason, message, details?)</code>, <code>forbidden(message, details?)</code></td>
|
||||
<td>II, VIII</td>
|
||||
<td>II, IX</td>
|
||||
<td>Chord's errors, thrown by a plugin</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -1409,7 +1611,7 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and VI
|
|||
</tr>
|
||||
<tr>
|
||||
<td><code>usePageState(key, initial)</code>, <code>scope.pageState(key, initial)</code></td>
|
||||
<td>IV, VIII</td>
|
||||
<td>IV, IX</td>
|
||||
<td>state the page keeps across plugin reloads</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
|
@ -1423,18 +1625,33 @@ plugin (now <code>self.query</code>, a live query Chord runs, chapters II and VI
|
|||
<td>a second user's client</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>defineMessages(namespace, catalogs)</code>, <code>t.translations</code>, <code>self.contribute(Translations, list)</code></td>
|
||||
<td>IV, V, VIII</td>
|
||||
<td>a plugin's messages and its catalogs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useMessages(t)</code>, <code>scope.messages(t)</code>, <code>t.message(key, ...args)</code>, <code>t.render(message)</code>, <code>MessageSchema</code></td>
|
||||
<td>IV, V, VIII</td>
|
||||
<td>texts in the viewer's language, messages as data</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>MessagesOf<typeof t></code>, <code>LocaleChoice</code></td>
|
||||
<td>VIII</td>
|
||||
<td>a translation plugin, the user's language</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>app.plugins.state</code>, <code>app.applyChanges(ctx)</code></td>
|
||||
<td>VI, VIII</td>
|
||||
<td>VI, IX</td>
|
||||
<td>marks and rollouts</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>app.exportData(address, path, ctx)</code>, <code>app.importData(path, address, ctx)</code></td>
|
||||
<td>IX</td>
|
||||
<td>X</td>
|
||||
<td>moving a worker's data</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>testPlugin(dir, { kind, api, modules, address, principal, stubs }, ctx)</code></td>
|
||||
<td>X</td>
|
||||
<td>XI</td>
|
||||
<td>one plugin alone</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
|
|
|
|||
BIN
packages/chord/docs/tutorial/screens/french.png
Normal file
BIN
packages/chord/docs/tutorial/screens/french.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -58,11 +58,12 @@ startWorker<P>({ api, kinds, busy, onError });
|
|||
`principal.server`), `createHttp` (the links endpoint at `/chord`), the plugins component with the two roots, one
|
||||
workers component per worker kind (none without worker kinds), the server's plugins loaded with the kit's core
|
||||
facets (`chord.http`, `chord.app.info`, `chord.app.sessions`, `chord.app.owner`, `chord.app.pairing`,
|
||||
`chord.app.files`), the owner token file, the listening port, the owner link. A plugin of the required root failing
|
||||
`chord.app.files`, `chord.themes`, `chord.themes.choice`, `chord.translations`, `chord.locale.choice`), the owner token file, the listening port, the owner link. A plugin of the required root failing
|
||||
rejects with the plugins component's `PluginError`, everything closed. `close` is idempotent.
|
||||
- **`startWorker`** is `workerMain` plus: the worker's directory `workers/<kind>/<key>/` and its store there, the host
|
||||
at the address acting as `principal.worker(address)`, connected to the server first (trusted, as the server's
|
||||
principal), its plugins loaded with the worker's core facets (`chord.app.worker-info`, `chord.app.files`), `busy`
|
||||
principal), its plugins loaded with the worker's core facets (`chord.app.worker-info`, `chord.app.files`,
|
||||
`chord.translations`), `busy`
|
||||
from the app. A worker's errors reach the server as a fixed "worker failed" (the error stays in the worker's
|
||||
`onError`).
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { createHost, type Facet, type Host, type HostError, type PluginFacet } f
|
|||
import type { JsonValue } from "../../json.ts";
|
||||
import { openNodeSqliteDatabase } from "../../sqlite/node.ts";
|
||||
import { openStore, type Store, sqliteBackend } from "../../store/index.ts";
|
||||
import { themeChoiceFacet, themesFacet } from "../../surfaces/index.ts";
|
||||
import { localeChoiceFacet, themeChoiceFacet, themesFacet, translationsFacet } from "../../surfaces/index.ts";
|
||||
import type { Watchable } from "../../watch.ts";
|
||||
import { createHttp, type HttpComponent, type HttpError, type ListenAddress } from "../http/index.ts";
|
||||
import {
|
||||
|
|
@ -253,6 +253,8 @@ export async function startServer<P extends Principal>(options: AppOptions<P>, c
|
|||
{ name: "chord.app.files", facet: filesFacet(join(layout.server, "files"), options.files, limits.uploads) },
|
||||
{ name: "chord.themes", facet: themesFacet() },
|
||||
{ name: "chord.themes.choice", facet: themeChoiceFacet() },
|
||||
{ name: "chord.translations", facet: translationsFacet() },
|
||||
{ name: "chord.locale.choice", facet: localeChoiceFacet() },
|
||||
];
|
||||
const surfaces: Surface<P>[] = [];
|
||||
const app = new RunningApp(host, http, workers, plugins, surfaces, { store, lock }, layout, options);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// `startWorker` (slice 13 §1): a worker of an app built with the kit, in its entry module. `workerMain` plus the kit's
|
||||
// part: the worker's directory under its kind and address with its store, the host at the worker's address acting as
|
||||
// the principal the server gave it, connected to the server first, its plugins loaded with the kit's worker facets
|
||||
// (`WorkerInfo`, `Files`).
|
||||
// (`WorkerInfo`, `Files`, `Translations` for a worker that renders final text).
|
||||
|
||||
import { join } from "node:path";
|
||||
import type { Principal } from "../../data/index.ts";
|
||||
|
|
@ -9,6 +9,7 @@ import { createHost } from "../../host/index.ts";
|
|||
import type { JsonValue } from "../../json.ts";
|
||||
import { openNodeSqliteDatabase } from "../../sqlite/node.ts";
|
||||
import { openStore, sqliteBackend } from "../../store/index.ts";
|
||||
import { translationsFacet } from "../../surfaces/index.ts";
|
||||
import type { Watchable } from "../../watch.ts";
|
||||
import { workerMain } from "../workers/index.ts";
|
||||
import type { AppApi, AppKind, KitStart } from "./app.ts";
|
||||
|
|
@ -63,6 +64,7 @@ export function startWorker<P extends Principal>(options: WorkerOptions): void {
|
|||
name: "chord.app.data",
|
||||
facet: hostDataFacet<P>(store, join(kit.dir, "files"), kit.server as unknown as P),
|
||||
},
|
||||
{ name: "chord.translations", facet: translationsFacet<P>() },
|
||||
];
|
||||
await plugins.load(host, { kind: kit.facet, core }, context);
|
||||
return options.busy === undefined ? {} : { busy: options.busy };
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const terminal = await startTerminal({
|
|||
url: "http://127.0.0.1:8788",
|
||||
credentials: { kind: "file", path: "~/.acme/owner-token" }, // or { kind: "token", token }, { kind: "pairing", label, save? }
|
||||
mode: "dark", colors: "truecolor", // the terminal's background and colours
|
||||
locales: ["de-DE"], // the user's languages: the system locale by default
|
||||
});
|
||||
process.exitCode = await terminal.exited;
|
||||
```
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export type TerminalOptions = {
|
|||
readonly colors?: ColorDepth;
|
||||
/** The terminal's background, choosing between the user's dark and light theme: dark by default. */
|
||||
readonly mode?: "dark" | "light";
|
||||
/** The user's languages, most preferred first, until they choose one: the system's locale by default. */
|
||||
readonly locales?: readonly string[];
|
||||
/** Opens a URL the user confirmed: the platform's opener by default. */
|
||||
readonly opener?: (url: string) => Promise<void>;
|
||||
};
|
||||
|
|
@ -232,6 +234,7 @@ class Launcher {
|
|||
rows: this.#rows,
|
||||
colors,
|
||||
mode: this.#options.mode ?? "dark",
|
||||
locales: this.#options.locales ?? systemLocales(),
|
||||
} as const;
|
||||
this.#requests = 0;
|
||||
this.#sandbox = await Sandbox.start(
|
||||
|
|
@ -464,3 +467,16 @@ class Launcher {
|
|||
|
||||
/** The launcher's SIGUSR1 listener: the signal does nothing. */
|
||||
function ignoreSignal(): void {}
|
||||
|
||||
/**
|
||||
* The system's languages: `LANGUAGE` (a colon list), then `LC_ALL`, `LC_MESSAGES`, `LANG` (`de_DE.UTF-8` is `de-DE`;
|
||||
* `C` and `POSIX` name none), then the runtime's default locale.
|
||||
*/
|
||||
function systemLocales(): readonly string[] {
|
||||
const { LANGUAGE, LC_ALL, LC_MESSAGES, LANG } = process.env;
|
||||
const named = [...(LANGUAGE ?? "").split(":"), LC_ALL, LC_MESSAGES, LANG].flatMap((value) => {
|
||||
const tag = value?.replace(/[.@].*$/, "").replace(/_/g, "-");
|
||||
return tag === undefined || tag === "" || tag === "C" || tag === "POSIX" ? [] : [tag];
|
||||
});
|
||||
return [...new Set([...named, Intl.DateTimeFormat().resolvedOptions().locale])];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ peer), non-React apps never load it. No components, no styling.
|
|||
- `useWatch(watchable)`: its current value (`undefined` while it has none), re-rendering on change
|
||||
(`useSyncExternalStore` over `watchStore`).
|
||||
- `useRegistry(R)`: the registry's composed value; `useRefState(ref)`: the reference's state.
|
||||
- `useMessages(t)`: `{ t, locale }`, a plugin's messages bound to the host's language (followed by `startPage`),
|
||||
re-rendering when the locale or a translation changes; before a language is followed, the first catalog.
|
||||
- `usePageState(key, initial)`: `useState`'s pair, the value kept by the page (its host's life) under `key`
|
||||
(`surfaces/page-state.ts`, shared with `TuiScope.pageState`), so a component remounted by a plugin reload keeps it.
|
||||
- A component unmounted by a reload has not left (design §21): its releases are plain releases. A component of a
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
// React bindings (slice 11 §3, decision "Slice 11, P7"; p2's `useObject`, `useReadable`, `useRegistry`): a host in
|
||||
// context, references held for a component's life, watchables read with `useSyncExternalStore`, and page state kept
|
||||
// for the host's life. A component unmounted by a reload has not left (design §21): its releases are plain releases,
|
||||
// presence is the app's. A component of a reloaded plugin is a new function and remounts, its `useState` starting
|
||||
// over; what must survive is page state (decision "Tutorial gaps" (1)). No components, no styling.
|
||||
// context, references held for a component's life, watchables read with `useSyncExternalStore`, page state kept for
|
||||
// the host's life, and a plugin's messages in the host's language. A component unmounted by a reload has not left
|
||||
// (design §21): its releases are plain releases, presence is the app's. A component of a reloaded plugin is a new
|
||||
// function and remounts, its `useState` starting over; what must survive is page state (decision "Tutorial gaps" (1)).
|
||||
// No components, no styling.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
|
|
@ -20,6 +21,7 @@ import type { Principal } from "../data/index.ts";
|
|||
import { canonicalJson } from "../data/principal.ts";
|
||||
import type { Held, Host, RefState } from "../host/index.ts";
|
||||
import type { Registry } from "../registries/index.ts";
|
||||
import { hostLanguage, type Messages, type Translator } from "../surfaces/index.ts";
|
||||
import { pageState } from "../surfaces/page-state.ts";
|
||||
import type { NativeWatchable, Watchable } from "../watch.ts";
|
||||
import { watchStore } from "./stores.ts";
|
||||
|
|
@ -89,3 +91,16 @@ export function usePageState<T>(key: string, initial: T): readonly [T, (value: T
|
|||
const state = pageState(useHost(), key, initial);
|
||||
return [useSyncExternalStore(state.subscribe, state.read, state.read), state.set];
|
||||
}
|
||||
|
||||
/**
|
||||
* `messages` in the language of the component's host (the user's pick, else the device's, matched against the
|
||||
* catalogs), with its locale; re-renders when the locale or a translation changes. Before the host follows a language
|
||||
* (`startPage` does), `t` renders the first catalog and `locale` is `undefined`.
|
||||
*/
|
||||
export function useMessages<N extends string, M>(
|
||||
messages: Messages<N, M>,
|
||||
): { readonly t: Translator<N, M>; readonly locale: string | undefined } {
|
||||
const state = hostLanguage(useHost());
|
||||
const language = useSyncExternalStore(state.subscribe, state.read, state.read);
|
||||
return useMemo(() => ({ t: messages.in(language), locale: language?.locale }), [messages, language]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
// Chord v2's React bindings (`@earendil-works/chord/react`; slice 11 §3): optional, non-React apps never load them.
|
||||
|
||||
export { HostProvider, useHold, useHost, usePageState, useRefState, useRegistry, useWatch } from "./hooks.ts";
|
||||
export {
|
||||
HostProvider,
|
||||
useHold,
|
||||
useHost,
|
||||
useMessages,
|
||||
usePageState,
|
||||
useRefState,
|
||||
useRegistry,
|
||||
useWatch,
|
||||
} from "./hooks.ts";
|
||||
export { type ExternalStore, watchStore } from "./stores.ts";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# surfaces
|
||||
|
||||
The client surfaces' portable part (slice 11; design §23 "Web and TUI"; decisions "Slice 11's points", "Slice 11, P3"
|
||||
to "P9", "Looks and themes"). What a page and a terminal's sandbox run alike, and what the server serves them: exported
|
||||
to "P9", "Looks and themes", "Messages and languages in the app kit"). What a page and a terminal's sandbox run alike, and what the server serves them: exported
|
||||
from `@earendil-works/chord`.
|
||||
|
||||
- **Scripts objects** (`scripts.ts`): `WebScripts` and `TuiScripts`, remote objects on the server (`hostKind("server")()`,
|
||||
|
|
@ -32,6 +32,15 @@ from `@earendil-works/chord`.
|
|||
host, `FALLBACK_THEMES`, `activeTheme(themes, pick, mode)`, `themeVariables` (`--<role>`, `--<web extra>`),
|
||||
`terminalColor` (a role's `tui` override, else its colour, else the fallback's; true colour or the nearest of 256);
|
||||
`ThemeChoice` (remote on the server, per principal: `pick` `{ dark, light }` and `choose`) with `themeChoiceFacet`
|
||||
keeping every pick in one persisted document by principal key; `followActiveTheme(host, mode, changed, ctx)`.
|
||||
keeping every pick in one persisted document by principal key (`preference.ts`, shared with the locale choice);
|
||||
`followActiveTheme(host, mode, changed, ctx)`.
|
||||
- **Messages and languages** (`messages.ts`, `language.ts`, `format.ts`, `translations.ts`, `locale-choice.ts`,
|
||||
`active-language.ts`; design §23): `defineMessages(namespace, catalogs)` (a typed `t`; `t.message`, `t.render`,
|
||||
`t.in(language)`, `t.translations`), `MessagesOf`, `MessageData`, `MessageSchema`; `Language`
|
||||
(`createLanguage(locale, translations, report)`; internally the render scope `inLanguage`, `matchLocale`,
|
||||
`resolveLocale`); `formatTime`, `formatNumber`; the `Translations` registry (a list of `{ namespace, locale, messages, override? }` per
|
||||
contributor; one catalog per namespace and locale) with `translationsFacet`; `LocaleChoice` with
|
||||
`localeChoiceFacet`; `followActiveLanguage(host, preferred, changed, report, ctx)`, keeping the host's language as
|
||||
its page state (`hostLanguage`).
|
||||
|
||||
Tests: `test/surfaces/`.
|
||||
|
|
|
|||
96
packages/chord/src/surfaces/active-language.ts
Normal file
96
packages/chord/src/surfaces/active-language.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// The active language of a client (decision "Messages and languages in the app kit" (3)): the `Translations` registry
|
||||
// on the client's host (served there by the kit), the user's `LocaleChoice` (held on the server; until it binds, or
|
||||
// when the app serves none, no pick) and the device's languages, combined. The host keeps it as page state
|
||||
// (`hostLanguage`), where `useMessages` and `TuiScope.messages` read and follow it; each change also calls the client's
|
||||
// writer (`lang` and `dir` on a page). Reports are passed on once each.
|
||||
|
||||
import { type Context, withAbortSignal } from "../context/index.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
import type { Host } from "../host/index.ts";
|
||||
import { createLanguage, type Language, type MessagesReport, resolveLocale } from "./language.ts";
|
||||
import { LocaleChoice } from "./locale-choice.ts";
|
||||
import { type PageState, pageState } from "./page-state.ts";
|
||||
import { NO_TRANSLATIONS, type TranslationIndex, Translations } from "./translations.ts";
|
||||
|
||||
/** A client's active language as it follows it: the device's languages can change (a browser's), and it ends. */
|
||||
export type LanguageFollower = {
|
||||
/** The device's languages changed: the locale is resolved again. */
|
||||
setDevice(preferred: readonly string[]): void;
|
||||
stop(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Follows the active language on `host` for the device's `preferred` languages: sets the host's language (and calls
|
||||
* `changed` with it) at once and at every change of the registry, the user's pick or the device's languages that
|
||||
* changes the locale or the catalogs, until `stop`. `report` gets each distinct report once.
|
||||
*/
|
||||
export async function followActiveLanguage<P extends Principal>(
|
||||
host: Host<P>,
|
||||
preferred: readonly string[],
|
||||
changed: (language: Language) => void,
|
||||
report: (report: MessagesReport) => void,
|
||||
context: Context,
|
||||
): Promise<LanguageFollower> {
|
||||
const abort = new AbortController();
|
||||
const ctx = withAbortSignal(abort.signal, context);
|
||||
const state = hostLanguage(host);
|
||||
const once = reportOnce(report);
|
||||
let translations: TranslationIndex = NO_TRANSLATIONS;
|
||||
let pick: string | null = null;
|
||||
let device = preferred;
|
||||
const update = (): void => {
|
||||
const locale = resolveLocale(pick, device, translations.locales);
|
||||
const current = state.read();
|
||||
if (current?.locale === locale && current.translations === translations) return;
|
||||
const language = createLanguage(locale, translations, once);
|
||||
state.set(language);
|
||||
changed(language);
|
||||
};
|
||||
update();
|
||||
const registry = await host.registry(Translations, ctx);
|
||||
translations = registry.value;
|
||||
update();
|
||||
(await registry.watch(ctx)).start(async (call) => {
|
||||
if (call.kind === "value") translations = call.value;
|
||||
update();
|
||||
});
|
||||
const choice = host.hold(LocaleChoice, {}, ctx);
|
||||
choice.pick.watch(ctx).then(
|
||||
(watch) =>
|
||||
watch.start(async (call) => {
|
||||
if (call.kind === "value") pick = call.value;
|
||||
update();
|
||||
}),
|
||||
// The hold ended (the follower stopped) before its first value.
|
||||
() => {},
|
||||
);
|
||||
return {
|
||||
setDevice: (next) => {
|
||||
device = next;
|
||||
update();
|
||||
},
|
||||
stop: () => {
|
||||
abort.abort();
|
||||
choice.release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The page state key of a host's language. */
|
||||
const LANGUAGE = "chord.language";
|
||||
|
||||
/** `host`'s active language, kept as its page state: `undefined` until a follower sets it (then `t` renders the first catalog). */
|
||||
export function hostLanguage<P extends Principal>(host: Host<P>): PageState<Language | undefined> {
|
||||
return pageState<Language | undefined, P>(host, LANGUAGE, undefined);
|
||||
}
|
||||
|
||||
/** `report`, called once per distinct report (by kind, namespace, locale and key). */
|
||||
function reportOnce(report: (report: MessagesReport) => void): (report: MessagesReport) => void {
|
||||
const seen = new Set<string>();
|
||||
return (each) => {
|
||||
const id = `${each.kind}|${each.namespace}|${"locale" in each ? each.locale : ""}|${each.key}`;
|
||||
if (seen.has(id)) return;
|
||||
seen.add(id);
|
||||
report(each);
|
||||
};
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import type { Principal } from "../data/index.ts";
|
|||
import { createHost, type Host, type HostError, type PluginFacet, type PluginFailure } from "../host/index.ts";
|
||||
import type { Link } from "../link/index.ts";
|
||||
import { type ConnectionState, keepConnected, type LoopEnvironment, type SocketEvents } from "./connection.ts";
|
||||
import type { MessagesReport } from "./language.ts";
|
||||
import { LoadedFacets, type SetEntry } from "./loaded.ts";
|
||||
import { type ClientModules, evaluateRegistered, type RegisteredScript } from "./registered.ts";
|
||||
import type { ScriptEntry, ScriptSet, ScriptsContract } from "./scripts.ts";
|
||||
|
|
@ -18,7 +19,9 @@ import type { ScriptEntry, ScriptSet, ScriptsContract } from "./scripts.ts";
|
|||
export type ClientError =
|
||||
| { readonly kind: "host"; readonly error: HostError }
|
||||
| { readonly kind: "plugins"; readonly failed: readonly PluginFailure[] }
|
||||
| { readonly kind: "update_failed"; readonly error: unknown };
|
||||
| { readonly kind: "update_failed"; readonly error: unknown }
|
||||
/** A message rendered by a fallback, a contributed message that threw, a foreign message (each once). */
|
||||
| { readonly kind: "messages"; readonly report: MessagesReport };
|
||||
|
||||
/** What `startClient` takes. */
|
||||
export type ClientOptions<P extends Principal> = {
|
||||
|
|
|
|||
47
packages/chord/src/surfaces/format.ts
Normal file
47
packages/chord/src/surfaces/format.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Dates, times and numbers in the viewer's locale (decision "Messages and languages in the app kit" (1)): `Intl` with
|
||||
// the current language's locale, so a message function's `formatTime(at)` follows the language it renders in. Outside a
|
||||
// render the platform's default locale applies. A language's default formatters are made once, kept as long as it is.
|
||||
|
||||
import { currentLanguage, type Language } from "./language.ts";
|
||||
|
||||
/** A time (a `Date`, milliseconds, or an ISO string as messages carry it) in the current locale; `14:05` by default. */
|
||||
export function formatTime(at: Date | number | string, options?: Intl.DateTimeFormatOptions): string {
|
||||
const language = currentLanguage();
|
||||
const format =
|
||||
options === undefined && language !== undefined
|
||||
? formattersOf(language).time
|
||||
: new Intl.DateTimeFormat(language?.locale, options ?? TIME);
|
||||
return format.format(new Date(at));
|
||||
}
|
||||
|
||||
/** A number in the current locale (`1.234,5` in German). */
|
||||
export function formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
|
||||
const language = currentLanguage();
|
||||
const format =
|
||||
options === undefined && language !== undefined
|
||||
? formattersOf(language).number
|
||||
: new Intl.NumberFormat(language?.locale, options);
|
||||
return format.format(value);
|
||||
}
|
||||
|
||||
/** `formatTime`'s default: hours and minutes. */
|
||||
const TIME: Intl.DateTimeFormatOptions = { timeStyle: "short" };
|
||||
|
||||
/** A language's default formatters. */
|
||||
type Formatters = { readonly time: Intl.DateTimeFormat; readonly number: Intl.NumberFormat };
|
||||
|
||||
/** The default formatters of each language, made at its first use: a formatter costs far more than a format. */
|
||||
const FORMATTERS = new WeakMap<Language, Formatters>();
|
||||
|
||||
/** `language`'s default formatters. */
|
||||
function formattersOf(language: Language): Formatters {
|
||||
let formatters = FORMATTERS.get(language);
|
||||
if (formatters === undefined) {
|
||||
formatters = {
|
||||
time: new Intl.DateTimeFormat(language.locale, TIME),
|
||||
number: new Intl.NumberFormat(language.locale),
|
||||
};
|
||||
FORMATTERS.set(language, formatters);
|
||||
}
|
||||
return formatters;
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
// The client surfaces' portable part (slice 11): the scripts objects a server serves and clients hold, plugin scripts
|
||||
// that register themselves, a client host kept connected and current, a host's loaded plugins, and themes.
|
||||
// that register themselves, a client host kept connected and current, a host's loaded plugins, themes, and messages and
|
||||
// languages.
|
||||
|
||||
export { followActiveLanguage, hostLanguage, type LanguageFollower } from "./active-language.ts";
|
||||
export { followActiveTheme, type ThemeFollower } from "./active-theme.ts";
|
||||
export {
|
||||
type Client,
|
||||
|
|
@ -16,7 +18,24 @@ export {
|
|||
type LoopOptions,
|
||||
type SocketEvents,
|
||||
} from "./connection.ts";
|
||||
export { formatNumber, formatTime } from "./format.ts";
|
||||
export { createLanguage, type Language, type MessagesReport } from "./language.ts";
|
||||
export { LoadedFacets, type Prepared, type SetEntry } from "./loaded.ts";
|
||||
export { LocaleChoice, localeChoiceFacet } from "./locale-choice.ts";
|
||||
export {
|
||||
type Catalog,
|
||||
type CatalogShape,
|
||||
type DataKeys,
|
||||
defineMessages,
|
||||
type MessageArgs,
|
||||
type MessageData,
|
||||
type MessageParam,
|
||||
MessageSchema,
|
||||
type Messages,
|
||||
type MessagesOf,
|
||||
type MessageValue,
|
||||
type Translator,
|
||||
} from "./messages.ts";
|
||||
export {
|
||||
type ClientModules,
|
||||
evaluateRegistered,
|
||||
|
|
@ -40,3 +59,9 @@ export {
|
|||
themesFacet,
|
||||
themeVariables,
|
||||
} from "./themes.ts";
|
||||
export {
|
||||
type Translation,
|
||||
type TranslationIndex,
|
||||
Translations,
|
||||
translationsFacet,
|
||||
} from "./translations.ts";
|
||||
|
|
|
|||
119
packages/chord/src/surfaces/language.ts
Normal file
119
packages/chord/src/surfaces/language.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// A language and rendering in it (decision "Messages and languages in the app kit" (1), (3), refined in the build): a
|
||||
// `Language` is a locale with its text direction, the host's composed catalogs and where fallbacks are reported. A
|
||||
// render is synchronous, and while one runs its language is the current one: a message function's own `formatTime` or a
|
||||
// plain `t(...)` it calls renders in it. Outside a render there is no current language (a server, a test, a log line):
|
||||
// `t` renders the first catalog. No module-wide setting (the current language exists only while a render runs): one
|
||||
// process runs many hosts, and a server renders per recipient.
|
||||
|
||||
import { canonicalLocale, type TranslationIndex } from "./translations.ts";
|
||||
|
||||
/** A locale to render in: its direction, its fallback chain, the host's catalogs and the reports' destination. */
|
||||
export type Language = {
|
||||
/** A canonical language tag (`de`, `pt-BR`). */
|
||||
readonly locale: string;
|
||||
readonly dir: "ltr" | "rtl";
|
||||
/** The locales tried in order before a namespace's first catalog: `pt-BR`, `pt`. */
|
||||
readonly chain: readonly string[];
|
||||
readonly translations: TranslationIndex;
|
||||
readonly report: (report: MessagesReport) => void;
|
||||
};
|
||||
|
||||
/** What rendering reports: a key found only further down the chain, a contributed message that threw, a foreign message. */
|
||||
export type MessagesReport =
|
||||
| {
|
||||
readonly kind: "fallback";
|
||||
readonly namespace: string;
|
||||
readonly locale: string;
|
||||
readonly key: string;
|
||||
/** The locale whose catalog had the key; `undefined`: the namespace's first catalog. */
|
||||
readonly used: string | undefined;
|
||||
}
|
||||
| {
|
||||
readonly kind: "failed";
|
||||
readonly namespace: string;
|
||||
readonly locale: string;
|
||||
readonly key: string;
|
||||
/** The contributing plugin; `undefined`: the namespace's own catalogs (a message from a link that does not fit). */
|
||||
readonly plugin: string | undefined;
|
||||
readonly error: unknown;
|
||||
}
|
||||
/** `t.render` got a message of another namespace or a key the catalogs do not have: it shows the key. */
|
||||
| { readonly kind: "unknown"; readonly namespace: string; readonly key: string; readonly expected: string };
|
||||
|
||||
/** The language `locale` (canonical) with the host's `translations`; `report` gets every report as it happens. */
|
||||
export function createLanguage(
|
||||
locale: string,
|
||||
translations: TranslationIndex,
|
||||
report: (report: MessagesReport) => void,
|
||||
): Language {
|
||||
return { locale, dir: directionOf(locale), chain: chainOf(locale), translations, report };
|
||||
}
|
||||
|
||||
/** The language of the render under way; `undefined` outside one. */
|
||||
let rendering: Language | undefined;
|
||||
|
||||
/** Runs `render` with `language` as the current language (synchronous: the language is gone when it returns). */
|
||||
export function inLanguage<T>(language: Language | undefined, render: () => T): T {
|
||||
const outer = rendering;
|
||||
rendering = language;
|
||||
try {
|
||||
return render();
|
||||
} finally {
|
||||
rendering = outer;
|
||||
}
|
||||
}
|
||||
|
||||
/** The current language: the render under way's, `undefined` outside one. */
|
||||
export function currentLanguage(): Language | undefined {
|
||||
return rendering;
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale to use: the user's `pick` when a catalog offers it (or its base), else the first of the device's
|
||||
* `preferred` some catalog offers, else English (the source of truth) when offered, else the first offered, else `en`.
|
||||
*/
|
||||
export function resolveLocale(pick: string | null, preferred: readonly string[], offered: readonly string[]): string {
|
||||
return (
|
||||
matchLocale(pick === null ? [] : [pick], offered) ??
|
||||
matchLocale(preferred, offered) ??
|
||||
(offered.includes("en") ? "en" : (offered[0] ?? "en"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first of `preferred` that `offered` (canonical tags) has: itself, one of its bases (`de-CH` for `de-CH-1996`, then
|
||||
* `de`), or another of its language (`de-DE` for `de-AT`); `undefined` when none matches. Invalid tags are skipped
|
||||
* (the device's list is the platform's data).
|
||||
*/
|
||||
export function matchLocale(preferred: readonly string[], offered: readonly string[]): string | undefined {
|
||||
for (const wanted of preferred) {
|
||||
const locale = canonicalLocale(wanted);
|
||||
if (locale === undefined) continue;
|
||||
const exact = chainOf(locale).find((each) => offered.includes(each));
|
||||
if (exact !== undefined) return exact;
|
||||
const language = baseLanguage(locale);
|
||||
const related = offered.find((each) => baseLanguage(each) === language);
|
||||
if (related !== undefined) return related;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** `locale` and its bases, longest first: `zh-Hant-TW`, `zh-Hant`, `zh`. */
|
||||
function chainOf(locale: string): readonly string[] {
|
||||
const parts = locale.split("-");
|
||||
return parts.map((_, index) => parts.slice(0, parts.length - index).join("-"));
|
||||
}
|
||||
|
||||
/** The language subtag of a canonical tag (`pt` of `pt-BR`). */
|
||||
function baseLanguage(locale: string): string {
|
||||
return locale.split("-")[0]!;
|
||||
}
|
||||
|
||||
/** The scripts written right to left (ISO 15924), as `Intl.Locale` maximizes a tag's script. */
|
||||
const RTL_SCRIPTS = new Set(["Adlm", "Arab", "Hebr", "Mand", "Nkoo", "Rohg", "Samr", "Syrc", "Thaa", "Yezi"]);
|
||||
|
||||
/** The text direction of `locale`: right to left when its likely script is (Arabic for `ar`, `ur`, `fa`). */
|
||||
function directionOf(locale: string): Language["dir"] {
|
||||
const script = new Intl.Locale(locale).maximize().script;
|
||||
return script !== undefined && RTL_SCRIPTS.has(script) ? "rtl" : "ltr";
|
||||
}
|
||||
19
packages/chord/src/surfaces/locale-choice.ts
Normal file
19
packages/chord/src/surfaces/locale-choice.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// The user's locale choice (decision "Messages and languages in the app kit" (3)): one locale per principal, a
|
||||
// preference the server keeps (the kit serves it) and every page and terminal of that principal holds, so choosing a
|
||||
// language re-renders them all at once, without a reload. `null` until the user chooses: the device's languages decide.
|
||||
|
||||
import { nul, str, union } from "../contracts/index.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
import type { Facet } from "../host/index.ts";
|
||||
import { preferenceContract, preferenceFacet } from "./preference.ts";
|
||||
|
||||
/** A language tag (`de`, `pt-BR`; at most 35 characters, BCP 47's practical bound), or `null`: the device's. */
|
||||
const Pick = union([str({ max: 35, pattern: "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$" }), nul()]);
|
||||
|
||||
/** The caller's locale choice, on the server: its pick, and `choose` to change it. */
|
||||
export const LocaleChoice = preferenceContract("chord.locale.choice", Pick);
|
||||
|
||||
/** The kit's server facet for locale choices: the store of picks and each principal's `LocaleChoice`. */
|
||||
export function localeChoiceFacet<P extends Principal>(): Facet<P> {
|
||||
return preferenceFacet<P>(LocaleChoice, "chord.locale.picks", Pick, null);
|
||||
}
|
||||
178
packages/chord/src/surfaces/messages.ts
Normal file
178
packages/chord/src/surfaces/messages.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
// Messages in code (decision "Messages and languages in the app kit" (1), (4), refined in the build): a plugin defines
|
||||
// its texts once, per namespace, in a shared `messages.ts` every facet that renders imports: `defineMessages("notes", {
|
||||
// en: {...}, de: {...} })` returns a typed `t`. The first catalog defines the keys and their argument types; every
|
||||
// other catalog must have the same keys with the same signatures (a missing key, an unknown one or a wrong argument is
|
||||
// a type error); values are strings or functions (plurals, word order). `t` renders in a language: bound to a host's
|
||||
// (`useMessages`, `scope.messages`, `t.in`), or plain, in the render under way's, else in the first catalog.
|
||||
// `t.message` builds a message as data (`{ namespace, key, params }`) for state, notices and errors, which each client
|
||||
// renders with `t.render` in its own language, checked at runtime (it crossed a link).
|
||||
|
||||
import { arr, bool, nul, num, obj, str, union } from "../contracts/index.ts";
|
||||
import { checkName } from "../contracts/names.ts";
|
||||
import { currentLanguage, inLanguage, type Language } from "./language.ts";
|
||||
import { canonicalLocale, type Translation } from "./translations.ts";
|
||||
|
||||
/** A message's value: a text, or a function of its arguments returning one. */
|
||||
export type MessageValue = string | ((...args: never[]) => string);
|
||||
|
||||
/** A catalog: the messages of one namespace in one language, by key. */
|
||||
export type Catalog = { readonly [key: string]: MessageValue };
|
||||
|
||||
/** A catalog's shape as other catalogs must match it: its keys, strings as `string`, functions with their signatures. */
|
||||
export type CatalogShape<M> = { readonly [K in keyof M]: M[K] extends string ? string : M[K] };
|
||||
|
||||
/** The arguments of a message: its function's parameters, none for a text. */
|
||||
export type MessageArgs<V> = V extends (...args: infer A) => string ? A : [];
|
||||
|
||||
/** A parameter of a message as data: plain JSON (a date as an ISO string or a number), so it crosses links. */
|
||||
export type MessageParam = string | number | boolean | null;
|
||||
|
||||
/**
|
||||
* A message as data: its namespace, its key and its parameters; typed by `t.message` (`MessageData<"notes", "saved">`),
|
||||
* any namespace and key as `MessageSchema` carries it across a link. (`Message` is the link protocol's.)
|
||||
*/
|
||||
export type MessageData<N extends string = string, K extends string = string> = {
|
||||
readonly namespace: N;
|
||||
readonly key: K;
|
||||
// Not a readonly array: a message goes into documents, whose drafts are mutable (immutable by convention).
|
||||
readonly params: MessageParam[];
|
||||
};
|
||||
|
||||
/** The schema of a message as data, for contract members (`notice: union([MessageSchema, nul()])`). */
|
||||
export const MessageSchema = obj({
|
||||
namespace: str(),
|
||||
key: str(),
|
||||
params: arr(union([str(), num(), bool(), nul()])),
|
||||
});
|
||||
|
||||
/** The keys of `M` whose arguments are all `MessageParam`s: those `t.message` can build. */
|
||||
export type DataKeys<M> = {
|
||||
[K in keyof M & string]: MessageArgs<M[K]> extends readonly MessageParam[] ? K : never;
|
||||
}[keyof M & string];
|
||||
|
||||
/** A message of namespace `N`, or one of any namespace (`string`, from a schema): another literal namespace is refused. */
|
||||
type OfNamespace<G extends MessageData, N extends string> = string extends G["namespace"]
|
||||
? unknown
|
||||
: { readonly namespace: N };
|
||||
|
||||
/** Renders the messages of namespace `N` with catalog shape `M` in one language. */
|
||||
export type Translator<N extends string, M> = {
|
||||
/** The message `key` with its arguments. */
|
||||
<K extends keyof M & string>(key: K, ...args: MessageArgs<M[K]>): string;
|
||||
/** A message as data; a foreign namespace or an unknown key shows the key (and is reported). */
|
||||
render<G extends MessageData>(message: G & OfNamespace<G, N>): string;
|
||||
};
|
||||
|
||||
/** A plugin's messages: `t` itself (in the current language), its namespace, its catalogs as contributions, `message`. */
|
||||
export type Messages<N extends string, M> = Translator<N, M> & {
|
||||
readonly namespace: N;
|
||||
/** Its catalogs as `Translations` entries: a facet that renders contributes them. */
|
||||
readonly translations: readonly Translation[];
|
||||
/** The message `key` with its arguments as data, for state that clients render. */
|
||||
message<K extends DataKeys<M>>(key: K, ...args: MessageArgs<M[K]>): MessageData<N, K>;
|
||||
/** `t` in `language` (`undefined`: the first catalog). */
|
||||
in(language: Language | undefined): Translator<N, M>;
|
||||
};
|
||||
|
||||
/** The catalog shape of a plugin's messages: what a translation fills (`const fr: MessagesOf<typeof t> = {...}`). */
|
||||
export type MessagesOf<T> = T extends Messages<string, infer M> ? M : never;
|
||||
|
||||
/**
|
||||
* The messages of `namespace` (a name like a type's, `[a-z0-9@/._~-]`) in `catalogs` by locale; the first catalog
|
||||
* defines the keys and signatures, the others match it.
|
||||
*/
|
||||
export function defineMessages<const N extends string, M extends Catalog>(
|
||||
namespace: N,
|
||||
catalogs: { readonly [locale: string]: CatalogShape<M> },
|
||||
): Messages<N, CatalogShape<M>> {
|
||||
// Declaration policy: a namespace is a name, a catalog's key a language tag (JavaScript callers reach both).
|
||||
checkName(namespace, "A messages namespace");
|
||||
const own = new Map<string, Catalog>();
|
||||
for (const [locale, catalog] of Object.entries(catalogs)) {
|
||||
const canonical = canonicalLocale(locale);
|
||||
if (canonical === undefined) throw new Error(`${namespace}: "${locale}" is no language tag`);
|
||||
own.set(canonical, catalog);
|
||||
}
|
||||
const first = own.values().next().value;
|
||||
if (first === undefined) throw new Error(`${namespace}: defineMessages needs a catalog`);
|
||||
const source: Source = { namespace, own, first };
|
||||
const translations = [...own].map(([locale, messages]) => ({ namespace, locale, messages }));
|
||||
// The overloads `Messages` types are these functions', keyed by the catalog's shape.
|
||||
return Object.assign(translator<N, CatalogShape<M>>(source, currentLanguage), {
|
||||
namespace,
|
||||
translations,
|
||||
message: (key: string, ...params: MessageParam[]) => ({ namespace, key, params }),
|
||||
in: (language: Language | undefined) => translator<N, CatalogShape<M>>(source, () => language),
|
||||
}) as Messages<N, CatalogShape<M>>;
|
||||
}
|
||||
|
||||
/** A namespace's own catalogs, by canonical locale, and its first. */
|
||||
type Source = { readonly namespace: string; readonly own: ReadonlyMap<string, Catalog>; readonly first: Catalog };
|
||||
|
||||
/** A translator of `source`'s messages in the language `language` reads at each call. */
|
||||
function translator<N extends string, M>(source: Source, language: () => Language | undefined): Translator<N, M> {
|
||||
const t = (key: string, ...args: unknown[]): string => renderKey(source, language(), key, args);
|
||||
const render = (message: MessageData): string => renderData(source, language(), message);
|
||||
// The overloads `Translator` types are this one function's, keyed by the catalog's shape.
|
||||
return Object.assign(t, { render }) as unknown as Translator<N, M>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `key` of `source` in `language`: down its chain, at each locale the host's contributed catalog, then the plugin's own;
|
||||
* else the first catalog.
|
||||
*/
|
||||
function renderKey(source: Source, language: Language | undefined, key: string, args: readonly unknown[]): string {
|
||||
if (language === undefined) return call(source.first[key]!, args);
|
||||
return inLanguage(language, () => {
|
||||
for (const [index, locale] of language.chain.entries()) {
|
||||
const contributed = language.translations.catalogs.get(source.namespace)?.get(locale);
|
||||
if (contributed !== undefined && Object.hasOwn(contributed.value.messages, key)) {
|
||||
try {
|
||||
return found(language, source, key, index, call(contributed.value.messages[key]!, args));
|
||||
} catch (error) {
|
||||
// A contributed catalog is a plugin's code: reported, and the chain goes on (design §17).
|
||||
const { plugin } = contributed;
|
||||
language.report({ kind: "failed", namespace: source.namespace, locale, key, plugin, error });
|
||||
}
|
||||
}
|
||||
const own = source.own.get(locale);
|
||||
if (own !== undefined && own !== contributed?.value.messages && Object.hasOwn(own, key)) {
|
||||
return found(language, source, key, index, call(own[key]!, args));
|
||||
}
|
||||
}
|
||||
return found(language, source, key, language.chain.length, call(source.first[key]!, args));
|
||||
});
|
||||
}
|
||||
|
||||
/** `text`, found at `index` in `language`'s chain (its length: the first catalog), a fallback reported past the first. */
|
||||
function found(language: Language, source: Source, key: string, index: number, text: string): string {
|
||||
if (index > 0) {
|
||||
const used = language.chain[index];
|
||||
language.report({ kind: "fallback", namespace: source.namespace, locale: language.locale, key, used });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** A message's text: the text, or the function called with `args` (typed by the catalog's signature). */
|
||||
function call(value: MessageValue, args: readonly unknown[]): string {
|
||||
return typeof value === "string" ? value : (value as (...params: readonly unknown[]) => string)(...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* A message as data in `language`. Checked first (wire input: the message crossed a link as state): another namespace,
|
||||
* a key the first catalog lacks or params that are no list show the key, reported; so does a message that throws.
|
||||
*/
|
||||
function renderData(source: Source, language: Language | undefined, message: MessageData): string {
|
||||
const { namespace, key, params } = message;
|
||||
const known = namespace === source.namespace && Object.hasOwn(source.first, key) && Array.isArray(params);
|
||||
if (!known) {
|
||||
language?.report({ kind: "unknown", namespace, key, expected: source.namespace });
|
||||
return key;
|
||||
}
|
||||
try {
|
||||
return renderKey(source, language, key, params);
|
||||
} catch (error) {
|
||||
language?.report({ kind: "failed", namespace, locale: language.locale, key, plugin: undefined, error });
|
||||
return key;
|
||||
}
|
||||
}
|
||||
84
packages/chord/src/surfaces/preference.ts
Normal file
84
packages/chord/src/surfaces/preference.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// A user's preference kept by the server (slice 11 §4b; decision "Messages and languages in the app kit" (3)): one pick
|
||||
// per principal, kept by the kit's server and held by every page and terminal of that principal, so choosing restyles
|
||||
// or re-renders them all at once. The theme choice and the locale choice are two of them. Bounded (fix round 1: Opus
|
||||
// M4, Fable L7): the store keeps the picks of the `MAX_PICKS` principals who chose last (a public app's guests choose
|
||||
// too).
|
||||
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import { hostKind, local, map, method, obj, remote, type Schema, str } from "../contracts/index.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
import { principalKey } from "../data/principal.ts";
|
||||
import { defineState } from "../documents/index.ts";
|
||||
import { defineFacet, type Facet } from "../host/index.ts";
|
||||
import type { JsonValue } from "../json.ts";
|
||||
|
||||
/** A preference's contract as its facet serves it: the caller's `pick` (JSON) and `choose`, remote on the server. */
|
||||
export type PreferenceContract = ReturnType<typeof preferenceContract<string, JsonValue>>;
|
||||
|
||||
/** A preference's remote contract `name`: the caller's pick on the server (`pick`), and `choose` to change it. */
|
||||
export function preferenceContract<const N extends string, T>(name: N, pick: Schema<T>) {
|
||||
return remote(name, {
|
||||
version: 1,
|
||||
params: {},
|
||||
hostAddress: () => hostKind("server")(),
|
||||
variants: { main: { state: { pick }, methods: { choose: method(pick) } } },
|
||||
});
|
||||
}
|
||||
|
||||
/** How many principals' picks a store keeps: past it the earliest chooser's goes (they get the preference's none). */
|
||||
const MAX_PICKS = 10_000;
|
||||
|
||||
/**
|
||||
* The kit's server facet of the preference `Choice`: every principal's pick in one persisted document of the store
|
||||
* `store` by principal key, and each principal's `Choice` (`none` before a choice).
|
||||
*/
|
||||
export function preferenceFacet<P extends Principal>(
|
||||
Choice: PreferenceContract,
|
||||
store: string,
|
||||
pick: Schema<JsonValue>,
|
||||
none: JsonValue,
|
||||
): Facet<P> {
|
||||
const Picks = defineState(store, {
|
||||
schema: obj({ picks: map(pick) }),
|
||||
version: 1,
|
||||
initial: () => ({ picks: {} }),
|
||||
persist: true,
|
||||
checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 50,
|
||||
});
|
||||
// A persisted state needs a local type with empty params.
|
||||
const PickStore = local(store, {
|
||||
version: 1,
|
||||
params: {},
|
||||
members: { picks: map(pick), set: method(obj({ key: str(), pick })) },
|
||||
});
|
||||
return defineFacet<P>((host) => {
|
||||
host.serve(PickStore, (_params, self) => {
|
||||
const picks = self.state(Picks);
|
||||
return {
|
||||
picks: self.derive([picks], ([value]) => value.picks),
|
||||
set: async ({ key, pick: next }, ctx) => {
|
||||
await picks.change((draft) => {
|
||||
// Re-added last: the keys keep the order of choosing, the first the earliest.
|
||||
delete draft.picks[key];
|
||||
draft.picks[key] = next;
|
||||
const keys = Object.keys(draft.picks);
|
||||
for (const old of keys.slice(0, Math.max(0, keys.length - MAX_PICKS))) delete draft.picks[old];
|
||||
}, ctx);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
host.serve(Choice, (_params, principal, self) => {
|
||||
const picks = self.get(PickStore, {});
|
||||
const key = principalKey(principal, BACKGROUND_CONTEXT);
|
||||
return {
|
||||
variant: "main",
|
||||
members: {
|
||||
// A key that is a principal's canonical JSON: `Object.hasOwn`, no inherited member is a pick.
|
||||
pick: self.derive([picks.picks], ([all]) => (Object.hasOwn(all, key) ? all[key]! : none)),
|
||||
choose: (next, ctx) => picks.set({ key, pick: next }, ctx),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
// The user's theme choice (slice 11 §4b): one dark and one light theme id per principal, kept by the server (the kit
|
||||
// serves it) and held by every page and terminal of that principal, so choosing a theme restyles them all at once.
|
||||
// Bounded (fix round 1: Opus M4, Fable L7): an id is at most 128 characters, and the store keeps the picks of the
|
||||
// `MAX_PICKS` principals who chose last (a public app's guests choose too).
|
||||
// The user's theme choice (slice 11 §4b): one dark and one light theme id per principal, a preference the server keeps
|
||||
// (the kit serves it) and every page and terminal of that principal holds, so choosing a theme restyles them all at
|
||||
// once. An id is at most 128 characters (fix round 1: Opus M4, Fable L7).
|
||||
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import { hostKind, local, map, method, nul, obj, remote, str, union } from "../contracts/index.ts";
|
||||
import { nul, obj, str, union } from "../contracts/index.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
import { principalKey } from "../data/principal.ts";
|
||||
import { defineState } from "../documents/index.ts";
|
||||
import { defineFacet, type Facet } from "../host/index.ts";
|
||||
import type { Facet } from "../host/index.ts";
|
||||
import { preferenceContract, preferenceFacet } from "./preference.ts";
|
||||
import type { ThemePick } from "./themes.ts";
|
||||
|
||||
/** A theme id (at most 128 characters), or `null` for the first contributed theme of its mode. */
|
||||
|
|
@ -17,66 +14,13 @@ const ThemeId = union([str({ max: 128 }), nul()]);
|
|||
/** A pick: a theme id per mode. */
|
||||
const Pick = obj({ dark: ThemeId, light: ThemeId });
|
||||
|
||||
/** How many principals' picks the store keeps: past it the earliest chooser's goes (they see the first theme). */
|
||||
const MAX_PICKS = 10_000;
|
||||
|
||||
/** The caller's theme choice, on the server: its pick, and `choose` to change it. */
|
||||
export const ThemeChoice = remote("chord.themes.choice", {
|
||||
version: 1,
|
||||
params: {},
|
||||
hostAddress: () => hostKind("server")(),
|
||||
variants: { main: { state: { pick: Pick }, methods: { choose: method(Pick) } } },
|
||||
});
|
||||
|
||||
/** Every principal's pick, by principal key: one document of the server's store. */
|
||||
const Picks = defineState("chord.themes.picks", {
|
||||
schema: obj({ picks: map(Pick) }),
|
||||
version: 1,
|
||||
initial: () => ({ picks: {} }),
|
||||
persist: true,
|
||||
checkpointWhen: (_value, _ops, { deltasSinceBase }) => deltasSinceBase >= 50,
|
||||
});
|
||||
|
||||
/** The server's keeper of every pick (a persisted state needs a local type with empty params). */
|
||||
const PickStore = local("chord.themes.picks", {
|
||||
version: 1,
|
||||
params: {},
|
||||
members: { picks: map(Pick), set: method(obj({ key: str(), pick: Pick })) },
|
||||
});
|
||||
export const ThemeChoice = preferenceContract("chord.themes.choice", Pick);
|
||||
|
||||
/** No choice yet: the first contributed theme of each mode. */
|
||||
const NO_PICK: ThemePick = { dark: null, light: null };
|
||||
|
||||
/** The kit's server facet for theme choices: the store of picks and each principal's `ThemeChoice`. */
|
||||
export function themeChoiceFacet<P extends Principal>(): Facet<P> {
|
||||
return defineFacet<P>((host) => {
|
||||
host.serve(PickStore, (_params, self) => {
|
||||
const picks = self.state(Picks);
|
||||
return {
|
||||
picks: self.derive([picks], ([value]) => value.picks),
|
||||
set: async ({ key, pick }, ctx) => {
|
||||
await picks.change((draft) => {
|
||||
// Re-added last: the keys keep the order of choosing, the first the earliest.
|
||||
delete draft.picks[key];
|
||||
draft.picks[key] = pick;
|
||||
const keys = Object.keys(draft.picks);
|
||||
for (const old of keys.slice(0, Math.max(0, keys.length - MAX_PICKS))) delete draft.picks[old];
|
||||
}, ctx);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
host.serve(ThemeChoice, (_params, principal, self) => {
|
||||
const store = self.get(PickStore, {});
|
||||
const key = principalKey(principal, BACKGROUND_CONTEXT);
|
||||
return {
|
||||
variant: "main",
|
||||
members: {
|
||||
// A key that is a principal's canonical JSON: `Object.hasOwn`, no inherited member is a pick.
|
||||
pick: self.derive([store.picks], ([picks]) => (Object.hasOwn(picks, key) ? picks[key]! : NO_PICK)),
|
||||
choose: (pick, ctx) => store.set({ key, pick }, ctx),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
return preferenceFacet<P>(ThemeChoice, "chord.themes.picks", Pick, NO_PICK);
|
||||
}
|
||||
|
|
|
|||
90
packages/chord/src/surfaces/translations.ts
Normal file
90
packages/chord/src/surfaces/translations.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Languages as contributions (decision "Messages and languages in the app kit" (2), refined in the build): the
|
||||
// `Translations` registry, served on the server, pages and terminals by the kit. A contribution is a list of catalogs,
|
||||
// `{ namespace, locale, messages, override? }`: a plugin contributes its own catalogs (`t.translations`) from each
|
||||
// facet that renders, a translation plugin its catalogs for other plugins. The composed index has one catalog per
|
||||
// namespace and locale: the first by plugin name wins and the other is reported, except that `override: true` wins over
|
||||
// a catalog without it. Registries are per host, so catalogs (they hold functions) never cross links.
|
||||
|
||||
import type { Principal } from "../data/index.ts";
|
||||
import { defineFacet, type Facet } from "../host/index.ts";
|
||||
import {
|
||||
type ComposeContext,
|
||||
defineRegistry,
|
||||
type Registry,
|
||||
type RegistryTable,
|
||||
type TableItem,
|
||||
} from "../registries/index.ts";
|
||||
import type { Catalog } from "./messages.ts";
|
||||
|
||||
/** One catalog: the messages of `namespace` in `locale`; `override: true` replaces the plugin's own for that locale. */
|
||||
export type Translation = {
|
||||
readonly namespace: string;
|
||||
readonly locale: string;
|
||||
/** Partial allowed: a missing key falls back (the locale's base, then the namespace's first catalog). */
|
||||
readonly messages: Catalog;
|
||||
readonly override?: true;
|
||||
};
|
||||
|
||||
/** The composed catalogs: the one used per namespace and canonical locale, with its plugin; every locale offered. */
|
||||
export type TranslationIndex = {
|
||||
readonly catalogs: ReadonlyMap<string, ReadonlyMap<string, TableItem<Translation>>>;
|
||||
/** Every locale some catalog offers, canonical (`pt-BR`), sorted. */
|
||||
readonly locales: readonly string[];
|
||||
};
|
||||
|
||||
/** The catalogs plugins contribute, a list per contributor; served on the server, pages and terminals by the kit. */
|
||||
export const Translations: Registry<readonly Translation[], TranslationIndex> = defineRegistry<
|
||||
readonly Translation[],
|
||||
TranslationIndex
|
||||
>("chord.translations", { version: 1 });
|
||||
|
||||
/** No catalogs: what a host's language has before its `Translations` value arrives. */
|
||||
export const NO_TRANSLATIONS: TranslationIndex = { catalogs: new Map(), locales: [] };
|
||||
|
||||
/** The kit's facet serving `Translations` on a host. */
|
||||
export function translationsFacet<P extends Principal>(): Facet<P> {
|
||||
return defineFacet<P>((host) => host.serve(Translations, { compose: composeTranslations }));
|
||||
}
|
||||
|
||||
/** The index of `table`: per namespace and locale the winning catalog; conflicts and invalid locales reported. */
|
||||
export function composeTranslations(
|
||||
table: RegistryTable<readonly Translation[]>,
|
||||
{ report }: ComposeContext,
|
||||
): TranslationIndex {
|
||||
const catalogs = new Map<string, Map<string, TableItem<Translation>>>();
|
||||
const locales = new Set<string>();
|
||||
for (const { value, plugin } of table) {
|
||||
for (const translation of value) {
|
||||
const locale = canonicalLocale(translation.locale);
|
||||
if (locale === undefined) {
|
||||
report({ message: `"${translation.locale}" is no locale (${translation.namespace})`, plugins: [plugin] });
|
||||
continue;
|
||||
}
|
||||
locales.add(locale);
|
||||
let byLocale = catalogs.get(translation.namespace);
|
||||
if (byLocale === undefined) {
|
||||
byLocale = new Map();
|
||||
catalogs.set(translation.namespace, byLocale);
|
||||
}
|
||||
const taken = byLocale.get(locale);
|
||||
if (taken === undefined || (translation.override === true && taken.value.override !== true)) {
|
||||
byLocale.set(locale, { value: translation, plugin });
|
||||
} else if ((translation.override === true) === (taken.value.override === true)) {
|
||||
report({
|
||||
message: `${translation.namespace} in ${locale} is contributed twice`,
|
||||
plugins: [taken.plugin, plugin],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { catalogs, locales: [...locales].sort() };
|
||||
}
|
||||
|
||||
/** `locale` canonical (`pt-br` is `pt-BR`), or `undefined` when it is no language tag. */
|
||||
export function canonicalLocale(locale: string): string | undefined {
|
||||
try {
|
||||
return Intl.getCanonicalLocales(locale)[0];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ startTerminalRuntime<P>({
|
|||
const scope = new TuiScope(host, () => redraw()); // hold, watch, registry; dispose
|
||||
terminal.onInput((data) => …); terminal.write(frame);
|
||||
theme.fg("accent", "Notes"); // colours by role
|
||||
const t = scope.messages(messages); // a plugin's texts in the host's language
|
||||
},
|
||||
});
|
||||
```
|
||||
|
|
@ -22,10 +23,13 @@ startTerminalRuntime<P>({
|
|||
bridge on `globalThis.chordBridge`; the runtime takes it off before any plugin runs.
|
||||
- `channel.ts`: the sandbox's side: requests, `RelayLink` (a Chord link whose bytes the launcher relays), input and
|
||||
resizes. `terminal.ts`: the bridged terminal (size, write, input, resize). `theme.ts`: the theme lookup (`fg`, `bg`,
|
||||
`onChange`). `runtime.ts`: the client (`startClient` with `TuiScripts`; scripts run with `vm`; no wakes).
|
||||
`onChange`). `runtime.ts`: the client (`startClient` with `TuiScripts`; scripts run with `vm`; no wakes), the kit's
|
||||
themes and translations, the language followed from the user's pick and the launcher's `locales` (the bridge's
|
||||
start).
|
||||
- `scope.ts`: `TuiScope(host, changed)`: `hold`, `watch` (a reader, `undefined` while no value), `registry`,
|
||||
`pageState(key, initial)` (the host's value at `key`, shared with React's `usePageState`: a scope opened again after a
|
||||
plugin reload finds it), `dispose` (no `changed` after it). References wait and rebind by themselves; no terminal
|
||||
plugin reload finds it), `messages(t)` (a plugin's messages in the host's language at each call; a change of the
|
||||
locale or a translation calls `changed`), `dispose` (no `changed` after it). References wait and rebind by themselves; no terminal
|
||||
library dependency.
|
||||
|
||||
Tests: `test/tui/` and, with the launcher and sandbox, `test/node/tui/`.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export type BridgeStart = {
|
|||
readonly colors: "truecolor" | "256";
|
||||
/** The terminal's background: which of the user's two themes applies. */
|
||||
readonly mode: "dark" | "light";
|
||||
/** The user's languages, from the system's locale (`de-DE`), most preferred first. */
|
||||
readonly locales: readonly string[];
|
||||
};
|
||||
|
||||
/** A request of the sandbox the launcher answers. */
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
// `startTerminalRuntime` (slice 11 §4, decisions "Slice 11, P8" and "P9"): the terminal client inside the sandbox, in
|
||||
// the app's terminal entry (the base bundle the launcher downloaded). A client host like a page's: the plugin scripts
|
||||
// the launcher brought, loaded with the app's core facets and the kit's themes; the link to the server relayed by the
|
||||
// launcher, kept by the same connection loop; `TuiScripts` followed to apply new plugin scripts in place; a new base
|
||||
// bundle hands over to the launcher, which starts the new one. The app gets the host, the bridged terminal, the theme
|
||||
// lookup and the launcher's actions.
|
||||
// the launcher brought, loaded with the app's core facets and the kit's themes and translations; the language followed
|
||||
// from the user's pick and the system's locales the launcher read (scopes' messages redraw on a change); the link to
|
||||
// the server relayed by the launcher, kept by the same connection loop; `TuiScripts` followed to apply new plugin
|
||||
// scripts in place; a new base bundle hands over to the launcher, which starts the new one. The app gets the host, the
|
||||
// bridged terminal, the theme lookup and the launcher's actions.
|
||||
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
|
|
@ -15,12 +16,14 @@ import {
|
|||
type ConnectionState,
|
||||
FALLBACK_THEMES,
|
||||
type FacetFunction,
|
||||
followActiveLanguage,
|
||||
followActiveTheme,
|
||||
REGISTER,
|
||||
type RegisteredScript,
|
||||
startClient,
|
||||
TuiScripts,
|
||||
themesFacet,
|
||||
translationsFacet,
|
||||
} from "../surfaces/index.ts";
|
||||
import { BRIDGE, type Bridge } from "./bridge.ts";
|
||||
import { SandboxChannel } from "./channel.ts";
|
||||
|
|
@ -37,7 +40,10 @@ export type TerminalRuntimeOptions<P extends Principal> = {
|
|||
readonly onConnection?: (state: ConnectionState) => void;
|
||||
/** Draws the app, once its plugins are loaded. */
|
||||
readonly render: (runtime: TerminalRuntime<P>) => void;
|
||||
/** Plugins left out or kept old, a failed update, the host's errors; dropped by default (stderr is the launcher's). */
|
||||
/**
|
||||
* Plugins left out or kept old, a failed update, the host's errors, messages' reports; dropped by default (stderr
|
||||
* is the launcher's).
|
||||
*/
|
||||
readonly onError?: (error: ClientError) => void;
|
||||
};
|
||||
|
||||
|
|
@ -69,7 +75,12 @@ export async function startTerminalRuntime<P extends Principal>(
|
|||
const channel = new SandboxChannel(takeBridge());
|
||||
const { start } = channel;
|
||||
const scripts = new SandboxScripts(channel);
|
||||
const core: PluginFacet<P>[] = [{ name: "chord.themes", facet: themesFacet<P>() }, ...(options.core ?? [])];
|
||||
const core: PluginFacet<P>[] = [
|
||||
{ name: "chord.themes", facet: themesFacet<P>() },
|
||||
{ name: "chord.translations", facet: translationsFacet<P>() },
|
||||
...(options.core ?? []),
|
||||
];
|
||||
const onError = options.onError ?? (() => {});
|
||||
const client = await startClient<P>({
|
||||
scripts: TuiScripts,
|
||||
base: start.base,
|
||||
|
|
@ -94,10 +105,17 @@ export async function startTerminalRuntime<P extends Principal>(
|
|||
...(options.canRetry === undefined ? {} : { canRetry: options.canRetry }),
|
||||
...(options.onConnection === undefined ? {} : { onConnection: options.onConnection }),
|
||||
environment: TERMINAL_ENVIRONMENT,
|
||||
onError: options.onError ?? (() => {}),
|
||||
onError,
|
||||
});
|
||||
const theme = new TerminalTheme(FALLBACK_THEMES[start.mode], start.colors);
|
||||
await followActiveTheme(client.host, start.mode, (active) => theme.set(active), BACKGROUND_CONTEXT);
|
||||
await followActiveLanguage(
|
||||
client.host,
|
||||
start.locales,
|
||||
() => {},
|
||||
(report) => onError({ kind: "messages", report }),
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
const runtime: TerminalRuntime<P> = {
|
||||
host: client.host,
|
||||
terminal: new BridgeTerminal(channel),
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// counterpart of the React bindings for terminal UI libraries. It holds references, follows watchables and registries,
|
||||
// calls one `changed` callback (the app's redraw) on every change, and releases everything at `dispose`. Its page state
|
||||
// is the host's, shared by every scope (React's `usePageState`): a scope opened again after a plugin reload finds it.
|
||||
// Its messages render in the host's language, and a change of the locale or a translation redraws.
|
||||
// References wait and rebind by themselves (no retry loop, unlike p2's); no terminal library dependency.
|
||||
|
||||
import { BACKGROUND_CONTEXT, withAbortSignal } from "../context/index.ts";
|
||||
|
|
@ -9,6 +10,7 @@ import type { Contract, ParamsOf } from "../contracts/index.ts";
|
|||
import type { Principal } from "../data/index.ts";
|
||||
import type { Held, Host } from "../host/index.ts";
|
||||
import type { Registry } from "../registries/index.ts";
|
||||
import { hostLanguage, type Language, type MessageData, type Messages, type Translator } from "../surfaces/index.ts";
|
||||
import { type PageState, pageState } from "../surfaces/page-state.ts";
|
||||
import type { NativeWatchable, Watchable } from "../watch.ts";
|
||||
|
||||
|
|
@ -81,6 +83,28 @@ export class TuiScope<P extends Principal = Principal> {
|
|||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* `messages` in the host's language as it is at each call (the first catalog before the host follows one); a change
|
||||
* of the locale or a translation calls `changed` until `dispose`.
|
||||
*/
|
||||
messages<N extends string, M>(messages: Messages<N, M>): Translator<N, M> {
|
||||
const language = hostLanguage(this.#host);
|
||||
this.#releases.push(language.subscribe(() => this.#changed()));
|
||||
let bound: { readonly language: Language | undefined; readonly t: PlainTranslator } | undefined;
|
||||
const current = (): PlainTranslator => {
|
||||
const now = language.read();
|
||||
// A translator's overloads are one function's, keyed by the catalog's shape: called here as that function.
|
||||
if (bound === undefined || bound.language !== now) {
|
||||
bound = { language: now, t: messages.in(now) as unknown as PlainTranslator };
|
||||
}
|
||||
return bound.t;
|
||||
};
|
||||
const t: PlainTranslator = Object.assign((key: string, ...args: unknown[]) => current()(key, ...args), {
|
||||
render: (message: MessageData) => current().render(message),
|
||||
});
|
||||
return t as unknown as Translator<N, M>;
|
||||
}
|
||||
|
||||
/** Releases every reference and ends every watch; `changed` is not called after it. */
|
||||
dispose(): void {
|
||||
this.#abort.abort();
|
||||
|
|
@ -92,3 +116,6 @@ export class TuiScope<P extends Principal = Principal> {
|
|||
return withAbortSignal(this.#abort.signal, BACKGROUND_CONTEXT);
|
||||
}
|
||||
}
|
||||
|
||||
/** A translator as the function it is: any key, any arguments. */
|
||||
type PlainTranslator = ((key: string, ...args: unknown[]) => string) & { render(message: MessageData): string };
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ startPage<P>({
|
|||
each, and resolve the fetch that asked for them; no `eval` (the page's CSP: `'self'` and the nonce).
|
||||
- The kit's `Themes` provider is among the core facets; the active theme (the registry, the user's `ThemeChoice`,
|
||||
`prefers-color-scheme`) is written as CSS custom properties and `color-scheme` on `:root` (`themes.ts`), no reload.
|
||||
- The kit's `Translations` provider is among them too; the active language (the registry, the user's `LocaleChoice`,
|
||||
`navigator.languages`, followed on `languagechange`) is the host's language for `useMessages`, and `lang` and `dir`
|
||||
on the root element (`language.ts`, `followLanguage`); reports reach `onError` as `{ kind: "messages", report }`.
|
||||
- A new set's plugin stylesheets replace the old ones (`syncStylesheets`); an outdated page reloads.
|
||||
- Wakes (`environment.ts`): visible again, `online`, `pageshow` from the back/forward cache, Chromium's network change.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Chord v2's web surface, the browser side (`@earendil-works/chord/web`; slice 11 §2): `startPage` in the app's web
|
||||
// entry, and the themes it writes. The server side is `/surfaces`.
|
||||
// entry, and the themes and the language it writes. The server side is `/surfaces`.
|
||||
|
||||
export { pageEnvironment } from "./environment.ts";
|
||||
export { followLanguage } from "./language.ts";
|
||||
export { type PageOptions, startPage } from "./page.ts";
|
||||
export { followThemes } from "./themes.ts";
|
||||
|
|
|
|||
35
packages/chord/src/web/language.ts
Normal file
35
packages/chord/src/web/language.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/// <reference lib="dom" />
|
||||
// The page's language (decision "Messages and languages in the app kit" (3)): the active language followed from the
|
||||
// browser's `navigator.languages`, the user's `LocaleChoice` and the catalogs, written as `lang` and `dir` on the root
|
||||
// element (right to left through the app's logical CSS properties). Components re-render through `useMessages`; nothing
|
||||
// reloads.
|
||||
|
||||
import type { Context } from "../context/index.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
import type { Host } from "../host/index.ts";
|
||||
import { followActiveLanguage, type MessagesReport } from "../surfaces/index.ts";
|
||||
|
||||
/** Follows the language on `host` and writes it on the root element until the returned stop; `report` gets reports. */
|
||||
export async function followLanguage<P extends Principal>(
|
||||
host: Host<P>,
|
||||
report: (report: MessagesReport) => void,
|
||||
context: Context,
|
||||
): Promise<() => void> {
|
||||
const root = document.documentElement;
|
||||
const follower = await followActiveLanguage(
|
||||
host,
|
||||
navigator.languages,
|
||||
(language) => {
|
||||
root.lang = language.locale;
|
||||
root.dir = language.dir;
|
||||
},
|
||||
report,
|
||||
context,
|
||||
);
|
||||
const devicechanged = (): void => follower.setDevice(navigator.languages);
|
||||
addEventListener("languagechange", devicechanged);
|
||||
return () => {
|
||||
follower.stop();
|
||||
removeEventListener("languagechange", devicechanged);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
/// <reference lib="dom" />
|
||||
// `startPage` (slice 11 §2, decisions "Slice 11, P4" to "P6"): a page's client host, in the app's web entry (the base
|
||||
// bundle). It takes the plugin scripts the page's combined file registered, loads them with the app's core facets and
|
||||
// the kit's themes, keeps its link to the server (the connection loop), follows `WebScripts` to apply new plugin
|
||||
// scripts in place, reloads the page when the server's base bundle changed, and hands the host to the app's render.
|
||||
// / <reference lib="dom" /> `startPage` (slice 11 §2, decisions "Slice 11, P4" to "P6"): a page's client host, in the
|
||||
// app's web entry (the base bundle). It takes the plugin scripts the page's combined file registered, loads them with
|
||||
// the app's core facets and the kit's themes and translations, follows the theme and the language, keeps its link to
|
||||
// the server (the connection loop), follows `WebScripts` to apply new plugin scripts in place, reloads the page when
|
||||
// the server's base bundle changed, and hands the host to the app's render.
|
||||
|
||||
import { BACKGROUND_CONTEXT } from "../context/index.ts";
|
||||
import type { Principal } from "../data/index.ts";
|
||||
|
|
@ -15,9 +15,11 @@ import {
|
|||
type ConnectionState,
|
||||
startClient,
|
||||
themesFacet,
|
||||
translationsFacet,
|
||||
WebScripts,
|
||||
} from "../surfaces/index.ts";
|
||||
import { pageEnvironment } from "./environment.ts";
|
||||
import { followLanguage } from "./language.ts";
|
||||
import { type PageBootstrap, PageScripts, syncStylesheets } from "./scripts.ts";
|
||||
import { followThemes } from "./themes.ts";
|
||||
|
||||
|
|
@ -34,7 +36,7 @@ export type PageOptions<P extends Principal> = {
|
|||
readonly onConnection?: (state: ConnectionState) => void;
|
||||
/** Renders the app with its host, once the plugins are loaded. */
|
||||
readonly render: (host: Client<P>["host"]) => void;
|
||||
/** Plugins left out or kept old, a failed update, the host's errors: the console by default. */
|
||||
/** Plugins left out or kept old, a failed update, the host's errors, messages' reports: the console by default. */
|
||||
readonly onError?: (error: ClientError) => void;
|
||||
};
|
||||
|
||||
|
|
@ -47,7 +49,12 @@ export async function startPage<P extends Principal>(options: PageOptions<P>): P
|
|||
const bootstrap = (globalThis as { readonly chordPage?: PageBootstrap }).chordPage;
|
||||
if (bootstrap === undefined) throw new Error("startPage: the page has no Chord bootstrap (PageAssets.bootstrap)");
|
||||
const scripts = new PageScripts(bootstrap);
|
||||
const core: PluginFacet<P>[] = [{ name: "chord.themes", facet: themesFacet<P>() }, ...(options.core ?? [])];
|
||||
const core: PluginFacet<P>[] = [
|
||||
{ name: "chord.themes", facet: themesFacet<P>() },
|
||||
{ name: "chord.translations", facet: translationsFacet<P>() },
|
||||
...(options.core ?? []),
|
||||
];
|
||||
const onError = options.onError ?? ((error: ClientError) => console.error("chord page:", error));
|
||||
const client = await startClient<P>({
|
||||
scripts: WebScripts,
|
||||
base: scripts.base,
|
||||
|
|
@ -67,9 +74,10 @@ export async function startPage<P extends Principal>(options: PageOptions<P>): P
|
|||
...(options.onConnection === undefined ? {} : { onConnection: options.onConnection }),
|
||||
applied: (set) => syncStylesheets(set, PREFIX),
|
||||
environment: pageEnvironment(),
|
||||
onError: options.onError ?? ((error) => console.error("chord page:", error)),
|
||||
onError,
|
||||
});
|
||||
await followThemes(client.host, BACKGROUND_CONTEXT);
|
||||
await followLanguage(client.host, (report) => onError({ kind: "messages", report }), BACKGROUND_CONTEXT);
|
||||
options.render(client.host);
|
||||
return client;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each
|
||||
// numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker,
|
||||
// routes, the page, the terminal, a theme plugin added while the app runs, a second user, an edit applied in place,
|
||||
// and a list exported and imported. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts`
|
||||
// shows the page in Chromium.
|
||||
// routes, the page, the terminal, a theme plugin added while the app runs, a second user, languages, an edit applied in
|
||||
// place, and a list exported and imported. The lists plugin's own test is `plugins/lists/worker.test.ts`;
|
||||
// `screenshots.ts` shows the page in Chromium.
|
||||
// Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running):
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts
|
||||
import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
|
|
@ -12,9 +12,17 @@ import { fileURLToPath } from "node:url";
|
|||
import { List } from "@todo/app/v1";
|
||||
import { WebSocket } from "ws";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts";
|
||||
import {
|
||||
type ChordError,
|
||||
createHost,
|
||||
LocaleChoice,
|
||||
WebScripts,
|
||||
type WebSocketLike,
|
||||
webSocketLink,
|
||||
} from "../../../src/index.ts";
|
||||
import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts";
|
||||
import { type Item, Todos } from "./plugins/lists/contract.ts";
|
||||
import { messages } from "./plugins/lists/messages.ts";
|
||||
import type { TodoPrincipal } from "./src/principals.ts";
|
||||
import { startTodo, todoDirectories } from "./start.ts";
|
||||
|
||||
|
|
@ -107,6 +115,8 @@ if (sandboxAvailable()) {
|
|||
input: new PassThrough(),
|
||||
output,
|
||||
colors: "truecolor",
|
||||
// The machine's languages (its system locale by default), here fixed for the book's output.
|
||||
locales: ["en-US"],
|
||||
});
|
||||
await until(() => screen(frames).includes("butter"));
|
||||
console.log("5. the terminal shows:");
|
||||
|
|
@ -158,11 +168,44 @@ if (adas.variant === "member") {
|
|||
await until(() => groceries.items.value.some(({ by }) => by === "ada"));
|
||||
console.log(" the server's reference sees:", show(groceries.items.value.slice(-1)));
|
||||
|
||||
// 8. An edit applied in place: the lists plugin's worker facet trims a todo's text, so a blank one is empty and the
|
||||
// 8. Languages. The owner chooses German: a preference the server keeps per principal, which every client of the owner
|
||||
// follows, so the terminal redraws in German without restarting. Ada's pick is her own. Clearing the done todos
|
||||
// leaves a notice as data, a key and its parameters: the server's plain render is the English catalog, the
|
||||
// terminal renders the same state in German.
|
||||
const ownerChooses = (locale: string) =>
|
||||
app.host.root(async (ctx) => (await ctx.get(LocaleChoice, {})).choose(locale, ctx), context, { principal: OWNER });
|
||||
await ownerChooses("de");
|
||||
console.log("8. the owner chooses German");
|
||||
if (terminal !== undefined) {
|
||||
await until(() => screen(frames).includes("Einkäufe"));
|
||||
console.log(" the terminal shows:");
|
||||
for (const line of screen(frames).trim().split("\r\n")) console.log(` ${line}`);
|
||||
}
|
||||
const adasChoice = await ada.get(LocaleChoice, {}, context);
|
||||
await adasChoice.choose("fr", context);
|
||||
console.log(" ada's pick:", adasChoice.pick.value);
|
||||
adasChoice.release();
|
||||
await app.host.root(
|
||||
async (ctx) => {
|
||||
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||
if (todos.variant === "owner") await todos.clear(ctx);
|
||||
},
|
||||
context,
|
||||
{ principal: OWNER },
|
||||
);
|
||||
await until(() => groceries.notice.value !== null);
|
||||
console.log(" the notice:", groceries.notice.value);
|
||||
console.log(" the server renders it:", messages.render(groceries.notice.value!));
|
||||
if (terminal !== undefined) {
|
||||
await until(() => screen(frames).includes("entfernt"));
|
||||
console.log(" the terminal renders it:", screen(frames).trim().split("\r\n").at(-1));
|
||||
}
|
||||
|
||||
// 9. An edit applied in place: the lists plugin's worker facet trims a todo's text, so a blank one is empty and the
|
||||
// store refuses it with Chord's `invalid/args`, which reaches Ada as it would a refused argument. The
|
||||
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
|
||||
// the terminal. On the worker each object of the plugin takes its fate: the store opens a native, so it restarts
|
||||
// (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's
|
||||
// the terminal. On the worker each object of the plugin takes its fate: the store opens no native, so it is swapped
|
||||
// in place (its live query runs again); the views wait for it, then go on with the new code. Ada's
|
||||
// reference follows them.
|
||||
// The edit: `add` in the `Todos` view, before and after.
|
||||
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
|
||||
|
|
@ -171,7 +214,7 @@ const workerFile = join(root, "lists", "worker.ts");
|
|||
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
|
||||
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
|
||||
const applied = await app.applyChanges(context);
|
||||
console.log("8. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", "));
|
||||
console.log("9. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", "));
|
||||
for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
|
||||
const fates = "report" in host ? host.report.objects.map(({ object, kind }) => `${object} ${kind}`) : [];
|
||||
console.log(` on ${host.host}:`, [...new Set(fates)].join(", "));
|
||||
|
|
@ -185,11 +228,11 @@ const refused = await adas.add(" ", context).then(
|
|||
);
|
||||
console.log(" ada adds ' ':", refused);
|
||||
|
||||
// 9. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing
|
||||
// 10. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing
|
||||
// the archive makes a new list with the same todos. An address that has data is never overwritten.
|
||||
const archive = join(base, "groceries.tar");
|
||||
await app.exportData(List({ id: "groceries" }), archive, context);
|
||||
console.log("9. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing");
|
||||
console.log("10. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing");
|
||||
await app.importData(archive, List({ id: "picnic" }), context);
|
||||
const picnic = await app.host.get(Todos, { list: "picnic" }, context);
|
||||
console.log(" imported as picnic:", show(picnic.items.value));
|
||||
|
|
@ -207,7 +250,7 @@ if (process.argv.includes("--serve")) {
|
|||
await ada.close(context);
|
||||
await app.close(context);
|
||||
await rm(base, { recursive: true, force: true });
|
||||
console.log("10. closed");
|
||||
console.log("11. closed");
|
||||
}
|
||||
|
||||
/** Todos as one line: `[x] milk (owner), [ ] eggs (owner)`. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
// French for the lists plugin, written once and contributed from each facet that renders (registries are per host). The
|
||||
// lists plugin's keys and signatures come from its messages' type, an import erased at build.
|
||||
|
||||
import type { MessagesOf, Translation } from "@todo/app/v1";
|
||||
import type { messages } from "../lists/messages.ts";
|
||||
|
||||
const lists: MessagesOf<typeof messages> = {
|
||||
title: "Courses",
|
||||
placeholder: "Nouvelle tâche",
|
||||
clearDone: "Retirer les tâches faites",
|
||||
open: (n) => `${n} à faire`,
|
||||
cleared: (by) => `${by} a retiré les tâches faites`,
|
||||
};
|
||||
|
||||
export const french: readonly Translation[] = [{ namespace: "todo.lists", locale: "fr", messages: lists }];
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@todo/french",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// French in terminals.
|
||||
|
||||
import { defineFacet, Translations } from "@todo/app/v1";
|
||||
import { french } from "./fr.ts";
|
||||
|
||||
export default defineFacet((_host, self) => self.contribute(Translations, french));
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// French on pages.
|
||||
|
||||
import { defineFacet, Translations } from "@todo/app/v1";
|
||||
import { french } from "./fr.ts";
|
||||
|
||||
export default defineFacet((_host, self) => self.contribute(Translations, french));
|
||||
|
|
@ -1,7 +1,21 @@
|
|||
// The lists plugin's contract: a list's todos as one principal sees them, on the list's worker. Its facets of every
|
||||
// kind import it, and so do code and tests outside the plugin (they resolve `@todo/app/v1` as plugins do).
|
||||
|
||||
import { arr, bool, int, List, method, num, obj, remote, type Static, str } from "@todo/app/v1";
|
||||
import {
|
||||
arr,
|
||||
bool,
|
||||
int,
|
||||
List,
|
||||
MessageSchema,
|
||||
method,
|
||||
nul,
|
||||
num,
|
||||
obj,
|
||||
remote,
|
||||
type Static,
|
||||
str,
|
||||
union,
|
||||
} from "@todo/app/v1";
|
||||
|
||||
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
|
||||
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
|
||||
|
|
@ -9,9 +23,12 @@ export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: n
|
|||
/** A todo (see `Item`). */
|
||||
export type Item = Static<typeof Item>;
|
||||
|
||||
/** The list's latest notice, as data each viewer renders in its own language; `null` before the first. */
|
||||
export const Notice = union([MessageSchema, nul()]);
|
||||
|
||||
/**
|
||||
* A list's todos as one principal sees them: the newest 50 (live), adding one and ticking one off (or on again); the
|
||||
* owner also clears the done ones.
|
||||
* A list's todos as one principal sees them: the newest 50 (live) and the list's notice, adding one and ticking one off
|
||||
* (or on again); the owner also clears the done ones.
|
||||
*/
|
||||
export const Todos = remote("todo.todos", {
|
||||
version: 1,
|
||||
|
|
@ -19,11 +36,11 @@ export const Todos = remote("todo.todos", {
|
|||
hostAddress: ({ list }) => List({ id: list }),
|
||||
variants: {
|
||||
member: {
|
||||
state: { items: arr(Item) },
|
||||
state: { items: arr(Item), notice: Notice },
|
||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
|
||||
},
|
||||
owner: {
|
||||
state: { items: arr(Item) },
|
||||
state: { items: arr(Item), notice: Notice },
|
||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@
|
|||
border: 1px solid var(--accent); border-radius: var(--radius, 0); }
|
||||
.lists-panel button { margin-top: 0.5rem; background: none; color: var(--accent); border: 1px solid var(--accent);
|
||||
border-radius: var(--radius, 0); padding: 0.3rem 0.6rem; }
|
||||
.lists-notice { color: var(--warning); font-size: 0.9em; }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
// The lists plugin's messages, in English (the source of truth) and German: one file every facet that renders imports,
|
||||
// and the worker, which writes a notice as data for each viewer to render.
|
||||
|
||||
import { defineMessages } from "@todo/app/v1";
|
||||
|
||||
export const messages = defineMessages("todo.lists", {
|
||||
en: {
|
||||
title: "Groceries",
|
||||
placeholder: "Add a todo",
|
||||
clearDone: "Clear done",
|
||||
open: (n: number) => (n === 1 ? "1 open" : `${n} open`),
|
||||
cleared: (by: string) => `${by} cleared the done todos`,
|
||||
},
|
||||
de: {
|
||||
title: "Einkäufe",
|
||||
placeholder: "Neues Todo",
|
||||
clearDone: "Erledigte entfernen",
|
||||
open: (n) => `${n} offen`,
|
||||
cleared: (by) => `${by} hat die erledigten Todos entfernt`,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,18 +1,31 @@
|
|||
// The lists plugin in the terminal: the groceries list's todos, live; done ones muted.
|
||||
// The lists plugin in the terminal: its title, the groceries list's todos, live, done ones muted, and the list's
|
||||
// notice. Its texts are in the terminal's language (`scope.messages`), and its catalogs are contributed here: the
|
||||
// plugin's own languages are its contributions to the terminal's `Translations`.
|
||||
|
||||
import { defineFacet } from "@todo/app/v1";
|
||||
import { defineFacet, Translations } from "@todo/app/v1";
|
||||
import { Screens } from "@todo/app/v1/tui";
|
||||
import { Todos } from "./contract.ts";
|
||||
import { messages } from "./messages.ts";
|
||||
|
||||
export default defineFacet((_host, self) =>
|
||||
export default defineFacet((_host, self) => {
|
||||
self.contribute(Translations, messages.translations);
|
||||
self.contribute(Screens, {
|
||||
title: "Groceries",
|
||||
name: "groceries",
|
||||
open: (scope, theme) => {
|
||||
const items = scope.watch(scope.hold(Todos, { list: "groceries" }).items);
|
||||
return () =>
|
||||
(items() ?? []).map((item) =>
|
||||
item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`,
|
||||
);
|
||||
const t = scope.messages(messages);
|
||||
const todos = scope.hold(Todos, { list: "groceries" });
|
||||
const items = scope.watch(todos.items);
|
||||
const notice = scope.watch(todos.notice);
|
||||
return () => {
|
||||
const shown = notice();
|
||||
return [
|
||||
theme.fg("accent", t("title")),
|
||||
...(items() ?? []).map((item) =>
|
||||
item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`,
|
||||
),
|
||||
...(shown === undefined || shown === null ? [] : [theme.fg("warning", t.render(shown))]),
|
||||
];
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for
|
||||
// the owner "Clear done". The list is the page's `#hash`, `groceries` without one. The half-typed todo is page state:
|
||||
// a reload of this plugin remounts the panel, and the page keeps what was typed.
|
||||
// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, for
|
||||
// the owner "Clear done", and the list's notice. The list is the page's `#hash`, `groceries` without one. The
|
||||
// half-typed todo is page state: a reload of this plugin remounts the panel, and the page keeps what was typed. Its
|
||||
// texts are in the page's language (`useMessages`), and its catalogs are its contribution to the page's `Translations`.
|
||||
|
||||
import { BACKGROUND_CONTEXT as context, defineFacet } from "@todo/app/v1";
|
||||
import { Panels, useHold, usePageState, useWatch } from "@todo/app/v1/web";
|
||||
import { BACKGROUND_CONTEXT as context, defineFacet, Translations } from "@todo/app/v1";
|
||||
import { Panels, useHold, useMessages, usePageState, useWatch } from "@todo/app/v1/web";
|
||||
import { createElement as h } from "react";
|
||||
import { Todos } from "./contract.ts";
|
||||
import { messages } from "./messages.ts";
|
||||
import "./lists.css";
|
||||
|
||||
function TodoList() {
|
||||
const { t } = useMessages(messages);
|
||||
const list = location.hash.slice(1) || "groceries";
|
||||
const todos = useHold(Todos, { list });
|
||||
const items = useWatch(todos?.items) ?? [];
|
||||
const notice = useWatch(todos?.notice);
|
||||
// Kept by the page, not the component: the new code's component finds the half-typed todo.
|
||||
const [text, setText] = usePageState(`lists.draft.${list}`, "");
|
||||
const add = (event: { preventDefault(): void }) => {
|
||||
|
|
@ -23,6 +27,7 @@ function TodoList() {
|
|||
return h(
|
||||
"div",
|
||||
{ className: "lists-panel" },
|
||||
h("h2", null, t("title")),
|
||||
h(
|
||||
"ul",
|
||||
null,
|
||||
|
|
@ -44,14 +49,18 @@ function TodoList() {
|
|||
{ onSubmit: add },
|
||||
h("input", {
|
||||
value: text,
|
||||
placeholder: "Add a todo",
|
||||
placeholder: t("placeholder"),
|
||||
onChange: (event: { target: { value: string } }) => setText(event.target.value),
|
||||
}),
|
||||
),
|
||||
todos?.variant === "owner"
|
||||
? h("button", { type: "button", onClick: () => void todos.clear(context) }, "Clear done")
|
||||
? h("button", { type: "button", onClick: () => void todos.clear(context) }, t("clearDone"))
|
||||
: null,
|
||||
notice === undefined || notice === null ? null : h("p", { className: "lists-notice" }, t.render(notice)),
|
||||
);
|
||||
}
|
||||
|
||||
export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList }));
|
||||
export default defineFacet((_host, self) => {
|
||||
self.contribute(Translations, messages.translations);
|
||||
self.contribute(Panels, { name: "groceries", Component: TodoList });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the
|
||||
// app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code
|
||||
// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it, and one
|
||||
// without text is refused. The test imports the app's API by its package name and the plugin's contract from its
|
||||
// `contract.ts`, as the facets do.
|
||||
// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it, one
|
||||
// without text is refused, and clearing leaves a notice as data. The test imports the app's API by its package name and
|
||||
// the plugin's contract from its `contract.ts`, as the facets do.
|
||||
// Run from packages/chord:
|
||||
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
|
@ -68,6 +68,7 @@ describe("the lists plugin on a list's worker", () => {
|
|||
await todos.tick({ id: 1, done: true }, ctx);
|
||||
await todos.clear(ctx);
|
||||
expect(todos.items.value.map(({ text }) => text)).toEqual(["eggs"]);
|
||||
expect(todos.notice.value).toEqual({ namespace: "todo.lists", key: "cleared", params: ["owner"] });
|
||||
},
|
||||
context,
|
||||
{ principal: OWNER },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and
|
||||
// read by one local object, `Store`, which shows the newest 50 live with a live query (`self.query`): Chord runs it
|
||||
// before the store goes live and again after every commit that writes the table. Each principal's `Todos` view adds
|
||||
// todos as that principal, and gives the owner `clear`. The store refuses a todo without text with Chord's
|
||||
// todos as that principal, and gives the owner `clear`, which leaves a notice: a message as data (`messages.message`),
|
||||
// never a sentence, so each viewer reads it in its own language. The store refuses a todo without text with Chord's
|
||||
// `invalid/args`.
|
||||
|
||||
import {
|
||||
|
|
@ -9,6 +10,7 @@ import {
|
|||
bool,
|
||||
type Ctx,
|
||||
defineFacet,
|
||||
defineState,
|
||||
defineTable,
|
||||
eq,
|
||||
int,
|
||||
|
|
@ -20,7 +22,8 @@ import {
|
|||
query,
|
||||
str,
|
||||
} from "@todo/app/v1";
|
||||
import { Item, Todos } from "./contract.ts";
|
||||
import { Item, Notice, Todos } from "./contract.ts";
|
||||
import { messages } from "./messages.ts";
|
||||
|
||||
/** Every todo of the list, by its number. */
|
||||
const Items = defineTable("todo.items", {
|
||||
|
|
@ -35,15 +38,23 @@ const NEWEST = query(Items).orderBy((item) => item.id, "desc");
|
|||
/** How many todos the live query holds. */
|
||||
const WINDOW = 50;
|
||||
|
||||
/** The list's latest notice (in memory: a notice is news, gone with the worker). */
|
||||
const Notices = defineState("todo.notice", {
|
||||
schema: obj({ notice: Notice }),
|
||||
version: 1,
|
||||
initial: () => ({ notice: null }),
|
||||
});
|
||||
|
||||
/** The list's todos, once per worker: empty params, so it lives as long as the worker and may keep data in its store. */
|
||||
const Store = local("todo.store", {
|
||||
version: 1,
|
||||
params: {},
|
||||
members: {
|
||||
items: arr(Item),
|
||||
notice: Notice,
|
||||
add: method(obj({ text: str(), by: str() })),
|
||||
tick: method(obj({ id: int(), done: bool() })),
|
||||
clear: method(),
|
||||
clear: method(obj({ by: str() })),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -52,9 +63,11 @@ export default defineFacet((host) => {
|
|||
const items = self.table(Items);
|
||||
// The newest todos, live: read before the store goes live, read again after every commit writing the table.
|
||||
const newest = self.query(items, NEWEST, WINDOW);
|
||||
const notices = self.state(Notices);
|
||||
return {
|
||||
// Oldest first, as the list shows them.
|
||||
items: self.derive([newest], ([rows]) => [...rows].reverse()),
|
||||
notice: self.derive([notices], ([value]) => value.notice),
|
||||
add: async ({ text, by }, ctx) => {
|
||||
// Refused as Chord refuses arguments that fail their schema: the caller gets `invalid/args`.
|
||||
if (text === "") throw invalid("args", "a todo needs text");
|
||||
|
|
@ -69,8 +82,12 @@ export default defineFacet((host) => {
|
|||
await self.tx(async (tx) => tx.updateWhere(items, (item) => eq(item.id, id), { done }), ctx);
|
||||
return null;
|
||||
},
|
||||
clear: async (ctx) => {
|
||||
clear: async ({ by }, ctx) => {
|
||||
await self.tx(async (tx) => tx.deleteWhere(items, (item) => eq(item.done, true)), ctx);
|
||||
// The key and its parameters, typed against the English catalog: every viewer renders its own text.
|
||||
await notices.change((draft) => {
|
||||
draft.notice = messages.message("cleared", by);
|
||||
}, ctx);
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
|
@ -81,10 +98,11 @@ export default defineFacet((host) => {
|
|||
const by = principal.kind === "user" ? principal.name : principal.kind;
|
||||
const members = {
|
||||
items: self.derive([store.items], ([items]) => items),
|
||||
notice: self.derive([store.notice], ([notice]) => notice),
|
||||
add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),
|
||||
tick: (tick: { id: number; done: boolean }, ctx: Ctx) => store.tick(tick, ctx),
|
||||
};
|
||||
if (principal.kind !== "owner") return { variant: "member", members };
|
||||
return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear(ctx) } };
|
||||
return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear({ by }, ctx) } };
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
// Screenshots of the tutorial's page (`docs/tutorial/screens/`), taken in headless Chromium: the page after the owner
|
||||
// signed in with the owner link, the page in the Harbor theme added while it runs, and the page after an edit of the
|
||||
// lists plugin's web facet applied in place.
|
||||
// signed in with the owner link, the page in the Harbor theme added while it runs, the page after an edit of the
|
||||
// lists plugin's web facet applied in place, and the page after the owner chose French, with the list's notice.
|
||||
// Run from packages/chord, with Chromium (`CHROMIUM_BIN`, else `chromium-docker` on the `PATH`):
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/screenshots.ts docs/tutorial/screens
|
||||
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { LocaleChoice } from "../../../src/index.ts";
|
||||
import { Browser, CHROMIUM } from "../../node/web/browser.ts";
|
||||
import { Todos } from "./plugins/lists/contract.ts";
|
||||
import { startTodo, todoDirectories } from "./start.ts";
|
||||
|
|
@ -38,10 +39,10 @@ await app.host.root(
|
|||
{ principal: { kind: "owner" } },
|
||||
);
|
||||
|
||||
/** The edit of step 3: a line counting the open todos above the list. */
|
||||
/** The edit of step 3: a line counting the open todos above the list, in the page's language. */
|
||||
const BEFORE = `h(
|
||||
"ul",`;
|
||||
const AFTER = `h("p", { className: "lists-open" }, \`\${items.filter((item) => !item.done).length} open\`),
|
||||
const AFTER = `h("p", { className: "lists-open" }, t("open", items.filter((item) => !item.done).length)),
|
||||
h(
|
||||
"ul",`;
|
||||
|
||||
|
|
@ -89,6 +90,24 @@ try {
|
|||
await shoot("edited.png");
|
||||
const after = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
|
||||
console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`);
|
||||
|
||||
// 4. The owner chooses French and clears the done todos: the page re-renders in French with the list's notice, the
|
||||
// half-typed todo still there.
|
||||
await app.host.root(
|
||||
async (ctx) => {
|
||||
await (await ctx.get(LocaleChoice, {})).choose("fr", ctx);
|
||||
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||
if (todos.variant === "owner") await todos.clear(ctx);
|
||||
},
|
||||
context,
|
||||
{ principal: { kind: "owner" } },
|
||||
);
|
||||
await browser.wait("document.querySelector('.lists-notice')?.textContent === 'owner a retiré les tâches faites'");
|
||||
await browser.evaluate("document.querySelector('.lists-panel input').focus()");
|
||||
await shoot("french.png");
|
||||
const french = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
|
||||
const lang = await browser.evaluate<string>("document.documentElement.lang");
|
||||
console.log(`after choosing French: lang="${lang}", ${JSON.stringify(french)} still typed`);
|
||||
console.log("the page did not reload:", await browser.evaluate<boolean>("window.marked === true"));
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
|
|
|||
|
|
@ -16,13 +16,18 @@ export {
|
|||
arr,
|
||||
bool,
|
||||
type Ctx,
|
||||
defineMessages,
|
||||
defineState,
|
||||
defineTable,
|
||||
eq,
|
||||
forbidden,
|
||||
int,
|
||||
invalid,
|
||||
local,
|
||||
MessageSchema,
|
||||
type MessagesOf,
|
||||
method,
|
||||
nul,
|
||||
num,
|
||||
obj,
|
||||
PluginError,
|
||||
|
|
@ -32,6 +37,9 @@ export {
|
|||
str,
|
||||
type Theme,
|
||||
Themes,
|
||||
type Translation,
|
||||
Translations,
|
||||
union,
|
||||
} from "../../../../../../src/index.ts";
|
||||
export type { TodoPrincipal } from "../../principals.ts";
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import { defineRegistry } from "../../../../../../src/index.ts";
|
|||
import type { TerminalTheme, TuiScope } from "../../../../../../src/tui/index.ts";
|
||||
|
||||
/**
|
||||
* A part of the terminal's screen: its title, and `open`, which holds what the part shows in `scope` (released when the
|
||||
* terminal closes it) and returns a reader of its lines, coloured by role with `theme`.
|
||||
* A part of the terminal's screen: its name (a key, never shown), and `open`, which holds what the part shows in
|
||||
* `scope` (released when the terminal closes it) and returns a reader of its lines, its title first, coloured by role
|
||||
* with `theme`.
|
||||
*/
|
||||
export type Screen = {
|
||||
readonly title: string;
|
||||
readonly name: string;
|
||||
readonly open: (scope: TuiScope, theme: TerminalTheme) => () => readonly string[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// The todo app's API v1 for web facets (`@todo/app/v1/web`): the `Panels` registry the page shows, and Chord's React
|
||||
// hooks (a plugin imports them from here: Chord itself is not a package plugins may name).
|
||||
// hooks, `useMessages` among them (a plugin imports them from here: Chord itself is not a package plugins may name).
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { defineRegistry } from "../../../../../../src/index.ts";
|
||||
|
||||
export { useHold, usePageState, useWatch } from "../../../../../../src/react/index.ts";
|
||||
export { useHold, useMessages, usePageState, useWatch } from "../../../../../../src/react/index.ts";
|
||||
|
||||
/** A panel of the page: its title and its React component. */
|
||||
export type Panel = { readonly title: string; readonly Component: ComponentType };
|
||||
/** A panel of the page: its name (a key, never shown) and its React component, which shows its own title. */
|
||||
export type Panel = { readonly name: string; readonly Component: ComponentType };
|
||||
|
||||
/** The page's panels, one per contributing plugin, in plugin-name order. */
|
||||
export const Panels = defineRegistry<Panel, readonly Panel[]>("todo.panels", { version: 1 });
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ void startTerminalRuntime({
|
|||
const scope = new TuiScope(runtime.host, () => draw());
|
||||
opened = { screens: current, scope, lines: current.map((screen) => screen.open(scope, theme)) };
|
||||
}
|
||||
const lines = current.flatMap((screen, n) => [theme.fg("accent", screen.title), ...opened!.lines[n]!()]);
|
||||
const lines = opened.lines.flatMap((read) => read());
|
||||
terminal.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`);
|
||||
};
|
||||
theme.onChange(draw);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ function Page() {
|
|||
"main",
|
||||
null,
|
||||
h("h1", null, "Todo"),
|
||||
...panels.map(({ title, Component }) => h("section", { key: title }, h("h2", null, title), h(Component))),
|
||||
...panels.map(({ name, Component }) => h("section", { key: name }, h(Component))),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Sandbox } from "../../../../src/node/tui/sandbox.ts";
|
|||
|
||||
const sandbox = await Sandbox.start(
|
||||
process.argv[2]!,
|
||||
{ base: "test", combined: "", columns: 80, rows: 24, colors: "truecolor", mode: "dark" },
|
||||
{ base: "test", combined: "", columns: 80, rows: 24, colors: "truecolor", mode: "dark", locales: ["en"] },
|
||||
(message) => {
|
||||
console.log(JSON.stringify({ kind: message.kind, data: (message as { data?: unknown }).data }));
|
||||
if (message.kind === "exit") void sandbox.terminate();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// The web entry of the browser tests (the base bundle): `startPage` with the tests' API modules and React, a core facet
|
||||
// serving `Panels` and the counter, and a React app: the panels (`useRegistry`), a draft input (component state an
|
||||
// in-place reload keeps), a counter held while shown (`useHold`, `useWatch`, `useRefState`), and a note in page state
|
||||
// (`usePageState`) whose component the "remount" button replaces by another function, as a plugin reload does.
|
||||
// (`usePageState`) whose component the "remount" button replaces by another function, as a plugin reload does, and a
|
||||
// greeting in the page's language (`useMessages`), the core facet contributing its catalogs.
|
||||
|
||||
import * as React from "react";
|
||||
import { createElement as h, useState } from "react";
|
||||
|
|
@ -12,6 +13,7 @@ import * as chord from "../../../../src/index.ts";
|
|||
import {
|
||||
HostProvider,
|
||||
useHold,
|
||||
useMessages,
|
||||
usePageState,
|
||||
useRefState,
|
||||
useRegistry,
|
||||
|
|
@ -20,13 +22,20 @@ import {
|
|||
import { startPage } from "../../../../src/web/index.ts";
|
||||
import * as webApi from "./api.ts";
|
||||
|
||||
const { defineFacet } = chord;
|
||||
const { defineFacet, defineMessages, Translations } = chord;
|
||||
|
||||
/** The page's own messages. */
|
||||
const greetings = defineMessages("test.page", {
|
||||
en: { hello: "Hello", count: (n: number) => (n === 1 ? "1 click" : `${n} clicks`) },
|
||||
de: { hello: "Hallo", count: (n) => (n === 1 ? "1 Klick" : `${n} Klicks`) },
|
||||
});
|
||||
|
||||
/** What the tests read from the page. */
|
||||
const probe = globalThis as unknown as { loads: number; counterState?: () => string | undefined; connection?: string };
|
||||
probe.loads = (probe.loads ?? 0) + 1;
|
||||
|
||||
const core = defineFacet((host) => {
|
||||
const core = defineFacet((host, self) => {
|
||||
self.contribute(Translations, greetings.translations);
|
||||
host.serve(webApi.Panels, { compose: (table) => table.map(({ value }) => value) });
|
||||
host.serve(webApi.Counter, (_params, self) => {
|
||||
const count = self.writable(0);
|
||||
|
|
@ -71,6 +80,12 @@ function noteInput(key: string) {
|
|||
/** The note's component and the components replacing it: each a new function to React, as a reloaded plugin's. */
|
||||
const NOTES = [() => noteInput("note"), () => noteInput("note"), () => noteInput("other")];
|
||||
|
||||
/** The greeting, in the page's language. */
|
||||
function Greeting() {
|
||||
const { t, locale } = useMessages(greetings);
|
||||
return h("p", { id: "greeting", "data-locale": locale }, `${t("hello")}, ${t("count", 2)}`);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const panels = useRegistry(webApi.Panels) ?? [];
|
||||
const [draft, setDraft] = useState("");
|
||||
|
|
@ -89,6 +104,7 @@ function App() {
|
|||
shown ? h(Counter) : null,
|
||||
h("button", { type: "button", id: "remount", onClick: () => setNote((value) => value + 1) }, "remount"),
|
||||
h(NOTES[note % NOTES.length]!),
|
||||
h(Greeting),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// Tests of the React bindings (slice 11 §3) in a real browser (Chromium; skipped without one), on the fixture's page:
|
||||
// `useHold` holds while mounted and releases on unmount, `useWatch` re-renders on change, `useRefState` follows the
|
||||
// reference's state, `useRegistry` the registry (the panels), `usePageState` keeps a value across a remount by another
|
||||
// component function and starts a new key fresh.
|
||||
// component function and starts a new key fresh, `useMessages` re-renders in a language chosen while the page runs,
|
||||
// half-typed inputs kept.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { LocaleChoice } from "../../../src/index.ts";
|
||||
import { Browser, CHROMIUM } from "../web/browser.ts";
|
||||
import { freePort, panelPlugin, signIn, versionedEntry, webApp } from "../web/support.ts";
|
||||
|
||||
|
|
@ -68,4 +70,28 @@ describe.skipIf(CHROMIUM === undefined)("React bindings", () => {
|
|||
await app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it("re-renders messages in a language chosen live, keeping half-typed inputs", async () => {
|
||||
const { app } = await webApp(await versionedEntry("1"), await freePort());
|
||||
try {
|
||||
await signIn(browser, app.ownerLink());
|
||||
await browser.wait('document.getElementById("greeting")?.textContent === "Hello, 2 clicks"');
|
||||
await browser.evaluate('document.getElementById("draft").focus()');
|
||||
await browser.type("oat");
|
||||
await browser.evaluate('document.getElementById("note").focus()');
|
||||
await browser.type("mi");
|
||||
await browser.evaluate('void (globalThis.marked = document.getElementById("draft"))');
|
||||
// The owner chooses German: the page's host follows the pick, the greeting re-renders, nothing reloads.
|
||||
await app.host.root(async (ctx) => (await ctx.get(LocaleChoice, {})).choose("de", ctx), context, {
|
||||
principal: { kind: "owner" },
|
||||
});
|
||||
await browser.wait('document.getElementById("greeting").textContent === "Hallo, 2 Klicks"');
|
||||
expect(await browser.evaluate<string>('document.getElementById("greeting").dataset.locale')).toBe("de");
|
||||
expect(await browser.evaluate<string>('document.getElementById("draft").value')).toBe("oat");
|
||||
expect(await browser.evaluate<string>('document.getElementById("note").value')).toBe("mi");
|
||||
expect(await browser.evaluate<boolean>('document.getElementById("draft") === globalThis.marked')).toBe(true);
|
||||
} finally {
|
||||
await app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
|
|
|||
50
packages/chord/test/node/web/language.test.ts
Normal file
50
packages/chord/test/node/web/language.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Tests of the page's language (decision "Messages and languages in the app kit" (3)) in a real browser (Chromium;
|
||||
// skipped without one): the root element's `lang` and `dir` follow the device's languages matched against the catalogs,
|
||||
// then the user's pick, right to left for a right-to-left locale a plugin contributes.
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { LocaleChoice } from "../../../src/index.ts";
|
||||
import { Browser, CHROMIUM } from "./browser.ts";
|
||||
import { freePort, signIn, versionedEntry, webApp } from "./support.ts";
|
||||
|
||||
/** A web plugin contributing an Arabic catalog of the page's messages. */
|
||||
const ARABIC = {
|
||||
"web.ts": [
|
||||
'import { defineFacet, Translations } from "@test/app/v1";',
|
||||
'export default defineFacet((_host, self) => self.contribute(Translations, [{ namespace: "test.page", locale: "ar", messages: { hello: "مرحبا" } }]));',
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
describe.skipIf(CHROMIUM === undefined)("the page's language", () => {
|
||||
let browser: Browser;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await Browser.start();
|
||||
}, 120_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close();
|
||||
});
|
||||
|
||||
it("writes lang and dir on the root element, from the device, then the user's pick", async () => {
|
||||
const { app } = await webApp(await versionedEntry("1"), await freePort(), { plugins: { arabic: ARABIC } });
|
||||
try {
|
||||
await signIn(browser, app.ownerLink());
|
||||
await browser.wait('document.getElementById("greeting") !== null');
|
||||
// The browser's own languages (English), matched against the catalogs' locales.
|
||||
expect(await browser.evaluate<string>("document.documentElement.lang")).toBe("en");
|
||||
await app.host.root(async (ctx) => (await ctx.get(LocaleChoice, {})).choose("ar", ctx), context, {
|
||||
principal: { kind: "owner" },
|
||||
});
|
||||
await browser.wait('document.documentElement.dir === "rtl"');
|
||||
expect(await browser.evaluate<string>("document.documentElement.lang")).toBe("ar");
|
||||
// The key Arabic lacks falls back to the first catalog, key by key.
|
||||
expect(await browser.evaluate<string>('document.getElementById("greeting").textContent')).toBe(
|
||||
"مرحبا, 2 clicks",
|
||||
);
|
||||
} finally {
|
||||
await app.close(context);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
125
packages/chord/test/surfaces/active-language.test.ts
Normal file
125
packages/chord/test/surfaces/active-language.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// Tests of the active language of a client (decision "Messages and languages in the app kit" (3)): a client follows
|
||||
// the `Translations` registry, its principal's `LocaleChoice` and the device's languages; the host keeps the language
|
||||
// as page state, set again only when the locale or the catalogs change; reports are passed on once each.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||
import {
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineMessages,
|
||||
followActiveLanguage,
|
||||
hostKind,
|
||||
hostLanguage,
|
||||
type Language,
|
||||
LocaleChoice,
|
||||
localeChoiceFacet,
|
||||
type MessagesReport,
|
||||
memoryLinkPair,
|
||||
openStore,
|
||||
sqliteBackend,
|
||||
Translations,
|
||||
translationsFacet,
|
||||
} from "../../src/index.ts";
|
||||
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
|
||||
|
||||
type TestPrincipal = { readonly kind: "server" } | { readonly kind: "user"; readonly id: string };
|
||||
|
||||
const notes = defineMessages("notes", {
|
||||
en: { newNote: "New note", empty: "No notes" },
|
||||
de: { newNote: "Neue Notiz", empty: "Keine Notizen" },
|
||||
});
|
||||
|
||||
/** A server with locale choices, and a client of `ada` whose plugins contribute the notes catalogs and `fr`. */
|
||||
async function setup() {
|
||||
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(":memory:")), context);
|
||||
const server = createHost<TestPrincipal>({
|
||||
address: hostKind("server")(),
|
||||
principal: { kind: "server" },
|
||||
store,
|
||||
onError: () => {},
|
||||
});
|
||||
await server.load([{ name: "chord.locale.choice", facet: localeChoiceFacet<TestPrincipal>() }], context);
|
||||
const client = createHost<TestPrincipal>({ onError: () => {} });
|
||||
await client.load(
|
||||
[
|
||||
{ name: "chord.translations", facet: translationsFacet<TestPrincipal>() },
|
||||
{
|
||||
name: "@test/notes",
|
||||
facet: defineFacet<TestPrincipal>((_host, self) => self.contribute(Translations, notes.translations)),
|
||||
},
|
||||
{
|
||||
name: "@test/french",
|
||||
facet: defineFacet<TestPrincipal>((_host, self) =>
|
||||
self.contribute(Translations, [
|
||||
{ namespace: "notes", locale: "fr", messages: { newNote: "Nouvelle note" } },
|
||||
]),
|
||||
),
|
||||
},
|
||||
],
|
||||
context,
|
||||
);
|
||||
const [mine, theirs] = memoryLinkPair();
|
||||
server.accept(theirs, { principal: { kind: "user", id: "ada" } }, context);
|
||||
client.connect(mine, { principal: { kind: "server" } }, context);
|
||||
const close = async () => {
|
||||
await client.close(context);
|
||||
await server.close(context);
|
||||
await store.close(context);
|
||||
};
|
||||
return { client, close };
|
||||
}
|
||||
|
||||
/** Waits until `check` holds. */
|
||||
async function until(check: () => boolean): Promise<void> {
|
||||
for (let index = 0; index < 200 && !check(); index++) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(check()).toBe(true);
|
||||
}
|
||||
|
||||
describe("following the active language", () => {
|
||||
it("follows the device's languages, the principal's pick and the catalogs, kept as the host's page state", async () => {
|
||||
const { client, close } = await setup();
|
||||
const seen: Language[] = [];
|
||||
expect(hostLanguage(client).read()).toBeUndefined();
|
||||
const follower = await followActiveLanguage(
|
||||
client,
|
||||
["de-AT", "en"],
|
||||
(language) => seen.push(language),
|
||||
() => {},
|
||||
context,
|
||||
);
|
||||
expect(seen.at(-1)?.locale).toBe("de");
|
||||
expect(hostLanguage(client).read()).toBe(seen.at(-1));
|
||||
expect(notes.in(hostLanguage(client).read())("newNote")).toBe("Neue Notiz");
|
||||
const choice = await client.get(LocaleChoice, {}, context);
|
||||
await choice.choose("fr", context);
|
||||
await until(() => seen.at(-1)?.locale === "fr");
|
||||
follower.setDevice(["en"]);
|
||||
expect(seen.at(-1)?.locale).toBe("fr");
|
||||
await choice.choose(null, context);
|
||||
await until(() => seen.at(-1)?.locale === "en");
|
||||
const count = seen.length;
|
||||
follower.setDevice(["en-GB"]);
|
||||
expect(seen.length).toBe(count);
|
||||
follower.stop();
|
||||
choice.release();
|
||||
await close();
|
||||
});
|
||||
|
||||
it("passes each distinct report on once", async () => {
|
||||
const { client, close } = await setup();
|
||||
const reports: MessagesReport[] = [];
|
||||
const follower = await followActiveLanguage(
|
||||
client,
|
||||
["fr"],
|
||||
() => {},
|
||||
(report) => reports.push(report),
|
||||
context,
|
||||
);
|
||||
const t = notes.in(hostLanguage(client).read());
|
||||
expect([t("empty"), t("empty"), t("newNote")]).toEqual(["No notes", "No notes", "Nouvelle note"]);
|
||||
expect(reports).toEqual([{ kind: "fallback", namespace: "notes", locale: "fr", key: "empty", used: undefined }]);
|
||||
follower.stop();
|
||||
await close();
|
||||
});
|
||||
});
|
||||
39
packages/chord/test/surfaces/format.test.ts
Normal file
39
packages/chord/test/surfaces/format.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Tests of the `Intl` helpers (decision "Messages and languages in the app kit" (1)): times and numbers in the current
|
||||
// language's locale inside a render, the platform's outside one; a language's default formatters go with it.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLanguage, formatNumber, formatTime } from "../../src/index.ts";
|
||||
import { inLanguage } from "../../src/surfaces/language.ts";
|
||||
import { NO_TRANSLATIONS } from "../../src/surfaces/translations.ts";
|
||||
import { expectCollected } from "../support.ts";
|
||||
|
||||
const language = (locale: string) => createLanguage(locale, NO_TRANSLATIONS, () => {});
|
||||
|
||||
const AT = new Date(2026, 10, 3, 14, 5);
|
||||
|
||||
describe("formatting in the current language", () => {
|
||||
it("formats times and numbers in the render's locale", () => {
|
||||
const de = language("de");
|
||||
expect(inLanguage(de, () => formatNumber(1234.5))).toBe("1.234,5");
|
||||
expect(inLanguage(language("en-US"), () => formatNumber(1234.5))).toBe("1,234.5");
|
||||
expect(inLanguage(de, () => formatTime(AT))).toBe("14:05");
|
||||
expect(inLanguage(language("en-US"), () => formatTime(AT.getTime()))).toBe("2:05 PM");
|
||||
expect(inLanguage(de, () => formatTime(AT.toISOString(), { hour: "numeric" }))).toBe("14 Uhr");
|
||||
});
|
||||
|
||||
it("formats in the platform's default locale outside a render", () => {
|
||||
expect(formatNumber(1234.5)).toBe(new Intl.NumberFormat().format(1234.5));
|
||||
expect(formatTime(AT)).toBe(new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(AT));
|
||||
});
|
||||
});
|
||||
|
||||
describe("a language's formatters", () => {
|
||||
it("are collected with the language", async () => {
|
||||
const ref = (() => {
|
||||
const de = language("de");
|
||||
inLanguage(de, () => [formatTime(AT), formatNumber(1)]);
|
||||
return new WeakRef(de);
|
||||
})();
|
||||
await expectCollected(ref);
|
||||
});
|
||||
});
|
||||
63
packages/chord/test/surfaces/language.test.ts
Normal file
63
packages/chord/test/surfaces/language.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Tests of a language and rendering in it (decision "Messages and languages in the app kit" (3)): a language's
|
||||
// direction and fallback chain; the locale resolved from the user's pick, the device's languages and the catalogs'
|
||||
// locales; the render scope, current only while a render runs and nested renders restoring the outer one.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createLanguage } from "../../src/index.ts";
|
||||
import { currentLanguage, inLanguage, matchLocale, resolveLocale } from "../../src/surfaces/language.ts";
|
||||
import { NO_TRANSLATIONS } from "../../src/surfaces/translations.ts";
|
||||
|
||||
const language = (locale: string) => createLanguage(locale, NO_TRANSLATIONS, () => {});
|
||||
|
||||
describe("a language", () => {
|
||||
it("has its locale's direction and its chain of bases", () => {
|
||||
expect(language("de").dir).toBe("ltr");
|
||||
expect(language("ar").dir).toBe("rtl");
|
||||
expect(language("he-IL").dir).toBe("rtl");
|
||||
expect(language("pt-BR").chain).toEqual(["pt-BR", "pt"]);
|
||||
expect(language("zh-Hant-TW").chain).toEqual(["zh-Hant-TW", "zh-Hant", "zh"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matching a locale", () => {
|
||||
it("takes the first preference offered: itself, a base, or another region of its language", () => {
|
||||
expect(matchLocale(["fr", "de"], ["de", "en"])).toBe("de");
|
||||
expect(matchLocale(["de-AT"], ["de", "en"])).toBe("de");
|
||||
expect(matchLocale(["de-AT"], ["de-DE", "en"])).toBe("de-DE");
|
||||
expect(matchLocale(["pt-br"], ["pt", "pt-BR"])).toBe("pt-BR");
|
||||
expect(matchLocale(["ja"], ["de", "en"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips a device language that is no tag", () => {
|
||||
expect(matchLocale(["not a tag", "de"], ["de"])).toBe("de");
|
||||
});
|
||||
|
||||
it("resolves the user's pick first, then the device's, then English, then the first offered", () => {
|
||||
expect(resolveLocale("fr", ["de"], ["de", "en", "fr"])).toBe("fr");
|
||||
expect(resolveLocale("it", ["de"], ["de", "en", "fr"])).toBe("de");
|
||||
expect(resolveLocale(null, ["ja"], ["de", "en"])).toBe("en");
|
||||
expect(resolveLocale(null, ["ja"], ["de", "fr"])).toBe("de");
|
||||
expect(resolveLocale(null, [], [])).toBe("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the render scope", () => {
|
||||
it("is current only while a render runs, and a nested render restores the outer", () => {
|
||||
const de = language("de");
|
||||
const fr = language("fr");
|
||||
expect(currentLanguage()).toBeUndefined();
|
||||
const seen = inLanguage(de, () => [
|
||||
currentLanguage(),
|
||||
inLanguage(fr, () => currentLanguage()),
|
||||
currentLanguage(),
|
||||
]);
|
||||
expect(seen).toEqual([de, fr, de]);
|
||||
expect(currentLanguage()).toBeUndefined();
|
||||
expect(() =>
|
||||
inLanguage(de, () => {
|
||||
throw new Error("render failed");
|
||||
}),
|
||||
).toThrow("render failed");
|
||||
expect(currentLanguage()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
46
packages/chord/test/surfaces/locale-choice.test.ts
Normal file
46
packages/chord/test/surfaces/locale-choice.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Tests of the locale choice (decision "Messages and languages in the app kit" (3)): a language tag or `null` per
|
||||
// principal, kept by the kit's server facet; anything else refused.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||
import {
|
||||
createHost,
|
||||
hostKind,
|
||||
LocaleChoice,
|
||||
localeChoiceFacet,
|
||||
memoryLinkPair,
|
||||
openStore,
|
||||
sqliteBackend,
|
||||
} from "../../src/index.ts";
|
||||
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
|
||||
|
||||
type TestPrincipal = { readonly kind: "server" } | { readonly kind: "user"; readonly id: string };
|
||||
|
||||
describe("the locale choice", () => {
|
||||
it("keeps a language tag or null per principal and refuses anything else", async () => {
|
||||
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(":memory:")), context);
|
||||
const server = createHost<TestPrincipal>({
|
||||
address: hostKind("server")(),
|
||||
principal: { kind: "server" },
|
||||
store,
|
||||
onError: () => {},
|
||||
});
|
||||
await server.load([{ name: "chord.locale.choice", facet: localeChoiceFacet<TestPrincipal>() }], context);
|
||||
const client = createHost<TestPrincipal>({ onError: () => {} });
|
||||
await client.load([], context);
|
||||
const [mine, theirs] = memoryLinkPair();
|
||||
server.accept(theirs, { principal: { kind: "user", id: "ada" } }, context);
|
||||
client.connect(mine, { principal: { kind: "server" } }, context);
|
||||
const choice = await client.get(LocaleChoice, {}, context);
|
||||
expect(choice.pick.value).toBeNull();
|
||||
await choice.choose("pt-BR", context);
|
||||
await choice.choose(null, context);
|
||||
for (const bad of ["not a tag", "x".repeat(36), "de_DE"]) {
|
||||
await expect(choice.choose(bad, context)).rejects.toMatchObject({ code: "invalid", reason: "args" });
|
||||
}
|
||||
choice.release();
|
||||
await client.close(context);
|
||||
await server.close(context);
|
||||
await store.close(context);
|
||||
});
|
||||
});
|
||||
212
packages/chord/test/surfaces/messages.test.ts
Normal file
212
packages/chord/test/surfaces/messages.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
// Tests of messages in code (decision "Messages and languages in the app kit" (1), (4)): `defineMessages` types every
|
||||
// other catalog by the first (type errors for a missing, unknown or wrongly typed key) and checks its declaration; `t`
|
||||
// renders down a language's chain (the host's catalog first, then the plugin's own, then the first catalog), reporting
|
||||
// fallbacks; a contributed message that throws is reported and skipped; `t.message` builds typed data and `t.render`
|
||||
// renders it, a foreign namespace or an unknown key showing the key.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createLanguage,
|
||||
defineMessages,
|
||||
formatNumber,
|
||||
type MessageData,
|
||||
type MessageSchema,
|
||||
type MessagesOf,
|
||||
type MessagesReport,
|
||||
type Static,
|
||||
type Translation,
|
||||
} from "../../src/index.ts";
|
||||
import { composeTranslations } from "../../src/surfaces/translations.ts";
|
||||
|
||||
const notes = defineMessages("notes", {
|
||||
en: {
|
||||
newNote: "New note",
|
||||
empty: "No notes yet",
|
||||
count: (n: number) => (n === 1 ? "1 note" : `${formatNumber(n)} notes`),
|
||||
saved: (title: string, at: string) => `Saved "${title}" at ${at}`,
|
||||
when: (at: Date) => at.toISOString(),
|
||||
},
|
||||
de: {
|
||||
newNote: "Neue Notiz",
|
||||
empty: "Noch keine Notizen",
|
||||
count: (n) => (n === 1 ? "1 Notiz" : `${formatNumber(n)} Notizen`),
|
||||
saved: (title, at) => `„${title}“ um ${at} gespeichert`,
|
||||
when: (at) => at.toISOString(),
|
||||
},
|
||||
pt: { newNote: "Nova nota", empty: "Sem notas", count: (n) => `${n} notas`, saved: (t) => t, when: (at) => `${at}` },
|
||||
});
|
||||
|
||||
const other = defineMessages("other", { en: { hello: "Hello" } });
|
||||
|
||||
/** A language for `locale` with the contributed `translations`, its reports collected in `reports`. */
|
||||
function language(locale: string, translations: readonly Translation[] = [], reports: MessagesReport[] = []) {
|
||||
const index = composeTranslations([{ plugin: "@test/fr", version: 1, value: translations }], { report: () => {} });
|
||||
return createLanguage(locale, index, (report) => reports.push(report));
|
||||
}
|
||||
|
||||
describe("defineMessages", () => {
|
||||
it("types every catalog by the first", () => {
|
||||
defineMessages("typed", {
|
||||
en: { a: "A", b: (n: number) => `${n}`, c: "C" },
|
||||
// @ts-expect-error a catalog without a key of the first
|
||||
de: { a: "A", b: (n) => `${n}` },
|
||||
});
|
||||
defineMessages("typed", {
|
||||
en: { a: "A", b: (n: number) => `${n}` },
|
||||
// @ts-expect-error a key's argument of another type
|
||||
de: { a: "A", b: (n: string) => n },
|
||||
});
|
||||
defineMessages("typed", {
|
||||
en: { a: "A" },
|
||||
// @ts-expect-error a key the first does not have
|
||||
de: { a: "A", c: "C" },
|
||||
});
|
||||
// Type checks only: never called.
|
||||
const calls = () => {
|
||||
// @ts-expect-error a wrong argument
|
||||
notes("count", "3");
|
||||
// @ts-expect-error an unknown key
|
||||
notes("svaed");
|
||||
// @ts-expect-error a missing argument
|
||||
notes("saved", "Plan");
|
||||
};
|
||||
const fr: MessagesOf<typeof notes> = {
|
||||
newNote: "Nouvelle note",
|
||||
empty: "Aucune note",
|
||||
count: (n) => `${n} notes`,
|
||||
saved: (title, at) => `« ${title} » ${at}`,
|
||||
when: (at) => at.toISOString(),
|
||||
};
|
||||
// @ts-expect-error a translation's type has every key
|
||||
const partial: MessagesOf<typeof notes> = { newNote: "Nouvelle note" };
|
||||
expect([fr.newNote, partial.newNote, typeof calls]).toEqual(["Nouvelle note", "Nouvelle note", "function"]);
|
||||
});
|
||||
|
||||
it("refuses a namespace that is no name and a catalog key that is no language tag", () => {
|
||||
expect(() => defineMessages("Notes!", { en: { a: "A" } })).toThrow(/namespace/);
|
||||
expect(() => defineMessages("notes", { "not a tag": { a: "A" } })).toThrow(/no language tag/);
|
||||
expect(() => defineMessages("notes", {})).toThrow(/needs a catalog/);
|
||||
});
|
||||
|
||||
it("lists its catalogs as contributions, by canonical locale", () => {
|
||||
const regional = defineMessages("regional", { en: { a: "A" }, "pt-br": { a: "Á" } });
|
||||
expect(regional.translations.map(({ namespace, locale }) => `${namespace} ${locale}`)).toEqual([
|
||||
"regional en",
|
||||
"regional pt-BR",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering in a language", () => {
|
||||
it("renders the first catalog outside a language", () => {
|
||||
expect(notes("count", 2)).toBe("2 notes");
|
||||
expect(notes.in(undefined)("newNote")).toBe("New note");
|
||||
});
|
||||
|
||||
it("renders the plugin's own catalog of the language, values and functions", () => {
|
||||
const de = notes.in(language("de"));
|
||||
expect(de("newNote")).toBe("Neue Notiz");
|
||||
expect(de("count", 1234)).toBe("1.234 Notizen");
|
||||
});
|
||||
|
||||
it("prefers the host's contributed catalog, falling back key by key down the chain, reported", () => {
|
||||
const reports: MessagesReport[] = [];
|
||||
const brazil = language(
|
||||
"pt-BR",
|
||||
[{ namespace: "notes", locale: "pt-BR", messages: { newNote: "Nova anotação" } }],
|
||||
reports,
|
||||
);
|
||||
const t = notes.in(brazil);
|
||||
expect(t("newNote")).toBe("Nova anotação");
|
||||
expect(t("empty")).toBe("Sem notas");
|
||||
expect(reports).toEqual([{ kind: "fallback", namespace: "notes", locale: "pt-BR", key: "empty", used: "pt" }]);
|
||||
});
|
||||
|
||||
it("takes a key a partial contributed catalog lacks from the plugin's own of the same locale", () => {
|
||||
const reports: MessagesReport[] = [];
|
||||
const better = [
|
||||
{ namespace: "notes", locale: "de", messages: { newNote: "Neue Notiz!" }, override: true as const },
|
||||
];
|
||||
const t = notes.in(language("de", better, reports));
|
||||
expect([t("newNote"), t("empty")]).toEqual(["Neue Notiz!", "Noch keine Notizen"]);
|
||||
expect(reports).toEqual([]);
|
||||
});
|
||||
|
||||
it("falls back to the first catalog when no catalog of the chain has the key", () => {
|
||||
const reports: MessagesReport[] = [];
|
||||
expect(notes.in(language("ja", [], reports))("newNote")).toBe("New note");
|
||||
expect(reports).toEqual([
|
||||
{ kind: "fallback", namespace: "notes", locale: "ja", key: "newNote", used: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a contributed message that throws and goes on (here: the plugin's own catalog)", () => {
|
||||
const reports: MessagesReport[] = [];
|
||||
const error = new Error("broken translation");
|
||||
const messages = {
|
||||
count: () => {
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
const t = notes.in(language("de", [{ namespace: "notes", locale: "de", messages, override: true }], reports));
|
||||
expect(t("count", 2)).toBe("2 Notizen");
|
||||
expect(reports[0]).toEqual({
|
||||
kind: "failed",
|
||||
namespace: "notes",
|
||||
locale: "de",
|
||||
key: "count",
|
||||
plugin: "@test/fr",
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a plain `t` inside a message function in the language under way", () => {
|
||||
const outer = defineMessages("outer", {
|
||||
en: { line: (n: number) => `${notes("count", n)}!` },
|
||||
de: { line: (n) => `${notes("count", n)}!` },
|
||||
});
|
||||
expect(outer.in(language("de"))("line", 2)).toBe("2 Notizen!");
|
||||
expect(outer("line", 2)).toBe("2 notes!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messages as data", () => {
|
||||
it("builds typed data for the keys whose arguments are data", () => {
|
||||
const saved = notes.message("saved", "Plan", "2026-11-03T10:00:00Z");
|
||||
expect(saved).toEqual({ namespace: "notes", key: "saved", params: ["Plan", "2026-11-03T10:00:00Z"] });
|
||||
const key: "saved" = saved.key;
|
||||
// @ts-expect-error a `Date` is no data: it would not cross a link
|
||||
notes.message("when", new Date());
|
||||
// @ts-expect-error a wrong argument
|
||||
notes.message("count", "2");
|
||||
expect(key).toBe("saved");
|
||||
});
|
||||
|
||||
it("renders a message in the viewer's language", () => {
|
||||
const saved = notes.message("saved", "Plan", "10:00");
|
||||
expect(notes.in(language("de")).render(saved)).toBe("„Plan“ um 10:00 gespeichert");
|
||||
expect(notes.render(saved)).toBe('Saved "Plan" at 10:00');
|
||||
// @ts-expect-error a message of another namespace
|
||||
notes.render(other.message("hello"));
|
||||
});
|
||||
|
||||
it("shows the key of a foreign or unknown message from a link, reported", () => {
|
||||
const reports: MessagesReport[] = [];
|
||||
const t = notes.in(language("de", [], reports));
|
||||
const foreign: Static<typeof MessageSchema> = { namespace: "other", key: "hello", params: [] };
|
||||
const unknown: MessageData = { namespace: "notes", key: "renamed", params: [] };
|
||||
expect(t.render(foreign)).toBe("hello");
|
||||
expect(t.render(unknown)).toBe("renamed");
|
||||
expect(reports).toEqual([
|
||||
{ kind: "unknown", namespace: "other", key: "hello", expected: "notes" },
|
||||
{ kind: "unknown", namespace: "notes", key: "renamed", expected: "notes" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the key of a message whose params do not fit, reported", () => {
|
||||
const reports: MessagesReport[] = [];
|
||||
const wrong: MessageData = { namespace: "notes", key: "when", params: ["not a date"] };
|
||||
expect(notes.in(language("de", [], reports)).render(wrong)).toBe("when");
|
||||
expect(reports[0]).toMatchObject({ kind: "failed", namespace: "notes", key: "when", plugin: undefined });
|
||||
});
|
||||
});
|
||||
80
packages/chord/test/surfaces/preference.test.ts
Normal file
80
packages/chord/test/surfaces/preference.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Tests of a preference the server keeps per principal (decision "Messages and languages in the app kit" (3), the theme
|
||||
// choice's pattern): each principal's pick, `none` before a choice, kept in the server's store across a restart.
|
||||
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||
import { createHost, hostKind, int, memoryLinkPair, openStore, sqliteBackend } from "../../src/index.ts";
|
||||
import { openNodeSqliteDatabase } from "../../src/sqlite/node.ts";
|
||||
import { preferenceContract, preferenceFacet } from "../../src/surfaces/preference.ts";
|
||||
|
||||
type TestPrincipal = { readonly kind: "server" } | { readonly kind: "user"; readonly id: string };
|
||||
|
||||
const Zoom = preferenceContract("test.zoom.choice", int({ min: 50, max: 200 }));
|
||||
|
||||
/** A server keeping zoom picks in the store at `path`, and a client of each of `users`. */
|
||||
async function setup(path: string, users: readonly string[]) {
|
||||
const store = await openStore(sqliteBackend(await openNodeSqliteDatabase(path)), context);
|
||||
const server = createHost<TestPrincipal>({
|
||||
address: hostKind("server")(),
|
||||
principal: { kind: "server" },
|
||||
store,
|
||||
onError: () => {},
|
||||
});
|
||||
await server.load(
|
||||
[{ name: "test.zoom", facet: preferenceFacet<TestPrincipal>(Zoom, "test.zoom.picks", int(), 100) }],
|
||||
context,
|
||||
);
|
||||
const clients = await Promise.all(
|
||||
users.map(async (id) => {
|
||||
const client = createHost<TestPrincipal>({ onError: () => {} });
|
||||
await client.load([], context);
|
||||
const [mine, theirs] = memoryLinkPair();
|
||||
server.accept(theirs, { principal: { kind: "user", id } }, context);
|
||||
client.connect(mine, { principal: { kind: "server" } }, context);
|
||||
return client;
|
||||
}),
|
||||
);
|
||||
const close = async () => {
|
||||
for (const client of clients) await client.close(context);
|
||||
await server.close(context);
|
||||
await store.close(context);
|
||||
};
|
||||
return { clients, close };
|
||||
}
|
||||
|
||||
/** Waits until `check` holds. */
|
||||
async function until(check: () => boolean): Promise<void> {
|
||||
for (let index = 0; index < 200 && !check(); index++) await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(check()).toBe(true);
|
||||
}
|
||||
|
||||
describe("a preference per principal", () => {
|
||||
it("is none before a choice and each principal's own after", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "preference-"));
|
||||
const { clients, close } = await setup(join(dir, "store.db"), ["ada", "grace"]);
|
||||
const [ada, grace] = await Promise.all(clients.map((client) => client.get(Zoom, {}, context)));
|
||||
expect([ada!.pick.value, grace!.pick.value]).toEqual([100, 100]);
|
||||
await ada!.choose(150, context);
|
||||
await until(() => ada!.pick.value === 150);
|
||||
expect(grace!.pick.value).toBe(100);
|
||||
await expect(grace!.choose(500, context)).rejects.toMatchObject({ code: "invalid" });
|
||||
await close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is kept in the server's store across a restart", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "preference-"));
|
||||
const first = await setup(join(dir, "store.db"), ["ada"]);
|
||||
const choice = await first.clients[0]!.get(Zoom, {}, context);
|
||||
await choice.choose(120, context);
|
||||
await first.close();
|
||||
const second = await setup(join(dir, "store.db"), ["ada"]);
|
||||
const again = await second.clients[0]!.get(Zoom, {}, context);
|
||||
expect(again.pick.value).toBe(120);
|
||||
await second.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
84
packages/chord/test/surfaces/translations.test.ts
Normal file
84
packages/chord/test/surfaces/translations.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Tests of the `Translations` registry (decision "Messages and languages in the app kit" (2)): one catalog per
|
||||
// namespace and locale, the first by plugin name winning and the other reported, `override: true` winning over a
|
||||
// catalog without it; locales canonical, invalid ones reported and left out; every locale offered listed; served on a
|
||||
// host by the kit's facet.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||
import {
|
||||
createHost,
|
||||
defineFacet,
|
||||
defineMessages,
|
||||
type RegistryConflict,
|
||||
type Translation,
|
||||
Translations,
|
||||
translationsFacet,
|
||||
} from "../../src/index.ts";
|
||||
import { composeTranslations } from "../../src/surfaces/translations.ts";
|
||||
|
||||
const fr = (word: string, override?: true): Translation => ({
|
||||
namespace: "notes",
|
||||
locale: "fr",
|
||||
messages: { newNote: word },
|
||||
...(override === undefined ? {} : { override }),
|
||||
});
|
||||
|
||||
/** The index of `contributions` (by plugin, in plugin-name order) and its reported conflicts. */
|
||||
function compose(contributions: Record<string, readonly Translation[]>) {
|
||||
const conflicts: RegistryConflict[] = [];
|
||||
const table = Object.entries(contributions).map(([plugin, value]) => ({ plugin, version: 1, value }));
|
||||
const index = composeTranslations(table, { report: (conflict) => conflicts.push(conflict) });
|
||||
const word = (namespace: string, locale: string) =>
|
||||
index.catalogs.get(namespace)?.get(locale)?.value.messages.newNote;
|
||||
return { index, conflicts, word };
|
||||
}
|
||||
|
||||
describe("composing translations", () => {
|
||||
it("keeps the first by plugin name for a namespace and locale, reporting the other", () => {
|
||||
const { word, conflicts } = compose({ "@a/fr": [fr("Nouvelle note")], "@b/fr": [fr("Note neuve")] });
|
||||
expect(word("notes", "fr")).toBe("Nouvelle note");
|
||||
expect(conflicts).toEqual([{ message: "notes in fr is contributed twice", plugins: ["@a/fr", "@b/fr"] }]);
|
||||
});
|
||||
|
||||
it("lets `override: true` win over a catalog without it, unreported", () => {
|
||||
const { word, conflicts } = compose({ "@a/notes": [fr("Note")], "@b/better": [fr("Nouvelle note", true)] });
|
||||
expect(word("notes", "fr")).toBe("Nouvelle note");
|
||||
expect(conflicts).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports two overrides, the first by plugin name winning", () => {
|
||||
const { word, conflicts } = compose({ "@a/x": [fr("A", true)], "@b/y": [fr("B", true)] });
|
||||
expect(word("notes", "fr")).toBe("A");
|
||||
expect(conflicts.map(({ plugins }) => plugins)).toEqual([["@a/x", "@b/y"]]);
|
||||
});
|
||||
|
||||
it("keys catalogs by canonical locale, leaves invalid ones out reported, lists the locales offered", () => {
|
||||
const { index, word, conflicts } = compose({
|
||||
"@a/notes": defineMessages("notes", { en: { newNote: "New note" }, "pt-br": { newNote: "Nova nota" } })
|
||||
.translations,
|
||||
"@b/bad": [{ namespace: "notes", locale: "not a tag", messages: {} }],
|
||||
});
|
||||
expect(word("notes", "pt-BR")).toBe("Nova nota");
|
||||
expect(index.locales).toEqual(["en", "pt-BR"]);
|
||||
expect(conflicts).toEqual([{ message: '"not a tag" is no locale (notes)', plugins: ["@b/bad"] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the kit's facet", () => {
|
||||
it("serves the registry on a host, composing its plugins' contributions", async () => {
|
||||
const host = createHost({ onError: () => {} });
|
||||
await host.load(
|
||||
[
|
||||
{ name: "chord.translations", facet: translationsFacet() },
|
||||
{
|
||||
name: "@test/fr",
|
||||
facet: defineFacet((_host, self) => self.contribute(Translations, [fr("Nouvelle note")])),
|
||||
},
|
||||
],
|
||||
context,
|
||||
);
|
||||
const value = await host.registry(Translations, context);
|
||||
expect(value.value.locales).toEqual(["fr"]);
|
||||
await host.close(context);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,22 @@
|
|||
// Tests of `TuiScope` (slice 11 §4, decision "Slice 11, P9"): references held for a component, watchables and
|
||||
// registries followed, one `changed` callback for every change, everything released at `dispose`, and the host's page
|
||||
// state shared by every scope (decision "Tutorial gaps" (1)).
|
||||
// state shared by every scope (decision "Tutorial gaps" (1)), and messages in the host's language, a change redrawing
|
||||
// (decision "Messages and languages in the app kit").
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../src/context/index.ts";
|
||||
import { createHost, defineFacet, defineRegistry, local, method, str } from "../../src/index.ts";
|
||||
import {
|
||||
createHost,
|
||||
createLanguage,
|
||||
defineFacet,
|
||||
defineMessages,
|
||||
defineRegistry,
|
||||
hostLanguage,
|
||||
local,
|
||||
method,
|
||||
str,
|
||||
} from "../../src/index.ts";
|
||||
import { NO_TRANSLATIONS } from "../../src/surfaces/translations.ts";
|
||||
import { TuiScope } from "../../src/tui/scope.ts";
|
||||
|
||||
const Notes = local("test.notes", { version: 1, params: {}, members: { text: str(), write: method(str()) } });
|
||||
|
|
@ -109,3 +121,28 @@ describe("a scope's page state", () => {
|
|||
await h.close(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a scope's messages", () => {
|
||||
it("render in the host's language at each call, a change of it calling changed until dispose", async () => {
|
||||
const h = await host();
|
||||
const notes = defineMessages("notes", {
|
||||
en: { title: "Notes", count: (n: number) => `${n} notes` },
|
||||
de: { title: "Notizen", count: (n) => `${n} Notizen` },
|
||||
});
|
||||
let changes = 0;
|
||||
const scope = new TuiScope(h, () => changes++);
|
||||
const t = scope.messages(notes);
|
||||
expect(t("title")).toBe("Notes");
|
||||
hostLanguage(h).set(createLanguage("de", NO_TRANSLATIONS, () => {}));
|
||||
expect(changes).toBe(1);
|
||||
expect([t("title"), t("count", 2), t.render(notes.message("count", 3))]).toEqual([
|
||||
"Notizen",
|
||||
"2 Notizen",
|
||||
"3 Notizen",
|
||||
]);
|
||||
scope.dispose();
|
||||
hostLanguage(h).set(createLanguage("en", NO_TRANSLATIONS, () => {}));
|
||||
expect(changes).toBe(1);
|
||||
await h.close(context);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue