- 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.
143 lines
8.3 KiB
TypeScript
143 lines
8.3 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import {
|
|
compilePack, conditionHolds, coverage, createStringEngine, driftReport, inventoryLookup, packInventory, sliceInventory, validateString,
|
|
checkPackShape, isPageInventory, type StringInventory,
|
|
} from '../src/stringPacks.ts';
|
|
|
|
const INVENTORY: StringInventory = {
|
|
format: 1, site: 'demo', catalog: 'web', base: 'en',
|
|
entries: {
|
|
t1: { kind: 'text', hash: 'h1' },
|
|
r2: { kind: 'rich', slots: 2, hash: 'h2' },
|
|
r0: { kind: 'rich', slots: 0, hash: 'h3' },
|
|
rx: { kind: 'rich', hash: 'h4' },
|
|
},
|
|
};
|
|
const pack = (strings: Record<string, string>, extra: object = {}) =>
|
|
({ format: 1, site: 'demo', catalog: 'web', language: 'eo', name: 'P', authors: ['a'], strings, ...extra });
|
|
|
|
test('rich values: element slots {e0}..{n-1}, each once, in any order; nothing else', () => {
|
|
const shape = inventoryLookup(INVENTORY)('r2')!;
|
|
assert.ok('parts' in validateString(shape, 'Legu {e1} antaŭ {e0}.'));
|
|
assert.ok('parts' in validateString(shape, 'Legu {e0}.'), 'the last slot may be left out');
|
|
assert.match((validateString(shape, 'Legu {e1}.') as { reason: string }).reason, /\{e0\}.*missing/);
|
|
assert.match((validateString(shape, '{e0} {e0} {e1}') as { reason: string }).reason, /once/);
|
|
assert.match((validateString(shape, '{e0} {e1} {e2}') as { reason: string }).reason, /not a parameter/);
|
|
assert.match((validateString(shape, '{e0} {e1, plural, other {x}}') as { reason: string }).reason, /not a plural/);
|
|
assert.match((validateString(shape, '<a>{e0}</a> {e1}') as { reason: string }).reason, /markup/);
|
|
assert.match((validateString(inventoryLookup(INVENTORY)('rx')!, 'x') as { reason: string }).reason, /not translatable yet/);
|
|
assert.ok('parts' in validateString(inventoryLookup(INVENTORY)('r0')!, 'Nur teksto'));
|
|
});
|
|
|
|
test('rich engine: pieces + element order, idempotent, original kept; a non-array original is left alone', () => {
|
|
const compiled = compilePack(pack({ r2: 'A {e1} B {e0} C', t1: 'Hejmo' }), INVENTORY);
|
|
assert.equal(compiled.report.valid, 2);
|
|
const engine = createStringEngine(INVENTORY, [compiled]);
|
|
const site = ['Read ', , ' now'];
|
|
const out = engine.resolve('r2', site) as { pieces: string[]; order: number[] };
|
|
assert.deepEqual(out, { pieces: ['A ', ' B ', ' C'], order: [1, 0] });
|
|
assert.equal(engine.resolve('r2', out), out, 'feeding the result back changes nothing');
|
|
assert.equal(engine.originals().get('r2'), site);
|
|
assert.equal(engine.resolve('r2', 'not an array'), 'not an array');
|
|
assert.deepEqual(engine.stats().mismatched, ['r2']);
|
|
});
|
|
|
|
test('page inventory carries rich slots; closed rich stays closed; coverage counts replaceable rich', () => {
|
|
const page = packInventory(INVENTORY);
|
|
assert.deepEqual(page.rich, { r2: 2, r0: 0 });
|
|
assert.ok(isPageInventory(JSON.parse(JSON.stringify(page))));
|
|
assert.ok(!isPageInventory({ ...page, rich: { r2: -1 } }));
|
|
assert.equal(inventoryLookup(page)('r2')!.slots, 2);
|
|
assert.equal(inventoryLookup(page)('rx')!.kind, 'rich');
|
|
assert.equal(inventoryLookup(page)('rx')!.slots, undefined);
|
|
assert.deepEqual(sliceInventory(page, ['r2']).rich, { r2: 2 });
|
|
const cover = coverage(INVENTORY, compilePack(pack({ r2: '{e0}{e1}' }), INVENTORY));
|
|
assert.deepEqual(cover.all, { translatable: 3, translated: 1, closed: 1 });
|
|
const drift = driftReport(INVENTORY, { r2: { kind: 'rich', slots: 3, hash: 'h2' } }, ['main']);
|
|
assert.deepEqual(drift.reshaped, ['r2']);
|
|
});
|
|
|
|
test('language condition: a pack with when.languages applies only on matching site languages', () => {
|
|
assert.equal(conditionHolds(undefined, undefined), true);
|
|
assert.equal(conditionHolds({ languages: ['en'] }, 'en'), true);
|
|
assert.equal(conditionHolds({ languages: ['en'] }, 'en-GB'), true);
|
|
assert.equal(conditionHolds({ languages: ['en'] }, 'EN-gb'), true);
|
|
assert.equal(conditionHolds({ languages: ['en'] }, 'tr'), false);
|
|
assert.equal(conditionHolds({ languages: ['en'] }, 'ent'), false);
|
|
assert.equal(conditionHolds({ languages: ['en'] }, undefined), false);
|
|
const compiled = compilePack(pack({ t1: 'Tweet' }, { when: { languages: ['en'] } }), INVENTORY);
|
|
const engine = createStringEngine(INVENTORY, [compiled]);
|
|
assert.equal(engine.resolve('t1', 'Post'), 'Post', 'no language yet: conditional pack waits');
|
|
engine.setLanguage('tr');
|
|
assert.equal(engine.resolve('t1', 'Gönderi'), 'Gönderi');
|
|
const english = createStringEngine(INVENTORY, [compiled]);
|
|
english.setLanguage('en-GB');
|
|
assert.equal(english.resolve('t1', 'Post'), 'Tweet');
|
|
assert.equal(english.resolve('t1', 'Tweet'), 'Tweet');
|
|
});
|
|
|
|
test('when is checked as part of the envelope', () => {
|
|
const errors = (when: unknown) => checkPackShape(pack({ t1: 'x' }, { when })).filter(issue => issue.level === 'error').length;
|
|
assert.equal(errors({ languages: ['en', 'en-GB'] }), 0);
|
|
assert.equal(errors({ languages: [] }), 1);
|
|
assert.equal(errors({ languages: ['<b>'] }), 1);
|
|
assert.equal(errors({ code: 'x' }), 1);
|
|
assert.equal(errors('en'), 1);
|
|
});
|
|
|
|
const REWRITE_INVENTORY: StringInventory = {
|
|
format: 1, site: 'demo', catalog: 'web', base: 'en',
|
|
entries: {
|
|
t1: { kind: 'text', hash: 'h' },
|
|
m1: { kind: 'message', params: ['name', 'count'], plural: ['count'], hash: 'h' },
|
|
r1: { kind: 'rich', slots: 2, hash: 'h' },
|
|
f1: { kind: 'format', hash: 'h' },
|
|
},
|
|
};
|
|
const WORDS = { Post: 'Tweet', Posts: 'Tweets', post: 'tweet', posts: 'tweets', Repost: 'Retweet' };
|
|
const wording = (extra: object = {}) => pack({}, { rewrite: { words: WORDS }, when: { languages: ['en'] }, ...extra });
|
|
|
|
test('rewrite: whole words in the own text of the site, carrying none of its sentences', () => {
|
|
const compiled = compilePack(wording(), REWRITE_INVENTORY);
|
|
assert.equal(compiled.values.size, 0);
|
|
const engine = createStringEngine(REWRITE_INVENTORY, [compiled]);
|
|
engine.setLanguage('en');
|
|
assert.equal(engine.resolve('t1', 'Post, Repost and Posts of the postal service; compost'), 'Tweet, Retweet and Tweets of the postal service; compost');
|
|
assert.equal(engine.resolve('t1', 'Nothing here'), 'Nothing here');
|
|
const site = (e: { name: string; count: number }) => `${e.name} has ${e.count} posts`;
|
|
const message = engine.resolve('m1', site) as (e: unknown) => string;
|
|
assert.equal(message({ name: 'Post Malone', count: 3 }), 'Post Malone has 3 tweets', 'a parameter value is never reworded');
|
|
assert.equal(message({ name: 'Ann', count: 1 }), 'Ann has 1 tweets');
|
|
assert.equal(engine.resolve('m1', message), message, 'feeding the result back changes nothing');
|
|
assert.equal(engine.originals().get('m1'), site);
|
|
const rich = engine.resolve('r1', ['Your post ', , ' was a Repost']) as unknown[];
|
|
assert.deepEqual(rich, ['Your tweet ', , ' was a Retweet']);
|
|
const same = ['No words'];
|
|
assert.equal(engine.resolve('r1', same), same, 'an unchanged value is the same object');
|
|
assert.equal(engine.resolve('f1', 'Post'), 'Post', 'formatters are never touched');
|
|
assert.equal(engine.resolve('zzz', 'Post'), 'Post', 'unknown keys are never touched');
|
|
});
|
|
|
|
test('rewrite: language condition, order with replacing packs, and its own validation', () => {
|
|
const compiled = compilePack(wording(), REWRITE_INVENTORY);
|
|
const turkish = createStringEngine(REWRITE_INVENTORY, [compiled]);
|
|
turkish.setLanguage('tr');
|
|
assert.equal(turkish.resolve('t1', 'Post'), 'Post');
|
|
// A later pack's own text wins and is not reworded; an earlier pack's text is.
|
|
const later = compilePack(pack({ t1: 'Post office' }, { language: 'en-x-b' }), REWRITE_INVENTORY);
|
|
const earlier = compilePack(pack({ t1: 'Post office' }, { language: 'en-x-a' }), REWRITE_INVENTORY);
|
|
const a = createStringEngine(REWRITE_INVENTORY, [compiled, later]);
|
|
a.setLanguage('en');
|
|
assert.equal(a.resolve('t1', 'Post'), 'Post office');
|
|
const b = createStringEngine(REWRITE_INVENTORY, [earlier, compiled]);
|
|
b.setLanguage('en');
|
|
assert.equal(b.resolve('t1', 'Post'), 'Tweet office');
|
|
const bad = (rewrite: unknown) => checkPackShape(wording({ rewrite })).filter(issue => issue.level === 'error').length;
|
|
assert.equal(bad({ words: { Post: 'Tweet' } }), 0);
|
|
assert.equal(bad({ words: {} }), 1);
|
|
assert.equal(bad({ words: { Post: '<b>Tweet</b>' } }), 1);
|
|
assert.equal(bad({ words: { 'two words': 'x' } }), 1);
|
|
assert.equal(bad({ words: { Post: 'Tweet' }, code: 1 }), 1);
|
|
assert.equal(bad({ words: Object.fromEntries(Array.from({ length: 65 }, (_, i) => [`w${i}`, 'x'])) }), 1);
|
|
});
|