@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.
@@ -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();