@yuneta/gobj-ui 7.26.24 → 7.26.26
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 +49 -3
- package/dist/gobj-ui.cjs.js +77 -36
- package/dist/gobj-ui.es.js +77 -36
- package/package.json +3 -2
- package/scripts/validate-locales.mjs +667 -0
- package/src/c_g6_nodes_tree.js +37 -15
- package/src/c_yui_schema_editor.js +7 -1
- package/src/schema_editor_reload.wiring.test.js +33 -0
- package/src/schema_import.js +7 -3
- package/src/schema_import.test.js +4 -4
- package/src/yui_file_field.js +2 -2
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/***********************************************************************
|
|
3
|
+
* validate-locales.mjs
|
|
4
|
+
*
|
|
5
|
+
* The i18n check of every app that builds on gobj-ui, run from the
|
|
6
|
+
* APP's directory (its `prebuild`):
|
|
7
|
+
*
|
|
8
|
+
* node node_modules/@yuneta/gobj-ui/scripts/validate-locales.mjs
|
|
9
|
+
*
|
|
10
|
+
* It enforces, on every locale of the app:
|
|
11
|
+
*
|
|
12
|
+
* 1. Keys are ASCII (no em-dash, accents, etc.).
|
|
13
|
+
* 2. Keys are lower-case.
|
|
14
|
+
* 3. All locales carry the same key set (symmetric). A PLURAL key
|
|
15
|
+
* (`songs_one`, `songs_few`, ...: i18next's `_zero` `_one`
|
|
16
|
+
* `_two` `_few` `_many` `_other`) is compared by its base, and
|
|
17
|
+
* each locale carries the plural categories of ITS language
|
|
18
|
+
* (Intl.PluralRules): English `one`/`other`, Spanish
|
|
19
|
+
* `one`/`many`/`other`, Russian `one`/`few`/`many`/`other`,
|
|
20
|
+
* Chinese `other`. The key with no suffix (`songs`) serves
|
|
21
|
+
* `other` and `many` -- i18next falls back to it -- but
|
|
22
|
+
* `zero`, `one`, `two` and `few` change the grammar and need
|
|
23
|
+
* their own form when the language has them. A form the
|
|
24
|
+
* language never uses is a violation; `_zero` is always
|
|
25
|
+
* allowed (i18next uses it for a count of 0).
|
|
26
|
+
* 4. No duplicate key in a locale file (an object literal keeps
|
|
27
|
+
* the LAST one and says nothing -- a stale entry silently
|
|
28
|
+
* overrides a new one; it happened to "last" and "loading").
|
|
29
|
+
* 5. Every key the SOURCE uses is defined (i18next answers an
|
|
30
|
+
* unknown key with the key ITSELF, so a missing entry renders
|
|
31
|
+
* fine and simply never changes language -- invisible by
|
|
32
|
+
* construction, and it shipped: a Settings `label` column).
|
|
33
|
+
*
|
|
34
|
+
* Rule 5 reads the app's own source (recursively), the shell
|
|
35
|
+
* declaration (`src/app_config.json`) and the gobj-ui modules the
|
|
36
|
+
* app MOUNTS, transitively -- deep, through the barrel, or by a
|
|
37
|
+
* dynamic import(): the library translates through the APP's
|
|
38
|
+
* i18next, so a key it asks for and the app does not define
|
|
39
|
+
* renders raw (that is how the treedb editor shipped reading
|
|
40
|
+
* "edit", "new", "delete" in every language). The library scanned
|
|
41
|
+
* is THIS one -- the package the app installed and builds with,
|
|
42
|
+
* the one this script ships in.
|
|
43
|
+
*
|
|
44
|
+
* Options (paths relative to the app's directory):
|
|
45
|
+
*
|
|
46
|
+
* --src <dir> a source directory to scan, besides src/
|
|
47
|
+
* (repeatable: code shared by several apps)
|
|
48
|
+
* --locales <dir> where the locales are (default src/locales/)
|
|
49
|
+
* --config <file> the shell declaration
|
|
50
|
+
* (default src/app_config.json)
|
|
51
|
+
*
|
|
52
|
+
* And, in the app's package.json, what is particular to it:
|
|
53
|
+
*
|
|
54
|
+
* "validate-locales": {
|
|
55
|
+
* "attr_keys": ["app_title", "welcome_lead"],
|
|
56
|
+
* "array_keys": ["welcome_features"]
|
|
57
|
+
* }
|
|
58
|
+
*
|
|
59
|
+
* `attr_keys` names properties whose string value is an i18n key
|
|
60
|
+
* (`app_title: "central"`), `array_keys` properties whose value is
|
|
61
|
+
* an array of keys. A key the app hands over that way reaches no
|
|
62
|
+
* t() the scan can read.
|
|
63
|
+
*
|
|
64
|
+
* A locale is a file <code>.js of the locales directory (en.js,
|
|
65
|
+
* es.js, pt-br.js; flags.js and locales.js are not) exporting
|
|
66
|
+
* `{<code>}` with a `translation` dict. It is imported directly,
|
|
67
|
+
* not through locales.js, which drags in gobj-js and i18next and
|
|
68
|
+
* expects a browser. A file with a locale's name and no such
|
|
69
|
+
* export is said and skipped.
|
|
70
|
+
*
|
|
71
|
+
* Exits 1 on any violation, so the build fails loudly.
|
|
72
|
+
*
|
|
73
|
+
* Up to gobj-ui 7.26.24 every app carried its own copy, and the
|
|
74
|
+
* copies drifted into three families with different rules
|
|
75
|
+
* (REV-CLOUD V17.3.4). This one is their union, and an app keeps
|
|
76
|
+
* no copy.
|
|
77
|
+
*
|
|
78
|
+
* Copyright (c) 2025-2026, ArtGins.
|
|
79
|
+
* All Rights Reserved.
|
|
80
|
+
***********************************************************************/
|
|
81
|
+
import {readdirSync, readFileSync, existsSync, statSync} from "fs";
|
|
82
|
+
import {resolve} from "path";
|
|
83
|
+
import {pathToFileURL} from "url";
|
|
84
|
+
|
|
85
|
+
const ASCII_RE = /^[\x20-\x7e]+$/;
|
|
86
|
+
const LOCALE_FILE_RE = /^([a-z]{2,3}(?:[-_][a-zA-Z]{2,4})?)\.js$/;
|
|
87
|
+
const PLURAL_RE = /^(.+)_(zero|one|two|few|many|other)$/;
|
|
88
|
+
|
|
89
|
+
/* This package: the library the app builds with. */
|
|
90
|
+
const LIB = new URL("../", import.meta.url);
|
|
91
|
+
const LIB_SRC = new URL("src/", LIB);
|
|
92
|
+
const LIB_INDEX = new URL("index.js", LIB);
|
|
93
|
+
|
|
94
|
+
/* The app: the directory it is run from. */
|
|
95
|
+
const APP_DIR = pathToFileURL(process.cwd() + "/");
|
|
96
|
+
|
|
97
|
+
let errors = 0;
|
|
98
|
+
|
|
99
|
+
/***************************************************************
|
|
100
|
+
*
|
|
101
|
+
***************************************************************/
|
|
102
|
+
function fail(msg)
|
|
103
|
+
{
|
|
104
|
+
process.stderr.write(`validate-locales: ${msg}\n`);
|
|
105
|
+
errors++;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/***************************************************************
|
|
109
|
+
* A fatal problem of the setup, not a violation: said, exit 1
|
|
110
|
+
***************************************************************/
|
|
111
|
+
function abort(msg)
|
|
112
|
+
{
|
|
113
|
+
process.stderr.write(`validate-locales: ${msg}\n`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/***************************************************************
|
|
118
|
+
* Said, not a violation
|
|
119
|
+
***************************************************************/
|
|
120
|
+
function note(msg)
|
|
121
|
+
{
|
|
122
|
+
process.stdout.write(`validate-locales: ${msg}\n`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/***************************************************************
|
|
126
|
+
* The directory a path names, as a URL ending in '/'
|
|
127
|
+
***************************************************************/
|
|
128
|
+
function dir_url(path)
|
|
129
|
+
{
|
|
130
|
+
let p = resolve(process.cwd(), path);
|
|
131
|
+
return pathToFileURL(p.endsWith("/") ? p : p + "/");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/***************************************************************
|
|
135
|
+
*
|
|
136
|
+
***************************************************************/
|
|
137
|
+
function parse_args(argv)
|
|
138
|
+
{
|
|
139
|
+
let opts = {
|
|
140
|
+
src: [new URL("src/", APP_DIR)],
|
|
141
|
+
locales: new URL("src/locales/", APP_DIR),
|
|
142
|
+
config: new URL("src/app_config.json", APP_DIR),
|
|
143
|
+
attr_keys: [],
|
|
144
|
+
array_keys: []
|
|
145
|
+
};
|
|
146
|
+
for(let i = 0; i < argv.length; i++) {
|
|
147
|
+
let a = argv[i];
|
|
148
|
+
let v = argv[i + 1];
|
|
149
|
+
if(a === "--src" || a === "--locales" || a === "--config") {
|
|
150
|
+
if(!v) {
|
|
151
|
+
abort(`${a} needs a value`);
|
|
152
|
+
}
|
|
153
|
+
i++;
|
|
154
|
+
}
|
|
155
|
+
if(a === "--src") {
|
|
156
|
+
opts.src.push(dir_url(v));
|
|
157
|
+
} else if(a === "--locales") {
|
|
158
|
+
opts.locales = dir_url(v);
|
|
159
|
+
} else if(a === "--config") {
|
|
160
|
+
opts.config = pathToFileURL(resolve(process.cwd(), v));
|
|
161
|
+
} else {
|
|
162
|
+
abort(`unknown argument: ${a} (--src <dir>, --locales <dir>, --config <file>)`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/* What is particular to the app, in its package.json */
|
|
167
|
+
let pkg_url = new URL("package.json", APP_DIR);
|
|
168
|
+
if(existsSync(pkg_url)) {
|
|
169
|
+
let pkg;
|
|
170
|
+
try {
|
|
171
|
+
pkg = JSON.parse(readFileSync(pkg_url, "utf8"));
|
|
172
|
+
} catch(e) {
|
|
173
|
+
abort(`${pkg_url.pathname} is not valid json: ${e.message}`);
|
|
174
|
+
}
|
|
175
|
+
let cfg = pkg["validate-locales"] || {};
|
|
176
|
+
for(let name of ["attr_keys", "array_keys"]) {
|
|
177
|
+
let list = cfg[name] || [];
|
|
178
|
+
if(!Array.isArray(list) || !list.every((x) => /^[A-Za-z_$][\w$]*$/.test(x))) {
|
|
179
|
+
abort(`package.json "validate-locales".${name} must be a list of property names`);
|
|
180
|
+
}
|
|
181
|
+
opts[name] = list;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return opts;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/***************************************************************
|
|
188
|
+
* The locales of the app: {code: translation}
|
|
189
|
+
***************************************************************/
|
|
190
|
+
async function load_locales(locales_dir)
|
|
191
|
+
{
|
|
192
|
+
if(!existsSync(locales_dir)) {
|
|
193
|
+
abort(`no locales directory at ${locales_dir.pathname}`);
|
|
194
|
+
}
|
|
195
|
+
let codes = readdirSync(locales_dir)
|
|
196
|
+
.map((f) => (f.match(LOCALE_FILE_RE) || [])[1])
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.sort();
|
|
199
|
+
/* The reference is English when there is one */
|
|
200
|
+
if(codes.includes("en")) {
|
|
201
|
+
codes = ["en", ...codes.filter((c) => c !== "en")];
|
|
202
|
+
}
|
|
203
|
+
let locales = {};
|
|
204
|
+
for(let code of codes) {
|
|
205
|
+
let mod = await import(new URL(`${code}.js`, locales_dir).href);
|
|
206
|
+
let locale = mod[code] || mod[code.replace(/-/g, "_")] || mod.default;
|
|
207
|
+
if(!locale || !locale.translation || typeof locale.translation !== "object") {
|
|
208
|
+
note(`${code}.js exports no {${code}} with a translation dict: not a locale, skipped`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
locales[code] = locale.translation;
|
|
212
|
+
}
|
|
213
|
+
if(Object.keys(locales).length === 0) {
|
|
214
|
+
abort(`no locale (<code>.js exporting {<code>: {translation}}) in ${locales_dir.pathname}`);
|
|
215
|
+
}
|
|
216
|
+
return locales;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/***************************************************************
|
|
220
|
+
* The plural categories of a language (Intl.PluralRules)
|
|
221
|
+
***************************************************************/
|
|
222
|
+
function plural_categories(code)
|
|
223
|
+
{
|
|
224
|
+
try {
|
|
225
|
+
return new Set(new Intl.PluralRules(code.replace(/_/g, "-")).resolvedOptions().pluralCategories);
|
|
226
|
+
} catch(e) {
|
|
227
|
+
return new Set(["one", "other"]);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/***************************************************************
|
|
232
|
+
* A locale's keys split: plain ones, and {base: Set(categories)}
|
|
233
|
+
***************************************************************/
|
|
234
|
+
function split_plurals(keys)
|
|
235
|
+
{
|
|
236
|
+
let plain = new Set();
|
|
237
|
+
let plurals = new Map();
|
|
238
|
+
for(let k of keys) {
|
|
239
|
+
let m = k.match(PLURAL_RE);
|
|
240
|
+
if(m) {
|
|
241
|
+
if(!plurals.has(m[1])) {
|
|
242
|
+
plurals.set(m[1], new Set());
|
|
243
|
+
}
|
|
244
|
+
plurals.get(m[1]).add(m[2]);
|
|
245
|
+
} else {
|
|
246
|
+
plain.add(k);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return {plain, plurals};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/***************************************************************
|
|
253
|
+
*
|
|
254
|
+
***************************************************************/
|
|
255
|
+
async function main()
|
|
256
|
+
{
|
|
257
|
+
let opts = parse_args(process.argv.slice(2));
|
|
258
|
+
let locales = await load_locales(opts.locales);
|
|
259
|
+
let codes = Object.keys(locales);
|
|
260
|
+
|
|
261
|
+
/* Per-locale key checks (ASCII + lower-case) */
|
|
262
|
+
let split = {};
|
|
263
|
+
for(let code of codes) {
|
|
264
|
+
let keys = Object.keys(locales[code]);
|
|
265
|
+
split[code] = split_plurals(keys);
|
|
266
|
+
for(let k of keys) {
|
|
267
|
+
if(!ASCII_RE.test(k)) {
|
|
268
|
+
fail(`[${code}] non-ASCII key: ${JSON.stringify(k)}`);
|
|
269
|
+
}
|
|
270
|
+
if(k !== k.toLowerCase()) {
|
|
271
|
+
fail(`[${code}] non-lower-case key: ${JSON.stringify(k)}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/*
|
|
277
|
+
* The plural bases, of any locale: a plain key that is one of them
|
|
278
|
+
* belongs to its plural, and is checked with it
|
|
279
|
+
*/
|
|
280
|
+
let bases = new Set();
|
|
281
|
+
for(let code of codes) {
|
|
282
|
+
for(let base of split[code].plurals.keys()) {
|
|
283
|
+
bases.add(base);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Symmetry of the plain keys: every locale carries those of the first */
|
|
288
|
+
let ref = codes[0];
|
|
289
|
+
let plain_of = (code) => new Set([...split[code].plain].filter((k) => !bases.has(k)));
|
|
290
|
+
let ref_plain = plain_of(ref);
|
|
291
|
+
for(let code of codes.slice(1)) {
|
|
292
|
+
let cur = plain_of(code);
|
|
293
|
+
for(let k of ref_plain) {
|
|
294
|
+
if(!cur.has(k)) {
|
|
295
|
+
fail(`[${code}] missing key present in [${ref}]: ${JSON.stringify(k)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for(let k of cur) {
|
|
299
|
+
if(!ref_plain.has(k)) {
|
|
300
|
+
fail(`[${code}] extra key not in [${ref}]: ${JSON.stringify(k)}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/*
|
|
306
|
+
* The plural keys: every base in every locale, with the categories of
|
|
307
|
+
* ITS language -- not the same suffixes everywhere. The key with no
|
|
308
|
+
* suffix serves `other` and `many`.
|
|
309
|
+
*/
|
|
310
|
+
const SERVED_BY_THE_BASE = new Set(["other", "many"]);
|
|
311
|
+
for(let code of codes) {
|
|
312
|
+
let wanted = plural_categories(code);
|
|
313
|
+
for(let base of bases) {
|
|
314
|
+
let have = split[code].plurals.get(base) || new Set();
|
|
315
|
+
let has_base = split[code].plain.has(base);
|
|
316
|
+
for(let c of wanted) {
|
|
317
|
+
if(!have.has(c) && !(has_base && SERVED_BY_THE_BASE.has(c))) {
|
|
318
|
+
fail(`[${code}] plural ${JSON.stringify(base)} lacks the form "${c}" of its language` +
|
|
319
|
+
(SERVED_BY_THE_BASE.has(c)? ` (or the key ${JSON.stringify(base)} itself)` : ""));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for(let c of have) {
|
|
323
|
+
if(!wanted.has(c) && c !== "zero") {
|
|
324
|
+
fail(`[${code}] plural ${JSON.stringify(base)} has a form "${c}" its language never uses`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/*
|
|
331
|
+
* No DUPLICATE key in a locale file. A JS object literal keeps the LAST
|
|
332
|
+
* of two identical keys and says nothing, so the imported dict looks
|
|
333
|
+
* fine while the file lies. Only the raw text can see it.
|
|
334
|
+
*/
|
|
335
|
+
for(let code of codes) {
|
|
336
|
+
let src = strip_comments(readFileSync(new URL(`${code}.js`, opts.locales), "utf8"));
|
|
337
|
+
let seen = new Set();
|
|
338
|
+
let re = /^\s*"([^"]+)":/gm;
|
|
339
|
+
let m;
|
|
340
|
+
while((m = re.exec(src)) !== null) {
|
|
341
|
+
if(seen.has(m[1])) {
|
|
342
|
+
fail(`[${code}] DUPLICATE key (the later one silently wins): ${JSON.stringify(m[1])}`);
|
|
343
|
+
}
|
|
344
|
+
seen.add(m[1]);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* Every key the source asks for exists: a missing one is a violation, not a fallback */
|
|
349
|
+
let used = collect_used_keys(opts);
|
|
350
|
+
for(let k of used) {
|
|
351
|
+
if(!ref_plain.has(k) && !bases.has(k)) {
|
|
352
|
+
fail(`key used in the source but defined in NO locale: ${JSON.stringify(k)}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if(errors > 0) {
|
|
357
|
+
process.stderr.write(`validate-locales: ${errors} violation(s), see above\n`);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let n_keys = ref_plain.size + bases.size;
|
|
362
|
+
process.stdout.write(
|
|
363
|
+
`validate-locales: OK (${codes.length} locales x ${n_keys} keys` +
|
|
364
|
+
(bases.size? `, ${bases.size} plural` : "") +
|
|
365
|
+
`, ${used.size} used in the source)\n`);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/***************************************************************
|
|
369
|
+
* The i18n keys the source asks for
|
|
370
|
+
*
|
|
371
|
+
* A string literal is matched by its own quote: an apostrophe inside
|
|
372
|
+
* double quotes ("don't") is part of the key.
|
|
373
|
+
*
|
|
374
|
+
* The (?!\s*\+) guards skip a key BUILT BY CONCATENATION
|
|
375
|
+
* (`i18n: "kind_" + t.kind`): the literal is only a prefix, the real key
|
|
376
|
+
* cannot be known statically, and demanding "kind_" is a false positive.
|
|
377
|
+
* Such keys are not validated: keep them in sync by hand. The guard is
|
|
378
|
+
* right after the literal, INSIDE the call: `t('topic') + ':'`
|
|
379
|
+
* concatenates the result, and its key is asked for.
|
|
380
|
+
***************************************************************/
|
|
381
|
+
const STR = String.raw`(?:"([^"\n]+)"|'([^'\n]+)')`;
|
|
382
|
+
const STR_OR_TEMPLATE = String.raw`(?:"([^"\n]+)"|'([^'\n]+)'|` + "`" + String.raw`([^` + "`" + String.raw`$\n]+)` + "`" + ")";
|
|
383
|
+
const NOT_CONCAT = String.raw`(?!\s*\+)`;
|
|
384
|
+
|
|
385
|
+
function re(source)
|
|
386
|
+
{
|
|
387
|
+
return new RegExp(source, "g");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const PATTERNS = [
|
|
391
|
+
re(String.raw`\bt\(\s*` + STR_OR_TEMPLATE + NOT_CONCAT),
|
|
392
|
+
/* A local ALIAS of `t` is a blind spot: `yui_tabulator_i18n.js` asks
|
|
393
|
+
* for its whole chrome through `tr(key, default)`. */
|
|
394
|
+
re(String.raw`\btr\(\s*` + STR_OR_TEMPLATE + NOT_CONCAT),
|
|
395
|
+
/* The codes the schema checks hand to `t(finding.code)` /
|
|
396
|
+
* `t(conflict.code)`: an i18n key each, written where it is born. */
|
|
397
|
+
re(String.raw`\badd\(\s*['"](?:error|warning)['"]\s*,\s*` + STR),
|
|
398
|
+
re(String.raw`\bcode:\s*` + STR + NOT_CONCAT),
|
|
399
|
+
/* What refresh_language() re-translates */
|
|
400
|
+
re(String.raw`\bi18n:\s*` + STR + NOT_CONCAT),
|
|
401
|
+
re(String.raw`["']data-i18n(?:-title|-aria-label|-placeholder)?["']:\s*` + STR + NOT_CONCAT),
|
|
402
|
+
re(String.raw`\bsetAttribute\(\s*["']data-i18n(?:-title|-aria-label|-placeholder)?["']\s*,\s*` + STR + NOT_CONCAT),
|
|
403
|
+
/data-i18n(?:-title|-aria-label|-placeholder)?=["']([^"']+)["']/g
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
function literal_of(m)
|
|
407
|
+
{
|
|
408
|
+
for(let i = 1; i < m.length; i++) {
|
|
409
|
+
if(m[i] !== undefined) {
|
|
410
|
+
return m[i];
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function collect_used_keys(opts)
|
|
417
|
+
{
|
|
418
|
+
let keys = new Set();
|
|
419
|
+
|
|
420
|
+
let patterns = [...PATTERNS];
|
|
421
|
+
if(opts.attr_keys.length > 0) {
|
|
422
|
+
/* The app's own: properties whose value is a key */
|
|
423
|
+
patterns.push(re(String.raw`\b(?:` + opts.attr_keys.join("|") + String.raw`)\s*:\s*` + STR));
|
|
424
|
+
}
|
|
425
|
+
let array_re = opts.array_keys.length > 0?
|
|
426
|
+
re(String.raw`\b(?:` + opts.array_keys.join("|") + String.raw`)\s*:\s*\[([^\]]*)\]`) : null;
|
|
427
|
+
|
|
428
|
+
let scan = (url) => {
|
|
429
|
+
/* Comments are not code: the library documents the contract with
|
|
430
|
+
* examples like `data-i18n="<key>"`. */
|
|
431
|
+
let code = strip_comments(readFileSync(url, "utf8"));
|
|
432
|
+
for(let r of patterns) {
|
|
433
|
+
r.lastIndex = 0;
|
|
434
|
+
let m;
|
|
435
|
+
while((m = r.exec(code)) !== null) {
|
|
436
|
+
let k = literal_of(m);
|
|
437
|
+
/* A key INTERPOLATED in a template (`data-i18n="${key}"`)
|
|
438
|
+
* cannot be known statically either */
|
|
439
|
+
if(!k || k.includes("${")) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
keys.add(k);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if(array_re) {
|
|
446
|
+
array_re.lastIndex = 0;
|
|
447
|
+
let f;
|
|
448
|
+
while((f = array_re.exec(code)) !== null) {
|
|
449
|
+
let lit_re = re(STR);
|
|
450
|
+
let l;
|
|
451
|
+
while((l = lit_re.exec(f[1])) !== null) {
|
|
452
|
+
keys.add(literal_of(l));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return code;
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
let barrel = barrel_export_map();
|
|
460
|
+
|
|
461
|
+
/* The app's own source, and the code it shares with others */
|
|
462
|
+
let pending = [];
|
|
463
|
+
for(let dir of opts.src) {
|
|
464
|
+
if(!existsSync(dir)) {
|
|
465
|
+
abort(`no source directory at ${dir.pathname}`);
|
|
466
|
+
}
|
|
467
|
+
for(let url of source_files(dir, opts.locales)) {
|
|
468
|
+
let src = scan(url);
|
|
469
|
+
pending.push(...lib_imports(src, barrel, false));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
keys_from_shell_config(opts.config, keys);
|
|
474
|
+
|
|
475
|
+
/*
|
|
476
|
+
* ...and the gobj-ui views it MOUNTS. Only the modules actually
|
|
477
|
+
* imported are scanned, transitively: the library carries views the
|
|
478
|
+
* app never mounts.
|
|
479
|
+
*/
|
|
480
|
+
if(!existsSync(LIB_SRC)) {
|
|
481
|
+
abort(`no gobj-ui source at ${LIB_SRC.pathname}`);
|
|
482
|
+
}
|
|
483
|
+
let missing = [];
|
|
484
|
+
let seen = new Set();
|
|
485
|
+
while(pending.length > 0) {
|
|
486
|
+
let name = pending.pop();
|
|
487
|
+
if(seen.has(name)) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
seen.add(name);
|
|
491
|
+
let url = new URL(name, LIB_SRC);
|
|
492
|
+
if(!existsSync(url)) {
|
|
493
|
+
missing.push(name);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
let src = scan(url);
|
|
497
|
+
pending.push(...lib_imports(src, barrel, true));
|
|
498
|
+
}
|
|
499
|
+
if(missing.length > 0) {
|
|
500
|
+
abort(`modules of gobj-ui not found in ${LIB_SRC.pathname}: ${missing.join(", ")}`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return keys;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/***************************************************************
|
|
507
|
+
* The .js files of a source directory, recursively: not the tests, not
|
|
508
|
+
* the locales (their keys are definitions, not uses), not node_modules
|
|
509
|
+
* or a build
|
|
510
|
+
***************************************************************/
|
|
511
|
+
function source_files(dir, locales_dir)
|
|
512
|
+
{
|
|
513
|
+
let out = [];
|
|
514
|
+
let walk = (d) => {
|
|
515
|
+
if(d.href === locales_dir.href) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
for(let f of readdirSync(d).sort()) {
|
|
519
|
+
let url = new URL(f, d);
|
|
520
|
+
let st = statSync(url);
|
|
521
|
+
if(st.isDirectory()) {
|
|
522
|
+
if(f === "node_modules" || f === "dist" || f.startsWith(".")) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
walk(new URL(f + "/", d));
|
|
526
|
+
} else if(f.endsWith(".js") && !f.endsWith(".test.js")) {
|
|
527
|
+
out.push(url);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
walk(dir);
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/***************************************************************
|
|
536
|
+
* The shell declaration (`app_config.json`) holds i18n keys in DATA:
|
|
537
|
+
* every `name`, `aria_label` and `tooltip` of an item, the `label` of a
|
|
538
|
+
* C_YUI_NODE node, the `title` / `subtitle` of a route's kw. No `t()`
|
|
539
|
+
* names them, and a missing one renders in raw lower-case English for
|
|
540
|
+
* ever -- which is how four toolbar buttons announced themselves as
|
|
541
|
+
* "toggle language" in Spanish.
|
|
542
|
+
*
|
|
543
|
+
* A value that is not a KEY -- not lower-case ASCII -- is a literal the
|
|
544
|
+
* config carries on purpose: "ES/EN" on a language button, an endonym
|
|
545
|
+
* ("中文", "Español") in a language menu. `wordmark` and `alt` are
|
|
546
|
+
* deliberately out: a brand name is not translated. A key starting with
|
|
547
|
+
* `_` is a comment.
|
|
548
|
+
***************************************************************/
|
|
549
|
+
const CONFIG_I18N_FIELDS = ["name", "aria_label", "tooltip", "label", "title", "subtitle"];
|
|
550
|
+
|
|
551
|
+
function keys_from_shell_config(url, keys)
|
|
552
|
+
{
|
|
553
|
+
if(!existsSync(url)) {
|
|
554
|
+
note(`no ${url.pathname}: no shell keys checked`);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
let config;
|
|
558
|
+
try {
|
|
559
|
+
config = JSON.parse(readFileSync(url, "utf8"));
|
|
560
|
+
} catch(e) {
|
|
561
|
+
abort(`${url.pathname} is not valid json: ${e.message}`);
|
|
562
|
+
}
|
|
563
|
+
let walk = (node) => {
|
|
564
|
+
if(Array.isArray(node)) {
|
|
565
|
+
for(let item of node) {
|
|
566
|
+
walk(item);
|
|
567
|
+
}
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if(!node || typeof node !== "object") {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
for(let [k, v] of Object.entries(node)) {
|
|
574
|
+
if(k.startsWith("_")) {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if(CONFIG_I18N_FIELDS.includes(k) && typeof v === "string" && v
|
|
578
|
+
&& ASCII_RE.test(v) && v === v.toLowerCase()) {
|
|
579
|
+
keys.add(v);
|
|
580
|
+
}
|
|
581
|
+
walk(v);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
walk(config);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/***************************************************************
|
|
588
|
+
* Drop /* ... *\/ and // ... comments (good enough for a key scan: a "//"
|
|
589
|
+
* or "/*" inside a string would only ever cost a key we then report)
|
|
590
|
+
***************************************************************/
|
|
591
|
+
function strip_comments(src)
|
|
592
|
+
{
|
|
593
|
+
return src
|
|
594
|
+
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
|
595
|
+
.replace(/(^|[^:"'`\\])\/\/[^\n]*/g, "$1");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/***************************************************************
|
|
599
|
+
* The barrel's public surface: exported symbol -> the src/ module that
|
|
600
|
+
* defines it, read from index.js's re-exports
|
|
601
|
+
* (`export {a, b as c} from "./src/x.js"`). A barrel import is resolved
|
|
602
|
+
* to the modules it really pulls in, not to the whole library.
|
|
603
|
+
***************************************************************/
|
|
604
|
+
function barrel_export_map()
|
|
605
|
+
{
|
|
606
|
+
let map = new Map();
|
|
607
|
+
let src;
|
|
608
|
+
try {
|
|
609
|
+
src = strip_comments(readFileSync(LIB_INDEX, "utf8"));
|
|
610
|
+
} catch(e) {
|
|
611
|
+
abort(`cannot read the gobj-ui barrel at ${LIB_INDEX.pathname}: ${e.message}`);
|
|
612
|
+
}
|
|
613
|
+
let r = /export\s*\{([^}]*)\}\s*from\s*["']\.\/src\/([\w.-]+\.js)["']/g;
|
|
614
|
+
let m;
|
|
615
|
+
while((m = r.exec(src)) !== null) {
|
|
616
|
+
let mod = m[2];
|
|
617
|
+
for(let sym of m[1].split(",")) {
|
|
618
|
+
sym = sym.trim();
|
|
619
|
+
if(!sym) {
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
/* `a as b` is published as `b` */
|
|
623
|
+
let parts = sym.split(/\s+as\s+/);
|
|
624
|
+
map.set(parts[parts.length - 1].trim(), mod);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return map;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/***************************************************************
|
|
631
|
+
* The gobj-ui source modules a file imports:
|
|
632
|
+
* - deep: "@yuneta/gobj-ui/src/x.js", also by a dynamic import()
|
|
633
|
+
* - barrel: "@yuneta/gobj-ui[/index.js]", resolved per imported symbol
|
|
634
|
+
* - inside the library: "./x.js" (in the app it names an app file,
|
|
635
|
+
* scanned already)
|
|
636
|
+
***************************************************************/
|
|
637
|
+
function lib_imports(src, barrel, inside_lib)
|
|
638
|
+
{
|
|
639
|
+
let out = [];
|
|
640
|
+
let m;
|
|
641
|
+
|
|
642
|
+
let from_app = /(?:from\s+|import\(\s*)["']@yuneta\/gobj-ui\/src\/([\w.-]+\.js)["']/g;
|
|
643
|
+
while((m = from_app.exec(src)) !== null) {
|
|
644
|
+
out.push(m[1]);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
let from_barrel = /import\s*\{([^}]*)\}\s*from\s*["']@yuneta\/gobj-ui(?:\/index\.js)?["']/g;
|
|
648
|
+
while((m = from_barrel.exec(src)) !== null) {
|
|
649
|
+
for(let sym of m[1].split(",")) {
|
|
650
|
+
sym = sym.trim().split(/\s+as\s+/)[0].trim();
|
|
651
|
+
let mod = barrel.get(sym);
|
|
652
|
+
if(mod) {
|
|
653
|
+
out.push(mod);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
if(inside_lib) {
|
|
659
|
+
let inside = /(?:from\s+|import\(\s*)["']\.\/([\w.-]+\.js)["']/g;
|
|
660
|
+
while((m = inside.exec(src)) !== null) {
|
|
661
|
+
out.push(m[1]);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
await main();
|