- 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.
12 KiB
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
- 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.
- 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.
- An inventory of the site's known keys. A pack can change only inventory keys. An unknown key is reported and never applied.
- Packs are data. Plain text in an ICU MessageFormat subset. Never HTML, never code.
The pack
{
"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}}"
}
}
siteandcatalogname 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.languageis a BCP 47 tag; a private-use tag is fine.baseis the language whose plural rules the pack follows when the runtime does not knowlanguage; without either, every number takes theotherform.when(optional) is a condition:{ "languages": ["en"] }applies the pack only while the site shows English (a tag matches itself and its regional forms:enmatchesen-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. Withoutwhena 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, andstringsmay be empty.stringsmaps 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,
selectordinalor 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, andrichentries the inventory gives noslots).
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
{
"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
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:
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) orno-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
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.