@gusnips/vite 0.8.16 → 0.8.18
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/README.md +76 -0
- package/dist/i18n.d.ts +111 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +681 -0
- package/dist/i18n.js.map +1 -0
- package/package.json +9 -3
- package/src/i18n.test.ts +509 -0
- package/src/i18n.ts +910 -0
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The i18n gate: what a compiler cannot see in a set of translation catalogs, checked where CI
|
|
3
|
+
* runs.
|
|
4
|
+
*
|
|
5
|
+
* Nine repos wrote this, 4,540 lines between them: six copies of one script and three written from
|
|
6
|
+
* scratch. Each copy knew a rule the others lacked, and six of them passed when a catalog failed to
|
|
7
|
+
* load — they printed "skipped" and returned zero, so a broken JSON file turned the gate green.
|
|
8
|
+
* This is the merge, and a catalog that does not load fails it.
|
|
9
|
+
*
|
|
10
|
+
* The canonical language is typed (`i18next.d.ts` reads it), so a `t()` with no key there is a
|
|
11
|
+
* compile error. The other languages are not, and neither is anything inside a string. That is
|
|
12
|
+
* what this checks:
|
|
13
|
+
*
|
|
14
|
+
* - **parity**: a key in one language is in every language, and no value is blank.
|
|
15
|
+
* - **plural**: a counted key carries every form ITS language selects, read from CLDR through
|
|
16
|
+
* `Intl.PluralRules`. Portuguese and Spanish have a `many` form English does not, for exact
|
|
17
|
+
* millions: a Portuguese page showed "104 mi followers" in English for exactly 104,000,000, and
|
|
18
|
+
* 15.1M on the same screen read correctly, which is why nobody saw it.
|
|
19
|
+
* - **placeholders**: every `{{name}}`, its `, format`, and every `<0>` tag survive translation. A
|
|
20
|
+
* `{{nome}}` fills with nothing, and a dropped `, number` prints 5000 where the reader writes
|
|
21
|
+
* 5.000.
|
|
22
|
+
* - **reserved**: no placeholder borrows a name `t()` reads as an option. `{{lng}}` switches the
|
|
23
|
+
* language instead of filling the gap.
|
|
24
|
+
* - **tags**: `<0>`…`</0>` open in order and close once, which is what `<Trans>` binds by.
|
|
25
|
+
* - the code rules, when `code` is given: every static `t("key")` exists, a template key's prefix
|
|
26
|
+
* exists, `<Trans>` takes no fallback children, and copy with markup does not go through `t()`.
|
|
27
|
+
* - **server-key**: every key the server can send resolves in every language.
|
|
28
|
+
*
|
|
29
|
+
* Runs on node and bun: `fs.promises.glob`, never Bun's `Glob`, and no `import.meta.dir`.
|
|
30
|
+
*/
|
|
31
|
+
import { glob, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
32
|
+
import { join, resolve } from "node:path";
|
|
33
|
+
|
|
34
|
+
/** One i18next namespace's catalog: nested objects with strings at the leaves. */
|
|
35
|
+
export type Catalog = object;
|
|
36
|
+
/** One language's catalogs, by namespace: `{ translation: {…} }`. */
|
|
37
|
+
export type Catalogs = Readonly<Record<string, Catalog>>;
|
|
38
|
+
|
|
39
|
+
export interface I18nBundle {
|
|
40
|
+
/** Shown beside every problem: `"apps/web"`. */
|
|
41
|
+
name: string;
|
|
42
|
+
locales: readonly string[];
|
|
43
|
+
/** The app's `fallbackLng`: the language the others are compared to, and code keys must be in. */
|
|
44
|
+
canonical: string;
|
|
45
|
+
/**
|
|
46
|
+
* One language's catalogs. A throw fails the check with its message — it never skips the bundle,
|
|
47
|
+
* because every other rule compares languages and a missing one would pass them all.
|
|
48
|
+
*/
|
|
49
|
+
load: (locale: string) => Catalogs | Promise<Catalogs>;
|
|
50
|
+
/** Copy kept as fragments, one area per file: see `mergeFragments`. Paths are relative to `root`. */
|
|
51
|
+
fragments?: FragmentsCheck;
|
|
52
|
+
/** Source that calls `t()`, as globs relative to `root`. Tests and `i18n/` folders are skipped. */
|
|
53
|
+
code?: readonly string[];
|
|
54
|
+
/** Every key the server can send, imported from where it is declared. Preferred over a scan. */
|
|
55
|
+
serverKeys?: readonly string[];
|
|
56
|
+
/** Server source to scan for `messageKey: "…"` when there is no list to import. */
|
|
57
|
+
serverCode?: readonly string[];
|
|
58
|
+
/** String literals in `serverCode` starting with one of these are keys too: `["serverErrors."]`. */
|
|
59
|
+
serverKeyPrefixes?: readonly string[];
|
|
60
|
+
/** Placeholders the app fills before i18next sees the string, so they may use a reserved name. */
|
|
61
|
+
brandVars?: Readonly<Record<string, string>>;
|
|
62
|
+
/** The namespace a `t()` bound to nothing reads. Default `"translation"`. */
|
|
63
|
+
defaultNamespace?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FragmentsCheck {
|
|
67
|
+
dir: string;
|
|
68
|
+
/**
|
|
69
|
+
* Where the merged catalogs are written, for an app that commits them. `out/<locale>.json` must
|
|
70
|
+
* then equal a fresh merge, so a hand edit or a fragment saved without re-merging fails. Leave it
|
|
71
|
+
* out when the app merges at runtime.
|
|
72
|
+
*/
|
|
73
|
+
out?: string;
|
|
74
|
+
/** Refuse two fragments that share a top-level key: see `MergeOptions`. */
|
|
75
|
+
ownTopLevel?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CheckI18nOptions {
|
|
79
|
+
/** What `code`, `serverCode` and `fragments` are relative to. Default: the working directory. */
|
|
80
|
+
root?: string;
|
|
81
|
+
/**
|
|
82
|
+
* Refuse a price typed into copy, for a product whose prices come from a formatter. Off by
|
|
83
|
+
* default: most copy that matches is an example, not a price.
|
|
84
|
+
*/
|
|
85
|
+
price?: RegExp;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type I18nRule =
|
|
89
|
+
| "load"
|
|
90
|
+
| "fragments"
|
|
91
|
+
| "parity"
|
|
92
|
+
| "plural"
|
|
93
|
+
| "placeholders"
|
|
94
|
+
| "reserved"
|
|
95
|
+
| "tags"
|
|
96
|
+
| "price"
|
|
97
|
+
| "missing-key"
|
|
98
|
+
| "dynamic-key"
|
|
99
|
+
| "trans-children"
|
|
100
|
+
| "markup"
|
|
101
|
+
| "server-key";
|
|
102
|
+
|
|
103
|
+
export interface I18nProblem {
|
|
104
|
+
rule: I18nRule;
|
|
105
|
+
/** A warning is shown and does not fail the gate. */
|
|
106
|
+
level: "error" | "warning";
|
|
107
|
+
bundle: string;
|
|
108
|
+
locale?: string;
|
|
109
|
+
key?: string;
|
|
110
|
+
/** `file:line`, for a problem found in code. */
|
|
111
|
+
at?: string;
|
|
112
|
+
message: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface I18nReport {
|
|
116
|
+
problems: I18nProblem[];
|
|
117
|
+
/** Canonical keys no static reference, template prefix or key-path literal reaches. Advisory. */
|
|
118
|
+
unused: { bundle: string; key: string }[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const PLURAL = /_(zero|one|two|few|many|other)$/;
|
|
122
|
+
|
|
123
|
+
/** Options `t()` takes beside the values. `count` is both, so it is not here. */
|
|
124
|
+
const RESERVED = new Set(["lng", "lngs", "ns", "context", "defaultValue", "keyPrefix", "ordinal"]);
|
|
125
|
+
|
|
126
|
+
/** Every leaf by dotted key, and every path that names a node, walking the tree once. A key whose
|
|
127
|
+
* own name holds a dot (`message.created`) stays one leaf, which a split-and-descend lookup
|
|
128
|
+
* would lose. */
|
|
129
|
+
function flatten(tree: unknown): { leaves: Map<string, unknown>; nodes: Set<string> } {
|
|
130
|
+
const leaves = new Map<string, unknown>();
|
|
131
|
+
const nodes = new Set<string>();
|
|
132
|
+
const walk = (node: unknown, prefix: string): void => {
|
|
133
|
+
if (node === null || typeof node !== "object") return;
|
|
134
|
+
for (const [key, value] of Object.entries(node)) {
|
|
135
|
+
const path = prefix === "" ? key : `${prefix}.${key}`;
|
|
136
|
+
if (value !== null && typeof value === "object") {
|
|
137
|
+
nodes.add(path);
|
|
138
|
+
walk(value, path);
|
|
139
|
+
} else leaves.set(path, value);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(tree, "");
|
|
143
|
+
return { leaves, nodes };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
type Flat = ReturnType<typeof flatten>;
|
|
147
|
+
|
|
148
|
+
/** `x.count_other` → `["x.count", "other"]`; anything else → null. */
|
|
149
|
+
function pluralSplit(key: string): [string, string] | null {
|
|
150
|
+
const match = PLURAL.exec(key);
|
|
151
|
+
return match === null ? null : [key.slice(0, match.index), match[1] ?? ""];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function categories(locale: string): Set<string> {
|
|
155
|
+
return new Set(new Intl.PluralRules(locale).resolvedOptions().pluralCategories);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A value's placeholders and positional tags, as one comparable string. `{{ name ,number }}` and
|
|
159
|
+
* `{{name, number}}` are the same to i18next, so they read the same here; a dropped format is not. */
|
|
160
|
+
function tokens(value: string, dropCount: boolean): string {
|
|
161
|
+
const found = new Set<string>();
|
|
162
|
+
for (const match of value.matchAll(/\{\{(-?)\s*([^{},]+?)\s*(?:,\s*([^{}]*?)\s*)?\}\}/g)) {
|
|
163
|
+
const [, unescape = "", name = "", format] = match;
|
|
164
|
+
if (dropCount && name === "count") continue;
|
|
165
|
+
const spec = format === undefined ? "" : `, ${format.replace(/\s+/g, " ")}`;
|
|
166
|
+
found.add(`{{${unescape}${name}${spec}}}`);
|
|
167
|
+
}
|
|
168
|
+
for (const match of value.matchAll(/<(\/?)(\d+)\s*(\/?)>/g)) {
|
|
169
|
+
found.add(`<${match[1]}${match[2]}${match[3]}>`);
|
|
170
|
+
}
|
|
171
|
+
return [...found].sort().join(" ");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function placeholderNames(value: string): string[] {
|
|
175
|
+
return [...value.matchAll(/\{\{-?\s*([^{},]+?)\s*(?:,[^{}]*)?\}\}/g)].map((m) => m[1] ?? "");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** `<0>`…`</0>` must open in order — a tag's number is the count of tags opened before it — and
|
|
179
|
+
* close in reverse, once each: that is how `<Trans>` binds its components. Null when well-formed. */
|
|
180
|
+
function tagIssue(value: string): string | null {
|
|
181
|
+
const stack: number[] = [];
|
|
182
|
+
const opened = new Set<number>();
|
|
183
|
+
for (const [, close, number, selfClose] of value.matchAll(/<(\/)?(\d+)\s*(\/)?>/g)) {
|
|
184
|
+
const n = Number(number);
|
|
185
|
+
if (close === undefined) {
|
|
186
|
+
if (n !== opened.size) return `<${n}> is out of order`;
|
|
187
|
+
opened.add(n);
|
|
188
|
+
if (selfClose === undefined) stack.push(n);
|
|
189
|
+
} else if (stack.pop() !== n) {
|
|
190
|
+
return `</${n}> closes the wrong tag`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return stack.length > 0 ? `<${stack[stack.length - 1]}> is never closed` : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Code ──────────────────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/** Any call whose first argument is a string. The callee is then checked against the file's
|
|
199
|
+
* t-functions, so `console.log("a.b")` is not a key. */
|
|
200
|
+
const CALL = /\b([A-Za-z_$][\w$]*)\(\s*(["'`])((?:\\.|(?!\2).)*?)\2/g;
|
|
201
|
+
const I18N_KEY = /\bi18nKey\s*=\s*\{?\s*(["'`])((?:\\.|(?!\1).)*?)\1/g;
|
|
202
|
+
/** `labelKey: "nav.home"`: keys built in data and passed to `t()` as a variable, which a call scan
|
|
203
|
+
* cannot see. Counted only when the first segment is a real area, so `apiKey: "…"` is not one. */
|
|
204
|
+
const KEY_FIELD = /\b[a-zA-Z][a-zA-Z0-9]*[kK]ey\s*[:=]\s*\{?\s*(["'`])((?:\\.|(?!\1).)*?)\1/g;
|
|
205
|
+
const MESSAGE_KEY = /\bmessageKey\s*:\s*(["'`])((?:\\.|(?!\1).)*?)\1/g;
|
|
206
|
+
/** A dotted literal naming an area (`"graph.enrichments"`), or a template's static head before
|
|
207
|
+
* `.${`. Either may be a prefix later fed to `t(`${prefix}.title`)`, so the subtree it names is
|
|
208
|
+
* not reported unused. */
|
|
209
|
+
const KEY_PATH = /(["'`])([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z0-9_]+)+)\1/g;
|
|
210
|
+
const TEMPLATE_HEAD = /`([a-zA-Z][a-zA-Z0-9_]*(?:\.[a-zA-Z0-9_]+)*)\.\$\{/g;
|
|
211
|
+
const TRANS_BLOCK = /<Trans\b[^>]*?(?:\/>|>([\s\S]*?)<\/Trans\s*>)/g;
|
|
212
|
+
/** Tags a reader would see as text if the string went through `t()`, which React escapes. */
|
|
213
|
+
const MARKUP = /<(?:code|strong|em|b|i|a|br)\b[^>]*>/;
|
|
214
|
+
|
|
215
|
+
const skipped = (file: string): boolean =>
|
|
216
|
+
/(?:^|\/)__tests__\//.test(file) ||
|
|
217
|
+
/\.(test|spec)\.[tj]sx?$/.test(file) ||
|
|
218
|
+
/(?:^|\/)i18n\//.test(file);
|
|
219
|
+
|
|
220
|
+
function lineAt(source: string, index: number): number {
|
|
221
|
+
let line = 1;
|
|
222
|
+
for (let i = 0; i < index; i++) if (source.charCodeAt(i) === 10) line++;
|
|
223
|
+
return line;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Each identifier a file binds to `t`, and the namespaces behind it:
|
|
227
|
+
* `const { t: tc } = useTranslation("requests")` → `tc` → `requests`. */
|
|
228
|
+
function bindings(source: string, defaultNs: string): Map<string, Set<string>> {
|
|
229
|
+
const found = new Map<string, Set<string>>();
|
|
230
|
+
const pattern =
|
|
231
|
+
/(?:const|let|var)\s*\{([^}]*)\}\s*=\s*useTranslation\(\s*(?:(["'`])([^"'`]*)\2)?\s*\)/g;
|
|
232
|
+
for (const match of source.matchAll(pattern)) {
|
|
233
|
+
const names = match[1] ?? "";
|
|
234
|
+
const alias = /(?:^|,)\s*t\s*:\s*([A-Za-z_$][\w$]*)/.exec(names)?.[1];
|
|
235
|
+
const name = alias ?? (/(?:^|,)\s*t\s*(?:,|$)/.test(names) ? "t" : undefined);
|
|
236
|
+
if (name === undefined) continue;
|
|
237
|
+
const set = found.get(name) ?? new Set<string>();
|
|
238
|
+
set.add(match[3] || defaultNs);
|
|
239
|
+
found.set(name, set);
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Blank `{…}` expressions and `{/* comments *\/}` in one innermost pass, keeping newlines, so a
|
|
245
|
+
* `components={[<code />]}` does not read as the `<Trans>` closing itself.
|
|
246
|
+
* ponytail: one pass. Iterating to a fixed point lets a function body swallow whole `<Trans>`
|
|
247
|
+
* blocks once its inner braces are gone; an expression with nested braces stays unblanked. */
|
|
248
|
+
function blankExpressions(source: string): string {
|
|
249
|
+
return source.replace(/\{\/\*[\s\S]*?\*\/\}|\{[^{}]*\}/g, (m) => m.replace(/[^\n]/g, " "));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface Reference {
|
|
253
|
+
key: string;
|
|
254
|
+
namespaces: string[];
|
|
255
|
+
at: string;
|
|
256
|
+
hasDefault: boolean;
|
|
257
|
+
/** Reached through `t()`, which returns a string, so its copy may not carry markup. */
|
|
258
|
+
viaT: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
interface Prefix {
|
|
262
|
+
namespaces: string[];
|
|
263
|
+
/** The static text before `${`. */
|
|
264
|
+
head: string;
|
|
265
|
+
/** Whether `${` sits mid-segment (`read_${x}`) rather than after a dot. */
|
|
266
|
+
partial: boolean;
|
|
267
|
+
at: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
interface Scan {
|
|
271
|
+
references: Reference[];
|
|
272
|
+
prefixes: Map<string, Prefix>;
|
|
273
|
+
keyPaths: Set<string>;
|
|
274
|
+
/** The static end of a template with no static start: `.loadProblem` in `\`\${ns}.loadProblem\``. */
|
|
275
|
+
tails: Set<string>;
|
|
276
|
+
transChildren: string[];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface Areas {
|
|
280
|
+
defaultNs: string;
|
|
281
|
+
namespaces: Set<string>;
|
|
282
|
+
/** Top-level area → the namespaces declaring it. */
|
|
283
|
+
owners: Map<string, string[]>;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function record(
|
|
287
|
+
raw: string,
|
|
288
|
+
namespaces: string[],
|
|
289
|
+
file: string,
|
|
290
|
+
source: string,
|
|
291
|
+
index: number,
|
|
292
|
+
areas: Areas,
|
|
293
|
+
scan: Scan,
|
|
294
|
+
viaT: boolean,
|
|
295
|
+
): void {
|
|
296
|
+
let key = raw;
|
|
297
|
+
let candidates = namespaces;
|
|
298
|
+
const colon = raw.indexOf(":");
|
|
299
|
+
if (colon > 0 && areas.namespaces.has(raw.slice(0, colon))) {
|
|
300
|
+
candidates = [raw.slice(0, colon)];
|
|
301
|
+
key = raw.slice(colon + 1);
|
|
302
|
+
}
|
|
303
|
+
const at = `${file}:${lineAt(source, index)}`;
|
|
304
|
+
const dollar = key.indexOf("${");
|
|
305
|
+
if (dollar === -1) {
|
|
306
|
+
// A `defaultValue` beside the call still renders something, so a miss there is a warning.
|
|
307
|
+
const hasDefault = /defaultValue/.test(source.slice(index, index + 200));
|
|
308
|
+
scan.references.push({ key, namespaces: candidates, at, hasDefault, viaT });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const head = key.slice(0, dollar);
|
|
312
|
+
const tail = /\}(\.[\w.]+)$/.exec(key)?.[1];
|
|
313
|
+
if (head === "" && tail !== undefined) scan.tails.add(tail);
|
|
314
|
+
const partial = !head.endsWith(".");
|
|
315
|
+
const id = `${candidates.join("|")}:${head}`;
|
|
316
|
+
if (!scan.prefixes.has(id))
|
|
317
|
+
scan.prefixes.set(id, {
|
|
318
|
+
namespaces: candidates,
|
|
319
|
+
head: partial ? head : head.slice(0, -1),
|
|
320
|
+
partial,
|
|
321
|
+
at,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function scanCode(source: string, file: string, areas: Areas, scan: Scan): void {
|
|
326
|
+
const bound = bindings(source, areas.defaultNs);
|
|
327
|
+
for (const match of source.matchAll(CALL)) {
|
|
328
|
+
const callee = match[1] ?? "";
|
|
329
|
+
const namespaces = bound.get(callee) ?? (callee === "t" ? new Set([areas.defaultNs]) : null);
|
|
330
|
+
if (namespaces === null) continue;
|
|
331
|
+
record(match[3] ?? "", [...namespaces], file, source, match.index, areas, scan, true);
|
|
332
|
+
}
|
|
333
|
+
for (const match of source.matchAll(I18N_KEY)) {
|
|
334
|
+
record(match[2] ?? "", [areas.defaultNs], file, source, match.index, areas, scan, false);
|
|
335
|
+
}
|
|
336
|
+
for (const match of source.matchAll(KEY_FIELD)) {
|
|
337
|
+
const raw = match[2] ?? "";
|
|
338
|
+
const owners = areas.owners.get(raw.split(/[.$]/, 1)[0] ?? "");
|
|
339
|
+
if (owners !== undefined) record(raw, owners, file, source, match.index, areas, scan, false);
|
|
340
|
+
}
|
|
341
|
+
for (const pattern of [KEY_PATH, TEMPLATE_HEAD]) {
|
|
342
|
+
for (const match of source.matchAll(pattern)) {
|
|
343
|
+
const path = match[pattern === KEY_PATH ? 2 : 1] ?? "";
|
|
344
|
+
if (areas.owners.has(path.split(".", 1)[0] ?? "")) scan.keyPaths.add(path);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const blanked = blankExpressions(source);
|
|
348
|
+
for (const match of blanked.matchAll(TRANS_BLOCK)) {
|
|
349
|
+
if ((match[1] ?? "").trim() !== "")
|
|
350
|
+
scan.transChildren.push(`${file}:${lineAt(source, match.index)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function scanServer(
|
|
355
|
+
source: string,
|
|
356
|
+
file: string,
|
|
357
|
+
prefixes: readonly string[],
|
|
358
|
+
areas: Areas,
|
|
359
|
+
scan: Scan,
|
|
360
|
+
): void {
|
|
361
|
+
for (const match of source.matchAll(MESSAGE_KEY)) {
|
|
362
|
+
const raw = match[2] ?? "";
|
|
363
|
+
// Only keys into an area the client catalog has: a server may name keys of its own catalog too.
|
|
364
|
+
if (!areas.owners.has(raw.split(/[.$]/, 1)[0] ?? "")) continue;
|
|
365
|
+
record(raw, [areas.defaultNs], file, source, match.index, areas, scan, false);
|
|
366
|
+
}
|
|
367
|
+
for (const prefix of prefixes) {
|
|
368
|
+
for (const match of source.matchAll(/(["'`])([A-Za-z0-9_.]+)\1/g)) {
|
|
369
|
+
const raw = match[2] ?? "";
|
|
370
|
+
if (raw.startsWith(prefix) && raw.length > prefix.length)
|
|
371
|
+
record(raw, [areas.defaultNs], file, source, match.index, areas, scan, false);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function files(root: string, patterns: readonly string[]): Promise<string[]> {
|
|
377
|
+
const found = new Set<string>();
|
|
378
|
+
for (const pattern of patterns) {
|
|
379
|
+
for await (const file of glob(pattern, { cwd: root })) {
|
|
380
|
+
if (!skipped(file)) found.add(file);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return [...found].sort();
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Fragments ─────────────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
export interface MergeOptions {
|
|
389
|
+
/**
|
|
390
|
+
* Refuse two fragments that share a top-level key. Set it when the app merges fragments at
|
|
391
|
+
* runtime with a spread — `{ ...auth.en, ...nav.en }` — where the second `nav` replaces the
|
|
392
|
+
* first instead of joining it, and the keys of one fragment vanish with no error anywhere.
|
|
393
|
+
*/
|
|
394
|
+
ownTopLevel?: boolean;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export interface MergedFragments {
|
|
398
|
+
/** The text each `<locale>.json` must hold: keys sorted, two-space indent, a final newline. */
|
|
399
|
+
files: Map<string, string>;
|
|
400
|
+
/** A fragment that does not parse, or two that disagree. When not empty, nothing may be written. */
|
|
401
|
+
errors: string[];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
type Tree = { [key: string]: unknown };
|
|
405
|
+
|
|
406
|
+
const isTree = (value: unknown): value is Tree =>
|
|
407
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
408
|
+
|
|
409
|
+
/** A list is a value, not a tree: `t(key, { returnObjects: true })` hands it back whole. */
|
|
410
|
+
function mergeInto(
|
|
411
|
+
target: Tree,
|
|
412
|
+
source: Tree,
|
|
413
|
+
path: string,
|
|
414
|
+
origin: string,
|
|
415
|
+
errors: string[],
|
|
416
|
+
): void {
|
|
417
|
+
for (const [key, value] of Object.entries(source)) {
|
|
418
|
+
const existing = target[key];
|
|
419
|
+
if (isTree(value)) {
|
|
420
|
+
if (existing !== undefined && !isTree(existing)) {
|
|
421
|
+
errors.push(`${origin}: ${path}${key} is a string elsewhere and an object here`);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const child: Tree = isTree(existing) ? existing : {};
|
|
425
|
+
target[key] = child;
|
|
426
|
+
mergeInto(child, value, `${path}${key}.`, origin, errors);
|
|
427
|
+
} else if (typeof value !== "string" && !Array.isArray(value)) {
|
|
428
|
+
errors.push(`${origin}: ${path}${key} must be a string, a list or an object`);
|
|
429
|
+
} else if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(value)) {
|
|
430
|
+
errors.push(`${origin}: ${path}${key} is defined twice with different values`);
|
|
431
|
+
} else {
|
|
432
|
+
target[key] = value;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function sortTree(tree: Tree): Tree {
|
|
438
|
+
return Object.fromEntries(
|
|
439
|
+
Object.keys(tree)
|
|
440
|
+
.sort()
|
|
441
|
+
.map((key) => {
|
|
442
|
+
const value = tree[key];
|
|
443
|
+
return [key, isTree(value) ? sortTree(value) : value];
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Fold `dir/*.json` — one file per area, every language side by side, `{ "en": {…}, "pt-BR": {…} }`
|
|
450
|
+
* — into one catalog per language. Fragments exist so that people and agents building screens in
|
|
451
|
+
* parallel do not all edit the same three files. Deterministic: files in name order, keys sorted,
|
|
452
|
+
* so two runs write the same bytes and a derived catalog can be compared with a fresh merge.
|
|
453
|
+
*/
|
|
454
|
+
export async function mergeFragments(
|
|
455
|
+
dir: string,
|
|
456
|
+
locales: readonly string[],
|
|
457
|
+
options: MergeOptions = {},
|
|
458
|
+
): Promise<MergedFragments> {
|
|
459
|
+
const trees = new Map(locales.map((locale): [string, Tree] => [locale, {}]));
|
|
460
|
+
const errors: string[] = [];
|
|
461
|
+
const owners = new Map<string, string>();
|
|
462
|
+
const names = (await readdir(dir)).filter((name) => name.endsWith(".json")).sort();
|
|
463
|
+
for (const name of names) {
|
|
464
|
+
let fragment: unknown;
|
|
465
|
+
try {
|
|
466
|
+
fragment = JSON.parse(await readFile(join(dir, name), "utf8"));
|
|
467
|
+
} catch (error) {
|
|
468
|
+
errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (!isTree(fragment)) {
|
|
472
|
+
errors.push(`${name}: expected an object keyed by language`);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
for (const language of Object.keys(fragment)) {
|
|
476
|
+
if (!trees.has(language)) errors.push(`${name}: "${language}" is not one of the languages`);
|
|
477
|
+
}
|
|
478
|
+
if (options.ownTopLevel === true) {
|
|
479
|
+
const tops = new Set(
|
|
480
|
+
Object.values(fragment).flatMap((tree) => (isTree(tree) ? Object.keys(tree) : [])),
|
|
481
|
+
);
|
|
482
|
+
for (const top of tops) {
|
|
483
|
+
const owner = owners.get(top);
|
|
484
|
+
if (owner === undefined) owners.set(top, name);
|
|
485
|
+
else
|
|
486
|
+
errors.push(
|
|
487
|
+
`${name}: "${top}" is already a key of ${owner}, and a spread keeps only one of them`,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
for (const [locale, target] of trees) {
|
|
492
|
+
const tree = fragment[locale];
|
|
493
|
+
if (tree === undefined) continue;
|
|
494
|
+
if (!isTree(tree)) errors.push(`${name}: "${locale}" must be an object`);
|
|
495
|
+
else mergeInto(target, tree, "", `${name} [${locale}]`, errors);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
const files = new Map(
|
|
499
|
+
[...trees].map(([locale, tree]): [string, string] => [
|
|
500
|
+
locale,
|
|
501
|
+
`${JSON.stringify(sortTree(tree), null, 2)}\n`,
|
|
502
|
+
]),
|
|
503
|
+
);
|
|
504
|
+
return { files, errors };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Merge `dir` and write `out/<locale>.json`. Writes nothing when a fragment is broken, so a
|
|
508
|
+
* catalog is never missing the half that failed to parse. Returns the errors. */
|
|
509
|
+
export async function writeFragments(
|
|
510
|
+
dir: string,
|
|
511
|
+
out: string,
|
|
512
|
+
locales: readonly string[],
|
|
513
|
+
options: MergeOptions = {},
|
|
514
|
+
): Promise<string[]> {
|
|
515
|
+
const { files, errors } = await mergeFragments(dir, locales, options);
|
|
516
|
+
if (errors.length > 0) return errors;
|
|
517
|
+
await mkdir(out, { recursive: true });
|
|
518
|
+
for (const [locale, text] of files) await writeFile(join(out, `${locale}.json`), text);
|
|
519
|
+
return [];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ── The check ─────────────────────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
async function checkBundle(
|
|
525
|
+
bundle: I18nBundle,
|
|
526
|
+
options: CheckI18nOptions,
|
|
527
|
+
report: I18nReport,
|
|
528
|
+
): Promise<void> {
|
|
529
|
+
const root = options.root ?? process.cwd();
|
|
530
|
+
const defaultNs = bundle.defaultNamespace ?? "translation";
|
|
531
|
+
const problem = (p: Omit<I18nProblem, "bundle" | "level"> & { level?: "warning" }): void => {
|
|
532
|
+
report.problems.push({ bundle: bundle.name, level: "error", ...p });
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
if (!bundle.locales.includes(bundle.canonical)) {
|
|
536
|
+
problem({
|
|
537
|
+
rule: "load",
|
|
538
|
+
message: `the canonical language ${bundle.canonical} is not in the list`,
|
|
539
|
+
});
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const fragments = bundle.fragments;
|
|
544
|
+
if (fragments !== undefined) {
|
|
545
|
+
const merged = await mergeFragments(resolve(root, fragments.dir), bundle.locales, {
|
|
546
|
+
ownTopLevel: fragments.ownTopLevel,
|
|
547
|
+
}).catch((error: unknown) => ({
|
|
548
|
+
files: new Map<string, string>(),
|
|
549
|
+
errors: [`${fragments.dir}: ${error instanceof Error ? error.message : String(error)}`],
|
|
550
|
+
}));
|
|
551
|
+
for (const error of merged.errors) problem({ rule: "fragments", message: error });
|
|
552
|
+
const out = fragments.out;
|
|
553
|
+
// An app that merges at runtime commits no catalog, so there is nothing to compare.
|
|
554
|
+
if (out !== undefined)
|
|
555
|
+
for (const [locale, text] of merged.files) {
|
|
556
|
+
const file = join(out, `${locale}.json`);
|
|
557
|
+
const onDisk = await readFile(resolve(root, file), "utf8").catch(() => null);
|
|
558
|
+
if (onDisk !== text)
|
|
559
|
+
problem({
|
|
560
|
+
rule: "fragments",
|
|
561
|
+
locale,
|
|
562
|
+
message: `${file} ${onDisk === null ? "is missing" : "differs from its fragments"}; merge them again`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const loaded = new Map<string, Catalogs>();
|
|
568
|
+
for (const locale of bundle.locales) {
|
|
569
|
+
try {
|
|
570
|
+
loaded.set(locale, await bundle.load(locale));
|
|
571
|
+
} catch (error) {
|
|
572
|
+
problem({
|
|
573
|
+
rule: "load",
|
|
574
|
+
locale,
|
|
575
|
+
message: `could not load: ${error instanceof Error ? error.message : String(error)}`,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
// Every rule below compares languages, and a language that did not load reads as one with no keys.
|
|
580
|
+
if (loaded.size < bundle.locales.length) return;
|
|
581
|
+
|
|
582
|
+
const namespaces = new Set([...loaded.values()].flatMap((catalogs) => Object.keys(catalogs)));
|
|
583
|
+
const flat = new Map<string, Map<string, Flat>>();
|
|
584
|
+
for (const ns of namespaces) {
|
|
585
|
+
flat.set(
|
|
586
|
+
ns,
|
|
587
|
+
new Map(bundle.locales.map((locale) => [locale, flatten(loaded.get(locale)?.[ns])])),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const canonicalOf = (ns: string): Flat =>
|
|
591
|
+
flat.get(ns)?.get(bundle.canonical) ?? { leaves: new Map(), nodes: new Set() };
|
|
592
|
+
|
|
593
|
+
const brand = new Set(Object.keys(bundle.brandVars ?? {}));
|
|
594
|
+
const canonicalForms = categories(bundle.canonical);
|
|
595
|
+
// A `g` or `y` pattern keeps `lastIndex` between calls, so every other value would pass.
|
|
596
|
+
const price =
|
|
597
|
+
options.price && new RegExp(options.price.source, options.price.flags.replace(/[gy]/g, ""));
|
|
598
|
+
for (const ns of [...namespaces].sort()) {
|
|
599
|
+
const byLocale = flat.get(ns) ?? new Map<string, Flat>();
|
|
600
|
+
const label = (key: string): string => (ns === defaultNs ? key : `${ns}:${key}`);
|
|
601
|
+
|
|
602
|
+
// A key is a plural form only when its set has `_other`, which i18next always needs. That keeps
|
|
603
|
+
// `step_two` a key of its own instead of the `two` form of `step`.
|
|
604
|
+
const pluralBases = new Set<string>();
|
|
605
|
+
for (const { leaves } of byLocale.values()) {
|
|
606
|
+
for (const key of leaves.keys())
|
|
607
|
+
if (key.endsWith("_other")) pluralBases.add(key.slice(0, -6));
|
|
608
|
+
}
|
|
609
|
+
const baseOf = (key: string): string => {
|
|
610
|
+
const split = pluralSplit(key);
|
|
611
|
+
return split !== null && pluralBases.has(split[0]) ? split[0] : key;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// Parity by base: each language carries the forms its own rules select, so English has no
|
|
615
|
+
// `_many` to be missing.
|
|
616
|
+
const keysOf = new Map(
|
|
617
|
+
[...byLocale].map(([locale, { leaves }]) => [
|
|
618
|
+
locale,
|
|
619
|
+
new Set([...leaves.keys()].map(baseOf)),
|
|
620
|
+
]),
|
|
621
|
+
);
|
|
622
|
+
const union = new Set([...keysOf.values()].flatMap((keys) => [...keys]));
|
|
623
|
+
for (const locale of bundle.locales) {
|
|
624
|
+
const keys = keysOf.get(locale) ?? new Set<string>();
|
|
625
|
+
for (const key of [...union].sort()) {
|
|
626
|
+
if (!keys.has(key))
|
|
627
|
+
problem({ rule: "parity", locale, key: label(key), message: "missing" });
|
|
628
|
+
}
|
|
629
|
+
for (const [key, value] of byLocale.get(locale)?.leaves ?? []) {
|
|
630
|
+
if (typeof value !== "string")
|
|
631
|
+
problem({ rule: "parity", locale, key: label(key), message: "is not a string" });
|
|
632
|
+
else if (value.trim() === "")
|
|
633
|
+
problem({ rule: "parity", locale, key: label(key), message: "is empty" });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
for (const locale of bundle.locales) {
|
|
638
|
+
const { leaves } = byLocale.get(locale) ?? { leaves: new Map<string, unknown>() };
|
|
639
|
+
const needed = categories(locale);
|
|
640
|
+
const has = keysOf.get(locale) ?? new Set<string>();
|
|
641
|
+
for (const base of [...pluralBases].sort()) {
|
|
642
|
+
if (!has.has(base)) continue;
|
|
643
|
+
for (const form of needed) {
|
|
644
|
+
if (!leaves.has(`${base}_${form}`))
|
|
645
|
+
problem({
|
|
646
|
+
rule: "plural",
|
|
647
|
+
locale,
|
|
648
|
+
key: label(`${base}_${form}`),
|
|
649
|
+
message: `missing: ${locale} uses the ${form} form, and without it the reader gets another language`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
for (const key of leaves.keys()) {
|
|
653
|
+
const split = pluralSplit(key);
|
|
654
|
+
// `_zero` is i18next's own form for a count of 0, in every language.
|
|
655
|
+
if (split === null || split[0] !== base || needed.has(split[1]) || split[1] === "zero")
|
|
656
|
+
continue;
|
|
657
|
+
problem({
|
|
658
|
+
rule: "plural",
|
|
659
|
+
level: "warning",
|
|
660
|
+
locale,
|
|
661
|
+
key: label(key),
|
|
662
|
+
message: `${locale} never uses the ${split[1]} form, so nobody reads this`,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const canonical = canonicalOf(ns).leaves;
|
|
669
|
+
for (const locale of bundle.locales) {
|
|
670
|
+
for (const [key, value] of byLocale.get(locale)?.leaves ?? []) {
|
|
671
|
+
if (typeof value !== "string") continue;
|
|
672
|
+
const split = pluralSplit(key);
|
|
673
|
+
const plural = split !== null && pluralBases.has(split[0]) ? split : null;
|
|
674
|
+
|
|
675
|
+
for (const name of placeholderNames(value)) {
|
|
676
|
+
if (RESERVED.has(name) && !brand.has(name))
|
|
677
|
+
problem({
|
|
678
|
+
rule: "reserved",
|
|
679
|
+
locale,
|
|
680
|
+
key: label(key),
|
|
681
|
+
message: `{{${name}}} is an option t() reads, not a value it fills`,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
const tagProblem = /<\/?\d/.test(value) ? tagIssue(value) : null;
|
|
685
|
+
if (tagProblem !== null)
|
|
686
|
+
problem({ rule: "tags", locale, key: label(key), message: tagProblem });
|
|
687
|
+
if (price?.test(value))
|
|
688
|
+
problem({
|
|
689
|
+
rule: "price",
|
|
690
|
+
locale,
|
|
691
|
+
key: label(key),
|
|
692
|
+
message: `a price is typed into the copy: ${value.slice(0, 60)}`,
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
if (locale === bundle.canonical) continue;
|
|
696
|
+
// A form the canonical language never selects (`_many` in English) answers to its `_other`,
|
|
697
|
+
// even when the canonical catalog carries a dead copy of it.
|
|
698
|
+
const sameForm =
|
|
699
|
+
plural === null || plural[1] === "zero" || canonicalForms.has(plural[1])
|
|
700
|
+
? canonical.get(key)
|
|
701
|
+
: undefined;
|
|
702
|
+
const source =
|
|
703
|
+
sameForm ?? (plural === null ? undefined : canonical.get(`${plural[0]}_other`));
|
|
704
|
+
if (typeof source !== "string") continue;
|
|
705
|
+
// "One mailbox" and "{{count}} caixa" are both right for a single thing.
|
|
706
|
+
const dropCount = plural?.[1] === "one" || plural?.[1] === "zero";
|
|
707
|
+
const want = tokens(source, dropCount);
|
|
708
|
+
const got = tokens(value, dropCount);
|
|
709
|
+
if (want !== got)
|
|
710
|
+
problem({
|
|
711
|
+
rule: "placeholders",
|
|
712
|
+
locale,
|
|
713
|
+
key: label(key),
|
|
714
|
+
message: `has [${got}] where ${bundle.canonical} has [${want}]`,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const needsScan =
|
|
721
|
+
(bundle.code?.length ?? 0) > 0 ||
|
|
722
|
+
(bundle.serverCode?.length ?? 0) > 0 ||
|
|
723
|
+
(bundle.serverKeys?.length ?? 0) > 0;
|
|
724
|
+
if (!needsScan) return;
|
|
725
|
+
|
|
726
|
+
const owners = new Map<string, string[]>();
|
|
727
|
+
for (const ns of namespaces) {
|
|
728
|
+
const catalog = loaded.get(bundle.canonical)?.[ns] ?? {};
|
|
729
|
+
for (const area of Object.keys(catalog)) owners.set(area, [...(owners.get(area) ?? []), ns]);
|
|
730
|
+
}
|
|
731
|
+
const areas: Areas = { defaultNs, namespaces, owners };
|
|
732
|
+
const scan: Scan = {
|
|
733
|
+
references: [],
|
|
734
|
+
prefixes: new Map(),
|
|
735
|
+
keyPaths: new Set(),
|
|
736
|
+
tails: new Set(),
|
|
737
|
+
transChildren: [],
|
|
738
|
+
};
|
|
739
|
+
for (const file of await files(root, bundle.code ?? [])) {
|
|
740
|
+
scanCode(await readFile(resolve(root, file), "utf8"), file, areas, scan);
|
|
741
|
+
}
|
|
742
|
+
const codeReferences = scan.references.length;
|
|
743
|
+
for (const file of await files(root, bundle.serverCode ?? [])) {
|
|
744
|
+
scanServer(
|
|
745
|
+
await readFile(resolve(root, file), "utf8"),
|
|
746
|
+
file,
|
|
747
|
+
bundle.serverKeyPrefixes ?? [],
|
|
748
|
+
areas,
|
|
749
|
+
scan,
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const present = (f: Flat, key: string): boolean =>
|
|
754
|
+
f.leaves.has(key) ||
|
|
755
|
+
f.nodes.has(key) ||
|
|
756
|
+
["zero", "one", "two", "few", "many", "other"].some((form) => f.leaves.has(`${key}_${form}`));
|
|
757
|
+
const label = (namespaces: string[], key: string): string =>
|
|
758
|
+
namespaces.length === 1 && namespaces[0] === defaultNs ? key : `${namespaces.join("|")}:${key}`;
|
|
759
|
+
|
|
760
|
+
const seen = new Set<string>();
|
|
761
|
+
for (const [index, reference] of scan.references.entries()) {
|
|
762
|
+
const id = `${reference.at}:${reference.namespaces.join("|")}:${reference.key}`;
|
|
763
|
+
if (seen.has(id)) continue;
|
|
764
|
+
seen.add(id);
|
|
765
|
+
const fromServer = index >= codeReferences;
|
|
766
|
+
if (!reference.namespaces.some((ns) => present(canonicalOf(ns), reference.key))) {
|
|
767
|
+
problem({
|
|
768
|
+
rule: fromServer ? "server-key" : "missing-key",
|
|
769
|
+
...(reference.hasDefault ? { level: "warning" as const } : {}),
|
|
770
|
+
key: label(reference.namespaces, reference.key),
|
|
771
|
+
at: reference.at,
|
|
772
|
+
message: `is not in ${bundle.canonical}${reference.hasDefault ? " (a defaultValue covers it)" : ""}`,
|
|
773
|
+
});
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (reference.viaT) {
|
|
777
|
+
const value = reference.namespaces
|
|
778
|
+
.map((ns) => canonicalOf(ns).leaves.get(reference.key))
|
|
779
|
+
.find((v) => v !== undefined);
|
|
780
|
+
// t() returns a string and React escapes it, so the reader sees the angle brackets — in
|
|
781
|
+
// every language at once, since translations copy the tag. Five shipped before one was seen.
|
|
782
|
+
if (typeof value === "string" && MARKUP.test(value))
|
|
783
|
+
problem({
|
|
784
|
+
rule: "markup",
|
|
785
|
+
key: label(reference.namespaces, reference.key),
|
|
786
|
+
at: reference.at,
|
|
787
|
+
message: `carries markup, which t() prints as text; render it with <Trans i18nKey="${reference.key}" components={…} />`,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// A key the server sends is shown in whatever language the reader has, and fallback would hide
|
|
793
|
+
// a hole in any one of them.
|
|
794
|
+
for (const key of bundle.serverKeys ?? []) {
|
|
795
|
+
for (const locale of bundle.locales) {
|
|
796
|
+
const f = flat.get(defaultNs)?.get(locale);
|
|
797
|
+
if (f === undefined || !present(f, key))
|
|
798
|
+
problem({
|
|
799
|
+
rule: "server-key",
|
|
800
|
+
locale,
|
|
801
|
+
key,
|
|
802
|
+
message: `the server can send it, and ${locale} does not have it`,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
for (const at of scan.transChildren)
|
|
808
|
+
problem({
|
|
809
|
+
rule: "trans-children",
|
|
810
|
+
at,
|
|
811
|
+
message:
|
|
812
|
+
"<Trans> has children; they are indexed too and shift every <n>. Pass components={…} and self-close it",
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const leavesOf = (ns: string): string[] => [...canonicalOf(ns).leaves.keys()];
|
|
816
|
+
const prefixFound = (prefix: Prefix): boolean =>
|
|
817
|
+
prefix.head === "" ||
|
|
818
|
+
prefix.namespaces.some((ns) =>
|
|
819
|
+
prefix.partial
|
|
820
|
+
? leavesOf(ns).some((key) => key.startsWith(prefix.head))
|
|
821
|
+
: present(canonicalOf(ns), prefix.head),
|
|
822
|
+
);
|
|
823
|
+
for (const prefix of scan.prefixes.values()) {
|
|
824
|
+
if (!prefixFound(prefix))
|
|
825
|
+
problem({
|
|
826
|
+
rule: "dynamic-key",
|
|
827
|
+
key: label(prefix.namespaces, `${prefix.head}${prefix.partial ? "" : "."}*`),
|
|
828
|
+
at: prefix.at,
|
|
829
|
+
message: `no key in ${bundle.canonical} starts this way`,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if ((bundle.code?.length ?? 0) === 0) return;
|
|
834
|
+
for (const ns of [...namespaces].sort()) {
|
|
835
|
+
const used = new Set(
|
|
836
|
+
scan.references.filter((r) => r.namespaces.includes(ns)).map((r) => r.key),
|
|
837
|
+
);
|
|
838
|
+
const heads = [...scan.prefixes.values()].filter(
|
|
839
|
+
(p) => p.namespaces.includes(ns) && p.head !== "",
|
|
840
|
+
);
|
|
841
|
+
const paths = [...scan.keyPaths].filter((path) => present(canonicalOf(ns), path));
|
|
842
|
+
const reached = (key: string): boolean =>
|
|
843
|
+
used.has(key) ||
|
|
844
|
+
heads.some((p) =>
|
|
845
|
+
p.partial ? key.startsWith(p.head) : key === p.head || key.startsWith(`${p.head}.`),
|
|
846
|
+
) ||
|
|
847
|
+
paths.some((path) => key === path || key.startsWith(`${path}.`)) ||
|
|
848
|
+
[...scan.tails].some((tail) => key.endsWith(tail));
|
|
849
|
+
for (const key of leavesOf(ns).sort()) {
|
|
850
|
+
// `t("x", { count })` reaches `x_one` and `x_other` through `x`.
|
|
851
|
+
const split = pluralSplit(key);
|
|
852
|
+
if (reached(key) || (split !== null && reached(split[0]))) continue;
|
|
853
|
+
report.unused.push({ bundle: bundle.name, key: ns === defaultNs ? key : `${ns}:${key}` });
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Run every rule over every bundle. Returns what it found rather than printing or exiting, so a repo
|
|
860
|
+
* can add checks of its own to the same report and decide how CI fails:
|
|
861
|
+
*
|
|
862
|
+
* ```ts
|
|
863
|
+
* const report = await checkI18n([web, site]);
|
|
864
|
+
* console.log(formatI18nReport(report));
|
|
865
|
+
* process.exit(report.problems.some((p) => p.level === "error") ? 1 : 0);
|
|
866
|
+
* ```
|
|
867
|
+
*/
|
|
868
|
+
export async function checkI18n(
|
|
869
|
+
bundles: readonly I18nBundle[],
|
|
870
|
+
options: CheckI18nOptions = {},
|
|
871
|
+
): Promise<I18nReport> {
|
|
872
|
+
const report: I18nReport = { problems: [], unused: [] };
|
|
873
|
+
for (const bundle of bundles) await checkBundle(bundle, options, report);
|
|
874
|
+
return report;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** The report as lines for a terminal: one per problem, grouped by bundle, and a last line that
|
|
878
|
+
* says whether the gate passed. Pass `showUnused` to list the advisory keys, not just count them. */
|
|
879
|
+
export function formatI18nReport(
|
|
880
|
+
report: I18nReport,
|
|
881
|
+
options: { showUnused?: boolean } = {},
|
|
882
|
+
): string {
|
|
883
|
+
const lines: string[] = [];
|
|
884
|
+
const bundles = [
|
|
885
|
+
...new Set([...report.problems.map((p) => p.bundle), ...report.unused.map((u) => u.bundle)]),
|
|
886
|
+
];
|
|
887
|
+
for (const bundle of bundles) {
|
|
888
|
+
lines.push(bundle);
|
|
889
|
+
for (const p of report.problems.filter((q) => q.bundle === bundle)) {
|
|
890
|
+
const where = [p.locale, p.key].filter((part) => part !== undefined).join(" ");
|
|
891
|
+
const at = p.at === undefined ? "" : ` (${p.at})`;
|
|
892
|
+
lines.push(
|
|
893
|
+
` ${p.level === "error" ? "✖" : "⚠"} ${p.rule}: ${where}${where === "" ? "" : " "}${p.message}${at}`,
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
const unused = report.unused.filter((u) => u.bundle === bundle);
|
|
897
|
+
if (unused.length > 0) {
|
|
898
|
+
lines.push(` · ${unused.length} key(s) no code reaches, as far as a scan can tell`);
|
|
899
|
+
if (options.showUnused === true) for (const u of unused) lines.push(` ${u.key}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const errors = report.problems.filter((p) => p.level === "error").length;
|
|
903
|
+
const warnings = report.problems.length - errors;
|
|
904
|
+
lines.push(
|
|
905
|
+
errors === 0
|
|
906
|
+
? `✔ i18n: no errors${warnings > 0 ? `, ${warnings} warning(s)` : ""}`
|
|
907
|
+
: `✖ i18n: ${errors} error(s)${warnings > 0 ? `, ${warnings} warning(s)` : ""}`,
|
|
908
|
+
);
|
|
909
|
+
return lines.join("\n");
|
|
910
|
+
}
|