# 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}` 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 `/strings/.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.