@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 +11 -0
- package/README.md +114 -2
- package/dist/browser.d.ts +0 -0
- package/dist/browser.js +6 -0
- package/dist/catalogue.d.ts +32 -0
- package/dist/catalogue.js +115 -0
- package/dist/checks.d.ts +15 -0
- package/dist/checks.js +128 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +85 -0
- package/dist/detect.d.ts +49 -0
- package/dist/detect.js +108 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +10 -0
- package/dist/format.d.ts +23 -0
- package/dist/format.js +215 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +10 -0
- package/dist/languages.d.ts +4 -0
- package/dist/languages.js +7 -0
- package/dist/strings.d.ts +96 -0
- package/dist/strings.js +238 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +252 -0
- package/package.json +59 -4
package/dist/strings.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// defineStrings: an app's catalogue, its locales and its fallback chain, set
|
|
2
|
+
// up once and checked at start. Server only (internationalization standard
|
|
3
|
+
// §7.5): the catalogue holds every language, so it never goes to a browser.
|
|
4
|
+
// Look words up here and pass the finished words to the page.
|
|
5
|
+
import { GLOSS, isMeta, readReview, REVIEW_STATUS } from "./catalogue.js";
|
|
6
|
+
import { localeCookie, readLocaleCookie, resolveLocale, } from "./detect.js";
|
|
7
|
+
import { WorkbenchStringsError } from "./errors.js";
|
|
8
|
+
import { formatMessage, parseMessage, placeholdersOf } from "./format.js";
|
|
9
|
+
import { WORKBENCH_LANGUAGES } from "./languages.js";
|
|
10
|
+
/** The name groups a key must start with (§5.3). A new class of string adds a group here, by PR. */
|
|
11
|
+
export const NAMESPACES = ["ui", "enum", "question", "validation", "email", "notification", "page", "error", "meta"];
|
|
12
|
+
function invalid(why) {
|
|
13
|
+
throw new WorkbenchStringsError("invalid_options", why);
|
|
14
|
+
}
|
|
15
|
+
function defaultOnMissing() {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
return (m) => {
|
|
18
|
+
const id = `${m.requested}\u0000${m.key}`;
|
|
19
|
+
if (seen.has(id))
|
|
20
|
+
return;
|
|
21
|
+
if (seen.size >= 1000)
|
|
22
|
+
seen.clear();
|
|
23
|
+
seen.add(id);
|
|
24
|
+
const line = { level: m.resolved === null ? "error" : "warn", event: "strings.missing", ...m };
|
|
25
|
+
console.warn(JSON.stringify(line));
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Sets up an app's strings. Throws WorkbenchStringsError at start when a rule is broken. */
|
|
29
|
+
export function defineStrings(options) {
|
|
30
|
+
const base = options.base.locale;
|
|
31
|
+
const locales = [...options.locales];
|
|
32
|
+
const planned = [...(options.planned ?? [])];
|
|
33
|
+
const translations = options.translations ?? {};
|
|
34
|
+
const onMissing = options.onMissing ?? defaultOnMissing();
|
|
35
|
+
// The locale set (§4).
|
|
36
|
+
if (locales.length === 0)
|
|
37
|
+
invalid("locales is empty");
|
|
38
|
+
if (new Set(locales).size !== locales.length)
|
|
39
|
+
invalid("locales names a locale twice");
|
|
40
|
+
if (!locales.includes(base))
|
|
41
|
+
invalid(`locales must include the base, ${base}`);
|
|
42
|
+
for (const l of locales) {
|
|
43
|
+
if (!WORKBENCH_LANGUAGES.includes(l)) {
|
|
44
|
+
invalid(`${l} is not a Workbench language (${WORKBENCH_LANGUAGES.join(", ")}). A new language is added to the shared list first`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const p of planned) {
|
|
48
|
+
if (locales.includes(p))
|
|
49
|
+
invalid(`${p} is both live and planned. A planned locale is absent from locales`);
|
|
50
|
+
if (p in translations)
|
|
51
|
+
invalid(`${p} is planned but has a catalogue. Add it to locales when it has words`);
|
|
52
|
+
}
|
|
53
|
+
for (const l of locales)
|
|
54
|
+
if (l !== base && !(l in translations))
|
|
55
|
+
invalid(`${l} has no catalogue in translations`);
|
|
56
|
+
for (const l of Object.keys(translations)) {
|
|
57
|
+
if (l === base)
|
|
58
|
+
invalid(`the base, ${base}, goes in base, not translations`);
|
|
59
|
+
if (!locales.includes(l))
|
|
60
|
+
invalid(`translations has ${l}, which is not in locales`);
|
|
61
|
+
}
|
|
62
|
+
const defaultLocale = options.defaultLocale ?? base;
|
|
63
|
+
if (!locales.includes(defaultLocale))
|
|
64
|
+
invalid(`defaultLocale ${defaultLocale} is not in locales`);
|
|
65
|
+
// The fallback chain, written once (§6).
|
|
66
|
+
const parents = options.parents ?? {};
|
|
67
|
+
for (const [child, parent] of Object.entries(parents)) {
|
|
68
|
+
if (!locales.includes(child) || !locales.includes(parent))
|
|
69
|
+
invalid(`parents ${child} -> ${parent}: both must be live locales`);
|
|
70
|
+
if (child === base)
|
|
71
|
+
invalid("the base has no parent: every chain ends there");
|
|
72
|
+
}
|
|
73
|
+
const chains = new Map();
|
|
74
|
+
for (const l of locales) {
|
|
75
|
+
const chain = [l];
|
|
76
|
+
let at = l;
|
|
77
|
+
while (at !== base) {
|
|
78
|
+
at = parents[at] ?? base;
|
|
79
|
+
if (chain.includes(at))
|
|
80
|
+
invalid(`parents loop at ${at}`);
|
|
81
|
+
chain.push(at);
|
|
82
|
+
}
|
|
83
|
+
chains.set(l, Object.freeze(chain));
|
|
84
|
+
}
|
|
85
|
+
// Catalogues: parsed once, so a broken string fails at start.
|
|
86
|
+
const catalogues = new Map([[base, options.base.strings], ...Object.entries(translations)]);
|
|
87
|
+
const messages = new Map();
|
|
88
|
+
const reviews = new Map();
|
|
89
|
+
for (const [l, cat] of catalogues) {
|
|
90
|
+
const parsed = new Map();
|
|
91
|
+
for (const [key, text] of Object.entries(cat)) {
|
|
92
|
+
if (typeof text !== "string") {
|
|
93
|
+
throw new WorkbenchStringsError("invalid_catalogue", `${l}: ${key} is not a string (the file must be flat)`);
|
|
94
|
+
}
|
|
95
|
+
if (key.startsWith(GLOSS)) {
|
|
96
|
+
throw new WorkbenchStringsError("invalid_catalogue", l === base
|
|
97
|
+
? `${l}: the base catalogue has a gloss (${key}). Glosses belong only to translations`
|
|
98
|
+
: `${l}: has glosses, so it is the authored file. Pass the generated file from strings/runtime/`);
|
|
99
|
+
}
|
|
100
|
+
if (isMeta(key))
|
|
101
|
+
continue;
|
|
102
|
+
try {
|
|
103
|
+
parsed.set(key, parseMessage(text));
|
|
104
|
+
}
|
|
105
|
+
catch (e) {
|
|
106
|
+
throw new WorkbenchStringsError("invalid_string", `${l} ${key}: ${e.message}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
messages.set(l, parsed);
|
|
110
|
+
const review = readReview(cat, l);
|
|
111
|
+
reviews.set(l, review);
|
|
112
|
+
if (l === base)
|
|
113
|
+
continue;
|
|
114
|
+
if (cat[REVIEW_STATUS] === undefined) {
|
|
115
|
+
throw new WorkbenchStringsError("invalid_catalogue", `${l}: has no ${REVIEW_STATUS}. Every translated catalogue says how it was made and reviewed`);
|
|
116
|
+
}
|
|
117
|
+
// The review gate's hard edge: nothing translated goes public with no named reviewer.
|
|
118
|
+
if (options.env === "production" && !review.reviewer) {
|
|
119
|
+
throw new WorkbenchStringsError("no_reviewer", `${l} is live in production with no named native-speaker reviewer. Add "_review.reviewer": "Name, cadence" to its catalogue, or take it out of locales`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const keys = Object.keys(options.base.strings).filter((k) => !isMeta(k));
|
|
123
|
+
// Detection: countries, aliases and prefixes.
|
|
124
|
+
const countries = {};
|
|
125
|
+
const regions = new Map();
|
|
126
|
+
for (const l of locales) {
|
|
127
|
+
const region = l.split("-")[1];
|
|
128
|
+
if (region && /^[A-Z]{2}$/.test(region))
|
|
129
|
+
regions.set(region, [...(regions.get(region) ?? []), l]);
|
|
130
|
+
}
|
|
131
|
+
for (const [region, ls] of regions)
|
|
132
|
+
if (ls.length === 1)
|
|
133
|
+
countries[region] = ls[0];
|
|
134
|
+
for (const [country, l] of Object.entries(options.countries ?? {})) {
|
|
135
|
+
if (!/^[A-Z]{2}$/.test(country))
|
|
136
|
+
invalid(`countries: ${country} is not a two-letter country code`);
|
|
137
|
+
if (!locales.includes(l))
|
|
138
|
+
invalid(`countries: ${country} -> ${l}, which is not a live locale`);
|
|
139
|
+
countries[country] = l;
|
|
140
|
+
}
|
|
141
|
+
const aliases = { ...(options.aliases ?? {}) };
|
|
142
|
+
for (const [tag, l] of Object.entries(aliases))
|
|
143
|
+
if (!locales.includes(l))
|
|
144
|
+
invalid(`aliases: ${tag} -> ${l}, which is not a live locale`);
|
|
145
|
+
const prefixes = {};
|
|
146
|
+
for (const l of locales) {
|
|
147
|
+
const p = options.prefixes?.[l] ?? l.toLowerCase();
|
|
148
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(p))
|
|
149
|
+
invalid(`prefixes: "${p}" for ${l} must be lower-case letters, digits and hyphens`);
|
|
150
|
+
if (Object.values(prefixes).includes(p))
|
|
151
|
+
invalid(`prefixes: "${p}" is used twice`);
|
|
152
|
+
prefixes[l] = p;
|
|
153
|
+
}
|
|
154
|
+
for (const l of Object.keys(options.prefixes ?? {}))
|
|
155
|
+
if (!locales.includes(l))
|
|
156
|
+
invalid(`prefixes: ${l} is not a live locale`);
|
|
157
|
+
const byPrefix = new Map(Object.entries(prefixes).map(([l, p]) => [p, l]));
|
|
158
|
+
// A signed-off locale whose parent is not would serve the parent's unreviewed words.
|
|
159
|
+
const reviewed = (l) => (chains.get(l) ?? [l]).every((x) => x === base || reviews.get(x)?.signedOff !== undefined);
|
|
160
|
+
const rules = Object.freeze({ locales, base, defaultLocale, countries, aliases, reviewed });
|
|
161
|
+
const liveOrBase = (l) => (chains.has(l) ? l : base);
|
|
162
|
+
function lookup(locale, key) {
|
|
163
|
+
for (const l of chains.get(liveOrBase(locale))) {
|
|
164
|
+
const m = messages.get(l).get(key);
|
|
165
|
+
if (m)
|
|
166
|
+
return { message: m, from: l };
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
function t(locale, key, values) {
|
|
171
|
+
const requested = liveOrBase(locale);
|
|
172
|
+
const found = lookup(requested, key);
|
|
173
|
+
if (!found) {
|
|
174
|
+
// The test kit makes the base complete, so this is a bug: show the key,
|
|
175
|
+
// as a visible bug report, and log it.
|
|
176
|
+
onMissing({ key, requested, resolved: null });
|
|
177
|
+
return key;
|
|
178
|
+
}
|
|
179
|
+
if (found.from !== requested)
|
|
180
|
+
onMissing({ key, requested, resolved: found.from });
|
|
181
|
+
return formatMessage(found.message, requested, values);
|
|
182
|
+
}
|
|
183
|
+
const strings = {
|
|
184
|
+
base,
|
|
185
|
+
locales: Object.freeze(locales),
|
|
186
|
+
defaultLocale,
|
|
187
|
+
planned: Object.freeze(planned),
|
|
188
|
+
keys: Object.freeze(keys),
|
|
189
|
+
rules,
|
|
190
|
+
prefixes: Object.freeze(prefixes),
|
|
191
|
+
parents: Object.freeze({ ...parents }),
|
|
192
|
+
catalogue: (l) => catalogues.get(l) ?? invalid(`${l} is not a live locale`),
|
|
193
|
+
chain: (l) => chains.get(l) ?? invalid(`${l} is not a live locale`),
|
|
194
|
+
review: (l) => reviews.get(l) ?? invalid(`${l} is not a live locale`),
|
|
195
|
+
reviewed,
|
|
196
|
+
has: (key) => !isMeta(key) && messages.get(base).has(key),
|
|
197
|
+
t,
|
|
198
|
+
pick(locale, ks) {
|
|
199
|
+
return Object.fromEntries(ks.map((k) => [k, t(locale, k)]));
|
|
200
|
+
},
|
|
201
|
+
email(locale, name, part, values) {
|
|
202
|
+
const requested = liveOrBase(locale);
|
|
203
|
+
const key = `email.${name}.${part}`;
|
|
204
|
+
const found = lookup(requested, key);
|
|
205
|
+
if (!found) {
|
|
206
|
+
onMissing({ key, requested, resolved: null });
|
|
207
|
+
throw new WorkbenchStringsError("missing_string", `${key} is missing in ${requested} and in the base, ${base}`);
|
|
208
|
+
}
|
|
209
|
+
if (found.from !== requested)
|
|
210
|
+
onMissing({ key, requested, resolved: found.from });
|
|
211
|
+
for (const ph of placeholdersOf(found.message)) {
|
|
212
|
+
const name = ph.replace(/#plural$/, "");
|
|
213
|
+
if (values?.[name] == null)
|
|
214
|
+
throw new WorkbenchStringsError("missing_value", `${key} needs {${name}}`);
|
|
215
|
+
}
|
|
216
|
+
return formatMessage(found.message, requested, values);
|
|
217
|
+
},
|
|
218
|
+
recipientLocale: (saved) => (saved != null && chains.has(saved) ? saved : base),
|
|
219
|
+
detect(request, opts = {}) {
|
|
220
|
+
const url = new URL(request.url);
|
|
221
|
+
const cf = request.cf;
|
|
222
|
+
return resolveLocale({
|
|
223
|
+
prefix: strings.localeFromPath(url.pathname)?.locale,
|
|
224
|
+
cookie: readLocaleCookie(request.headers.get("cookie")),
|
|
225
|
+
account: opts.account,
|
|
226
|
+
country: typeof cf?.country === "string" ? cf.country : undefined,
|
|
227
|
+
acceptLanguage: request.headers.get("accept-language"),
|
|
228
|
+
}, rules);
|
|
229
|
+
},
|
|
230
|
+
localeFromPath(pathname) {
|
|
231
|
+
const m = /^\/([^/]+)(\/.*)?$/.exec(pathname);
|
|
232
|
+
const locale = m ? byPrefix.get(m[1].toLowerCase()) : undefined;
|
|
233
|
+
return locale ? { locale, rest: m[2] ?? "/" } : null;
|
|
234
|
+
},
|
|
235
|
+
cookie: (l) => (chains.has(l) ? localeCookie(l) : invalid(`${l} is not a live locale`)),
|
|
236
|
+
};
|
|
237
|
+
return Object.freeze(strings);
|
|
238
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type StringsCheck } from "./checks.ts";
|
|
2
|
+
import { type Strings } from "./strings.ts";
|
|
3
|
+
export type { StringsCheck } from "./checks.ts";
|
|
4
|
+
export interface StringsKitOptions {
|
|
5
|
+
/** The app's defineStrings result. */
|
|
6
|
+
strings: Strings<string>;
|
|
7
|
+
/** Raw text of every authored file, strings/strings.<locale>.json, by locale. */
|
|
8
|
+
authored: Readonly<Record<string, string>>;
|
|
9
|
+
/** Raw text of every generated file, strings/runtime/strings.<locale>.json, by locale (no base). */
|
|
10
|
+
runtime: Readonly<Record<string, string>>;
|
|
11
|
+
/** The app's source files' text, for the dead-key sweep. */
|
|
12
|
+
sources: readonly string[] | Readonly<Record<string, string>>;
|
|
13
|
+
/** The app's emails and their parts, from its email catalogue (`[]` for none). Their `email.<name>.<part>` keys are checked against this, not the sources. */
|
|
14
|
+
emails: readonly {
|
|
15
|
+
name: string;
|
|
16
|
+
parts: readonly string[];
|
|
17
|
+
}[];
|
|
18
|
+
/** Other key prefixes built at run time, e.g. "validation." for workbench-forms or "enum.status.". Never swept as dead. */
|
|
19
|
+
composed?: readonly string[];
|
|
20
|
+
/** Each locale's expected fallback chain, pinned by the app. Required when it sets `parents`. */
|
|
21
|
+
chains?: Readonly<Record<string, readonly string[]>>;
|
|
22
|
+
/** Words the brand voice forbids, per locale, matched on word boundaries. */
|
|
23
|
+
forbidden?: Readonly<Record<string, readonly string[]>>;
|
|
24
|
+
}
|
|
25
|
+
export declare function checkStrings(options: StringsKitOptions): StringsCheck[];
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// The test kit (internationalization standard §11): the standard's tests, run
|
|
2
|
+
// against an app's own catalogue, configuration and code. It needs no test
|
|
3
|
+
// framework; an app runs it from its own tests:
|
|
4
|
+
//
|
|
5
|
+
// import { checkStrings } from '@mccaskie-labs/workbench-strings/testing'
|
|
6
|
+
// for (const check of checkStrings({ strings, authored, runtime, sources, emails }))
|
|
7
|
+
// it(check.name, () => expect(check.problems).toEqual([]))
|
|
8
|
+
//
|
|
9
|
+
// It takes file text rather than paths so it runs in the Workers test runtime
|
|
10
|
+
// too (load the files with Vite's import.meta.glob and `?raw`).
|
|
11
|
+
import { isMeta, readCatalogue } from "./catalogue.js";
|
|
12
|
+
import { checkFiles } from "./checks.js";
|
|
13
|
+
import { resolveLocale } from "./detect.js";
|
|
14
|
+
import { formatMessage, parseMessage, placeholdersOf } from "./format.js";
|
|
15
|
+
import { NAMESPACES } from "./strings.js";
|
|
16
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
17
|
+
export function checkStrings(options) {
|
|
18
|
+
const { strings } = options;
|
|
19
|
+
const checks = checkFiles({ base: strings.base, authored: options.authored, runtime: options.runtime });
|
|
20
|
+
const add = (name) => {
|
|
21
|
+
const c = { name, problems: [] };
|
|
22
|
+
checks.push(c);
|
|
23
|
+
return c;
|
|
24
|
+
};
|
|
25
|
+
// The files must be the ones the app runs with.
|
|
26
|
+
const files = add("Every live locale has an authored file, and no other locale does");
|
|
27
|
+
for (const l of strings.locales)
|
|
28
|
+
if (!(l in options.authored))
|
|
29
|
+
files.problems.push(`no strings.${l}.json`);
|
|
30
|
+
for (const l of Object.keys(options.authored)) {
|
|
31
|
+
if (!strings.locales.includes(l)) {
|
|
32
|
+
files.problems.push(strings.planned.includes(l) ? `${l} is planned: a planned locale has no file until it goes live` : `strings.${l}.json is not a live locale`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let baseCat = {};
|
|
36
|
+
try {
|
|
37
|
+
baseCat = readCatalogue(options.authored[strings.base] ?? "{}", strings.base);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Reported by the file checks.
|
|
41
|
+
}
|
|
42
|
+
const baseKeys = Object.keys(baseCat).filter((k) => !isMeta(k));
|
|
43
|
+
// The strings under test must be built from these files.
|
|
44
|
+
const tied = add("defineStrings was given these files: the base and every generated catalogue");
|
|
45
|
+
if (JSON.stringify(Object.entries(strings.catalogue(strings.base))) !== JSON.stringify(Object.entries(baseCat))) {
|
|
46
|
+
tied.problems.push(`the base passed to defineStrings is not strings.${strings.base}.json`);
|
|
47
|
+
}
|
|
48
|
+
for (const l of strings.locales.filter((x) => x !== strings.base)) {
|
|
49
|
+
try {
|
|
50
|
+
const rc = readCatalogue(options.runtime[l] ?? "{}", l);
|
|
51
|
+
if (JSON.stringify(Object.entries(strings.catalogue(l))) !== JSON.stringify(Object.entries(rc))) {
|
|
52
|
+
tied.problems.push(`the ${l} catalogue passed to defineStrings is not runtime/strings.${l}.json`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Reported by the file checks.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const ns = add(`Every key starts with a declared name group (${NAMESPACES.join(", ")})`);
|
|
60
|
+
for (const k of baseKeys) {
|
|
61
|
+
const group = k.split(".")[0];
|
|
62
|
+
if (!NAMESPACES.includes(group) || k.split(".").length < 2)
|
|
63
|
+
ns.problems.push(k);
|
|
64
|
+
}
|
|
65
|
+
// Email keys: checked against the declared emails, the only thing that can.
|
|
66
|
+
const emails = add("Email words: every declared email part exists, and no email key is undeclared");
|
|
67
|
+
const declared = new Set(options.emails.flatMap((e) => e.parts.map((p) => `email.${e.name}.${p}`)));
|
|
68
|
+
for (const k of declared)
|
|
69
|
+
if (!(k in baseCat))
|
|
70
|
+
emails.problems.push(`${k} is missing from the base`);
|
|
71
|
+
for (const k of baseKeys)
|
|
72
|
+
if (k.startsWith("email.") && !declared.has(k))
|
|
73
|
+
emails.problems.push(`${k} belongs to no declared email`);
|
|
74
|
+
// Dead keys and unknown keys, matched exactly on quotes, never by substring.
|
|
75
|
+
const sources = Array.isArray(options.sources) ? options.sources : Object.values(options.sources);
|
|
76
|
+
const text = sources.join("\n");
|
|
77
|
+
const notCatalogue = add("The sources hold no catalogue file (its keys would vouch for themselves)");
|
|
78
|
+
sources.forEach((src, i) => {
|
|
79
|
+
if (/"_review\.status"\s*:/.test(src) || baseKeys.some((k) => src.includes(`"${k}":`))) {
|
|
80
|
+
notCatalogue.problems.push(`source ${Array.isArray(options.sources) ? i : Object.keys(options.sources)[i]} looks like a catalogue file`);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
const composed = ["email.", ...(options.composed ?? [])];
|
|
84
|
+
const dead = add("Dead-key sweep: every base key is used by the app's code");
|
|
85
|
+
for (const k of baseKeys) {
|
|
86
|
+
if (composed.some((p) => k.startsWith(p)))
|
|
87
|
+
continue;
|
|
88
|
+
if (!new RegExp(`(["'\`])${escape(k)}\\1`).test(text))
|
|
89
|
+
dead.problems.push(`${k} is used nowhere`);
|
|
90
|
+
}
|
|
91
|
+
const unknown = add("Every key the code names exists in the base");
|
|
92
|
+
const groups = NAMESPACES.join("|");
|
|
93
|
+
const keyLiteral = new RegExp(`(["'\`])((?:${groups})\\.[A-Za-z0-9_.-]+)\\1`, "g");
|
|
94
|
+
const known = new Set(baseKeys);
|
|
95
|
+
for (const m of text.matchAll(keyLiteral)) {
|
|
96
|
+
const k = m[2];
|
|
97
|
+
const fileName = /\.(ts|tsx|js|mjs|json|css|html|astro|md)$/.test(k);
|
|
98
|
+
if (!known.has(k) && !fileName && !composed.some((p) => k.startsWith(p)) && !k.endsWith(".")) {
|
|
99
|
+
unknown.problems.push(`${k} is not in the base`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
unknown.problems = [...new Set(unknown.problems)];
|
|
103
|
+
// The voice: forbidden words on word boundaries, so a short particle inside a longer word passes.
|
|
104
|
+
const voice = add("Voice: no locale uses a word the brand voice forbids");
|
|
105
|
+
for (const [l, banned] of Object.entries(options.forbidden ?? {})) {
|
|
106
|
+
if (!(l in options.authored)) {
|
|
107
|
+
voice.problems.push(`forbidden names ${l}, which has no authored file`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
let cat;
|
|
111
|
+
try {
|
|
112
|
+
cat = readCatalogue(options.authored[l], l);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
for (const w of banned) {
|
|
118
|
+
const re = new RegExp(`(?<![\\p{L}\\p{N}])${escape(w)}(?![\\p{L}\\p{N}])`, "iu");
|
|
119
|
+
for (const [k, v] of Object.entries(cat)) {
|
|
120
|
+
// Placeholder names are not words the reader sees.
|
|
121
|
+
const words = v.replace(/\{\s*[A-Za-z_][A-Za-z0-9_]*\s*(?=[,}])/g, "{");
|
|
122
|
+
if (!isMeta(k) && re.test(words))
|
|
123
|
+
voice.problems.push(`${l} ${k} uses "${w}"`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// The chain, as the app pinned it: a register must go through its parent.
|
|
128
|
+
const chain = add("Chain shape: each locale falls back as the app pinned it, ending at the base");
|
|
129
|
+
if (Object.keys(strings.parents).length && !options.chains)
|
|
130
|
+
chain.problems.push("the app sets parents, so pin each locale's chain in chains");
|
|
131
|
+
for (const [l, want] of Object.entries(options.chains ?? {})) {
|
|
132
|
+
if (!strings.locales.includes(l)) {
|
|
133
|
+
chain.problems.push(`chains names ${l}, which is not live`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const got = strings.chain(l);
|
|
137
|
+
if (got.join(" -> ") !== want.join(" -> "))
|
|
138
|
+
chain.problems.push(`${l}: ${got.join(" -> ")}, pinned ${want.join(" -> ")}`);
|
|
139
|
+
}
|
|
140
|
+
// Detection, with this app's rules.
|
|
141
|
+
const det = detectionChecks(strings);
|
|
142
|
+
checks.push(...det);
|
|
143
|
+
const plurals = add("Plural by CLDR category: every plural string renders in every locale for 0, 1, 2 and 5");
|
|
144
|
+
for (const l of strings.locales) {
|
|
145
|
+
for (const k of baseKeys) {
|
|
146
|
+
let message;
|
|
147
|
+
try {
|
|
148
|
+
message = parseMessage(baseCat[k]);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const names = placeholdersOf(message);
|
|
154
|
+
if (!names.some((n) => n.endsWith("#plural")))
|
|
155
|
+
continue;
|
|
156
|
+
for (const n of [0, 1, 2, 5]) {
|
|
157
|
+
const values = Object.fromEntries(names.map((x) => [x.replace(/#plural$/, ""), x.endsWith("#plural") ? n : "x"]));
|
|
158
|
+
const out = strings.t(l, k, values);
|
|
159
|
+
if (/\{[A-Za-z_]/.test(out))
|
|
160
|
+
plurals.problems.push(`${l} ${k} with ${n}: "${out}"`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const intl = add("Unknown locale survives: formatting in every locale, planned ones too, does not throw");
|
|
165
|
+
const sample = parseMessage("{n, plural, =0 {none} one {# one} other {# other}} {x}");
|
|
166
|
+
for (const l of [...strings.locales, ...strings.planned]) {
|
|
167
|
+
try {
|
|
168
|
+
formatMessage(sample, l, { n: 2, x: "y" });
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
intl.problems.push(`${l}: ${e.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return checks;
|
|
175
|
+
}
|
|
176
|
+
function detectionChecks(strings) {
|
|
177
|
+
const rules = strings.rules;
|
|
178
|
+
const all = { ...rules, reviewed: () => true };
|
|
179
|
+
const out = [];
|
|
180
|
+
const check = (name, fn) => {
|
|
181
|
+
const problems = [];
|
|
182
|
+
fn(problems);
|
|
183
|
+
out.push({ name, problems });
|
|
184
|
+
};
|
|
185
|
+
const expectLocale = (p, what, signals, r, want) => {
|
|
186
|
+
const got = resolveLocale(signals, r).locale;
|
|
187
|
+
if (got !== want)
|
|
188
|
+
p.push(`${what}: got ${got}, expected ${want}`);
|
|
189
|
+
};
|
|
190
|
+
const [a, b] = [strings.locales[0], strings.locales.find((l) => l !== strings.locales[0]) ?? strings.locales[0]];
|
|
191
|
+
const countryFor = (l) => Object.entries(rules.countries).find(([, v]) => v === l)?.[0];
|
|
192
|
+
check("Resolution precedence: prefix > cookie > account > country > header > default", (p) => {
|
|
193
|
+
const ladder = ["prefix", "cookie", "account", "country", "acceptLanguage"];
|
|
194
|
+
for (let i = 0; i < ladder.length - 1; i++) {
|
|
195
|
+
for (const [hi, lo] of [[a, b], [b, a]]) {
|
|
196
|
+
if (hi === lo)
|
|
197
|
+
continue;
|
|
198
|
+
const signals = { [ladder[i]]: hi, [ladder[i + 1]]: lo };
|
|
199
|
+
if (ladder[i] === "country")
|
|
200
|
+
signals.country = countryFor(hi);
|
|
201
|
+
if (ladder[i + 1] === "country")
|
|
202
|
+
signals.country = countryFor(lo);
|
|
203
|
+
if (signals.country === undefined && (ladder[i] === "country" || ladder[i + 1] === "country"))
|
|
204
|
+
continue;
|
|
205
|
+
expectLocale(p, `${ladder[i]} ${hi} over ${ladder[i + 1]} ${lo}`, signals, all, hi);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
expectLocale(p, "no signal at all", {}, all, rules.defaultLocale);
|
|
209
|
+
});
|
|
210
|
+
check("Geo beats header: a local visitor whose phone reports another language gets the local locale", (p) => {
|
|
211
|
+
for (const [country, l] of Object.entries(rules.countries)) {
|
|
212
|
+
const other = strings.locales.find((x) => x !== l);
|
|
213
|
+
if (other)
|
|
214
|
+
expectLocale(p, `country ${country}, header ${other}`, { country, acceptLanguage: other }, all, l);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
check("Unknown country falls through to the header, never to the base", (p) => {
|
|
218
|
+
for (const l of strings.locales) {
|
|
219
|
+
for (const country of ["XX", "T1", undefined]) {
|
|
220
|
+
expectLocale(p, `country ${country ?? "absent"}, header ${l}`, { country, acceptLanguage: l }, all, l);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
expectLocale(p, "country XX, no header", { country: "XX" }, all, rules.defaultLocale);
|
|
224
|
+
});
|
|
225
|
+
check("A known country with no locale and no usable header gets the base", (p) => {
|
|
226
|
+
const elsewhere = ["AQ", "QM", "GL"].find((c) => !(c in rules.countries));
|
|
227
|
+
if (elsewhere)
|
|
228
|
+
expectLocale(p, `country ${elsewhere}`, { country: elsewhere }, all, strings.base);
|
|
229
|
+
});
|
|
230
|
+
check("Quality weights honoured: Accept-Language sorted by q, not first tag wins", (p) => {
|
|
231
|
+
if (a !== b)
|
|
232
|
+
expectLocale(p, `${b};q=0.4, ${a};q=0.9`, { acceptLanguage: `${b};q=0.4, ${a};q=0.9` }, all, a);
|
|
233
|
+
});
|
|
234
|
+
check("Old and other tags accepted (aliases)", (p) => {
|
|
235
|
+
for (const [tag, l] of Object.entries(rules.aliases))
|
|
236
|
+
expectLocale(p, `header ${tag}`, { acceptLanguage: tag }, all, l);
|
|
237
|
+
});
|
|
238
|
+
check("Gate armed: an unreviewed locale is never inferred, and a chosen one is still served", (p) => {
|
|
239
|
+
for (const l of strings.locales.filter((x) => !strings.reviewed(x))) {
|
|
240
|
+
const country = countryFor(l);
|
|
241
|
+
if (country)
|
|
242
|
+
expectLocale(p, `country ${country}`, { country }, rules, strings.base);
|
|
243
|
+
expectLocale(p, `header ${l}`, { acceptLanguage: l }, rules, strings.base);
|
|
244
|
+
if (rules.defaultLocale === l)
|
|
245
|
+
expectLocale(p, "default", {}, rules, strings.base);
|
|
246
|
+
expectLocale(p, `prefix ${l}`, { prefix: l }, rules, l);
|
|
247
|
+
expectLocale(p, `cookie ${l}`, { cookie: l }, rules, l);
|
|
248
|
+
expectLocale(p, `account ${l}`, { account: l }, rules, l);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
return out;
|
|
252
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mccaskie-labs/workbench-strings",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The words an app's own screens and emails say: one file per language, named placeholders and plurals, one fallback chain, language detection, the translation review gate and a test kit. Server only.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mccaskie-labs/workbench.git",
|
|
10
|
+
"directory": "packages/strings"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"sideEffects": [
|
|
16
|
+
"./dist/browser.js"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"workerd": "./dist/index.js",
|
|
22
|
+
"worker": "./dist/index.js",
|
|
23
|
+
"node": "./dist/index.js",
|
|
24
|
+
"browser": "./dist/browser.js",
|
|
25
|
+
"default": "./dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./testing": {
|
|
28
|
+
"types": "./dist/testing.d.ts",
|
|
29
|
+
"default": "./dist/testing.js"
|
|
30
|
+
},
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"bin": {
|
|
34
|
+
"workbench-strings": "dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"typecheck": "tsc -p tsconfig.check.json && tsc -p test/tsconfig.json",
|
|
46
|
+
"test": "vitest run",
|
|
47
|
+
"build": "node scripts/build.mjs",
|
|
48
|
+
"prepack": "node scripts/build.mjs"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@mccaskie-labs/workbench-config": "^0.1.14"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"zod": "^4.1.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "24.19.2",
|
|
58
|
+
"vitest": "^5.1.0",
|
|
59
|
+
"zod": "4.6.7"
|
|
60
|
+
}
|
|
61
|
+
}
|