- 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.
222 lines
12 KiB
Markdown
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.
|