@stealthscale/vite-plugin-i18n 0.1.0 → 0.3.2

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/dist/index.mjs CHANGED
@@ -1,20 +1,21 @@
1
- import { existsSync, globSync, readFileSync } from "node:fs";
1
+ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { parse } from "yaml";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
4
  import { normalizePath } from "vite";
5
- import { manifestAt, packageAt, text, writeIfChanged } from "@stealthscale/vite-plugin-base";
5
+ import { manifestAt, packageAt, scratchDir, text, writeIfChanged } from "@stealthscale/vite-plugin-base";
6
6
  //#region src/find.ts
7
7
  /**
8
- * Locates every `locales/<language>/<namespace>.json` an application and its dependencies ship.
8
+ * Finds every catalogue file under `locales/<language>/` that an application and its dependencies
9
+ * ship.
9
10
  *
10
11
  * @remarks
11
- * A namespace is the first path segment under the language. `menu.json` and
12
+ * A namespace is the first path segment under the language, so `menu.json` and
12
13
  * `menu/sections/billing.json` both belong to the namespace `menu`, and the second contributes
13
- * its keys under `sections.billing`. A namespace is fetched as one module whatever it is split
14
- * into.
14
+ * its keys under `sections.billing`. A namespace is fetched as one module however many files it
15
+ * is split across.
15
16
  */
16
17
  /**
17
- * The directory a package keeps its catalogues in.
18
+ * Gives the directory a package keeps its catalogues in.
18
19
  */
19
20
  const LOCALES = "locales";
20
21
  /**
@@ -22,10 +23,14 @@ const LOCALES = "locales";
22
23
  */
23
24
  const EXTENSION = /\.(?:json|ya?ml)$/u;
24
25
  /**
25
- * Returns the namespace a file belongs to.
26
+ * The scope of the house's packages, which the walk follows from an application in any scope,
27
+ * because the plugin host renders words from the catalogue its package publishes.
28
+ */
29
+ const HOUSE = "@stealthscale";
30
+ /**
31
+ * Returns the namespace a file belongs to, which is its first path segment with any extension
32
+ * stripped.
26
33
  *
27
- * @remarks
28
- * The first path segment, with the extension stripped when the file is the namespace itself.
29
34
  * @param relative - The file's path under the language directory.
30
35
  */
31
36
  function namespaceOf(relative) {
@@ -33,10 +38,8 @@ function namespaceOf(relative) {
33
38
  return at === -1 ? relative.replace(EXTENSION, "") : relative.slice(0, at);
34
39
  }
35
40
  /**
36
- * Returns the prefix a file's keys are nested under.
41
+ * Returns the dotted prefix a file's keys nest under, taken from its path below the namespace.
37
42
  *
38
- * @remarks
39
- * The path below the namespace with the extension stripped and each separator replaced by a dot.
40
43
  * @param relative - The file's path under the language directory.
41
44
  * @returns The prefix, or an empty string when the file is the namespace itself.
42
45
  */
@@ -45,7 +48,8 @@ function prefixOf(relative) {
45
48
  return at === -1 ? "" : relative.slice(at + 1).replace(EXTENSION, "").replaceAll("/", ".");
46
49
  }
47
50
  /**
48
- * Lists every catalogue file under one directory's `locales`, at any depth.
51
+ * Lists every catalogue file under one directory's `locales`, at any depth, and nothing where the
52
+ * directory has no `locales`.
49
53
  *
50
54
  * @param directory - The package or application directory to search.
51
55
  * @param owner - The package name to record on each result.
@@ -69,7 +73,7 @@ function cataloguesIn(directory, owner, own) {
69
73
  });
70
74
  }
71
75
  /**
72
- * Returns the scope a package name carries.
76
+ * Returns the scope part of a package name.
73
77
  *
74
78
  * @param name - The package name, or undefined when the manifest declares none.
75
79
  * @returns The scope such as `@stealthscale`, or undefined for an unscoped name.
@@ -82,16 +86,16 @@ function scopeOf(name) {
82
86
  *
83
87
  * @remarks
84
88
  * `dependencies` and `peerDependencies` both count, because a component package declares its
85
- * siblings as peers. The application's `devDependencies` count too, because an example declares
86
- * the packages it demonstrates there.
89
+ * siblings as peers. The root's `devDependencies` count as well, because an application declares
90
+ * the packages its examples import and a package declares the siblings its specimens import.
87
91
  * @param manifest - The parsed package.json.
88
- * @param own - True for the application's own manifest.
92
+ * @param starting - True for the manifest the search starts from.
89
93
  */
90
- function namesIn(manifest, own) {
94
+ function namesIn(manifest, starting) {
91
95
  return [
92
96
  "dependencies",
93
97
  "peerDependencies",
94
- ...own ? ["devDependencies"] : []
98
+ ...starting ? ["devDependencies"] : []
95
99
  ].flatMap((field) => {
96
100
  const declared = manifest[field];
97
101
  return typeof declared === "object" && declared !== null ? Object.keys(declared) : [];
@@ -105,53 +109,57 @@ function namesIn(manifest, own) {
105
109
  * engine would read hundreds of manifests and find no catalogue in any of them.
106
110
  * @param directory - The directory to start from.
107
111
  * @param walk - The scopes to follow and the directories already visited.
108
- * @param own - True for the application's own directory.
109
- * @returns Each package with its manifest, the starting directory last.
112
+ * @param starting - True for the directory the search starts from, whose development dependencies
113
+ * are followed.
114
+ * @returns Each package with its manifest, the starting directory last, and nothing for a
115
+ * directory already visited or holding no manifest.
110
116
  */
111
- function walked(directory, walk, own) {
117
+ function walked(directory, walk, starting) {
112
118
  if (walk.seen.has(directory)) return [];
113
119
  walk.seen.add(directory);
114
120
  const manifest = manifestAt(directory);
115
121
  if (manifest === void 0) return [];
116
- return [...namesIn(manifest, own).filter((name) => walk.scopes.has(scopeOf(name) ?? "")).map((name) => packageAt(name, directory)).filter((at) => at !== void 0).flatMap((at) => walked(at, walk, false)), {
122
+ return [...namesIn(manifest, starting).filter((name) => walk.scopes.has(scopeOf(name) ?? "")).map((name) => packageAt(name, directory)).filter((at) => at !== void 0).flatMap((at) => walked(at, walk, false)), {
117
123
  at: directory,
118
124
  manifest
119
125
  }];
120
126
  }
121
127
  /**
122
- * Returns every catalogue an application can reach, its dependencies' first and its own last.
128
+ * Lists every catalogue a root can reach, its dependencies' first and its own last.
123
129
  *
124
130
  * @remarks
125
- * When two packages declare the same language and namespace, the one later in the result wins the
126
- * merge, which puts the application last. Each package is resolved the way an import of it
127
- * resolves, so a workspace link and an installed copy are found alike.
128
- * @param root - The application directory.
129
- * @param scopes - The scopes to follow. Defaults to the application's own scope.
131
+ * Where two packages declare the same language and namespace, the one later in the result wins
132
+ * the merge, and the root goes last for that reason. Each package is resolved through Node's
133
+ * resolution, so a workspace link and an installed copy are both found.
134
+ * @param root - The application or package directory.
135
+ * @param scopes - The scopes to follow. Defaults to the root's own scope and {@link HOUSE}.
130
136
  * @returns The catalogues in merge order.
131
137
  */
132
138
  function found(root, scopes) {
133
- const own = manifestAt(root);
134
- const named = own === void 0 ? void 0 : text(own, "name");
139
+ const manifest = manifestAt(root);
140
+ const named = manifest === void 0 ? void 0 : text(manifest, "name");
141
+ const application = manifest?.["private"] === true;
135
142
  return walked(root, {
136
- scopes: new Set(scopes ?? [scopeOf(named)].filter((scope) => scope !== void 0)),
143
+ scopes: new Set(scopes ?? [scopeOf(named), HOUSE].filter((scope) => scope !== void 0)),
137
144
  seen: /* @__PURE__ */ new Set()
138
- }, true).flatMap((one) => cataloguesIn(one.at, text(one.manifest, "name") ?? basename(one.at), one.at === root));
145
+ }, true).flatMap((one) => cataloguesIn(one.at, text(one.manifest, "name") ?? basename(one.at), application && one.at === root));
139
146
  }
140
147
  //#endregion
141
148
  //#region src/emit.ts
142
149
  /**
143
- * Generates the module an application imports its catalogues from, and one module per pair.
150
+ * Generates the virtual module an application imports its catalogues from, and one module per
151
+ * language and namespace pair.
144
152
  */
145
153
  /**
146
- * The specifier an application imports to reach its catalogues.
154
+ * Specifier an application imports to reach its catalogues.
147
155
  */
148
156
  const ID = "virtual:i18n";
149
157
  /**
150
158
  * Builds the specifier one language's namespace is fetched under.
151
159
  *
152
160
  * @remarks
153
- * Neither a language tag nor a namespace contains a slash, so `pairOfId` splits the result back
154
- * without ambiguity.
161
+ * Neither a language tag nor a namespace contains a slash, so {@link pairOfId} splits the result
162
+ * back without ambiguity.
155
163
  * @param language - The BCP 47 tag.
156
164
  * @param namespace - The namespace name.
157
165
  * @returns A specifier such as `virtual:i18n/nl/menu`.
@@ -162,7 +170,7 @@ function pairId(language, namespace) {
162
170
  /**
163
171
  * Splits a pair module's specifier back into its language and namespace.
164
172
  *
165
- * @param id - A specifier, with or without the resolution prefix.
173
+ * @param id - A specifier, with or without the resolved-module prefix.
166
174
  * @returns The pair, or undefined when the specifier addresses something else.
167
175
  */
168
176
  function pairOfId(id) {
@@ -173,7 +181,7 @@ function pairOfId(id) {
173
181
  } : void 0;
174
182
  }
175
183
  /**
176
- * Indexes catalogues by language and namespace, preserving the order they were found in.
184
+ * Indexes catalogues by language and namespace, keeping the order they were found in.
177
185
  *
178
186
  * @param catalogues - Every catalogue found.
179
187
  */
@@ -189,7 +197,7 @@ function indexed(catalogues) {
189
197
  return byLanguage;
190
198
  }
191
199
  /**
192
- * Returns one language's catalogues indexed by namespace.
200
+ * Returns one language's catalogues, indexed by namespace.
193
201
  *
194
202
  * @param index - The indexed catalogues.
195
203
  * @param language - The BCP 47 tag.
@@ -199,7 +207,7 @@ function ofLanguage(index, language) {
199
207
  return index.get(language) ?? /* @__PURE__ */ new Map();
200
208
  }
201
209
  /**
202
- * Returns every file that belongs to one pair.
210
+ * Returns the files that belong to one language and namespace pair.
203
211
  *
204
212
  * @param index - The indexed catalogues.
205
213
  * @param language - The BCP 47 tag.
@@ -210,7 +218,7 @@ function filesOf(index, language, namespace) {
210
218
  return ofLanguage(index, language).get(namespace) ?? [];
211
219
  }
212
220
  /**
213
- * Returns true when a parsed value is an object whose every leaf is a string.
221
+ * Reports whether a parsed value is an object whose every leaf is a string.
214
222
  *
215
223
  * @param value - The parsed file contents.
216
224
  */
@@ -221,7 +229,7 @@ function isWords(value) {
221
229
  * Parses one catalogue file, as JSON or as YAML according to its extension.
222
230
  *
223
231
  * @param catalogue - The file to read.
224
- * @returns Its contents, nested as written.
232
+ * @returns The contents, nested as written.
225
233
  * @throws {@link Error} When the file does not parse to an object of strings.
226
234
  */
227
235
  function wordsOf(catalogue) {
@@ -231,7 +239,7 @@ function wordsOf(catalogue) {
231
239
  return parsed;
232
240
  }
233
241
  /**
234
- * Parses one catalogue file and nests its contents under the file's prefix.
242
+ * Parses one catalogue file and nests its contents under the file's own prefix.
235
243
  *
236
244
  * @remarks
237
245
  * A `title` key in `menu/sections/billing.json` comes back as `sections.billing.title`.
@@ -243,8 +251,8 @@ function nested(catalogue) {
243
251
  /**
244
252
  * Merges two sets of contents key by key, so the later one overwrites only the keys it declares.
245
253
  *
246
- * @param under - The contents merged so far.
247
- * @param over - The contents to apply on top.
254
+ * @param under - Contents merged so far.
255
+ * @param over - Contents to apply on top.
248
256
  */
249
257
  function merged(under, over) {
250
258
  const result = { ...under };
@@ -257,13 +265,13 @@ function merged(under, over) {
257
265
  /**
258
266
  * Parses and merges every file of one pair, each nested under its own prefix.
259
267
  *
260
- * @param files - The files in merge order, the last one winning each key.
268
+ * @param files - The files in merge order. The last one wins each key.
261
269
  */
262
270
  function mergedWords(files) {
263
271
  return files.reduce((words, file) => merged(words, nested(file)), {});
264
272
  }
265
273
  /**
266
- * Generates the module one pair is fetched as, holding every file of the pair merged.
274
+ * Generates the module a pair is fetched as, with every file of the pair merged into it.
267
275
  *
268
276
  * @param files - The files in merge order.
269
277
  */
@@ -271,19 +279,49 @@ function pairModule(files) {
271
279
  return `export default ${JSON.stringify(mergedWords(files))};\n`;
272
280
  }
273
281
  /**
274
- * Generates the loader table: one dynamic import per pair, so a namespace costs one request.
282
+ * Matches a language tag or a namespace that the loader table writes inside a string literal as it
283
+ * is: letters, digits, `_`, `.` and `-`.
284
+ */
285
+ const NAME$1 = /^[\w.-]+$/u;
286
+ /**
287
+ * Returns a language tag or a namespace that the loader table can write inside a string literal.
275
288
  *
289
+ * @param name - The language tag or the namespace.
290
+ * @throws {@link Error} When the name contains a character other than a letter, a digit, `_`, `.`
291
+ * or `-`.
292
+ */
293
+ function checked$1(name) {
294
+ if (!NAME$1.test(name)) throw new Error(`The catalogue name "${name}" contains a character other than a letter, a digit, "_", "." or "-".`);
295
+ return name;
296
+ }
297
+ /**
298
+ * Generates the loader table, one dynamic import per pair, for the languages the module does not
299
+ * inline.
300
+ *
301
+ * @remarks
302
+ * Every entry ends in its own comma, so a table with no language and a language with no namespace
303
+ * both close as valid objects. An inlined language gets no loader entry. Leaving one in would
304
+ * still make the bundler emit a chunk per pair even though nothing calls it, which is how the
305
+ * docs build came to emit the `specimen` namespace twice. The table writes each language tag and
306
+ * namespace inside its string literal as it is, after `checked` refuses any other character.
276
307
  * @param index - The indexed catalogues.
308
+ * @param inlining - Languages the module inlines.
277
309
  * @returns The table as JavaScript source.
310
+ * @throws {@link Error} When a language tag or a namespace contains a character other than a
311
+ * letter, a digit, `_`, `.` or `-`.
278
312
  */
279
- function loaders(index) {
280
- return `{\n${[...index.entries()].map(([language, byNamespace]) => {
281
- const imports = [...byNamespace.keys()].map((namespace) => ` ${JSON.stringify(namespace)}: () => import(${JSON.stringify(pairId(language, namespace))})`);
282
- return ` ${JSON.stringify(language)}: {\n${imports.join(",\n")},\n }`;
283
- }).join(",\n")},\n}`;
313
+ function loaders(index, inlining) {
314
+ const languages = [...index.entries()].filter(([language]) => !inlining.has(language)).map(([language, byNamespace]) => {
315
+ const tag = checked$1(language);
316
+ return ` "${tag}": {\n${[...byNamespace.keys()].map((namespace) => {
317
+ const name = checked$1(namespace);
318
+ return ` "${name}": () => import("${pairId(tag, name)}"),`;
319
+ }).join("\n")}\n },`;
320
+ });
321
+ return languages.length === 0 ? "{}" : `{\n${languages.join("\n")}\n}`;
284
322
  }
285
323
  /**
286
- * Merges each namespace of one language, for inlining into the module.
324
+ * Merges each namespace of one language, ready to inline into the module.
287
325
  *
288
326
  * @param index - The indexed catalogues.
289
327
  * @param language - The BCP 47 tag.
@@ -292,7 +330,7 @@ function inlined(index, language) {
292
330
  return Object.fromEntries([...ofLanguage(index, language).entries()].map(([namespace, files]) => [namespace, mergedWords(files)]));
293
331
  }
294
332
  /**
295
- * Lists every namespace any language declares, sorted.
333
+ * Lists every namespace any language declares, sorted, each one once.
296
334
  *
297
335
  * @param index - The indexed catalogues.
298
336
  */
@@ -305,24 +343,25 @@ function namespacesIn(index) {
305
343
  * Generates the module an application imports its catalogues from.
306
344
  *
307
345
  * @remarks
308
- * The fallback language is inlined so the first paint needs no request. Every other language is
309
- * reached through the loader. Under `eager` every language is inlined instead and the loader
310
- * fetches nothing, which costs one larger bundle and saves a request per language.
346
+ * The fallback language is inlined, so the first paint needs no request. Every other language is
347
+ * fetched through the loader. Under `eager` every language is inlined and the loader fetches
348
+ * nothing, trading a larger bundle for one saved request per language.
311
349
  * @param index - The indexed catalogues.
312
350
  * @param fallback - The language that defines every key.
313
- * @param eager - Inlines every language when true.
351
+ * @param eager - Whether to inline every language rather than the fallback alone.
314
352
  * @returns The module as JavaScript source.
315
353
  */
316
354
  function cataloguesModule(index, fallback, eager = false) {
317
355
  const languages = [...index.keys()].toSorted();
318
- const bundled = Object.fromEntries((eager ? languages : [fallback]).map((language) => [language, inlined(index, language)]));
356
+ const inlining = eager ? languages : [fallback];
357
+ const bundled = Object.fromEntries(inlining.map((language) => [language, inlined(index, language)]));
319
358
  return [
320
359
  `export const fallback = ${JSON.stringify(fallback)};`,
321
360
  `export const languages = ${JSON.stringify(languages)};`,
322
361
  `export const namespaces = ${JSON.stringify(namespacesIn(index))};`,
323
362
  `export const bundled = ${JSON.stringify(bundled)};`,
324
363
  `export const defaults = bundled[fallback] ?? {};`,
325
- `const loaders = ${eager ? "{}" : loaders(index)};`,
364
+ `const loaders = ${loaders(index, new Set(inlining))};`,
326
365
  "",
327
366
  "export async function load(language, namespace) {",
328
367
  " const loader = loaders[language]?.[namespace];",
@@ -340,22 +379,22 @@ function cataloguesModule(index, fallback, eager = false) {
340
379
  * Validates every catalogue against the fallback language.
341
380
  *
342
381
  * @remarks
343
- * An undefined key, a dropped placeholder and a key one package declares twice are all invisible
344
- * to a translator, so the build reports them instead.
382
+ * An undefined key, a dropped placeholder and a key one package declares twice all reach the page
383
+ * as a wrong string. The build reports them rather than shipping them.
345
384
  */
346
385
  /**
347
- * Matches an i18next placeholder, `{{name}}` or `{{name, format}}`.
386
+ * Pattern for an i18next placeholder, `{{name}}` or `{{name, format}}`, capturing the name.
348
387
  */
349
388
  const PLACEHOLDER = /\{\{\s*([^,}\s]+)/gu;
350
389
  /**
351
- * Matches a plural suffix, which is one of the six categories CLDR defines.
390
+ * Pattern for a plural suffix, one of the six categories CLDR defines.
352
391
  */
353
392
  const PLURAL = /_(?:zero|one|two|few|many|other)$/u;
354
393
  /**
355
394
  * Flattens nested contents to the dotted keys i18next addresses them by.
356
395
  *
357
- * @param words - The contents, nested as the file stores them.
358
- * @param prefix - The path down to these contents, empty at the top level.
396
+ * @param words - Contents, nested as the file stores them.
397
+ * @param prefix - Path down to these contents. Empty at the top level.
359
398
  */
360
399
  function flattened(words, prefix = "") {
361
400
  const flat = /* @__PURE__ */ new Map();
@@ -367,9 +406,9 @@ function flattened(words, prefix = "") {
367
406
  return flat;
368
407
  }
369
408
  /**
370
- * Lists the placeholder names one string carries.
409
+ * Lists the placeholder names one string contains.
371
410
  *
372
- * @param word - One translated string.
411
+ * @param word - A translated string.
373
412
  * @returns Each name once, sorted.
374
413
  */
375
414
  function placeholdersOf(word) {
@@ -378,6 +417,10 @@ function placeholdersOf(word) {
378
417
  /**
379
418
  * Strips a plural or context suffix, so `pages_one` can be checked against any `pages_*`.
380
419
  *
420
+ * @remarks
421
+ * Everything after the last underscore is taken as the suffix, whether or not it is one. A key
422
+ * whose own name carries an underscore loses its tail here and matches more broadly than it
423
+ * should.
381
424
  * @param key - A dotted key, with or without a suffix.
382
425
  */
383
426
  function stem(key) {
@@ -385,11 +428,11 @@ function stem(key) {
385
428
  return at === -1 ? key : key.slice(0, at);
386
429
  }
387
430
  /**
388
- * Lists the placeholders a translation has to carry.
431
+ * Lists the placeholders a translation is required to carry.
389
432
  *
390
433
  * @remarks
391
- * Every placeholder the fallback carries, except `count` in a plural form. Some languages spell
392
- * the number out for one of their forms, so `één pagina` is valid against `{{count}} page`.
434
+ * A plural form drops `count` from the requirement. Some languages spell the number out in one of
435
+ * their forms, so `één pagina` is valid against `{{count}} page`.
393
436
  * @param key - The translated key.
394
437
  * @param against - The fallback string for that key.
395
438
  */
@@ -398,13 +441,13 @@ function wanted(key, against) {
398
441
  return PLURAL.test(key) ? names.filter((name) => name !== "count") : names;
399
442
  }
400
443
  /**
401
- * Reports a placeholder a restated key drops.
444
+ * Reports the placeholders an overriding string drops.
402
445
  *
403
- * @param file - The file restating the key.
404
- * @param key - The key being restated.
405
- * @param word - The string this file gives it.
406
- * @param against - The string the fallback gives it.
407
- * @returns One problem, or an empty array when every placeholder survives.
446
+ * @param file - The file that overrides the key.
447
+ * @param key - The key being overridden.
448
+ * @param word - The string the overriding file gives the key.
449
+ * @param against - The string already defined for the key.
450
+ * @returns One problem, or an empty array when the string carries every placeholder.
408
451
  */
409
452
  function placeholdersChecked(file, key, word, against) {
410
453
  const carried = placeholdersOf(word);
@@ -418,6 +461,10 @@ function placeholdersChecked(file, key, word, against) {
418
461
  /**
419
462
  * Validates one translation against the fallback's definition of its namespace.
420
463
  *
464
+ * @remarks
465
+ * A key with no exact match falls back to the `_other` form of its stem, so a language carrying
466
+ * more plural categories than the fallback is checked against the fallback's plural string rather
467
+ * than reported as undefined.
421
468
  * @param file - The translation to check.
422
469
  * @param defined - The fallback's contents for that namespace, flattened.
423
470
  */
@@ -439,15 +486,15 @@ function checked(file, defined) {
439
486
  return found;
440
487
  }
441
488
  /**
442
- * Builds the fallback's definition of one namespace, collecting faults as it merges.
489
+ * Merges the fallback's files for one namespace and collects the faults the merge exposes.
443
490
  *
444
491
  * @remarks
445
- * A package defines whatever keys it ships. Another package may override one, and the override is
446
- * checked for placeholders. The application may override too, but may not add a key to a
447
- * namespace a package owns, because a key nobody else defines is a typo. One owner declaring a
448
- * key in two files is a fault wherever it happens, because the merge would silently pick one.
492
+ * A package defines whatever keys it ships and another package may override one. An application
493
+ * may override a key too, but a key it adds to a namespace a package owns is reported as a typo.
494
+ * One owner declaring a key in two files is a fault wherever it happens, because the merge picks
495
+ * one of them silently.
449
496
  * @param files - The fallback files of the namespace, in merge order.
450
- * @param found - The array faults are pushed onto.
497
+ * @param found - Array the faults are pushed onto.
451
498
  * @returns The namespace's contents, flattened.
452
499
  */
453
500
  function definition(files, found) {
@@ -478,12 +525,14 @@ function definition(files, found) {
478
525
  return new Map([...defined].map(([key, { word }]) => [key, word]));
479
526
  }
480
527
  /**
481
- * Validates every catalogue: the fallback's files against each other, then each translation against
482
- * the fallback.
528
+ * Validates the fallback's files against each other, then every translation against the fallback.
483
529
  *
530
+ * @remarks
531
+ * An index with no entry for the fallback language reports nothing, on the grounds that there is
532
+ * nothing to check against.
484
533
  * @param index - The indexed catalogues.
485
534
  * @param fallback - The language that defines every key.
486
- * @returns Every fault, the fallback's first, then in file order.
535
+ * @returns Every fault, the fallback's first and the rest in file order.
487
536
  */
488
537
  function problems(index, fallback) {
489
538
  const defining = index.get(fallback);
@@ -555,65 +604,131 @@ function declared(index, fallback) {
555
604
  //#endregion
556
605
  //#region src/plugin.ts
557
606
  /**
558
- * The plugin itself: it finds the catalogues, serves the module an application loads them from,
559
- * writes their types, and pushes a changed string to a running page.
607
+ * Vite plugin for translation catalogues: finds them, serves the modules an application loads them
608
+ * from, writes their types, and pushes a changed string to a running page.
560
609
  */
561
610
  /**
562
- * The resolved identifier, whose leading NUL marks the module as this plugin's.
611
+ * Resolved id of the catalogues module, with the leading NUL that marks it as generated.
563
612
  */
564
613
  const RESOLVED = `\0${ID}`;
565
614
  /**
566
- * The custom event name a changed catalogue is sent to the page under.
615
+ * Name the plugin registers under, which `cataloguesOf` finds it by.
616
+ */
617
+ const NAME = "stealth:i18n";
618
+ /**
619
+ * Returns the catalogue plugin's `api` among a configuration's plugins, or undefined where the
620
+ * configuration has no catalogue plugin.
621
+ *
622
+ * @param plugins - The plugins of a resolved configuration.
623
+ */
624
+ function cataloguesOf(plugins) {
625
+ return plugins.find((plugin) => plugin.name === NAME)?.api;
626
+ }
627
+ /**
628
+ * Name of the custom event a changed catalogue is sent to the page under.
567
629
  *
568
630
  * @remarks
569
631
  * The foundation listens for the same string. Neither package imports the other, so the name is a
570
- * contract between them rather than a shared constant.
632
+ * contract between them and not a shared constant.
571
633
  */
572
634
  const EVENT = "i18n:catalogue";
573
635
  /**
574
- * The default language that defines every key.
636
+ * Default for {@link Options.fallback}: the language every key is defined in.
575
637
  */
576
638
  const FALLBACK = "en";
577
639
  /**
578
- * The default path the generated types are written to, against the project root.
640
+ * Default for {@link Options.types}, resolved against the project root.
579
641
  */
580
642
  const TYPES = "src/i18n.gen.d.ts";
581
643
  /**
644
+ * Directory name the plugin's scratch space takes under the system temporary directory.
645
+ */
646
+ const SCRATCH = "stealth-i18n";
647
+ /**
648
+ * File under the scratch that the plugin rewrites whenever a catalogue file appears or disappears
649
+ * outside the module-per-file server.
650
+ *
651
+ * @remarks
652
+ * The catalogues module lists the stamp as a file to watch, so the bundler rebuilds the module
653
+ * when the stamp changes. A bundler watches the files a module read, and a new file is not one of
654
+ * them, so the plugin turns its appearance into a change to the stamp.
655
+ */
656
+ const STAMP = "topology";
657
+ /**
658
+ * The payload sent when the set of languages and namespaces changes under a server that bundles.
659
+ */
660
+ const RELOAD = {
661
+ path: "*",
662
+ type: "full-reload"
663
+ };
664
+ /**
665
+ * Renders every language and namespace pair an index contains as sorted lines, for comparing one
666
+ * search against the last.
667
+ *
668
+ * @remarks
669
+ * The catalogues module's exports and loader table depend on the shape as well as on the words. A
670
+ * page that already imported the module cannot pick up a shape change from a pushed event, so the
671
+ * plugin hands the module back for a reload.
672
+ */
673
+ function shapeOf(index) {
674
+ return [...index.entries()].flatMap(([language, byNamespace]) => [...byNamespace.keys()].map((namespace) => `${language}/${namespace}`)).toSorted().join("\n");
675
+ }
676
+ /**
582
677
  * Searches for the catalogues again and indexes them, keeping the namespaces the options accept.
583
678
  *
584
- * @param state - The state to write the result into.
679
+ * @param state - The state whose catalogues, index and shape are replaced.
585
680
  * @param options - Where to search and which namespaces to keep.
681
+ * @returns True when the set of languages and namespaces differs from the last search's.
586
682
  */
587
683
  function refound(state, options) {
588
684
  const wanted = options.namespaces ?? (() => true);
589
685
  state.catalogues = found(state.resolved.root, options.scopes).filter((one) => wanted(one.namespace));
590
686
  state.index = indexed(state.catalogues);
687
+ const shape = shapeOf(state.index);
688
+ const reshaped = shape !== state.shape;
689
+ state.shape = shape;
690
+ return reshaped;
591
691
  }
592
692
  /**
593
693
  * Writes the generated types, unless the options turn them off.
594
694
  *
595
695
  * @param state - The catalogues found.
596
696
  * @param options - Where to write and which language types the keys.
597
- * @returns True when the file changed.
697
+ * @returns True when the file's content changed, false when it matched or types are off.
598
698
  */
599
699
  function retyped(state, options) {
600
700
  if (options.types === false) return false;
601
701
  return writeIfChanged(resolve(state.resolved.root, options.types ?? TYPES), declared(state.index, options.fallback ?? FALLBACK));
602
702
  }
603
703
  /**
604
- * Formats each fault in the catalogues as one line.
704
+ * Rewrites the stamp with a new timestamp, so a bundler watching it rebuilds the catalogues
705
+ * module.
706
+ *
707
+ * @remarks
708
+ * `process.hrtime.bigint` has nanosecond resolution, so two writes within the same millisecond
709
+ * still produce different content.
710
+ * @param state - The state that contains the stamp's path.
711
+ */
712
+ function stamped(state) {
713
+ mkdirSync(dirname(state.stamp), { recursive: true });
714
+ writeFileSync(state.stamp, `${process.hrtime.bigint()}\n`);
715
+ }
716
+ /**
717
+ * Formats each fault in the catalogues as one line naming the file, the key where the fault has
718
+ * one, and what is wrong.
605
719
  *
606
720
  * @param state - The catalogues found.
607
721
  * @param options - Which language defines the keys.
722
+ * @returns One line per fault, empty when every catalogue is valid.
608
723
  */
609
724
  function wrong(state, options) {
610
725
  return problems(state.index, options.fallback ?? FALLBACK).map(({ file: at, key, says }) => `${at}${key === "" ? "" : `: ${key}`} ${says}`);
611
726
  }
612
727
  /**
613
- * Reads the pair a catalogue file belongs to out of its path.
728
+ * Reads the language and namespace a catalogue file belongs to out of its path.
614
729
  *
615
730
  * @param path - A normalised path under a `locales` directory.
616
- * @returns The pair, or undefined when no language directory sits under `locales`.
731
+ * @returns The pair, or undefined when the path has no language directory under `locales`.
617
732
  */
618
733
  function pairOf(path) {
619
734
  const rest = path.slice(path.lastIndexOf(`/${LOCALES}/`) + 7 + 2);
@@ -625,7 +740,7 @@ function pairOf(path) {
625
740
  };
626
741
  }
627
742
  /**
628
- * Returns true when a path is a catalogue file.
743
+ * Returns true for a path with a catalogue extension under a `locales` directory.
629
744
  *
630
745
  * @param path - A normalised path.
631
746
  */
@@ -633,9 +748,22 @@ function catalogued(path) {
633
748
  return EXTENSION.test(path) && path.includes(`/locales/`);
634
749
  }
635
750
  /**
636
- * Invalidates a module when the graph holds it, and does nothing when nothing imported it.
751
+ * Returns the `locales` directory a catalogue is under, which contains every language of its
752
+ * package.
637
753
  *
638
- * @param graph - The module graph.
754
+ * @remarks
755
+ * A catalogue in a namespace directory, such as `locales/en/specimen/badge.json`, is two levels
756
+ * below its language. The directory is cut from the path at the catalogue's own language, so a
757
+ * watcher given it reports a language directory that appears beside that one.
758
+ */
759
+ function localesOf(catalogue) {
760
+ const at = catalogue.file.lastIndexOf(`/${LOCALES}/${catalogue.language}/`);
761
+ return catalogue.file.slice(0, at + 7 + 1);
762
+ }
763
+ /**
764
+ * Invalidates a module the graph contains, and does nothing for one nothing imported.
765
+ *
766
+ * @param graph - The module graph to look the module up in and invalidate through.
639
767
  * @param id - The module's resolved identifier.
640
768
  */
641
769
  function stale(graph, id) {
@@ -643,11 +771,22 @@ function stale(graph, id) {
643
771
  if (node !== void 0) graph.invalidateModule?.(node);
644
772
  }
645
773
  /**
646
- * Pushes a changed pair to a running page and invalidates the modules holding the old strings.
774
+ * Returns the payload a changed pair is sent to the page with: every file of the pair, merged.
775
+ */
776
+ function changedOf(state, pair) {
777
+ return {
778
+ language: pair.language,
779
+ namespace: pair.namespace,
780
+ words: mergedWords(filesOf(state.index, pair.language, pair.namespace))
781
+ };
782
+ }
783
+ /**
784
+ * Pushes a changed pair to a running page, invalidates the modules that contain the old strings,
785
+ * and warns about any fault the change introduced.
647
786
  *
648
787
  * @remarks
649
- * The catalogues module is invalidated as well, because the fallback language is inlined in it,
650
- * so a page loaded after the change gets the current strings.
788
+ * The catalogues module is invalidated as well as the pair's own, because the fallback language
789
+ * is inlined in it, so a page loaded after the change gets the current strings.
651
790
  * @param state - The catalogues found.
652
791
  * @param options - Which language defines the keys, for the validation that follows.
653
792
  * @param pair - The language and namespace that changed.
@@ -656,40 +795,144 @@ function stale(graph, id) {
656
795
  function resent(state, options, pair, watching) {
657
796
  stale(watching.environment.moduleGraph, `\0${pairId(pair.language, pair.namespace)}`);
658
797
  stale(watching.environment.moduleGraph, RESOLVED);
659
- watching.environment.hot.send(EVENT, {
660
- language: pair.language,
661
- namespace: pair.namespace,
662
- words: mergedWords(filesOf(state.index, pair.language, pair.namespace))
663
- });
798
+ watching.environment.hot.send(EVENT, changedOf(state, pair));
664
799
  for (const line of wrong(state, options)) globalThis.console.warn(line);
665
800
  }
666
801
  /**
667
- * Handles a catalogue file that was added or deleted.
802
+ * Pushes the pair a changed file belongs to under a server that bundles, and warns about any fault
803
+ * the change introduced.
668
804
  *
669
805
  * @remarks
670
- * The search and the types run again, because the set of languages may have changed. The module
671
- * is invalidated rather than sent, because sending it would refresh the component tree when only
672
- * the strings changed.
806
+ * That server applies a rebuilt catalogues module in place, through the first module above it
807
+ * that accepts a hot update, and the i18next instance the page built keeps its strings. The pair
808
+ * therefore goes to the page as the event the hot update hook sends. The server's watcher and
809
+ * the bundler's both report an edit, so a payload equal to the last one sent is dropped.
810
+ * @param state - The catalogues found, and the last payload sent.
811
+ * @param options - Which language defines the keys, for the validation that follows.
812
+ * @param path - The normalised path of the file that changed.
813
+ * @param channel - The channel to the page.
814
+ */
815
+ function pushed(state, options, path, channel) {
816
+ const pair = pairOf(path);
817
+ if (pair === void 0) return;
818
+ const changed = changedOf(state, pair);
819
+ const sent = JSON.stringify(changed);
820
+ if (sent === state.sent) return;
821
+ state.sent = sent;
822
+ channel.send(EVENT, changed);
823
+ for (const line of wrong(state, options)) globalThis.console.warn(line);
824
+ }
825
+ /**
826
+ * Runs the search and the types again after a change a bundler reported, and rewrites the stamp
827
+ * where the catalogues module has to be built again.
828
+ *
829
+ * @remarks
830
+ * A module lists the files it read, so an edit to one of them rebuilds the module without the
831
+ * stamp. A file that appears or disappears is in no module's list, so the stamp is rewritten for
832
+ * it, as it is for a change to the set of languages and namespaces.
833
+ * @param state - The catalogues found, searched again here.
834
+ * @param options - Where to search.
835
+ * @param change - The kind of change, where the bundler reports one.
836
+ * @returns True when the set of languages and namespaces changed.
837
+ */
838
+ function rebuilt(state, options, change) {
839
+ const reshaped = refound(state, options);
840
+ retyped(state, options);
841
+ if (reshaped || change?.event === "create" || change?.event === "delete") stamped(state);
842
+ return reshaped;
843
+ }
844
+ /**
845
+ * Follows a catalogue change during a watching build or under a server that bundles, and brings a
846
+ * running page up to date under the server.
847
+ *
848
+ * @remarks
849
+ * A server that serves a module per file reports the same change to `hotUpdate`, so a change
850
+ * under that server is ignored here. A server that bundles runs no hot update hook. There the
851
+ * changed pair goes to the page, and the page reloads when the set of languages and namespaces
852
+ * changed, because a running page cannot take a new set from an event. A watching build has no
853
+ * page.
673
854
  * @param state - The catalogues found.
855
+ * @param options - Where to search and which language defines the keys.
856
+ * @param reported - The change, the environment and the file.
857
+ */
858
+ function followed(state, options, reported) {
859
+ const { change, environment, path } = reported;
860
+ if (state.resolved.command === "build") {
861
+ rebuilt(state, options, change);
862
+ return;
863
+ }
864
+ if (environment?.config.isBundled !== true) return;
865
+ const reshaped = rebuilt(state, options, change);
866
+ const channel = environment.hot;
867
+ if (channel === void 0) return;
868
+ if (reshaped) channel.send(RELOAD);
869
+ else pushed(state, options, path, channel);
870
+ }
871
+ /**
872
+ * Handles a catalogue file that was added or deleted.
873
+ *
874
+ * @remarks
875
+ * The search and the types run again, because the set of languages and namespaces may have
876
+ * changed. Where it did, the catalogues module goes back to the server for a reload, because a
877
+ * running page imported the old set and no pushed event replaces it. Where only the words of a
878
+ * pair changed, the pair is pushed to the page and the page keeps its state.
879
+ * @param state - The catalogues found, searched again here.
674
880
  * @param options - Where to search.
675
881
  * @param path - The file that appeared or disappeared.
676
882
  * @param watching - The environment the file changed in.
883
+ * @returns The catalogues module where the server has to reload it, and an empty array otherwise.
677
884
  */
678
885
  function refollowed(state, options, path, watching) {
679
- refound(state, options);
886
+ const reshaped = refound(state, options);
680
887
  retyped(state, options);
888
+ if (reshaped) {
889
+ stamped(state);
890
+ const node = watching.environment.moduleGraph.getModuleById(RESOLVED);
891
+ return node === void 0 ? [] : [node];
892
+ }
681
893
  const pair = pairOf(path);
682
894
  if (pair === void 0) stale(watching.environment.moduleGraph, RESOLVED);
683
895
  else resent(state, options, pair, watching);
896
+ return [];
684
897
  }
685
898
  /**
686
- * Builds the plugin that finds the catalogues, types their keys, and serves `virtual:i18n`.
899
+ * Selects the files whose words the catalogues module inlines: every catalogue when eager, and the
900
+ * fallback language's alone otherwise.
901
+ *
902
+ * @param state - The catalogues found.
903
+ * @param options - Whether every language is inlined, and which one is otherwise.
904
+ * @returns The catalogues to inline, which are also the files the module watches.
905
+ */
906
+ function inlinedFiles(state, options) {
907
+ if (options.eager === true) return state.catalogues;
908
+ return [...ofLanguage(state.index, options.fallback ?? FALLBACK).values()].flat();
909
+ }
910
+ /**
911
+ * Returns what the plugin offers another plugin through its `api`. The catalogues and the words are
912
+ * read from the state when they are called, so they follow every search after a change.
913
+ *
914
+ * @param state - The catalogues found, searched again on each change.
915
+ * @param options - The fallback language.
916
+ */
917
+ function offered(state, options) {
918
+ return {
919
+ catalogues: () => state.catalogues,
920
+ fallback: options.fallback ?? FALLBACK,
921
+ words: (language, namespace) => mergedWords(filesOf(state.index, language, namespace))
922
+ };
923
+ }
924
+ /**
925
+ * Creates the plugin that finds the catalogues, types their keys, and serves `virtual:i18n`.
687
926
  *
688
927
  * @remarks
689
- * On a dev server a catalogue change is pushed to the page as an event rather than a reload, so
690
- * the page keeps its state. In a build an invalid catalogue throws instead.
691
- * @param options - Where to search and what to write. `Options` documents every member.
692
- * @returns The plugin.
928
+ * On a dev server the plugin sends a changed catalogue to the page as an event and not as a
929
+ * reload, so the page keeps its state. A build throws on an invalid catalogue. Every module
930
+ * lists the files it read as files to watch, so a bundler that rebuilds on a change rebuilds the
931
+ * module.
932
+ * @param options - Where to search and what to write. {@link Options} Documents every member, and
933
+ * every one has a default.
934
+ * @returns The plugin, with its state set to the working directory until `configResolved`
935
+ * replaces it.
693
936
  */
694
937
  function i18n(options = {}) {
695
938
  const state = {
@@ -698,13 +941,19 @@ function i18n(options = {}) {
698
941
  resolved: {
699
942
  command: "serve",
700
943
  root: process.cwd()
701
- }
944
+ },
945
+ sent: "",
946
+ shape: "",
947
+ stamp: join(scratchDir(SCRATCH, process.cwd()), STAMP)
702
948
  };
703
949
  return {
950
+ api: offered(state, options),
704
951
  /**
705
- * Throws on an invalid catalogue during a build, and warns during a dev server run.
952
+ * Throws on an invalid catalogue during a build, and warns through the bundler on a dev server
953
+ * run.
706
954
  *
707
- * @throws {@link Error} When a catalogue defines an unknown key or drops a placeholder.
955
+ * @throws {@link Error} When the command is `build` and a catalogue defines an unknown key or
956
+ * drops a placeholder. The message lists every fault, one per line.
708
957
  */
709
958
  buildStart() {
710
959
  const lines = wrong(state, options);
@@ -713,76 +962,93 @@ function i18n(options = {}) {
713
962
  for (const line of lines) this.warn(line);
714
963
  },
715
964
  /**
716
- * Records the root, runs the search, and writes the types.
965
+ * Records the resolved configuration and the stamp's path for that root, then runs the first
966
+ * search and writes the types.
717
967
  *
718
968
  * @param config - The resolved configuration.
719
969
  */
720
970
  configResolved(config) {
721
971
  state.resolved = config;
972
+ state.stamp = join(scratchDir(SCRATCH, config.root), STAMP);
722
973
  refound(state, options);
723
974
  retyped(state, options);
724
975
  },
725
976
  /**
726
- * Adds every `locales` directory to the watcher, including those outside the project root.
977
+ * Registers every `locales` directory with the watcher, including those outside the project
978
+ * root, so a file, a namespace or a language that appears under one is reported.
727
979
  *
728
- * @param server - The dev server.
980
+ * @param server - The dev server whose watcher the directories are added to.
729
981
  */
730
982
  configureServer(server) {
731
- server.watcher.add([...new Set(state.catalogues.map((one) => dirname(dirname(one.file))))]);
983
+ server.watcher.add([...new Set(state.catalogues.map((one) => localesOf(one)))]);
732
984
  },
733
985
  /**
734
986
  * Handles a catalogue change on a dev server by pushing the new strings to the page.
735
987
  *
736
- * @param changed - The file, what happened to it, and the modules the change reached.
737
- * @returns An empty array for a catalogue, or undefined for any other file.
988
+ * @remarks
989
+ * The types are written again on an edit too, because a key or a placeholder added to the
990
+ * fallback language changes what the page may ask for.
991
+ * @param changed - The file, what happened to it, and the modules the change affects.
992
+ * @returns The catalogues module where the page has to reload it, an empty array for a
993
+ * catalogue pushed to the page, or undefined for a file this plugin does not handle, which
994
+ * leaves the change to the bundler.
738
995
  */
739
996
  hotUpdate(changed) {
740
997
  const path = normalizePath(changed.file);
741
998
  if (!catalogued(path)) return void 0;
742
- if (changed.type !== "update") {
743
- refollowed(state, options, path, this);
744
- return [];
745
- }
999
+ if (changed.type !== "update") return refollowed(state, options, path, this);
746
1000
  const catalogue = state.catalogues.find((one) => one.file === path);
747
1001
  if (catalogue === void 0) return void 0;
1002
+ retyped(state, options);
748
1003
  resent(state, options, catalogue, this);
749
1004
  return [];
750
1005
  },
751
1006
  /**
752
- * Serves the catalogues module, or the module one pair is fetched as.
1007
+ * Serves the catalogues module, or the module one pair is fetched as, declaring the files each
1008
+ * one read as files to watch.
753
1009
  *
754
- * @param id - The module being loaded.
755
- * @returns The source, or undefined when the module is not this plugin's.
1010
+ * @param id - The resolved identifier of the module being loaded.
1011
+ * @returns The generated source, or undefined when the module is not this plugin's.
756
1012
  */
757
1013
  load(id) {
758
- if (id === RESOLVED) return cataloguesModule(state.index, options.fallback ?? FALLBACK, options.eager ?? false);
1014
+ if (id === RESOLVED) {
1015
+ if (!existsSync(state.stamp)) stamped(state);
1016
+ this.addWatchFile(state.stamp);
1017
+ for (const one of inlinedFiles(state, options)) this.addWatchFile(one.file);
1018
+ return cataloguesModule(state.index, options.fallback ?? FALLBACK, options.eager ?? false);
1019
+ }
759
1020
  const pair = pairOfId(id);
760
- return pair === void 0 ? void 0 : pairModule(filesOf(state.index, pair.language, pair.namespace));
1021
+ if (pair === void 0) return void 0;
1022
+ const files = filesOf(state.index, pair.language, pair.namespace);
1023
+ for (const one of files) this.addWatchFile(one.file);
1024
+ return pairModule(files);
761
1025
  },
762
- name: "stealth:i18n",
1026
+ name: NAME,
763
1027
  /**
764
- * Claims the catalogues specifier and every pair specifier.
1028
+ * Claims the catalogues specifier and every pair specifier, leaving every other import alone.
765
1029
  *
766
1030
  * @param id - The specifier being resolved.
767
- * @returns The resolved identifier, or undefined for any other import.
1031
+ * @returns The specifier behind a NUL, or undefined for any other import.
768
1032
  */
769
1033
  resolveId(id) {
770
1034
  return id === "virtual:i18n" || pairOfId(id) !== void 0 ? `\0${id}` : void 0;
771
1035
  },
772
1036
  /**
773
- * Runs the search and the types again during a watching build.
1037
+ * Follows a catalogue change during a watching build or under a server that bundles.
774
1038
  *
775
- * @remarks
776
- * A dev server reports the same change to `hotUpdate`, which pushes the strings to the page,
777
- * so this hook ignores a change while serving. A watching build has no page and rebuilds.
778
- * @param id - The file that changed.
1039
+ * @param id - The path of the file that changed.
1040
+ * @param change - Whether the file was created, deleted or updated, where the bundler reports
1041
+ * it.
779
1042
  */
780
- watchChange(id) {
781
- if (state.resolved.command !== "build" || !catalogued(normalizePath(id))) return;
782
- refound(state, options);
783
- retyped(state, options);
1043
+ watchChange(id, change) {
1044
+ const path = normalizePath(id);
1045
+ if (catalogued(path)) followed(state, options, {
1046
+ change,
1047
+ environment: this.environment,
1048
+ path
1049
+ });
784
1050
  }
785
1051
  };
786
1052
  }
787
1053
  //#endregion
788
- export { EVENT, FOUNDATION, ID, LOCALES, cataloguesModule, declared, found, i18n, indexed, pairId, pairOfId, problems };
1054
+ export { EVENT, FOUNDATION, ID, LOCALES, cataloguesModule, cataloguesOf, declared, found, i18n, indexed, pairId, pairOfId, problems };