@mccaskie-labs/workbench-strings 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # @mccaskie-labs/workbench-strings
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - No code change. The first version published by the publish workflow, which confirms the package's npm trusted publisher.
8
+
9
+ ## 0.1.0
10
+
11
+ - First version: `defineStrings` (lookup with named placeholders and one level of plural, one fallback chain, detection with country before Accept-Language, the review gate, `email.<name>.<part>` lookups), the `workbench-strings build|check` command that strips back-translation glosses, and the test kit at `/testing` (`docs/spec.md` §10, 11 November 2026).
package/README.md CHANGED
@@ -1,3 +1,115 @@
1
- # Temporary Holding Version
1
+ # @mccaskie-labs/workbench-strings
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The words an app's own screens and emails say, for apps built on Workbench, a private platform. One file per language in the app's repository, one lookup with named placeholders and plurals, one fallback chain, language detection, back-translation notes stripped at build, the translation review gate, and the internationalization standard's tests as a kit. Published publicly so Workbench projects can install it with no token; it is not supported for other uses.
4
+
5
+ It is separate from the Gateway's voices and language packs for AI writing (`docs/spec.md` §2.8): those guide what the AI drafts; this holds the product's fixed, reviewed words. It follows `internationalization.md` in Robert's project standards; section numbers below (§) are that standard's.
6
+
7
+ **Server only.** The catalogue holds every language, so importing it into browser code would ship them all to every visitor. Look words up on the server and pass the finished words to the page. A browser bundle that imports the package gets a module that throws.
8
+
9
+ ```sh
10
+ npm install @mccaskie-labs/workbench-strings @mccaskie-labs/workbench-config zod
11
+ ```
12
+
13
+ ## Files
14
+
15
+ ```
16
+ strings/
17
+ strings.en-AU.json the base: authored, complete, no glosses
18
+ strings.en-PH.json a translation: words plus `_gloss.` back-translations
19
+ runtime/
20
+ strings.en-PH.json GENERATED by `npx workbench-strings build --base en-AU`
21
+ ```
22
+
23
+ Flat JSON, one entry per line. Keys start with a name group (§5.3): `ui.`, `enum.`, `question.`, `validation.`, `email.`, `notification.`, `page.`, `error.`, `meta.`. A new class of string adds a group to this package by PR. Keys starting `_` are metadata, never words:
24
+
25
+ | Key | In | Meaning |
26
+ |---|---|---|
27
+ | `_gloss.<key>` | translations only | A literal back-translation of what the translation says, next to it so an edit to one shows beside the other. Stripped at build. Never counts as review |
28
+ | `_review.status` | every translation | How it was made and reviewed, e.g. "AI-DRAFTED, NOT REVIEWED BY A NATIVE SPEAKER". Survives the strip |
29
+ | `_review.reviewer` | every translation, before production | The named native-speaker reviewer and cadence: `"Maria Santos, monthly"` |
30
+ | `_review.signed_off` | when reviewed | `"Maria Santos, 2026-11-11"`. Its presence is the only thing that lets the language be served to someone who did not choose it |
31
+
32
+ Commit the generated files. `npx workbench-strings check --base en-AU` runs the file checks in CI and fails on a stale generated file.
33
+
34
+ ## Use
35
+
36
+ ```ts
37
+ // SERVER ONLY: the catalogue holds every language. Pass finished words to the page.
38
+ import { defineStrings } from '@mccaskie-labs/workbench-strings'
39
+ import { workbenchEnv } from '@mccaskie-labs/workbench-config'
40
+ import enAU from '../strings/strings.en-AU.json'
41
+ import enPH from '../strings/runtime/strings.en-PH.json'
42
+
43
+ export const strings = defineStrings({
44
+ env: workbenchEnv(env),
45
+ base: { locale: 'en-AU', strings: enAU },
46
+ translations: { 'en-PH': enPH },
47
+ locales: ['en-PH', 'en-AU'], // switcher order; each must be a Workbench language
48
+ defaultLocale: 'en-PH', // what an unknown visitor sees
49
+ })
50
+
51
+ const { locale } = strings.detect(request, { account: user?.locale })
52
+ strings.t(locale, 'ui.cart.count', { count: 3 }) // "3 items in your cart"
53
+ strings.email(strings.recipientLocale(user.locale), 'welcome', 'subject', { app: 'Sulok' })
54
+ ```
55
+
56
+ `defineStrings` checks everything at start and throws `WorkbenchStringsError` (`invalid_options`, `invalid_catalogue`, `invalid_string`, `no_reviewer`). Keys are typed from the base file, so a key that does not exist is a type error.
57
+
58
+ ### Strings
59
+
60
+ - `{name}` placeholders, named only, and one level of plural: `"{count, plural, =0 {Your cart is empty} one {# item} other {# items}}"`. The arm is chosen by `Intl.PluralRules` for the real locale; `=0` arms first; `other` is required; `#` goes through `Intl.NumberFormat`. No select, no nesting.
61
+ - A `{name}` value is inserted as given, so a year or an order number is never grouped. Format money and dates first (`workbench-country`).
62
+ - On a page a missing value stays visible as `{name}`; in an email it throws `missing_value`.
63
+ - One key holds one complete sentence. Never branch on a count in code.
64
+
65
+ ### The fallback chain
66
+
67
+ Each locale falls back to its parent (`parents`) and then to the base. A register (a dialect or formality tier with its own tag) needs its tag in the shared language list first, which today holds only `lang-REGION` tags. A word missing in a translation comes from the next locale on the chain and is logged as one JSON line (`strings.missing`, with `requested` and `resolved`); pass `onMissing` to send it elsewhere. `t()` and `email()` use the same chain.
68
+
69
+ - A key missing even in the base is a bug the kit catches: `t()` shows the key and logs an error; `email()` throws `missing_string`, because an email has nobody watching it fail.
70
+
71
+ ### Detection
72
+
73
+ `detect(request, { account })` checks, in order: the URL prefix (`/en-ph/...`), the `__Host-locale` cookie, the account's saved language, the country (`request.cf.country`), `Accept-Language`, then `defaultLocale`.
74
+
75
+ - **Country before Accept-Language.** In the Philippines a phone reports English whatever its owner reads. The header stays below country for someone abroad who set their home language.
76
+ - An unknown country (`XX`, Tor, none in local development) falls through to the header. A known country with no locale and no usable header gets the base.
77
+ - Countries default to each locale's region when only one locale has it (en-PH serves PH); set `countries` to change that. `aliases` adds other header tags.
78
+ - `localeFromPath`, `cookie(locale)` (Secure, Lax, a year, no Domain) and `prefixes` (lower-case tags by default; none owns the bare tree) help with routing.
79
+
80
+ ### The review gate
81
+
82
+ A language nobody has signed off is served only to someone who chose it: the URL prefix, the cookie and the account are honoured; country, `Accept-Language` and the default resolve to the base instead (`gated: true`). The browser's language counts as inferred, not chosen; that is one line in `src/detect.ts`. Lifting the gate is a catalogue edit: name the reviewer, agree the cadence, they review, write `_review.signed_off`.
83
+
84
+ In production a translated language with no `_review.reviewer` refuses to start (`no_reviewer`): a named native-speaker reviewer is needed before any translated word goes public. Staging sits behind Access, so it can carry unreviewed words for the review itself.
85
+
86
+ ### Emails
87
+
88
+ This package owns the `email.<name>.<part>` keys. `email(locale, name, part, values)` looks up `email.welcome.subject` and so on in the recipient's saved language, at send time, one recipient at a time. The sign-in kit's email catalogue (`workbench-email`) names the emails and their parts.
89
+
90
+ ### Languages
91
+
92
+ `WORKBENCH_LANGUAGES` is the one list, shared with the Gateway's spelling choices (`capabilities/ai.json`): en-AU, en-PH, en-GB, en-US today. A new language is added there first. Planned locales go in `planned`, not in `locales`, and have no file until they have words.
93
+
94
+ ## The test kit
95
+
96
+ ```ts
97
+ import { checkStrings } from '@mccaskie-labs/workbench-strings/testing'
98
+ import { strings } from '../src/strings'
99
+
100
+ const authored = import.meta.glob('../strings/strings.*.json', { query: '?raw', import: 'default', eager: true })
101
+ // ...map paths to locales, the same for runtime/ and for the app's sources
102
+
103
+ for (const check of checkStrings({ strings, authored, runtime, sources, emails, composed: ['validation.'] }))
104
+ it(check.name, () => expect(check.problems).toEqual([]))
105
+ ```
106
+
107
+ It runs the standard's tests (§11) on the app's own files, settings and code: key coverage, no empty base values, placeholder parity, review status declared, chain termination and shape, each step of the detection order, country before header, unknown country falls through, quality weights, aliases, prefix distinctness, a host-bound cookie, unknown locales survive, gloss presence and orphans (on the authored files), the runtime file is byte for byte the authored file minus glosses and keeps its marker, the dead-key sweep and unknown keys (exact matches, never substrings), forbidden voice words on word boundaries, every declared email part exists, plurals render in every locale, `defineStrings` was given these very files, and the gate is armed. Glosses are needed only for a language other than the base's (an en-AU reviewer reads en-PH). It needs no test framework and reads text, not paths, so it runs in the Workers test runtime.
108
+
109
+ - `emails` (required, `[]` for none): the app's emails and their parts. The `email.` keys are checked against these, never swept as dead.
110
+ - `chains`: each locale's expected fallback chain. Required when the app sets `parents`, so a register's route through its parent is pinned.
111
+ - `sources`: the app's own code only. A catalogue file among them would vouch for its own keys, and the kit says so.
112
+ - `composed`: other key prefixes built at run time, never swept as dead. Add `validation.` when the app uses `workbench-forms`, whose codes become `validation.<form>.<field>.<code>` and `validation.<code>`.
113
+ - `forbidden`: words the brand voice forbids, per locale (the standard's question E6).
114
+
115
+ What the kit cannot check: whether a page actually renders in each language. Fetch every page in every locale by hand before the first translated release (§14.1).
File without changes
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ // What a browser bundle gets instead of the package (the "browser" export
3
+ // condition). The catalogue holds every language, so it must never ship to a
4
+ // phone: look words up on the server and pass the finished words to the page
5
+ // (internationalization standard §7.5).
6
+ throw new Error("@mccaskie-labs/workbench-strings is server only. Look the words up on the server and pass the finished words to the page.");
@@ -0,0 +1,32 @@
1
+ export type Catalogue = Readonly<Record<string, string>>;
2
+ export declare const GLOSS = "_gloss.";
3
+ export declare const REVIEW_STATUS = "_review.status";
4
+ export declare const isMeta: (key: string) => boolean;
5
+ /**
6
+ * Reads an authored or generated file. It must be a flat object of strings
7
+ * with one entry per line and no key twice (JSON.parse would keep the last
8
+ * duplicate without a word).
9
+ */
10
+ export declare function readCatalogue(text: string, name?: string): Catalogue;
11
+ /**
12
+ * The generated runtime file: the authored text minus its `_gloss.` lines,
13
+ * byte for byte otherwise. The `_review.` markers stay, because a marker that
14
+ * exists to stop a release must travel with the thing being released.
15
+ */
16
+ export declare function stripGlosses(text: string, name?: string): string;
17
+ export interface Review {
18
+ /** How the language was produced and reviewed, from `_review.status`. */
19
+ status: string | undefined;
20
+ /** The named native-speaker reviewer and their cadence. */
21
+ reviewer: {
22
+ name: string;
23
+ cadence: string;
24
+ } | undefined;
25
+ /** Who signed it off, and when. Undefined means not signed off. */
26
+ signedOff: {
27
+ name: string;
28
+ date: string;
29
+ } | undefined;
30
+ }
31
+ /** Reads a catalogue's review markers. A malformed marker throws: fix it, don't guess. */
32
+ export declare function readReview(catalogue: Catalogue, name?: string): Review;
@@ -0,0 +1,115 @@
1
+ // Catalogue files (internationalization standard §5.4, §10.3, §10.4).
2
+ //
3
+ // strings/strings.<base>.json authored, the source of truth, no glosses
4
+ // strings/strings.<locale>.json authored: strings plus `_gloss.<key>` twins
5
+ // strings/runtime/strings.<locale>.json GENERATED: the authored file minus glosses
6
+ //
7
+ // Flat JSON, one entry per line. Keys starting `_` are metadata, never words:
8
+ // every lookup and check skips them.
9
+ //
10
+ // - `_gloss.<key>` is a literal back-translation of what the translation
11
+ // says, kept next to it in the authored file so an edit to one shows beside
12
+ // the other in the diff. It is stripped at build, line by line, so the
13
+ // generated file is byte for byte the authored file minus those lines.
14
+ // - `_review.status` says how the language was produced and reviewed. Every
15
+ // translated catalogue carries it, and it survives the strip.
16
+ // - `_review.reviewer` is "Name, cadence": the named native-speaker reviewer.
17
+ // A translated language can't reach production without one.
18
+ // - `_review.signed_off` is "Name, YYYY-MM-DD". Its presence is the only
19
+ // thing that lets a language be served to someone who did not choose it.
20
+ import { WorkbenchStringsError } from "./errors.js";
21
+ export const GLOSS = "_gloss.";
22
+ export const REVIEW_STATUS = "_review.status";
23
+ const REVIEW_REVIEWER = "_review.reviewer";
24
+ const REVIEW_SIGNED_OFF = "_review.signed_off";
25
+ export const isMeta = (key) => key.startsWith("_");
26
+ // One `"key": "value"` entry per line, with an optional trailing comma.
27
+ const ENTRY = /^\s*"((?:[^"\\]|\\.)*)"\s*:\s*"(?:[^"\\]|\\.)*"\s*,?\s*$/;
28
+ function bad(name, why) {
29
+ throw new WorkbenchStringsError("invalid_catalogue", `${name}: ${why}`);
30
+ }
31
+ /**
32
+ * Reads an authored or generated file. It must be a flat object of strings
33
+ * with one entry per line and no key twice (JSON.parse would keep the last
34
+ * duplicate without a word).
35
+ */
36
+ export function readCatalogue(text, name = "catalogue") {
37
+ let parsed;
38
+ try {
39
+ parsed = JSON.parse(text);
40
+ }
41
+ catch (e) {
42
+ bad(name, `not valid JSON (${e.message})`);
43
+ }
44
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
45
+ bad(name, "not a JSON object");
46
+ const entries = Object.entries(parsed);
47
+ for (const [k, v] of entries)
48
+ if (typeof v !== "string")
49
+ bad(name, `"${k}" is not a string (the file must be flat)`);
50
+ const seen = new Set();
51
+ let lines = 0;
52
+ for (const line of text.split("\n")) {
53
+ const m = ENTRY.exec(line);
54
+ if (!m)
55
+ continue;
56
+ lines++;
57
+ const key = JSON.parse(`"${m[1]}"`);
58
+ if (seen.has(key))
59
+ bad(name, `"${key}" appears twice`);
60
+ seen.add(key);
61
+ }
62
+ if (lines !== entries.length)
63
+ bad(name, "write one entry per line");
64
+ return Object.freeze(Object.fromEntries(entries));
65
+ }
66
+ /**
67
+ * The generated runtime file: the authored text minus its `_gloss.` lines,
68
+ * byte for byte otherwise. The `_review.` markers stay, because a marker that
69
+ * exists to stop a release must travel with the thing being released.
70
+ */
71
+ export function stripGlosses(text, name = "catalogue") {
72
+ readCatalogue(text, name);
73
+ const kept = [];
74
+ for (const line of text.split("\n")) {
75
+ const m = ENTRY.exec(line);
76
+ if (m && JSON.parse(`"${m[1]}"`).startsWith(GLOSS))
77
+ continue;
78
+ kept.push(line);
79
+ }
80
+ // Dropping the last entry leaves a trailing comma on the one before it.
81
+ for (let i = kept.length - 1; i >= 0; i--) {
82
+ if (!ENTRY.test(kept[i]))
83
+ continue;
84
+ kept[i] = kept[i].replace(/,(\s*)$/, "$1");
85
+ break;
86
+ }
87
+ const out = kept.join("\n");
88
+ readCatalogue(out, `${name} (generated)`);
89
+ return out;
90
+ }
91
+ /** Reads a catalogue's review markers. A malformed marker throws: fix it, don't guess. */
92
+ export function readReview(catalogue, name = "catalogue") {
93
+ const status = catalogue[REVIEW_STATUS]?.trim() || undefined;
94
+ let reviewer;
95
+ const r = catalogue[REVIEW_REVIEWER];
96
+ if (r !== undefined) {
97
+ const m = /^\s*([^,]*\S)\s*,\s*(\S.*?)\s*$/.exec(r);
98
+ if (!m)
99
+ bad(name, `${REVIEW_REVIEWER} must be "Name, cadence", e.g. "Maria Santos, monthly"`);
100
+ reviewer = { name: m[1], cadence: m[2] };
101
+ }
102
+ let signedOff;
103
+ const s = catalogue[REVIEW_SIGNED_OFF];
104
+ if (s !== undefined) {
105
+ const m = /^\s*([^,]*\S)\s*,\s*(\d{4}-\d{2}-\d{2})\s*$/.exec(s);
106
+ const date = m ? new Date(`${m[2]}T00:00:00Z`) : undefined;
107
+ if (!m || !date || Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== m[2]) {
108
+ bad(name, `${REVIEW_SIGNED_OFF} must be "Name, YYYY-MM-DD", e.g. "Maria Santos, 2026-11-11"`);
109
+ }
110
+ if (date.getTime() > Date.now() + 86_400_000)
111
+ bad(name, `${REVIEW_SIGNED_OFF} is dated in the future (${m[2]})`);
112
+ signedOff = { name: m[1], date: m[2] };
113
+ }
114
+ return { status, reviewer, signedOff };
115
+ }
@@ -0,0 +1,15 @@
1
+ export interface StringsCheck {
2
+ /** What the check asserts, from the standard's table. */
3
+ name: string;
4
+ /** Empty when it passes. */
5
+ problems: string[];
6
+ }
7
+ export interface FileInputs {
8
+ /** The base locale. */
9
+ base: string;
10
+ /** Raw text of every authored file, strings/strings.<locale>.json, by locale. */
11
+ authored: Readonly<Record<string, string>>;
12
+ /** Raw text of every generated file, strings/runtime/strings.<locale>.json, by locale (no base). */
13
+ runtime: Readonly<Record<string, string>>;
14
+ }
15
+ export declare function checkFiles(input: FileInputs): StringsCheck[];
package/dist/checks.js ADDED
@@ -0,0 +1,128 @@
1
+ // The file checks (internationalization standard §11) that need only the
2
+ // files. The test kit runs them with the rest, and `workbench-strings check`
3
+ // runs them in CI without the app's code.
4
+ //
5
+ // The gloss checks read the AUTHORED files: against the generated ones every
6
+ // assertion would pass with nothing to check.
7
+ import { GLOSS, isMeta, readCatalogue, readReview, REVIEW_STATUS, stripGlosses } from "./catalogue.js";
8
+ import { parseMessage, placeholdersOf } from "./format.js";
9
+ const words = (c) => Object.keys(c).filter((k) => !isMeta(k));
10
+ export function checkFiles(input) {
11
+ const read = { name: "Files are flat, one entry per line, no key twice", problems: [] };
12
+ const cats = new Map();
13
+ for (const [l, text] of Object.entries(input.authored)) {
14
+ try {
15
+ cats.set(l, readCatalogue(text, `strings.${l}.json`));
16
+ }
17
+ catch (e) {
18
+ read.problems.push(e.message);
19
+ }
20
+ }
21
+ const baseCat = cats.get(input.base);
22
+ if (!(input.base in input.authored))
23
+ read.problems.push(`no authored file for the base, ${input.base}`);
24
+ if (!baseCat)
25
+ return [read];
26
+ const others = [...cats].filter(([l]) => l !== input.base);
27
+ const baseKeys = new Set(words(baseCat));
28
+ const coverage = { name: "Key coverage: no locale has a key the base lacks", problems: [] };
29
+ const empty = { name: "No empty base values", problems: [] };
30
+ const parse = { name: "Every string's placeholders and plurals parse", problems: [] };
31
+ const parity = { name: "Placeholder parity with the base", problems: [] };
32
+ const status = { name: "Review status declared in every translated catalogue", problems: [] };
33
+ const markers = { name: "Review markers are well formed", problems: [] };
34
+ const baseGloss = { name: "The base carries no gloss", problems: [] };
35
+ const glossPresent = { name: "Gloss presence: every translated key has a gloss (not needed in the base's own language)", problems: [] };
36
+ const glossOrphans = { name: "Gloss orphans: no gloss without its key", problems: [] };
37
+ const equal = { name: "Runtime catalogue is byte for byte the authored file minus glosses", problems: [] };
38
+ const runtimeMarker = { name: "Runtime carries no gloss and keeps the review marker", problems: [] };
39
+ const basePlaceholders = new Map();
40
+ for (const [l, cat] of cats) {
41
+ for (const k of words(cat)) {
42
+ let ph;
43
+ try {
44
+ ph = placeholdersOf(parseMessage(cat[k])).join(",");
45
+ }
46
+ catch (e) {
47
+ parse.problems.push(`${l} ${k}: ${e.message}`);
48
+ continue;
49
+ }
50
+ if (l === input.base)
51
+ basePlaceholders.set(k, ph);
52
+ }
53
+ try {
54
+ readReview(cat, l);
55
+ }
56
+ catch (e) {
57
+ markers.problems.push(e.message);
58
+ }
59
+ }
60
+ for (const k of baseKeys)
61
+ if (baseCat[k].trim() === "")
62
+ empty.problems.push(`${input.base} ${k} is empty`);
63
+ for (const k of Object.keys(baseCat))
64
+ if (k.startsWith(GLOSS))
65
+ baseGloss.problems.push(`${input.base} has ${k}`);
66
+ const language = (l) => l.toLowerCase().split("-")[0];
67
+ for (const [l, cat] of others) {
68
+ // A reviewer who reads en-AU reads en-PH: a gloss would only repeat the string.
69
+ const needsGloss = language(l) !== language(input.base);
70
+ for (const k of words(cat)) {
71
+ if (!baseKeys.has(k)) {
72
+ coverage.problems.push(`${l} has ${k}, which the base does not`);
73
+ continue;
74
+ }
75
+ try {
76
+ const ph = placeholdersOf(parseMessage(cat[k])).join(",");
77
+ const want = basePlaceholders.get(k);
78
+ if (want !== undefined && ph !== want)
79
+ parity.problems.push(`${l} ${k} uses {${ph}}, the base uses {${want}}`);
80
+ }
81
+ catch {
82
+ // Reported by the parse check.
83
+ }
84
+ const gloss = cat[`${GLOSS}${k}`];
85
+ if (needsGloss && gloss === undefined)
86
+ glossPresent.problems.push(`${l} ${k} has no ${GLOSS}${k}`);
87
+ if (gloss !== undefined && gloss.trim() === "")
88
+ glossPresent.problems.push(`${l} ${GLOSS}${k} is empty`);
89
+ }
90
+ for (const g of Object.keys(cat).filter((k) => k.startsWith(GLOSS))) {
91
+ if (cat[g.slice(GLOSS.length)] === undefined)
92
+ glossOrphans.problems.push(`${l} ${g} has no ${g.slice(GLOSS.length)}`);
93
+ }
94
+ if (!cat[REVIEW_STATUS]?.trim())
95
+ status.problems.push(`${l} has no ${REVIEW_STATUS}`);
96
+ const runtime = input.runtime[l];
97
+ if (runtime === undefined) {
98
+ equal.problems.push(`${l} has no generated file. Run: npx workbench-strings build`);
99
+ continue;
100
+ }
101
+ try {
102
+ if (runtime !== stripGlosses(input.authored[l], `strings.${l}.json`)) {
103
+ equal.problems.push(`runtime/strings.${l}.json is stale. Run: npx workbench-strings build`);
104
+ }
105
+ const rc = readCatalogue(runtime, `runtime/strings.${l}.json`);
106
+ // Checked again without stripGlosses, so a strip bug can't vouch for itself.
107
+ const want = Object.entries(cat).filter(([k]) => !k.startsWith(GLOSS));
108
+ if (JSON.stringify(Object.entries(rc)) !== JSON.stringify(want)) {
109
+ equal.problems.push(`runtime/strings.${l}.json does not hold exactly the authored entries minus glosses`);
110
+ }
111
+ if (Object.keys(rc).some((k) => k.startsWith(GLOSS)))
112
+ runtimeMarker.problems.push(`runtime/strings.${l}.json has glosses`);
113
+ if (cat[REVIEW_STATUS] !== undefined && rc[REVIEW_STATUS] !== cat[REVIEW_STATUS]) {
114
+ runtimeMarker.problems.push(`runtime/strings.${l}.json lost ${REVIEW_STATUS}`);
115
+ }
116
+ }
117
+ catch (e) {
118
+ equal.problems.push(e.message);
119
+ }
120
+ }
121
+ for (const l of Object.keys(input.runtime)) {
122
+ if (l === input.base)
123
+ equal.problems.push(`the base, ${l}, gets no generated file: delete runtime/strings.${l}.json`);
124
+ else if (!(l in input.authored))
125
+ equal.problems.push(`runtime/strings.${l}.json has no authored file`);
126
+ }
127
+ return [read, coverage, empty, parse, parity, status, markers, baseGloss, glossPresent, glossOrphans, equal, runtimeMarker];
128
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv: string[]): number;
package/dist/cli.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // `npx workbench-strings build [dir] --base <locale>`
3
+ // Writes strings/runtime/strings.<locale>.json for every translated
4
+ // language: the authored file minus its back-translation glosses. The base
5
+ // gets no generated file.
6
+ // `npx workbench-strings check [dir] --base <locale>`
7
+ // The file checks (internationalization standard §11) and a stale-runtime
8
+ // check, for CI. Exit 1 on any problem.
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { stripGlosses } from "./catalogue.js";
13
+ import { checkFiles } from "./checks.js";
14
+ const FILE = /^strings\.([A-Za-z0-9-]+)\.json$/;
15
+ function readDir(dir) {
16
+ if (!existsSync(dir))
17
+ return {};
18
+ const out = {};
19
+ for (const f of readdirSync(dir)) {
20
+ const m = FILE.exec(f);
21
+ if (m)
22
+ out[m[1]] = readFileSync(join(dir, f), "utf8");
23
+ }
24
+ return out;
25
+ }
26
+ export function main(argv) {
27
+ const args = [...argv];
28
+ const baseAt = args.indexOf("--base");
29
+ const base = baseAt === -1 ? undefined : args.splice(baseAt, 2)[1];
30
+ const [command, dir = "strings"] = args;
31
+ if ((command !== "build" && command !== "check") || !base) {
32
+ console.error("Usage: workbench-strings build|check [dir] --base <locale> (dir defaults to strings)");
33
+ return 2;
34
+ }
35
+ const authored = readDir(dir);
36
+ if (!(base in authored)) {
37
+ console.error(`No ${join(dir, `strings.${base}.json`)}`);
38
+ return 1;
39
+ }
40
+ const runtimeDir = join(dir, "runtime");
41
+ if (command === "build") {
42
+ if (/"_gloss\./.test(authored[base])) {
43
+ console.error(`strings.${base}.json is the base and must have no glosses`);
44
+ return 1;
45
+ }
46
+ mkdirSync(runtimeDir, { recursive: true });
47
+ for (const locale of Object.keys(readDir(runtimeDir))) {
48
+ if (locale === base || !(locale in authored)) {
49
+ rmSync(join(runtimeDir, `strings.${locale}.json`));
50
+ console.log(`removed ${join(runtimeDir, `strings.${locale}.json`)}`);
51
+ }
52
+ }
53
+ for (const [locale, text] of Object.entries(authored)) {
54
+ if (locale === base)
55
+ continue;
56
+ try {
57
+ writeFileSync(join(runtimeDir, `strings.${locale}.json`), stripGlosses(text, `strings.${locale}.json`));
58
+ }
59
+ catch (e) {
60
+ console.error(e.message);
61
+ return 1;
62
+ }
63
+ console.log(`wrote ${join(runtimeDir, `strings.${locale}.json`)}`);
64
+ }
65
+ return 0;
66
+ }
67
+ let failed = 0;
68
+ for (const c of checkFiles({ base, authored, runtime: readDir(runtimeDir) })) {
69
+ if (c.problems.length === 0)
70
+ continue;
71
+ failed++;
72
+ console.error(`✗ ${c.name}`);
73
+ for (const p of c.problems)
74
+ console.error(` ${p}`);
75
+ }
76
+ if (failed === 0)
77
+ console.log("Strings files pass every file check.");
78
+ return failed === 0 ? 0 : 1;
79
+ }
80
+ // Run only when this file is the program, compared by resolved path: a test
81
+ // that imports main() must never rewrite the files it is checking.
82
+ const self = realpathSync(fileURLToPath(import.meta.url));
83
+ const program = process.argv[1] ? realpathSync(resolve(process.argv[1])) : "";
84
+ if (self === program)
85
+ process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,49 @@
1
+ export type DetectionSource = "prefix" | "cookie" | "account" | "country" | "header" | "default";
2
+ export interface LocaleSignals {
3
+ /** The locale named by the URL's prefix, from localeFromPath. */
4
+ prefix?: string | null | undefined;
5
+ /** The remembered choice, from the `__Host-locale` cookie. */
6
+ cookie?: string | null | undefined;
7
+ /** The signed-in person's saved language. */
8
+ account?: string | null | undefined;
9
+ /** The two-letter country the edge reports (`request.cf.country` on Cloudflare). */
10
+ country?: string | null | undefined;
11
+ /** The raw Accept-Language header. */
12
+ acceptLanguage?: string | null | undefined;
13
+ }
14
+ export interface LocaleRules {
15
+ /** The live locales, in switcher order. */
16
+ locales: readonly string[];
17
+ /** Where every fallback chain ends. */
18
+ base: string;
19
+ /** What an unknown visitor sees. */
20
+ defaultLocale: string;
21
+ /** Country code to locale. */
22
+ countries: Readonly<Record<string, string>>;
23
+ /** Other Accept-Language tags (old or bare ones) to locale, matched without case. */
24
+ aliases: Readonly<Record<string, string>>;
25
+ /**
26
+ * Whether a locale has been signed off by a named reviewer. Required, so a
27
+ * caller that forgets the gate gets a type error, not unreviewed words.
28
+ */
29
+ reviewed: (locale: string) => boolean;
30
+ }
31
+ export interface Detection {
32
+ locale: string;
33
+ /** Which signal decided it. */
34
+ source: DetectionSource;
35
+ /** True when the review gate replaced an unreviewed inferred locale with the base. */
36
+ gated: boolean;
37
+ }
38
+ /** Accept-Language tags, best first by q; q=0 and `*` dropped, ties keep their order. */
39
+ export declare function parseAcceptLanguage(header: string | null | undefined): string[];
40
+ /** The best live locale for an Accept-Language header: exact tag, then alias, then a language only one locale has. */
41
+ export declare function matchAcceptLanguage(rules: Pick<LocaleRules, "locales" | "aliases">, header: string | null | undefined): string | undefined;
42
+ /** Resolves the locale for one request. */
43
+ export declare function resolveLocale(signals: LocaleSignals, rules: LocaleRules): Detection;
44
+ /** The cookie that remembers a choice. `__Host-` makes the browser refuse it with a Domain or without Secure. */
45
+ export declare const LOCALE_COOKIE = "__Host-locale";
46
+ /** A Set-Cookie value remembering `locale` for a year. Lax, so a visitor arriving from a link keeps their language. */
47
+ export declare function localeCookie(locale: string): string;
48
+ /** The locale cookie's value from a Cookie header, if any. */
49
+ export declare function readLocaleCookie(header: string | null | undefined): string | undefined;