waybetter/docs/STRING-PACKS.md
CRT 4a2e3850df String packs: rich-text entries, language conditions and word rewriting; theme doctor report; translate-mode words
- Rich sentences (a link or button inside text) can be replaced by data: the inventory gives a rich entry `slots`, a pack value places the site's own elements with {e0}, {e1}, ... (each once, the last may be left out; no markup, plural or other parameters). The engine returns a pieces + element order template; the page inventory carries `rich` slot counts.
- Packs may carry `when: { languages }` (applies only while the site shows one of them; `en` matches `en-GB`) and `rewrite: { words }` (whole-word rewording of the site's own text, messages keep their parameters' values; no sentences of the site in the pack). The hook tells the engine the site's language; a rewording pack makes the delivery carry the whole inventory.
- Apps can ship built-in packs (BuiltInPacks) delivered before installed ones; syncStringPacks hands the tabs a delivery at start only when it changed.
- Translate mode words are a function the app supplies (asked at each draw) and gain a `plural` word; host validates `when` and `rewrite` envelopes.
- Theme doctor: every finding says where (token path or CSS rule), what and how to fix, as catalog keys wb.doctor.*; the Theme Builder shows the full report; the import hint is translated. Doctor words are in the 17 supported catalogs.
- waybetter strings check describes conditions and rewrites.
2026-10-01 14:19:36 +07:00

222 lines
12 KiB
Markdown

# String packs
A string pack replaces the wrapped site's own UI strings with data: it adds a language the site
does not offer, fixes its translation, or rewords it. The framework has the formats, the validator,
the application engine, the hook interface, `waybetter strings check`, the Store kind `strings`
with its install screen, the Languages section and translate mode. Sentences with element slots
(rich text) are replaced with `{e0}`, `{e1}` placeholders for the site's own elements. A pack can also
reword the site's own text by words (`rewrite`) and apply only while the site is in some languages
(`when`).
## Rules
1. **Idempotent.** The result is a pure function of (the site's own values, the ordered pack list).
Applying twice equals applying once. Packs apply in the listed order; a later pack wins per key.
2. **The site's own language is the fallback.** A pack patches on top of whatever language the site
shows. A key no pack changes keeps exactly the site's value.
3. **An inventory of the site's known keys.** A pack can change only inventory keys. An unknown key
is reported and never applied.
4. **Packs are data.** Plain text in an ICU MessageFormat subset. Never HTML, never code.
## The pack
```json
{
"format": 1,
"site": "x",
"catalog": "web",
"language": "en-x-pirate",
"name": "Pirate",
"authors": ["Someone"],
"base": "en",
"strings": {
"a0000001": "Home port",
"b0000002": "{count, plural, one {# sighting} other {# sightings}}"
}
}
```
- `site` and `catalog` name the inventory the pack is for. One site can have several catalogs
(different builds of the site with different key spaces); each has its own inventory and packs.
- `language` is a BCP 47 tag; a private-use tag is fine. `base` is the language whose plural rules
the pack follows when the runtime does not know `language`; without either, every number takes
the `other` form.
- `when` (optional) is a condition: `{ "languages": ["en"] }` applies the pack only while the site
shows English (a tag matches itself and its regional forms: `en` matches `en-GB`; the match is
case-insensitive). The site's language is the one its catalog registers (`sink.matched`), so a
pack that rewords English never touches a Turkish page. Without `when` a pack always applies.
- `rewrite` (optional) rewords the site's own text instead of replacing keys:
`{ "words": { "Post": "Tweet", "posts": "tweets" } }`. Whole words only, case as written, 1 to 64
entries of plain letters and digits; applied to the text of every inventory key (plain strings, the
result of messages, the pieces of rich sentences) that no later pack replaced. A parameter's value
is never reworded (a user named "Post" stays "Post"). The pack carries none of the site's sentences,
so it keeps working when the site rewords a string, and `strings` may be empty.
- `strings` maps a key id to text. The ids are the inventory's; a pack never carries the site's own
text.
A value may use `{name}`, `{name, plural, one {…} other {…}}` (with `#` for the number and `=0`,
`=1` exact forms) and `{name, select, a {…} other {…}}`. A value is refused, and the site's text
stays, when:
- it names a parameter the original does not take;
- it pluralizes a parameter the original does not pluralize, or selects on one the original does
not select on (the original's plural parameters are the ones known to be numbers);
- a plural or select has no `other`;
- it uses a number or date format, `selectordinal` or a plural offset;
- it contains `<` or `>` (unless the inventory gives the key tag slots; then exactly those tags);
- it contains control or bidi-override characters, is empty or is longer than 2000 characters;
- the key's kind cannot be replaced by data yet (`format`, and `rich` entries the inventory gives no
`slots`).
A `rich` value (a sentence with a link or button inside) uses the original's element slots:
`{e0}` … `{e<slots-1>}` stand for the site's own elements, each exactly once (the last one may be
left out: the element after the final piece is not always there), in any order your sentence needs.
No plural, select, parameter or markup. A pack never carries HTML; the elements are the site's.
A parameter is inserted the way string concatenation would insert it (`String(value)`).
## The inventory
```json
{
"format": 1, "site": "x", "catalog": "web", "base": "en", "build": "…",
"entries": {
"a0000001": { "kind": "text", "hash": "0k3j…" },
"b0000002": { "kind": "message", "params": ["count"], "plural": ["count"], "hash": "…" },
"c0000003": { "kind": "rich", "slots": 2, "group": "rich", "hash": "…" }
}
}
```
| Kind | The site's value | A pack can replace it |
|---|---|---|
| `text` | a string | yes |
| `message` | a function of parameters that returns a string | yes |
| `rich` | text with element slots (`slots`) | yes, when the inventory knows `slots` |
| `format` | a compiled number, date or currency formatter | no |
`slots` (rich) is the number of element positions of the site's sentence: piece n is followed by the
caller's element n. `hash` is a hash of the base-language value (`hashText`), for the drift report; the inventory holds
no text of the site. `group` names the part of the catalog a key arrives with (default `main`).
`driftReport(inventory, observed, loadedGroups)` compares an inventory with a newer build: `added`,
`reworded` (the hash changed), `reshaped` (kind or parameters changed) and `vanished`. A key is
called vanished only when its group's base catalog was actually read.
`packInventory(inventory)` is the compact form: ids and shapes, no hashes. A page does not need
the whole inventory: `sliceInventory(inventory, ids)` is the part that the packs' keys need, and it
travels to the page together with the packs (`isPageInventory` checks its shape on arrival). The
full inventory stays where packs are installed and checked.
## The engine
```ts
const engine = createStringEngine(inventory, [compilePack(packA, inventory), compilePack(packB, inventory)]);
engine.resolve(id, siteValue); // packB's value, else packA's, else exactly siteValue
```
`resolve` returns a string for a `text` key, a function with the original's call shape for a
`message` key (closures over the parsed message; nothing is evaluated) and, for a `rich` key, a
`RichTemplate` `{ pieces, order }`: piece n is shown, then the site's element `order[n]` (the shape
X's own sentence renderer reads; wiring it into a site's renderer is the adapter's part). A pack's
`rewrite` is applied to whichever value results, so a reworded message is a function again.
`engine.setLanguage(language)` tells the engine which language the site shows (the hook does it when
the adapter reports `matched`); conditional packs wait for it. Feeding a value the engine
produced back in returns the same value. It keeps the site's own value of every key it changed
(`engine.originals()`), which is what a restore needs; `applyPacks` and `restoreCatalog` do both
for a whole catalog object without changing it. A live value whose kind differs from the
inventory's is left alone and listed in `engine.stats().mismatched`; a key the site has and the
inventory lacks is listed in `unlisted`.
## Hooking a site
How a site hands its strings to its own code is that site's implementation detail, so the hook is
an adapter the app supplies:
```ts
const adapter: CatalogHookAdapter = {
catalog: 'web',
install(win, sink) {
// At document start: arrange for the site's catalog to pass through
// sink.seen(group) a catalog file is loading
// sink.matched(language, group) the hook matched
// sink.resolve(id, original) the value to register
return () => { /* remove the hook */ };
},
};
const handle = installStringPacks(window, [{ adapter, inventory, packs }], { onStatus });
```
- The adapter must not change the site's objects: hand the site a shadow
(`Object.create(module)`) instead.
- Everything fails open: an exception in a wrapper leaves the site's behaviour as it was.
- A catalog without a usable pack is not hooked at all.
- The self-check decides after `settleMs`: `ok`, `not-matched` (a catalog file was seen but the
hook did not match: the site changed) or `no-catalog` (no catalog file was seen).
For a webpack site, `watchChunkArray(win, name, onChunk)` sees every chunk before the site's own
`push` gets it and passes it on unchanged; `mapChunkRequires(chunk, map)` lets one identified
chunk's modules receive a shadow of what they `require`.
`installTextNet(win, { map })` is a DOM safety net for labels no catalog hook can reach (the server
sends them already worded): exact text in text nodes, `aria-label`, `placeholder` and `title`, the
site's value kept per node, restored on `dispose()`. It is secondary: it cannot see parameters or
plural forms.
## Checking a pack
```sh
waybetter strings check pack.strings.json --inventory strings/x.web.inventory.json
```
Prints what was refused and why, the unknown keys, and the coverage per group (translated of
translatable, and how many keys are not translatable yet). Exit 1 when anything would not be
applied. Without `--inventory`, the inventories come from `waybetter.strings.json`
(`{ "inventories": ["…"] }`) in the app's root.
## Installing and turning packs on
String packs are a Store kind (`kind: "strings"`, with `language`; every item may also carry
`license` and `credits`) and install like themes and plugins: a dropped `*.strings.json`, a
`waybetter://install?kind=strings&url=…` link or the signed Store, always through the install screen.
The screen shows the pack's language, its coverage against the site's inventory, what is ignored
and what cannot be replaced yet, a sample of its text, that it is data only, and the pack file.
Native code (waybetter-host `store::strings`) only keeps data: it checks the envelope and that every
value is plain text, writes `<app data>/strings/<id>.json` and remembers the order and which packs
are on (at most 8). It never sees the inventory. Shell-only commands: `strings_list`,
`strings_read`, `strings_set {order, enabled}`, `strings_remove {id}`, `strings_apply {delivery}`.
The shell builds the delivery (`buildDelivery`: the packs that are on, in order, with the slice of
the inventory their keys need) and hands it to `strings_apply`. Native code stores it under a new
revision and puts it into every site tab's session storage (`wayBetter.stringPacks`) with a
data-only script that checks `https:` and the site's own hosts, then reloads the tab; a new tab
gets the same script as the first document-start layer. Safe mode leaves the layer out. A tab keeps
the newest revision it was given. The page runtime reads the delivery at document start, before
the site's scripts.
The Languages section of the panel (`createStringPacksSection({ inventory })`) lists the installed
packs with their coverage, turns them on and off, orders and removes them.
### Built-in packs
An app can ship packs of its own (`BuiltInPacks` in `panel/StringPacks.tsx`: a function returning pack
documents as text). They are delivered before the installed packs, so an installed pack wins where both
change a word, they follow the app's settings and they are not listed or removable. `syncStringPacks`
hands the tabs the delivery at start only when it differs from the one they were last given (every tab
reloads for a change). wayBetter for X's Twitter wording (Post -> Tweet, Repost -> Retweet) is one: a
`rewrite` pack for English that is delivered while its setting is on. A delivery with a rewording pack
carries the whole page inventory (the engine needs every key's shape), not a slice.
## Translate mode
Switched on in the Languages section. The delivery then carries the whole inventory, packs or not,
and the page runtime marks every replaceable string with an invisible marker naming its key
(`createMarker`). A bar at the bottom of each site tab says so; a click on a word opens an editor
with the key, the current text and its parameters. An edit is checked with the pack rules
(`checkEdit`) and kept in the tab's draft (`wayBetter.translateDraft`), which applies as the last
pack after a reload. "Copy pack file" copies the draft as a pack document. "Leave" turns the mode
off for that tab. The bar's and the editor's words are the app's own (`words` option of `installTranslateUi`, a function
that is asked again each time they are drawn, so a language switch shows at once); the English
defaults are `TRANSLATE_WORDS`. The markers can reach places that compare text exactly (the window title), so the
mode is for authors only and is never on by default.