@stealthscale/vite-plugin-i18n 0.0.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -7
- package/dist/cyclonedx/bom.json +1 -1
- package/dist/index.d.mts +85 -46
- package/dist/index.mjs +419 -153
- package/package.json +18 -18
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
|
-
*
|
|
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
|
|
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
|
|
14
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
86
|
-
* the packages
|
|
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
|
|
92
|
+
* @param starting - True for the manifest the search starts from.
|
|
89
93
|
*/
|
|
90
|
-
function namesIn(manifest,
|
|
94
|
+
function namesIn(manifest, starting) {
|
|
91
95
|
return [
|
|
92
96
|
"dependencies",
|
|
93
97
|
"peerDependencies",
|
|
94
|
-
...
|
|
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
|
|
109
|
-
*
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
128
|
+
* Lists every catalogue a root can reach, its dependencies' first and its own last.
|
|
123
129
|
*
|
|
124
130
|
* @remarks
|
|
125
|
-
*
|
|
126
|
-
* merge,
|
|
127
|
-
*
|
|
128
|
-
* @param root - The application directory.
|
|
129
|
-
* @param scopes - The scopes to follow. Defaults to the
|
|
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
|
|
134
|
-
const named =
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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 -
|
|
247
|
-
* @param over -
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
281
|
-
const
|
|
282
|
-
return ` ${
|
|
283
|
-
|
|
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,
|
|
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
|
-
*
|
|
310
|
-
*
|
|
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 -
|
|
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
|
|
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 = ${
|
|
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
|
|
344
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 -
|
|
358
|
-
* @param prefix -
|
|
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
|
|
409
|
+
* Lists the placeholder names one string contains.
|
|
371
410
|
*
|
|
372
|
-
* @param word -
|
|
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
|
|
431
|
+
* Lists the placeholders a translation is required to carry.
|
|
389
432
|
*
|
|
390
433
|
* @remarks
|
|
391
|
-
*
|
|
392
|
-
*
|
|
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
|
|
444
|
+
* Reports the placeholders an overriding string drops.
|
|
402
445
|
*
|
|
403
|
-
* @param file - The file
|
|
404
|
-
* @param key - The key being
|
|
405
|
-
* @param word - The string
|
|
406
|
-
* @param against - The string
|
|
407
|
-
* @returns One problem, or an empty array when every placeholder
|
|
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
|
-
*
|
|
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
|
|
446
|
-
*
|
|
447
|
-
*
|
|
448
|
-
*
|
|
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 -
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
632
|
+
* contract between them and not a shared constant.
|
|
571
633
|
*/
|
|
572
634
|
const EVENT = "i18n:catalogue";
|
|
573
635
|
/**
|
|
574
|
-
*
|
|
636
|
+
* Default for {@link Options.fallback}: the language every key is defined in.
|
|
575
637
|
*/
|
|
576
638
|
const FALLBACK = "en";
|
|
577
639
|
/**
|
|
578
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
751
|
+
* Returns the `locales` directory a catalogue is under, which contains every language of its
|
|
752
|
+
* package.
|
|
637
753
|
*
|
|
638
|
-
* @
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
671
|
-
*
|
|
672
|
-
* the
|
|
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
|
-
*
|
|
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
|
|
690
|
-
* the page keeps its state.
|
|
691
|
-
*
|
|
692
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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) =>
|
|
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
|
-
* @
|
|
737
|
-
*
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
1037
|
+
* Follows a catalogue change during a watching build or under a server that bundles.
|
|
774
1038
|
*
|
|
775
|
-
* @
|
|
776
|
-
*
|
|
777
|
-
*
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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 };
|