@yuneta/gobj-ui 7.26.26 → 7.26.28

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.
Files changed (44) hide show
  1. package/README.md +473 -19
  2. package/dist/gobj-ui.cjs.js +1100 -231
  3. package/dist/gobj-ui.es.js +1101 -232
  4. package/package.json +1 -1
  5. package/scripts/validate-locales.mjs +2334 -275
  6. package/src/c_g6_nodes_tree.js +1143 -260
  7. package/src/c_yui_form.js +11 -1
  8. package/src/c_yui_fsm_graph.js +5 -0
  9. package/src/c_yui_gclass.js +11 -0
  10. package/src/c_yui_gobj_tree_js.js +17 -0
  11. package/src/c_yui_json_graph.js +5 -0
  12. package/src/c_yui_nav.js +9 -6
  13. package/src/c_yui_pager.js +1 -1
  14. package/src/c_yui_period.js +11 -4
  15. package/src/c_yui_schema_editor.js +19 -7
  16. package/src/c_yui_shell.js +7 -7
  17. package/src/c_yui_treedb_graph.js +15 -6
  18. package/src/c_yui_treedb_schema.js +5 -0
  19. package/src/c_yui_treedb_topic_with_form.js +10 -10
  20. package/src/c_yui_treedb_topics.js +6 -6
  21. package/src/c_yui_window.js +2 -2
  22. package/src/c_yui_wizard.js +8 -1
  23. package/src/form_json_field.js +7 -0
  24. package/src/g6_canvas_tab_order.js +84 -0
  25. package/src/g6_canvas_tab_order.wiring.test.js +232 -0
  26. package/src/g6_nodes_tree_controls.test.js +547 -0
  27. package/src/g6_nodes_tree_focus.wiring.test.js +1360 -0
  28. package/src/g6_nodes_tree_i18n_keys.test.js +275 -0
  29. package/src/i18n_key_functions.js +146 -0
  30. package/src/i18n_key_functions.test.js +1204 -0
  31. package/src/lib_graph.js +38 -1
  32. package/src/nav_backbar_name.wiring.test.js +82 -0
  33. package/src/schema_flags.js +47 -0
  34. package/src/schema_import.js +7 -0
  35. package/src/shell_modals.js +20 -16
  36. package/src/shell_route_map.js +12 -2
  37. package/src/wizard_helpers.js +8 -0
  38. package/src/yui_dev.js +44 -33
  39. package/src/yui_dev_controls.test.js +174 -0
  40. package/src/yui_gclass_view.js +2 -2
  41. package/src/yui_graph_camera.js +1 -1
  42. package/src/yui_install.js +10 -8
  43. package/src/yui_table_select.js +2 -2
  44. package/src/yui_time.js +18 -3
@@ -22,7 +22,18 @@
22
22
  * `zero`, `one`, `two` and `few` change the grammar and need
23
23
  * their own form when the language has them. A form the
24
24
  * language never uses is a violation; `_zero` is always
25
- * allowed (i18next uses it for a count of 0).
25
+ * allowed (i18next uses it for a count of 0). i18next itself
26
+ * falls back to the bare key for ANY missing form: asking for
27
+ * `one`/`few` is a policy of grammar, not of i18next. A group
28
+ * is a plural only when some locale has its bare base
29
+ * (`songs`) or its `_other` form: keys that only END like
30
+ * forms are plain keys (`pick_one` alone; `step_one`,
31
+ * `step_two`, `step_three`, an enumeration). A form asked for
32
+ * directly, `t("pick_one")` next to a bare `pick`, is a key
33
+ * the source uses like any other. Ordinals
34
+ * (`place_ordinal_one`) are checked with the ordinal
35
+ * categories of each language; the `_ordinal_` infix says
36
+ * they are plural forms.
26
37
  * 4. No duplicate key in a locale file (an object literal keeps
27
38
  * the LAST one and says nothing -- a stale entry silently
28
39
  * overrides a new one; it happened to "last" and "loading").
@@ -31,15 +42,62 @@
31
42
  * fine and simply never changes language -- invisible by
32
43
  * construction, and it shipped: a Settings `label` column).
33
44
  *
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
45
+ * Rule 5 PARSES (acorn, never executes) the app's own source
46
+ * (recursively, .js and .mjs; not tests, not a locales/ directory),
47
+ * the shell declaration (`src/app_config.json`) and the gobj-ui
48
+ * modules the app MOUNTS, transitively -- deep, through the barrel,
49
+ * or by a dynamic import(): the library translates through the
50
+ * APP's i18next, so a key it asks for and the app does not define
39
51
  * renders raw (that is how the treedb editor shipped reading
40
52
  * "edit", "new", "delete" in every language). The library scanned
41
53
  * is THIS one -- the package the app installed and builds with,
42
- * the one this script ships in.
54
+ * the one this script ships in. In each file it reads as keys:
55
+ *
56
+ * - the string given to t() / tr(), to an `i18n` or `code`
57
+ * property, to a `data-i18n*` attribute (as a property, by
58
+ * setAttribute(), or written in markup), and the codes of the
59
+ * schema checks (`add("error", "<code>")`);
60
+ * - the strings given to a gobj-ui function that RECEIVES a key
61
+ * as an argument (`yui_shell_show_error(shell, "<key>")`,
62
+ * `icon_button(..., "<key>")`), at the positions its table
63
+ * names: I18N_KEY_FUNCTIONS, src/i18n_key_functions.js;
64
+ * - the keys a module PUBLISHES as data: an exported
65
+ * `<NAME>_I18N_KEYS` array of string literals (the keys it
66
+ * hands to t() as variables, yui_dev.js's 51). Read as data
67
+ * -- `export const`, `let` or `var`, `export {X}`, wrapped in
68
+ * Object.freeze() --; anything so named that is not an
69
+ * exported array of string literals is a violation, never
70
+ * skipped (a list not exported, declared in a function, by
71
+ * destructuring, or exported under another name included).
72
+ *
73
+ * A string is read where it is written, and FOLLOWED where the
74
+ * source writes it out: a literal, a template with no `${}`,
75
+ * every branch of `?:`, `||` and `??`, the right one of `&&`; a
76
+ * `const` to its value (`const KEY = "save"; t(KEY)` asks for
77
+ * "save"); a property of a const object literal (`DEFAULTS.ok`;
78
+ * every value of it for `NOTES[verdict]`); the element of a const
79
+ * array literal its `.map()`, `.forEach()`, ... callback or a
80
+ * for...of iterates. A key built by concatenation, held in a `let`,
81
+ * or arriving as data cannot be known: its module publishes it in
82
+ * a `<NAME>_I18N_KEYS` list.
83
+ *
84
+ * A MARKER at the place a key is read says where the keys of what
85
+ * follows it come from: `/\* i18n: <NAME>_I18N_KEYS *\/` (that
86
+ * published list; several by commas) or `/\* i18n: data *\/` (data,
87
+ * no key a list can hold). The library marks every key position
88
+ * it cannot write as a literal or a const (its own test fails
89
+ * otherwise); this script checks the markers it reads: each one
90
+ * must be a marker and stand before a place a key is read, the
91
+ * lists it names must be published by a module the app mounts,
92
+ * and the keys it marks that can be read must be in them. A
93
+ * marked `let` / `var` is READ, and so is one a marked member
94
+ * or element is read from (`LABELS[k]`, `picked = LIST[0]`, the
95
+ * `x` of `LIST.forEach((x) => ...)`): everything it is given in
96
+ * its scope (its value, every `=` to it; for a member, also what
97
+ * is written into it: `x.a = v`, `x.push(v)`, Object.assign())
98
+ * is followed, each literal is a key asked for and must be in
99
+ * the lists named, and one given nothing but literals is a
100
+ * violation: it is a const.
43
101
  *
44
102
  * Options (paths relative to the app's directory):
45
103
  *
@@ -59,17 +117,30 @@
59
117
  * `attr_keys` names properties whose string value is an i18n key
60
118
  * (`app_title: "central"`), `array_keys` properties whose value is
61
119
  * an array of keys. A key the app hands over that way reaches no
62
- * t() the scan can read.
120
+ * t() the scan can read. A name declared that no source uses is a
121
+ * violation (a typo would turn its check off).
63
122
  *
64
123
  * A locale is a file <code>.js of the locales directory (en.js,
65
124
  * es.js, pt-br.js; flags.js and locales.js are not) exporting
66
125
  * `{<code>}` with a `translation` dict. It is imported directly,
67
126
  * 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.
127
+ * expects a browser. A file that LOOKS like a locale -- it holds a
128
+ * `translation` dict, it exports its own name (es.js exporting
129
+ * `es`), or it has a default export -- and fails to load, or that
130
+ * cannot even be parsed, is a violation: skipping it would
131
+ * validate one language less and say OK. Any other file with a
132
+ * locale's name (app.js, a helper needing a browser) is said and
133
+ * skipped.
70
134
  *
71
135
  * Exits 1 on any violation, so the build fails loudly.
72
136
  *
137
+ * Needs `acorn`, which every app declares already for
138
+ * check-markup-strings.mjs.
139
+ *
140
+ * Its readers are exported: an app whose catalog has another shape
141
+ * (gobj-ui's own test-app, English as the source) loads the catalog
142
+ * itself and reuses collect_used_keys() for everything else.
143
+ *
73
144
  * Up to gobj-ui 7.26.24 every app carried its own copy, and the
74
145
  * copies drifted into three families with different rules
75
146
  * (REV-CLOUD V17.3.4). This one is their union, and an app keeps
@@ -78,13 +149,17 @@
78
149
  * Copyright (c) 2025-2026, ArtGins.
79
150
  * All Rights Reserved.
80
151
  ***********************************************************************/
81
- import {readdirSync, readFileSync, existsSync, statSync} from "fs";
82
- import {resolve} from "path";
83
- import {pathToFileURL} from "url";
152
+ import {readdirSync, readFileSync, existsSync, statSync, realpathSync} from "fs";
153
+ import {resolve, relative} from "path";
154
+ import {pathToFileURL, fileURLToPath} from "url";
155
+ import {I18N_KEY_FUNCTIONS} from "../src/i18n_key_functions.js";
84
156
 
85
157
  const ASCII_RE = /^[\x20-\x7e]+$/;
86
158
  const LOCALE_FILE_RE = /^([a-z]{2,3}(?:[-_][a-zA-Z]{2,4})?)\.js$/;
87
- const PLURAL_RE = /^(.+)_(zero|one|two|few|many|other)$/;
159
+ const PLURAL_RE = /^(.+?)(_ordinal)?_(zero|one|two|few|many|other)$/;
160
+ const PUBLISHED_LIST_RE = /_I18N_KEYS$/i;
161
+ const DATA_I18N_ATTR_RE = /^data-i18n(?:-title|-aria-label|-placeholder)?$/;
162
+ const DATA_I18N_MARKUP_RE = /data-i18n(?:-title|-aria-label|-placeholder)?=["']([^"']+)["']/g;
88
163
 
89
164
  /* This package: the library the app builds with. */
90
165
  const LIB = new URL("../", import.meta.url);
@@ -105,6 +180,14 @@ function fail(msg)
105
180
  errors++;
106
181
  }
107
182
 
183
+ /***************************************************************
184
+ * The violations reported so far (by fail())
185
+ ***************************************************************/
186
+ function violation_count()
187
+ {
188
+ return errors;
189
+ }
190
+
108
191
  /***************************************************************
109
192
  * A fatal problem of the setup, not a violation: said, exit 1
110
193
  ***************************************************************/
@@ -122,6 +205,18 @@ function note(msg)
122
205
  process.stdout.write(`validate-locales: ${msg}\n`);
123
206
  }
124
207
 
208
+ /*
209
+ * The parser. Resolved from this script up, which in an app reaches the
210
+ * app's own node_modules -- where check-markup-strings.mjs finds it too.
211
+ */
212
+ let acorn_parse;
213
+ try {
214
+ ({parse: acorn_parse} = await import("acorn"));
215
+ } catch(e) {
216
+ abort(`cannot load acorn (${e.message}): add "acorn" to the app's devDependencies ` +
217
+ `(check-markup-strings.mjs needs it too)`);
218
+ }
219
+
125
220
  /***************************************************************
126
221
  * The directory a path names, as a URL ending in '/'
127
222
  ***************************************************************/
@@ -184,6 +279,17 @@ function parse_args(argv)
184
279
  return opts;
185
280
  }
186
281
 
282
+ /***************************************************************
283
+ * The names a locale file <code>.js may export its locale under:
284
+ * pt-br -> pt-br, pt_br, ptBr, ptBR
285
+ ***************************************************************/
286
+ function locale_export_names(code)
287
+ {
288
+ let camel = code.replace(/[-_]([a-z])/gi, (m, c) => c.toUpperCase()); // pt-br -> ptBr
289
+ let upper = code.replace(/[-_]([a-z]+)/gi, (m, c) => c.toUpperCase()); // pt-br -> ptBR
290
+ return [...new Set([code, code.replace(/-/g, "_"), camel, upper])];
291
+ }
292
+
187
293
  /***************************************************************
188
294
  * The locales of the app: {code: translation}
189
295
  ***************************************************************/
@@ -201,47 +307,138 @@ async function load_locales(locales_dir)
201
307
  codes = ["en", ...codes.filter((c) => c !== "en")];
202
308
  }
203
309
  let locales = {};
310
+ let names = new Set();
204
311
  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;
312
+ let url = new URL(`${code}.js`, locales_dir);
313
+ let export_names = locale_export_names(code);
314
+
315
+ /* Parsed first, never trusted to the import: a syntax error is
316
+ * a broken file, not "a helper that needs a browser". */
317
+ let ast;
318
+ try {
319
+ ast = parse_source(readFileSync(url, "utf8"));
320
+ } catch(e) {
321
+ fail(`${code}.js cannot be parsed (${e.message}): its language is NOT validated`);
322
+ continue;
323
+ }
324
+ let looks = looks_like_a_locale(ast, export_names);
325
+
326
+ let mod;
327
+ try {
328
+ mod = await import(url.href);
329
+ } catch(e) {
330
+ if(looks) {
331
+ fail(`${code}.js ${looks} and cannot be loaded (${e.message}): ` +
332
+ `its language is NOT validated`);
333
+ } else {
334
+ /* A helper that happens to have a locale's name (app.js),
335
+ * and needs a browser to load: said and skipped */
336
+ note(`${code}.js cannot be loaded here (${e.message}): not a locale, skipped`);
337
+ }
338
+ continue;
339
+ }
340
+ let locale = null;
341
+ for(let name of export_names) {
342
+ if(mod[name]) {
343
+ locale = mod[name];
344
+ break;
345
+ }
346
+ }
347
+ locale = locale || mod.default;
207
348
  if(!locale || !locale.translation || typeof locale.translation !== "object") {
208
- note(`${code}.js exports no {${code}} with a translation dict: not a locale, skipped`);
349
+ if(looks) {
350
+ fail(`${code}.js ${looks} but exports no {${code}} with a translation dict: ` +
351
+ `its language is NOT validated`);
352
+ } else {
353
+ note(`${code}.js exports no {${code}} with a translation dict: not a locale, skipped`);
354
+ }
209
355
  continue;
210
356
  }
211
357
  locales[code] = locale.translation;
358
+ if(typeof locale.name === "string" && locale.name) {
359
+ names.add(locale.name); // its endonym: a literal of a language menu
360
+ }
212
361
  }
213
362
  if(Object.keys(locales).length === 0) {
214
363
  abort(`no locale (<code>.js exporting {<code>: {translation}}) in ${locales_dir.pathname}`);
215
364
  }
216
- return locales;
365
+ return {locales, names};
366
+ }
367
+
368
+ /***************************************************************
369
+ * Does a parsed file look like a locale? It does when it holds an
370
+ * object literal with a `translation` dict, when it exports one of
371
+ * the names of its locale (es.js exporting `es`), or when it has a
372
+ * default export. Returns what it saw (for the message), or "".
373
+ ***************************************************************/
374
+ function looks_like_a_locale(ast, export_names)
375
+ {
376
+ let saw = "";
377
+ for(let node of ast.body) {
378
+ if(node.type === "ExportDefaultDeclaration") {
379
+ return "has a default export";
380
+ }
381
+ if(node.type !== "ExportNamedDeclaration") {
382
+ continue;
383
+ }
384
+ let exported = [];
385
+ if(node.declaration && node.declaration.type === "VariableDeclaration") {
386
+ for(let d of node.declaration.declarations) {
387
+ exported.push(...pattern_names(d.id));
388
+ }
389
+ } else if(node.declaration && node.declaration.id) {
390
+ exported.push(node.declaration.id.name);
391
+ }
392
+ for(let spec of node.specifiers || []) {
393
+ exported.push(spec.exported.name || spec.exported.value);
394
+ }
395
+ let own = exported.find((name) => export_names.includes(name));
396
+ if(own) {
397
+ return `exports ${own}`;
398
+ }
399
+ if(exported.includes("default")) {
400
+ return "has a default export";
401
+ }
402
+ }
403
+ walk(ast, (n, ancestors) => {
404
+ if(!saw && n.type === "Property" && prop_name(n) === "translation"
405
+ && n.value.type === "ObjectExpression"
406
+ && ancestors[ancestors.length - 1].type === "ObjectExpression") {
407
+ saw = "holds a translation dict";
408
+ }
409
+ });
410
+ return saw;
217
411
  }
218
412
 
219
413
  /***************************************************************
220
414
  * The plural categories of a language (Intl.PluralRules)
221
415
  ***************************************************************/
222
- function plural_categories(code)
416
+ function plural_categories(code, ordinal)
223
417
  {
224
418
  try {
225
- return new Set(new Intl.PluralRules(code.replace(/_/g, "-")).resolvedOptions().pluralCategories);
419
+ return new Set(new Intl.PluralRules(code.replace(/_/g, "-"),
420
+ {type: ordinal? "ordinal" : "cardinal"}).resolvedOptions().pluralCategories);
226
421
  } catch(e) {
227
422
  return new Set(["one", "other"]);
228
423
  }
229
424
  }
230
425
 
231
426
  /***************************************************************
232
- * A locale's keys split: plain ones, and {base: Set(categories)}
427
+ * A locale's keys split: plain ones, and {group: Set(categories)}, a
428
+ * group being a base, or "<base>#ordinal" for `<base>_ordinal_<cat>`
233
429
  ***************************************************************/
234
- function split_plurals(keys)
430
+ function split_plurals(keys, plural_groups)
235
431
  {
236
432
  let plain = new Set();
237
433
  let plurals = new Map();
238
434
  for(let k of keys) {
239
435
  let m = k.match(PLURAL_RE);
240
- if(m) {
241
- if(!plurals.has(m[1])) {
242
- plurals.set(m[1], new Set());
436
+ let group = m? m[1] + (m[2]? "#ordinal" : "") : null;
437
+ if(m && plural_groups.has(group)) {
438
+ if(!plurals.has(group)) {
439
+ plurals.set(group, new Set());
243
440
  }
244
- plurals.get(m[1]).add(m[2]);
441
+ plurals.get(group).add(m[3]);
245
442
  } else {
246
443
  plain.add(k);
247
444
  }
@@ -249,20 +446,56 @@ function split_plurals(keys)
249
446
  return {plain, plurals};
250
447
  }
251
448
 
449
+ /***************************************************************
450
+ * Which `<x>_<category>` keys ARE plurals: a key that only ends like one
451
+ * (`pick_one`; `step_one` / `step_two` / `step_three`, an enumeration)
452
+ * is a key. A cardinal group is a plural when some locale has its bare
453
+ * base or its `_other` form -- every language has `other`, so a real
454
+ * plural always carries one of the two. An ordinal says it with its
455
+ * `_ordinal_` infix.
456
+ ***************************************************************/
457
+ function find_plural_groups(locales)
458
+ {
459
+ let forms = new Map(); // group -> Set of categories, in any locale
460
+ let bare = new Set();
461
+ for(let translation of Object.values(locales)) {
462
+ for(let k of Object.keys(translation)) {
463
+ bare.add(k);
464
+ let m = k.match(PLURAL_RE);
465
+ if(m) {
466
+ let group = m[1] + (m[2]? "#ordinal" : "");
467
+ if(!forms.has(group)) {
468
+ forms.set(group, new Set());
469
+ }
470
+ forms.get(group).add(m[3]);
471
+ }
472
+ }
473
+ }
474
+ let groups = new Set();
475
+ for(let [group, cats] of forms) {
476
+ if(group.endsWith("#ordinal") || bare.has(group) || cats.has("other")) {
477
+ groups.add(group);
478
+ }
479
+ }
480
+ return groups;
481
+ }
482
+
252
483
  /***************************************************************
253
484
  *
254
485
  ***************************************************************/
255
486
  async function main()
256
487
  {
257
488
  let opts = parse_args(process.argv.slice(2));
258
- let locales = await load_locales(opts.locales);
489
+ let {locales, names} = await load_locales(opts.locales);
490
+ opts.language_names = names;
259
491
  let codes = Object.keys(locales);
260
492
 
261
493
  /* Per-locale key checks (ASCII + lower-case) */
494
+ let plural_groups = find_plural_groups(locales);
262
495
  let split = {};
263
496
  for(let code of codes) {
264
497
  let keys = Object.keys(locales[code]);
265
- split[code] = split_plurals(keys);
498
+ split[code] = split_plurals(keys, plural_groups);
266
499
  for(let k of keys) {
267
500
  if(!ASCII_RE.test(k)) {
268
501
  fail(`[${code}] non-ASCII key: ${JSON.stringify(k)}`);
@@ -277,11 +510,9 @@ async function main()
277
510
  * The plural bases, of any locale: a plain key that is one of them
278
511
  * belongs to its plural, and is checked with it
279
512
  */
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
- }
513
+ let bases = new Set(); // the plain base of each plural group
514
+ for(let group of plural_groups) {
515
+ bases.add(group.replace(/#ordinal$/, ""));
285
516
  }
286
517
 
287
518
  /* Symmetry of the plain keys: every locale carries those of the first */
@@ -309,19 +540,21 @@ async function main()
309
540
  */
310
541
  const SERVED_BY_THE_BASE = new Set(["other", "many"]);
311
542
  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();
543
+ for(let group of plural_groups) {
544
+ let ordinal = group.endsWith("#ordinal");
545
+ let base = group.replace(/#ordinal$/, "");
546
+ let wanted = plural_categories(code, ordinal);
547
+ let have = split[code].plurals.get(group) || new Set();
315
548
  let has_base = split[code].plain.has(base);
316
549
  for(let c of wanted) {
317
550
  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` +
551
+ fail(`[${code}] ${ordinal? "ordinal" : "plural"} ${JSON.stringify(base)} lacks the form "${c}" of its language` +
319
552
  (SERVED_BY_THE_BASE.has(c)? ` (or the key ${JSON.stringify(base)} itself)` : ""));
320
553
  }
321
554
  }
322
555
  for(let c of have) {
323
556
  if(!wanted.has(c) && c !== "zero") {
324
- fail(`[${code}] plural ${JSON.stringify(base)} has a form "${c}" its language never uses`);
557
+ fail(`[${code}] ${ordinal? "ordinal" : "plural"} ${JSON.stringify(base)} has a form "${c}" its language never uses`);
325
558
  }
326
559
  }
327
560
  }
@@ -345,10 +578,26 @@ async function main()
345
578
  }
346
579
  }
347
580
 
581
+ /*
582
+ * A FORM asked for by its own name (`t("pick_one")`, a bare `pick`
583
+ * beside it) is defined when a locale carries that form.
584
+ */
585
+ let is_defined_form = (k) => {
586
+ let m = k.match(PLURAL_RE);
587
+ if(!m) {
588
+ return false;
589
+ }
590
+ let group = m[1] + (m[2]? "#ordinal" : "");
591
+ if(!plural_groups.has(group)) {
592
+ return false;
593
+ }
594
+ return codes.some((code) => (split[code].plurals.get(group) || new Set()).has(m[3]));
595
+ };
596
+
348
597
  /* Every key the source asks for exists: a missing one is a violation, not a fallback */
349
- let used = collect_used_keys(opts);
598
+ let {keys: used} = collect_used_keys(opts);
350
599
  for(let k of used) {
351
- if(!ref_plain.has(k) && !bases.has(k)) {
600
+ if(!ref_plain.has(k) && !bases.has(k) && !is_defined_form(k)) {
352
601
  fail(`key used in the source but defined in NO locale: ${JSON.stringify(k)}`);
353
602
  }
354
603
  }
@@ -365,303 +614,2113 @@ async function main()
365
614
  `, ${used.size} used in the source)\n`);
366
615
  }
367
616
 
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
617
 
385
- function re(source)
386
- {
387
- return new RegExp(source, "g");
388
- }
618
+ /***************************
619
+ * The parsed source
620
+ ***************************/
389
621
 
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
622
 
406
- function literal_of(m)
623
+ /***************************************************************
624
+ * A source file as an ESTree (acorn). Throws on a syntax error.
625
+ * Its comments (the i18n markers live there) and its text ride
626
+ * along as `ast.comments` and `ast.code`, not enumerable, so
627
+ * walk() never visits them.
628
+ ***************************************************************/
629
+ function parse_source(code)
407
630
  {
408
- for(let i = 1; i < m.length; i++) {
409
- if(m[i] !== undefined) {
410
- return m[i];
411
- }
412
- }
413
- return undefined;
631
+ let comments = [];
632
+ let ast = acorn_parse(code, {
633
+ ecmaVersion: "latest",
634
+ sourceType: "module",
635
+ allowHashBang: true,
636
+ onComment: comments
637
+ });
638
+ Object.defineProperty(ast, "comments", {value: comments, enumerable: false});
639
+ Object.defineProperty(ast, "code", {value: code, enumerable: false});
640
+ return ast;
414
641
  }
415
642
 
416
- function collect_used_keys(opts)
643
+ /***************************************************************
644
+ * Visit every node, with the chain of its ancestors (the last one
645
+ * is its parent). The chain is the walker's own: copy it to keep it.
646
+ ***************************************************************/
647
+ function walk(node, visit, ancestors = [])
417
648
  {
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
- }
649
+ visit(node, ancestors);
650
+ ancestors.push(node);
651
+ for(let key of Object.keys(node)) {
652
+ if(key === "type" || key === "start" || key === "end" || key === "loc") {
653
+ continue;
444
654
  }
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));
655
+ let v = node[key];
656
+ if(Array.isArray(v)) {
657
+ for(let c of v) {
658
+ if(c && typeof c.type === "string") {
659
+ walk(c, visit, ancestors);
453
660
  }
454
661
  }
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));
662
+ } else if(v && typeof v.type === "string") {
663
+ walk(v, visit, ancestors);
470
664
  }
471
665
  }
666
+ ancestors.pop();
667
+ }
472
668
 
473
- keys_from_shell_config(opts.config, keys);
669
+ /***************************************************************
670
+ * The line of a position of a parsed file
671
+ ***************************************************************/
672
+ function line_of(ast, pos)
673
+ {
674
+ return ast.code.slice(0, pos).split("\n").length;
675
+ }
474
676
 
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}`);
677
+ /***************************************************************
678
+ * The name of a property (`a:`, `"a-b":`, `["a"]:`), null when it is
679
+ * computed from a value
680
+ ***************************************************************/
681
+ function prop_name(p)
682
+ {
683
+ if(!p.computed && p.key.type === "Identifier") {
684
+ return p.key.name;
482
685
  }
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));
686
+ if(p.key.type === "Literal") {
687
+ return String(p.key.value);
498
688
  }
499
- if(missing.length > 0) {
500
- abort(`modules of gobj-ui not found in ${LIB_SRC.pathname}: ${missing.join(", ")}`);
689
+ return null;
690
+ }
691
+
692
+ /***************************************************************
693
+ * The name a call is made by: `f(...)` and `x.f(...)` are both `f`
694
+ ***************************************************************/
695
+ function callee_name(callee)
696
+ {
697
+ if(callee.type === "Identifier") {
698
+ return callee.name;
501
699
  }
700
+ if(callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier") {
701
+ return callee.property.name;
702
+ }
703
+ return null;
704
+ }
502
705
 
503
- return keys;
706
+ function is_string_literal(n, re)
707
+ {
708
+ return !!n && n.type === "Literal" && typeof n.value === "string" && (!re || re.test(n.value));
504
709
  }
505
710
 
506
711
  /***************************************************************
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
712
+ * `Object.<name>(...)`?
510
713
  ***************************************************************/
511
- function source_files(dir, locales_dir)
714
+ function is_object_call(n, name)
512
715
  {
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);
716
+ return n.type === "CallExpression" && n.callee.type === "MemberExpression"
717
+ && !n.callee.computed && n.callee.object.type === "Identifier"
718
+ && n.callee.object.name === "Object" && n.callee.property.name === name;
719
+ }
720
+
721
+
722
+ /***************************
723
+ * Scopes
724
+ ***************************/
725
+
726
+
727
+ const FUNCTION_RE = /^(FunctionDeclaration|FunctionExpression|ArrowFunctionExpression)$/;
728
+
729
+ /* The array methods whose callback receives each ELEMENT first */
730
+ const ITERATION_METHODS = new Set([
731
+ "map", "flatMap", "forEach", "filter", "find", "findLast", "some", "every"
732
+ ]);
733
+
734
+ /***************************************************************
735
+ * The names a binding pattern declares
736
+ ***************************************************************/
737
+ function pattern_names(p, out = [])
738
+ {
739
+ if(!p) {
740
+ return out;
741
+ }
742
+ switch(p.type) {
743
+ case "Identifier":
744
+ out.push(p.name);
745
+ break;
746
+ case "AssignmentPattern":
747
+ pattern_names(p.left, out);
748
+ break;
749
+ case "RestElement":
750
+ pattern_names(p.argument, out);
751
+ break;
752
+ case "ArrayPattern":
753
+ for(let e of p.elements) {
754
+ pattern_names(e, out);
528
755
  }
529
- }
530
- };
531
- walk(dir);
756
+ break;
757
+ case "ObjectPattern":
758
+ for(let q of p.properties) {
759
+ pattern_names(q.type === "RestElement"? q : q.value, out);
760
+ }
761
+ break;
762
+ default:
763
+ break;
764
+ }
532
765
  return out;
533
766
  }
534
767
 
535
768
  /***************************************************************
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.
769
+ * The `var`s a function body declares, hoisted to it (not those
770
+ * of a function nested in it), and its own name for a function
771
+ * expression: the names it binds besides its parameters, each
772
+ * with the declarators of a `var` (null when it is not a plain
773
+ * `var <name>`). Cached: a module asks for them once per name it
774
+ * reads.
542
775
  *
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.
776
+ * -> Map(name -> [VariableDeclarator] | null)
548
777
  ***************************************************************/
549
- const CONFIG_I18N_FIELDS = ["name", "aria_label", "tooltip", "label", "title", "subtitle"];
778
+ const FN_NAMES = new WeakMap();
550
779
 
551
- function keys_from_shell_config(url, keys)
780
+ function function_scope_names(fn)
552
781
  {
553
- if(!existsSync(url)) {
554
- note(`no ${url.pathname}: no shell keys checked`);
555
- return;
782
+ if(FN_NAMES.has(fn)) {
783
+ return FN_NAMES.get(fn);
556
784
  }
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}`);
785
+ let names = new Map();
786
+ if(fn.type === "FunctionExpression" && fn.id) {
787
+ names.set(fn.id.name, null);
562
788
  }
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") {
789
+ let visit = (n) => {
790
+ if(!n || typeof n.type !== "string" || FUNCTION_RE.test(n.type)) {
571
791
  return;
572
792
  }
573
- for(let [k, v] of Object.entries(node)) {
574
- if(k.startsWith("_")) {
575
- continue;
793
+ if(n.type === "VariableDeclaration" && n.kind === "var") {
794
+ for(let d of n.declarations) {
795
+ for(let name of pattern_names(d.id)) {
796
+ let plain = (d.id.type === "Identifier");
797
+ let had = names.has(name)? names.get(name) : [];
798
+ names.set(name, (plain && had)? [...had, d] : null);
799
+ }
576
800
  }
577
- if(CONFIG_I18N_FIELDS.includes(k) && typeof v === "string" && v
578
- && ASCII_RE.test(v) && v === v.toLowerCase()) {
579
- keys.add(v);
801
+ }
802
+ for(let key of Object.keys(n)) {
803
+ let v = n[key];
804
+ if(Array.isArray(v)) {
805
+ for(let c of v) {
806
+ visit(c);
807
+ }
808
+ } else if(v && typeof v.type === "string") {
809
+ visit(v);
580
810
  }
581
- walk(v);
582
811
  }
583
812
  };
584
- walk(config);
813
+ if(fn.body && fn.body.type === "BlockStatement") {
814
+ for(let s of fn.body.body) {
815
+ visit(s);
816
+ }
817
+ }
818
+ FN_NAMES.set(fn, names);
819
+ return names;
585
820
  }
586
821
 
587
822
  /***************************************************************
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)
823
+ * The name a function is known by: its own, or that of the variable
824
+ * or the property it is assigned to
590
825
  ***************************************************************/
591
- function strip_comments(src)
826
+ function function_name(fn, parent)
592
827
  {
593
- return src
594
- .replace(/\/\*[\s\S]*?\*\//g, " ")
595
- .replace(/(^|[^:"'`\\])\/\/[^\n]*/g, "$1");
828
+ if(fn.id) {
829
+ return fn.id.name;
830
+ }
831
+ if(parent && parent.type === "VariableDeclarator" && parent.id.type === "Identifier") {
832
+ return parent.id.name;
833
+ }
834
+ if(parent && (parent.type === "Property" || parent.type === "MethodDefinition")) {
835
+ return prop_name(parent);
836
+ }
837
+ return null;
596
838
  }
597
839
 
598
840
  /***************************************************************
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.
841
+ * The names a node that is not a function binds for what is inside
842
+ * it: name -> {kind: "const", init} | {kind: "each", of} |
843
+ * {kind: "var", declarators} (a plain `let <name>`; a `var` belongs
844
+ * to its function, or here to the module) | {kind: "other"}.
845
+ * Cached, like function_scope_names().
603
846
  ***************************************************************/
604
- function barrel_export_map()
847
+ const SCOPE_NAMES = new WeakMap();
848
+
849
+ function scope_names(a)
605
850
  {
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}`);
851
+ if(SCOPE_NAMES.has(a)) {
852
+ return SCOPE_NAMES.get(a);
612
853
  }
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;
854
+ let names = new Map();
855
+ let declare = (name, what) => {
856
+ let had = names.get(name);
857
+ if(!had) {
858
+ names.set(name, what);
859
+ } else if(had.kind === "var" && what.kind === "var") {
860
+ /* `var x = 1; ... var x = 2;`: one binding, two values */
861
+ names.set(name, {kind: "var", decl: "var", declarators: [...had.declarators, ...what.declarators]});
862
+ }
863
+ };
864
+ let statements = [];
865
+ switch(a.type) {
866
+ case "Program":
867
+ case "BlockStatement":
868
+ case "StaticBlock":
869
+ statements = a.body;
870
+ break;
871
+ case "SwitchStatement":
872
+ statements = a.cases.flatMap((c) => c.consequent);
873
+ break;
874
+ case "ForStatement":
875
+ statements = (a.init && a.init.type === "VariableDeclaration")? [a.init] : [];
876
+ break;
877
+ case "ForOfStatement":
878
+ case "ForInStatement":
879
+ if(a.left.type === "VariableDeclaration") {
880
+ for(let d of a.left.declarations) {
881
+ let each = (a.type === "ForOfStatement" && d.id.type === "Identifier");
882
+ for(let name of pattern_names(d.id)) {
883
+ declare(name, each? {kind: "each", of: a.right} : {kind: "other"});
884
+ }
885
+ }
886
+ }
887
+ break;
888
+ case "CatchClause":
889
+ for(let name of pattern_names(a.param)) {
890
+ declare(name, {kind: "other"});
891
+ }
892
+ break;
893
+ case "ClassExpression":
894
+ if(a.id) {
895
+ declare(a.id.name, {kind: "other"});
896
+ }
897
+ break;
898
+ default:
899
+ break;
900
+ }
901
+ for(let s of statements) {
902
+ let d = (s.type === "ExportNamedDeclaration" || s.type === "ExportDefaultDeclaration")?
903
+ s.declaration : s;
904
+ if(!d) {
905
+ continue;
906
+ }
907
+ if(d.type === "VariableDeclaration") {
908
+ if(d.kind === "var" && a.type !== "Program") {
909
+ continue; // hoisted: function_scope_names() holds it
910
+ }
911
+ for(let v of d.declarations) {
912
+ let plain = (v.id.type === "Identifier");
913
+ let what = {kind: "other"};
914
+ if(plain && d.kind === "const" && v.init) {
915
+ what = {kind: "const", init: v.init};
916
+ } else if(plain && d.kind !== "const") {
917
+ what = {kind: "var", decl: d.kind, declarators: [v]};
918
+ }
919
+ for(let name of pattern_names(v.id)) {
920
+ declare(name, what);
921
+ }
922
+ }
923
+ } else if((d.type === "FunctionDeclaration" || d.type === "ClassDeclaration") && d.id) {
924
+ declare(d.id.name, {kind: "other"});
925
+ } else if(d.type === "ImportDeclaration") {
926
+ for(let sp of d.specifiers) {
927
+ declare(sp.local.name, {kind: "other"});
621
928
  }
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
929
  }
626
930
  }
627
- return map;
931
+ SCOPE_NAMES.set(a, names);
932
+ return names;
628
933
  }
629
934
 
630
935
  /***************************************************************
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)
936
+ * What a name used in `scope` (the chain of ancestors of the use,
937
+ * outermost first) is bound to:
938
+ *
939
+ * {kind: "const", init, scope} a `const` with a value; `scope`
940
+ * is the chain down to the block
941
+ * that declares it, where its
942
+ * value is read
943
+ * {kind: "each", of, scope} an ELEMENT of `of`: the first
944
+ * parameter of a callback of
945
+ * `of.map()`, `.forEach()`, ...,
946
+ * or the variable of a for...of
947
+ * {kind: "param", fn, name, index}
948
+ * parameter `index` of the
949
+ * function `fn`, called `name`
950
+ * {kind: "var", decl, name, declarators, scope}
951
+ * a plain `let` or `var` (`decl`
952
+ * says which): what it
953
+ * holds is never followed, except
954
+ * where a marker says what it
955
+ * holds (var_leaves()); `scope`
956
+ * is the chain down to the node
957
+ * that declares it
958
+ * {kind: "other"} anything else: a function, a
959
+ * class, an import, a destructured
960
+ * name
961
+ * null declared nowhere: a global
636
962
  ***************************************************************/
637
- function lib_imports(src, barrel, inside_lib)
963
+ function binding_of(name, scope)
638
964
  {
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);
965
+ for(let i = scope.length - 1; i >= 0; i--) {
966
+ let a = scope[i];
967
+ if(FUNCTION_RE.test(a.type)) {
968
+ let parent = scope[i - 1];
969
+ for(let j = 0; j < a.params.length; j++) {
970
+ if(!pattern_names(a.params[j]).includes(name)) {
971
+ continue;
972
+ }
973
+ let p = (a.params[j].type === "AssignmentPattern")? a.params[j].left : a.params[j];
974
+ if(p.type !== "Identifier") {
975
+ return {kind: "other"};
976
+ }
977
+ if(j === 0 && parent && parent.type === "CallExpression" && parent.arguments[0] === a
978
+ && parent.callee.type === "MemberExpression" && !parent.callee.computed
979
+ && ITERATION_METHODS.has(parent.callee.property.name)) {
980
+ return {kind: "each", of: parent.callee.object, scope: scope.slice(0, i - 1)};
981
+ }
982
+ return {kind: "param", fn: a, name: function_name(a, parent), index: j};
983
+ }
984
+ let hoisted = function_scope_names(a);
985
+ if(hoisted.has(name)) {
986
+ let declarators = hoisted.get(name);
987
+ return declarators? {kind: "var", decl: "var", name, declarators, scope: scope.slice(0, i + 1)}
988
+ : {kind: "other"};
654
989
  }
990
+ continue;
655
991
  }
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]);
992
+ let d = scope_names(a).get(name);
993
+ if(!d) {
994
+ continue;
995
+ }
996
+ if(d.kind === "const") {
997
+ return {kind: "const", init: d.init, scope: scope.slice(0, i + 1)};
998
+ }
999
+ if(d.kind === "each") {
1000
+ return {kind: "each", of: d.of, scope: scope.slice(0, i)};
662
1001
  }
1002
+ if(d.kind === "var") {
1003
+ return {kind: "var", decl: d.decl, name, declarators: d.declarators, scope: scope.slice(0, i + 1)};
1004
+ }
1005
+ return d;
663
1006
  }
664
- return out;
1007
+ return null;
665
1008
  }
666
1009
 
667
- await main();
1010
+
1011
+ /***************************
1012
+ * What a value can be
1013
+ ***************************/
1014
+
1015
+
1016
+ /* How far a chain of consts is followed: a cycle stops here */
1017
+ const MAX_DEPTH = 24;
1018
+
1019
+ /***************************************************************
1020
+ * The values an expression can take, as far as the source writes
1021
+ * them out. Each is one of
1022
+ *
1023
+ * {kind: "value", node} an expression it cannot follow
1024
+ * further: a literal, an object
1025
+ * or array literal, a call
1026
+ * {kind: "nothing", node} `undefined`, or the property of
1027
+ * an object that has none
1028
+ * {kind: "param", node, binding, path}
1029
+ * a parameter of the enclosing
1030
+ * function, `path` the rest read
1031
+ * from it (".title", "[].label")
1032
+ * {kind: "unknown", node} a `let`, a `var` (with its
1033
+ * `binding`), an import, a
1034
+ * property of something unknown
1035
+ *
1036
+ * with `scope` (the ancestors of `node`, to read it further) and
1037
+ * `via`, the nodes of the place it is READ that it went through,
1038
+ * outermost first: where a marker may stand (markers_of()). An
1039
+ * unknown read from a plain `let` / `var` -- the variable itself, a
1040
+ * property of it, an element of it -- carries `var_root`: {binding,
1041
+ * steps}, the variable and the steps from it to what is read
1042
+ * (extend_root()), so a marker standing there can have it followed
1043
+ * (var_leaves()). What it follows:
1044
+ *
1045
+ * - every branch of `?:`, of `||` and `??`, the right one of `&&`
1046
+ * (the left one is a condition: when it is the value, it is
1047
+ * falsy, not a key);
1048
+ * - a `const`, to its value, read in the scope that declares it;
1049
+ * - a property of an object literal it reached (`DEFAULTS.ok`; for
1050
+ * a computed one, `NOTES[verdict]`, every value it holds), and an
1051
+ * element of an array literal (`LIST[i]`);
1052
+ * - an ELEMENT of an array literal it reached: the parameter of a
1053
+ * `.map()`, `.forEach()`, ... callback over it, the variable of a
1054
+ * for...of over it;
1055
+ * - Object.freeze(), which changes nothing it reads.
1056
+ *
1057
+ * ctx: {scope, via, off}. `off` is set once it left the place it is
1058
+ * read for the declaration of a const: `via` stops growing there.
1059
+ ***************************************************************/
1060
+ function expand(node, ctx, depth = 0)
1061
+ {
1062
+ if(!node) {
1063
+ return [];
1064
+ }
1065
+ let via = ctx.off? ctx.via : [...ctx.via, node];
1066
+ let here = {scope: ctx.scope, via: via, off: ctx.off};
1067
+ let item = (kind, extra) => Object.assign({kind, node, scope: ctx.scope, via, off: ctx.off}, extra);
1068
+ if(depth > MAX_DEPTH) {
1069
+ return [item("unknown")];
1070
+ }
1071
+
1072
+ switch(node.type) {
1073
+ case "ConditionalExpression":
1074
+ return [...expand(node.consequent, here, depth + 1),
1075
+ ...expand(node.alternate, here, depth + 1)];
1076
+
1077
+ case "LogicalExpression":
1078
+ return [...(node.operator !== "&&"? expand(node.left, here, depth + 1) : []),
1079
+ ...expand(node.right, here, depth + 1)];
1080
+
1081
+ case "Identifier": {
1082
+ if(node.name === "undefined") {
1083
+ return [item("nothing")];
1084
+ }
1085
+ let b = binding_of(node.name, ctx.scope);
1086
+ if(!b) {
1087
+ return [item("unknown")];
1088
+ }
1089
+ if(b.kind === "const") {
1090
+ return expand(b.init, {scope: b.scope, via: via, off: true}, depth + 1);
1091
+ }
1092
+ if(b.kind === "each") {
1093
+ return elements_of(b.of, {scope: b.scope, via: via, off: true}, depth + 1);
1094
+ }
1095
+ if(b.kind === "param") {
1096
+ return [item("param", {binding: b, path: ""})];
1097
+ }
1098
+ if(b.kind === "var") {
1099
+ return [item("unknown", {binding: b, var_root: {binding: b, steps: []}})];
1100
+ }
1101
+ return [item("unknown")];
1102
+ }
1103
+
1104
+ case "MemberExpression": {
1105
+ let step = member_step(node);
1106
+ let out = [];
1107
+ for(let o of expand(node.object, here, depth + 1)) {
1108
+ out.push(...step_values(o, step, node, depth + 1));
1109
+ }
1110
+ return out;
1111
+ }
1112
+
1113
+ case "CallExpression":
1114
+ if(is_object_call(node, "freeze") && node.arguments.length === 1) {
1115
+ return expand(node.arguments[0], here, depth + 1);
1116
+ }
1117
+ return [item("value")];
1118
+
1119
+ default:
1120
+ return [item("value")];
1121
+ }
1122
+ }
1123
+
1124
+ /***************************************************************
1125
+ * The step a member expression takes from its object:
1126
+ *
1127
+ * {prop: name, computed} a property: `x.label`, `x["a"]`,
1128
+ * `x[0]` (`computed` for brackets:
1129
+ * on an array, an index reads an
1130
+ * element)
1131
+ * {prop: null, computed} any property: `x[k]`
1132
+ * {each: true} an element: what a `.map()` /
1133
+ * `.forEach()` callback, a for...of
1134
+ * or a `[]` key position reads
1135
+ ***************************************************************/
1136
+ function member_step(node)
1137
+ {
1138
+ let name = null;
1139
+ if(!node.computed && node.property.type === "Identifier") {
1140
+ name = node.property.name;
1141
+ } else if(node.computed && node.property.type === "Literal") {
1142
+ name = String(node.property.value);
1143
+ }
1144
+ return {prop: name, computed: !!node.computed};
1145
+ }
1146
+
1147
+ /***************************************************************
1148
+ * The var_root of what is read one step further from something read
1149
+ * from a `let` / `var` (null when it was not)
1150
+ ***************************************************************/
1151
+ function extend_root(root, step)
1152
+ {
1153
+ return root? {binding: root.binding, steps: [...root.steps, step]} : null;
1154
+ }
1155
+
1156
+ /***************************************************************
1157
+ * The values one step (member_step()) reads from a value of expand():
1158
+ * a property, or an element. `node` is the node that reads it (null:
1159
+ * the value's own)
1160
+ ***************************************************************/
1161
+ function step_values(o, step, node, depth)
1162
+ {
1163
+ let at = node || o.node;
1164
+ if(step.each) {
1165
+ return element_values(o, depth);
1166
+ }
1167
+ if(o.kind === "param") {
1168
+ return [step.prop !== null? Object.assign({}, o, {path: o.path + "." + step.prop, node: at}) :
1169
+ Object.assign({}, o, {kind: "unknown", binding: null, var_root: null, node: at})];
1170
+ }
1171
+ if(o.kind === "value" && o.node.type === "ObjectExpression") {
1172
+ return property_values(o, step.prop, depth + 1);
1173
+ }
1174
+ if(o.kind === "value" && o.node.type === "ArrayExpression" && step.computed) {
1175
+ return element_values(o, depth + 1);
1176
+ }
1177
+ if(o.kind === "nothing" || (o.kind === "value" && o.node.type === "Literal")) {
1178
+ return [Object.assign({}, o, {kind: "nothing", node: at})];
1179
+ }
1180
+ return [Object.assign({}, o, {kind: "unknown", binding: null, node: at,
1181
+ var_root: extend_root(o.var_root, step)})];
1182
+ }
1183
+
1184
+ /***************************************************************
1185
+ * The values of property `name` of an object literal (`name` null:
1186
+ * of every property), each followed with expand()
1187
+ ***************************************************************/
1188
+ function property_values(o, name, depth)
1189
+ {
1190
+ let out = [];
1191
+ let scope = [...o.scope, o.node];
1192
+ let found = false;
1193
+ for(let p of o.node.properties) {
1194
+ if(p.type === "SpreadElement") {
1195
+ for(let s of expand(p.argument, {scope, via: o.via, off: true}, depth + 1)) {
1196
+ if(s.kind === "value" && s.node.type === "ObjectExpression") {
1197
+ let r = property_values(s, name, depth + 1).filter((x) => x.kind !== "nothing");
1198
+ found = found || r.length > 0;
1199
+ out.push(...r);
1200
+ } else if(s.kind !== "nothing") {
1201
+ found = true;
1202
+ out.push(Object.assign({}, s, {kind: "unknown", binding: null,
1203
+ var_root: extend_root(s.var_root, {prop: name, computed: false})}));
1204
+ }
1205
+ }
1206
+ continue;
1207
+ }
1208
+ let pn = prop_name(p);
1209
+ if(pn === null) {
1210
+ found = true;
1211
+ out.push({kind: "unknown", node: p, scope, via: o.via, off: o.off});
1212
+ continue;
1213
+ }
1214
+ if(name === null || pn === name) {
1215
+ found = true;
1216
+ let v = (p.kind === "init" && !p.method)? p.value : null;
1217
+ if(v) {
1218
+ out.push(...expand(v, {scope: [...scope, p], via: o.via, off: o.off}, depth + 1));
1219
+ } else {
1220
+ out.push({kind: "unknown", node: p, scope, via: o.via, off: o.off});
1221
+ }
1222
+ }
1223
+ }
1224
+ if(!found) {
1225
+ out.push({kind: "nothing", node: o.node, scope: o.scope, via: o.via, off: o.off});
1226
+ }
1227
+ return out;
1228
+ }
1229
+
1230
+ /***************************************************************
1231
+ * The elements of whatever `node` is, when it is an array literal
1232
+ * (after expand()): each element followed with expand(). A `.map()`
1233
+ * of one is the values its callback returns, each element bound to
1234
+ * the callback's parameter. Anything else is unknown, or a param.
1235
+ ***************************************************************/
1236
+ function elements_of(node, ctx, depth)
1237
+ {
1238
+ let out = [];
1239
+ for(let v of expand(node, ctx, depth + 1)) {
1240
+ out.push(...element_values(v, depth));
1241
+ }
1242
+ return out;
1243
+ }
1244
+
1245
+ /***************************************************************
1246
+ * The elements of one value of expand(): see elements_of()
1247
+ ***************************************************************/
1248
+ function element_values(v, depth)
1249
+ {
1250
+ let out = [];
1251
+ if(v.kind === "param") {
1252
+ out.push(Object.assign({}, v, {path: v.path + "[]"}));
1253
+ } else if(v.kind === "nothing" || (v.kind === "value" && v.node.type === "Literal")) {
1254
+ return out;
1255
+ } else if(v.kind === "value" && v.node.type === "ArrayExpression") {
1256
+ let scope = [...v.scope, v.node];
1257
+ for(let el of v.node.elements) {
1258
+ if(!el) {
1259
+ continue;
1260
+ }
1261
+ if(el.type === "SpreadElement") {
1262
+ out.push(...elements_of(el.argument, {scope, via: v.via, off: v.off}, depth + 1));
1263
+ continue;
1264
+ }
1265
+ out.push(...expand(el, {scope, via: v.via, off: v.off}, depth + 1));
1266
+ }
1267
+ } else if(v.kind === "value" && is_mapping(v.node)) {
1268
+ let cb = v.node.arguments[0];
1269
+ let scope = [...v.scope, v.node, cb];
1270
+ for(let r of returned_values(cb)) {
1271
+ out.push(...expand(r.node, {scope: [...scope, ...r.path], via: v.via, off: v.off}, depth + 1));
1272
+ }
1273
+ } else {
1274
+ out.push(Object.assign({}, v, {kind: "unknown", binding: null,
1275
+ var_root: extend_root(v.var_root, {each: true})}));
1276
+ }
1277
+ return out;
1278
+ }
1279
+
1280
+ /***************************************************************
1281
+ * `<array>.map(callback)` (or flatMap), the callback a function
1282
+ * literal with a plain first parameter?
1283
+ ***************************************************************/
1284
+ function is_mapping(n)
1285
+ {
1286
+ if(n.type !== "CallExpression" || n.callee.type !== "MemberExpression" || n.callee.computed
1287
+ || !/^(map|flatMap)$/.test(n.callee.property.name)) {
1288
+ return false;
1289
+ }
1290
+ let cb = n.arguments[0];
1291
+ return !!cb && FUNCTION_RE.test(cb.type) && cb.params.length > 0
1292
+ && cb.params[0].type === "Identifier";
1293
+ }
1294
+
1295
+ /***************************************************************
1296
+ * What a function returns: its expression body, or the argument of
1297
+ * every `return` of its block (not of a function nested in it), each
1298
+ * with the nodes between the function and it
1299
+ ***************************************************************/
1300
+ function returned_values(fn)
1301
+ {
1302
+ if(fn.body.type !== "BlockStatement") {
1303
+ return [{node: fn.body, path: []}];
1304
+ }
1305
+ let out = [];
1306
+ let visit = (n, path) => {
1307
+ if(!n || typeof n.type !== "string" || FUNCTION_RE.test(n.type)) {
1308
+ return;
1309
+ }
1310
+ if(n.type === "ReturnStatement") {
1311
+ if(n.argument) {
1312
+ out.push({node: n.argument, path: [...path, n]});
1313
+ }
1314
+ return;
1315
+ }
1316
+ for(let key of Object.keys(n)) {
1317
+ let v = n[key];
1318
+ if(Array.isArray(v)) {
1319
+ for(let c of v) {
1320
+ visit(c, [...path, n]);
1321
+ }
1322
+ } else if(v && typeof v.type === "string") {
1323
+ visit(v, [...path, n]);
1324
+ }
1325
+ }
1326
+ };
1327
+ visit(fn.body, []);
1328
+ return out;
1329
+ }
1330
+
1331
+ /***************************************************************
1332
+ * The leaves of what an expression can be, each one of
1333
+ *
1334
+ * {kind: "key", value} a non-empty string: a literal, a
1335
+ * template with no `${}`
1336
+ * {kind: "nothing"} null, undefined, "", a number, a
1337
+ * boolean: no key
1338
+ * {kind: "param", ...} a parameter (of an enclosing function),
1339
+ * see expand()
1340
+ * {kind: "unknown"} anything else: a variable it cannot
1341
+ * follow, a call, a concatenation, an
1342
+ * object
1343
+ *
1344
+ * each with its `node` and `via`. `scope` is the ancestors of `node`
1345
+ * (outermost first); `root` the node of the place it is read (a call,
1346
+ * a property), where a marker of the whole place stands.
1347
+ ***************************************************************/
1348
+ function leaves_of(node, scope = [], root = null)
1349
+ {
1350
+ let out = [];
1351
+ let ctx = {scope, via: root? [root] : [], off: false};
1352
+ for(let v of expand(node, ctx)) {
1353
+ out.push(classify(v));
1354
+ }
1355
+ return out;
1356
+ }
1357
+
1358
+ function classify(v)
1359
+ {
1360
+ if(v.kind !== "value") {
1361
+ return v;
1362
+ }
1363
+ let n = v.node;
1364
+ if(n.type === "Literal") {
1365
+ let kind = (typeof n.value === "string" && n.value)? "key" : "nothing";
1366
+ return Object.assign({}, v, {kind, value: n.value});
1367
+ }
1368
+ if(n.type === "TemplateLiteral" && n.expressions.length === 0) {
1369
+ let s = n.quasis[0].value.cooked;
1370
+ return Object.assign({}, v, {kind: s? "key" : "nothing", value: s});
1371
+ }
1372
+ return Object.assign({}, v, {kind: "unknown"});
1373
+ }
1374
+
1375
+ /***************************************************************
1376
+ * The strings an expression can be, as far as they are WRITTEN:
1377
+ * the keys of leaves_of(). `complete` says every value it can take
1378
+ * is known (keys and nothing). `scope` is the chain of ancestors of
1379
+ * `node`: without it, no name is followed.
1380
+ ***************************************************************/
1381
+ function static_keys(node, scope = [])
1382
+ {
1383
+ let keys = [];
1384
+ let complete = true;
1385
+ for(let leaf of leaves_of(node, scope)) {
1386
+ if(leaf.kind === "key") {
1387
+ keys.push(leaf.value);
1388
+ } else if(leaf.kind !== "nothing") {
1389
+ complete = false;
1390
+ }
1391
+ }
1392
+ return {keys, complete};
1393
+ }
1394
+
1395
+ /***************************************************************
1396
+ * The leaves at a key position of I18N_KEY_FUNCTIONS in one call:
1397
+ * "1", "2.title", "1.actions[].label", "2[].label". `scope` is the
1398
+ * chain of ancestors of the call. Each leaf is one of leaves_of(),
1399
+ * its `via` starting at the call.
1400
+ *
1401
+ * A position the call does not reach (an argument not given) has no
1402
+ * leaf: its default applies. An options object held in a variable,
1403
+ * a spread, a parameter: followed like any value.
1404
+ ***************************************************************/
1405
+ function values_at(call, path, scope = [])
1406
+ {
1407
+ let segs = String(path).split(".");
1408
+ let first = segs.shift().match(/^(\d+)(\[\])?$/);
1409
+ let at_call = {kind: "unknown", node: call, scope, via: [call], off: false};
1410
+ if(!first) {
1411
+ return [at_call];
1412
+ }
1413
+ let idx = Number(first[1]);
1414
+ let call_scope = [...scope, call];
1415
+ for(let i = 0; i <= idx && i < call.arguments.length; i++) {
1416
+ if(call.arguments[i].type === "SpreadElement") {
1417
+ return [Object.assign({}, at_call, {node: call.arguments[i]})];
1418
+ }
1419
+ }
1420
+ let arg = call.arguments[idx];
1421
+ if(!arg) {
1422
+ return [];
1423
+ }
1424
+
1425
+ let ctx = {scope: call_scope, via: [call], off: false};
1426
+ let cur = first[2]? elements_of(arg, ctx, 0) : expand(arg, ctx);
1427
+ for(let seg of segs) {
1428
+ let m = seg.match(/^([\w$]+)(\[\])?$/);
1429
+ if(!m) {
1430
+ return [at_call];
1431
+ }
1432
+ let next = [];
1433
+ for(let v of cur) {
1434
+ if(v.kind === "param") {
1435
+ next.push(Object.assign({}, v, {path: v.path + "." + m[1]}));
1436
+ } else if(v.kind === "value" && v.node.type === "ObjectExpression") {
1437
+ next.push(...property_values(v, m[1], 0));
1438
+ } else if(v.kind === "value" && is_object_call(v.node, "assign")) {
1439
+ let assigned = v.node.arguments.flatMap((a) =>
1440
+ expand(a, {scope: [...v.scope, v.node], via: v.via, off: v.off}));
1441
+ for(let a of assigned) {
1442
+ if(a.kind === "value" && a.node.type === "ObjectExpression") {
1443
+ next.push(...property_values(a, m[1], 0)
1444
+ .filter((x) => x.kind !== "nothing"));
1445
+ } else if(a.kind === "param") {
1446
+ next.push(Object.assign({}, a, {path: a.path + "." + m[1]}));
1447
+ } else if(a.kind !== "nothing") {
1448
+ next.push(Object.assign({}, a, {kind: "unknown", binding: null,
1449
+ var_root: extend_root(a.var_root, {prop: m[1], computed: false})}));
1450
+ }
1451
+ }
1452
+ } else if(v.kind === "nothing" || (v.kind === "value" && v.node.type === "Literal")) {
1453
+ continue;
1454
+ } else {
1455
+ next.push(Object.assign({}, v, {kind: "unknown", binding: null,
1456
+ var_root: extend_root(v.var_root, {prop: m[1], computed: false})}));
1457
+ }
1458
+ }
1459
+ if(m[2]) {
1460
+ let els = [];
1461
+ for(let v of next) {
1462
+ if(v.kind === "param") {
1463
+ els.push(Object.assign({}, v, {path: v.path + "[]"}));
1464
+ } else if(v.kind === "value") {
1465
+ els.push(...elements_of(v.node, v, 0));
1466
+ } else if(v.kind !== "nothing") {
1467
+ els.push(Object.assign({}, v, {var_root: extend_root(v.var_root, {each: true})}));
1468
+ }
1469
+ }
1470
+ next = els;
1471
+ }
1472
+ cur = next;
1473
+ }
1474
+ return cur.map(classify);
1475
+ }
1476
+
1477
+ /***************************************************************
1478
+ * The steps from a `let` / `var` to the member a node names, when
1479
+ * the node is the variable itself (no step) or a member of it
1480
+ * (`x.a`, `x[k]`, `x.a.b`): [step] (member_step()), or null when it
1481
+ * is not read from the variable. `is_it(id)` says whether an
1482
+ * identifier is the variable.
1483
+ ***************************************************************/
1484
+ function steps_to(node, is_it)
1485
+ {
1486
+ let steps = [];
1487
+ let n = node;
1488
+ while(n.type === "MemberExpression") {
1489
+ steps.unshift(member_step(n));
1490
+ n = n.object;
1491
+ }
1492
+ return is_it(n)? steps : null;
1493
+ }
1494
+
1495
+ /***************************************************************
1496
+ * Can two steps (member_step()) reach the same value? An element
1497
+ * meets an index (`x[0]`) and any property (`x[k]`); a property
1498
+ * meets the same name, and any property.
1499
+ ***************************************************************/
1500
+ function steps_meet(a, b)
1501
+ {
1502
+ if(a.each || b.each) {
1503
+ let other = a.each? b : a;
1504
+ return !!other.each || other.prop === null || (other.computed && /^\d+$/.test(other.prop));
1505
+ }
1506
+ return a.prop === null || b.prop === null || a.prop === b.prop;
1507
+ }
1508
+
1509
+ /***************************************************************
1510
+ * Do two paths of steps meet: is the shorter one a prefix of the
1511
+ * longer one, step by step?
1512
+ ***************************************************************/
1513
+ function paths_meet(a, b)
1514
+ {
1515
+ let n = Math.min(a.length, b.length);
1516
+ for(let i = 0; i < n; i++) {
1517
+ if(!steps_meet(a[i], b[i])) {
1518
+ return false;
1519
+ }
1520
+ }
1521
+ return true;
1522
+ }
1523
+
1524
+ /* The array methods that write ELEMENTS into an array: the index of
1525
+ * the first argument that is one (fill: only that one) */
1526
+ const ELEMENT_WRITERS = new Map([["push", 0], ["unshift", 0], ["splice", 2], ["fill", 0]]);
1527
+
1528
+ /***************************************************************
1529
+ * Everything a plain `let` / `var` is GIVEN, in the scope that
1530
+ * declares it: the value of each declarator (one with none holds
1531
+ * nothing), and the right side of every `=` to it (`||=`, `??=`,
1532
+ * `&&=` too: the old value stays possible besides). A write whose
1533
+ * value cannot be read -- `+=`, `++`, a destructuring, a for...in/of
1534
+ * over it -- is listed in `unknown`. A name of another binding
1535
+ * (shadowing) is not a write to it.
1536
+ *
1537
+ * With `members` (it holds a table, and what is read is a member of
1538
+ * it), also what is written INTO it: `x.a = v`, `x[k] = v`,
1539
+ * `x.a.b = v` (`steps`: from the variable to the member written),
1540
+ * the elements `x.push(v)`, `x.unshift(v)`, `x.splice(i, n, v)`,
1541
+ * `x.fill(v)` give it (a spread: every element of what is spread),
1542
+ * and `Object.assign(x, v)` (v written over x). A `+=` or `++` on a
1543
+ * member is unknown at that member.
1544
+ *
1545
+ * -> {values: [{node, scope, steps, spread, into}], unknown: [{node, steps}]}
1546
+ * `node` null: nothing (a declarator with no value); `into`:
1547
+ * written into the variable, not given to it
1548
+ ***************************************************************/
1549
+ function assigned_values(binding, members = false)
1550
+ {
1551
+ let values = [];
1552
+ let unknown = [];
1553
+ let first = binding.declarators[0];
1554
+ let root = binding.scope[binding.scope.length - 1];
1555
+ let is_it = (id, chain) => {
1556
+ if(!id || id.type !== "Identifier" || id.name !== binding.name) {
1557
+ return false;
1558
+ }
1559
+ let b = binding_of(id.name, chain);
1560
+ return !!b && b.kind === "var" && b.declarators[0] === first;
1561
+ };
1562
+ walk(root, (n, ancestors) => {
1563
+ let chain = [...ancestors, n];
1564
+ let into = (node) => (members? steps_to(node, (id) => is_it(id, chain)) : null);
1565
+ switch(n.type) {
1566
+ case "VariableDeclarator":
1567
+ if(binding.declarators.includes(n)) {
1568
+ values.push(n.init? {node: n.init, scope: chain, steps: []} : {node: null, steps: []});
1569
+ }
1570
+ break;
1571
+ case "AssignmentExpression": {
1572
+ let readable = /^(=|\|\|=|\?\?=|&&=)$/.test(n.operator);
1573
+ if(n.left.type === "Identifier") {
1574
+ if(is_it(n.left, chain)) {
1575
+ if(readable) {
1576
+ values.push({node: n.right, scope: chain, steps: []});
1577
+ } else {
1578
+ unknown.push({node: n, steps: []});
1579
+ }
1580
+ }
1581
+ } else if(n.left.type === "MemberExpression") {
1582
+ let steps = into(n.left);
1583
+ if(steps && readable) {
1584
+ values.push({node: n.right, scope: chain, steps, into: true});
1585
+ } else if(steps) {
1586
+ unknown.push({node: n, steps});
1587
+ }
1588
+ } else if(pattern_names(n.left).includes(binding.name)
1589
+ && is_it({type: "Identifier", name: binding.name}, chain)) {
1590
+ unknown.push({node: n, steps: []});
1591
+ }
1592
+ break;
1593
+ }
1594
+ case "UpdateExpression":
1595
+ if(is_it(n.argument, chain)) {
1596
+ unknown.push({node: n, steps: []});
1597
+ } else if(n.argument.type === "MemberExpression") {
1598
+ let steps = into(n.argument);
1599
+ if(steps) {
1600
+ unknown.push({node: n, steps});
1601
+ }
1602
+ }
1603
+ break;
1604
+ case "CallExpression": {
1605
+ if(!members) {
1606
+ break;
1607
+ }
1608
+ let c = n.callee;
1609
+ if(c.type === "MemberExpression" && !c.computed && c.property.type === "Identifier"
1610
+ && ELEMENT_WRITERS.has(c.property.name)) {
1611
+ let steps = into(c.object);
1612
+ if(!steps) {
1613
+ break;
1614
+ }
1615
+ let from = ELEMENT_WRITERS.get(c.property.name);
1616
+ let to = (c.property.name === "fill")? from + 1 : n.arguments.length;
1617
+ for(let a of n.arguments.slice(from, to)) {
1618
+ let spread = (a.type === "SpreadElement");
1619
+ values.push({node: spread? a.argument : a, scope: chain,
1620
+ steps: [...steps, {each: true}], spread, into: true});
1621
+ }
1622
+ } else if(is_object_call(n, "assign") && n.arguments.length > 0) {
1623
+ let steps = into(n.arguments[0]);
1624
+ if(!steps) {
1625
+ break;
1626
+ }
1627
+ for(let a of n.arguments.slice(1)) {
1628
+ if(a.type === "SpreadElement") {
1629
+ unknown.push({node: a, steps});
1630
+ } else {
1631
+ values.push({node: a, scope: chain, steps, into: true});
1632
+ }
1633
+ }
1634
+ }
1635
+ break;
1636
+ }
1637
+ case "ForOfStatement":
1638
+ case "ForInStatement":
1639
+ if(is_it(n.left, chain)) {
1640
+ unknown.push({node: n, steps: []});
1641
+ }
1642
+ break;
1643
+ default:
1644
+ break;
1645
+ }
1646
+ }, binding.scope.slice(0, -1));
1647
+ return {values, unknown};
1648
+ }
1649
+
1650
+ /***************************************************************
1651
+ * The leaves of what is read from a `let` / `var` -- the variable
1652
+ * itself, or a member or an element of it (`root`, a var_root of
1653
+ * expand()): every value it is given (assigned_values()), followed
1654
+ * with expand() and then along the steps of `root`, a `let` met on
1655
+ * the way followed into it.
1656
+ *
1657
+ * -> {leaves: [leaf], complete, written, unread: [node], lets: [binding]}
1658
+ *
1659
+ * `complete`: every leaf is known (a literal, a const that is one,
1660
+ * nothing) and every write was read. `written`: some leaf was
1661
+ * written INTO the variable (`x.a = "k"`, `x.push("k")`), which a
1662
+ * const would not carry to the reader. `unread`: the node of each
1663
+ * value it could not read. `lets`: the variables it went through,
1664
+ * the first one first.
1665
+ ***************************************************************/
1666
+ function var_leaves(root, seen = new Set(), depth = 0)
1667
+ {
1668
+ let b = root.binding;
1669
+ let steps = root.steps;
1670
+ let out = {leaves: [], complete: true, written: false, unread: [], lets: [b]};
1671
+ let id = `${b.declarators[0].start}:${JSON.stringify(steps)}`;
1672
+ if(seen.has(id)) {
1673
+ return out;
1674
+ }
1675
+ seen.add(id);
1676
+ if(depth > MAX_DEPTH) {
1677
+ out.complete = false;
1678
+ out.unread.push(b.declarators[0]);
1679
+ return out;
1680
+ }
1681
+ let {values, unknown} = assigned_values(b, steps.length > 0);
1682
+ for(let u of unknown) {
1683
+ if(paths_meet(u.steps, steps)) {
1684
+ out.complete = false;
1685
+ out.unread.push(u.node);
1686
+ }
1687
+ }
1688
+ for(let v of values) {
1689
+ if(!v.node || !paths_meet(v.steps, steps)) {
1690
+ continue;
1691
+ }
1692
+ if(v.steps.length > steps.length) {
1693
+ /* Written below what is read: what is read holds it, a table */
1694
+ out.complete = false;
1695
+ out.unread.push(v.node);
1696
+ continue;
1697
+ }
1698
+ if(v.into) {
1699
+ out.written = true;
1700
+ }
1701
+ let ctx = {scope: v.scope, via: [], off: false};
1702
+ let cur = v.spread? elements_of(v.node, ctx, 0) : expand(v.node, ctx);
1703
+ for(let step of steps.slice(v.steps.length)) {
1704
+ cur = cur.flatMap((o) => step_values(o, step, null, 0));
1705
+ }
1706
+ for(let o of cur) {
1707
+ let leaf = classify(o);
1708
+ if(leaf.kind === "unknown" && leaf.var_root) {
1709
+ let r = var_leaves(leaf.var_root, seen, depth + 1);
1710
+ out.leaves.push(...r.leaves);
1711
+ out.unread.push(...r.unread);
1712
+ out.lets.push(...r.lets);
1713
+ out.complete = out.complete && r.complete;
1714
+ out.written = out.written || r.written;
1715
+ continue;
1716
+ }
1717
+ out.leaves.push(leaf);
1718
+ if(leaf.kind !== "key" && leaf.kind !== "nothing") {
1719
+ out.complete = false;
1720
+ out.unread.push(leaf.node);
1721
+ }
1722
+ }
1723
+ }
1724
+ return out;
1725
+ }
1726
+
1727
+ /***************************************************************
1728
+ * What a SOURCE can hold, for a test that derives a published list
1729
+ * from it (i18n_key_functions.test.js): the keys of leaves_of(), a
1730
+ * `let` / `var` met followed into everything it is given
1731
+ * (var_leaves()). `scope` is the chain of ancestors of `node`.
1732
+ *
1733
+ * -> {keys: [string], unread: [{at, node}]}
1734
+ *
1735
+ * `unread`: every value it cannot read (a parameter, a call, a
1736
+ * write it cannot follow). `at` is the node of the place it is read,
1737
+ * `node` the value there -- or inside the `let` read there -- that
1738
+ * stops the reader.
1739
+ ***************************************************************/
1740
+ function source_keys(node, scope = [])
1741
+ {
1742
+ let keys = [];
1743
+ let unread = [];
1744
+ for(let leaf of leaves_of(node, scope)) {
1745
+ if(leaf.kind === "unknown" && leaf.var_root) {
1746
+ let r = var_leaves(leaf.var_root);
1747
+ for(let l of r.leaves) {
1748
+ if(l.kind === "key") {
1749
+ keys.push(l.value);
1750
+ }
1751
+ }
1752
+ for(let n of r.unread) {
1753
+ unread.push({at: leaf.node, node: n});
1754
+ }
1755
+ } else if(leaf.kind === "key") {
1756
+ keys.push(leaf.value);
1757
+ } else if(leaf.kind !== "nothing") {
1758
+ unread.push({at: leaf.node, node: leaf.node});
1759
+ }
1760
+ }
1761
+ return {keys, unread};
1762
+ }
1763
+
1764
+
1765
+ /***************************
1766
+ * Markers
1767
+ ***************************/
1768
+
1769
+
1770
+ /*
1771
+ * A marker is a comment, at the place a key is read, that says where
1772
+ * the keys of what stands after it come from:
1773
+ *
1774
+ * /\* i18n: data *\/ data, no key of the library:
1775
+ * a backend's comment, a topic
1776
+ * name, a host's own key, a
1777
+ * DOM node
1778
+ * /\* i18n: C_YUI_FOO_I18N_KEYS *\/ the keys a published list
1779
+ * holds (several, by commas)
1780
+ *
1781
+ * It marks the node that starts right after it (only whitespace in
1782
+ * between) and everything inside that node: an argument, a branch of
1783
+ * it, a property, or the whole call.
1784
+ */
1785
+ const MARKER_START_RE = /^\s*i18n\s*:/;
1786
+ const MARKER_RE = /^\s*i18n\s*:\s*(.*?)\s*$/;
1787
+ const MARKER_LIST_RE = /^[A-Za-z_$][\w$]*_I18N_KEYS$/;
1788
+
1789
+ /***************************************************************
1790
+ * The markers of a parsed file
1791
+ *
1792
+ * -> {at: Map(position of the node marked -> marker),
1793
+ * all: [marker], errors: [{line, why}]}
1794
+ *
1795
+ * marker: {kind: "data" | "lists", lists: [name], line, used: false}
1796
+ ***************************************************************/
1797
+ function markers_of(ast)
1798
+ {
1799
+ let at = new Map();
1800
+ let all = [];
1801
+ let errors = [];
1802
+ for(let c of ast.comments || []) {
1803
+ if(!MARKER_START_RE.test(c.value)) {
1804
+ continue;
1805
+ }
1806
+ let line = line_of(ast, c.start);
1807
+ let body = c.value.match(MARKER_RE)[1];
1808
+ let marker = null;
1809
+ if(body === "data") {
1810
+ marker = {kind: "data", lists: [], line, used: false};
1811
+ } else {
1812
+ let lists = body.split(/\s*,\s*/);
1813
+ if(lists.length > 0 && lists.every((x) => MARKER_LIST_RE.test(x))) {
1814
+ marker = {kind: "lists", lists, line, used: false};
1815
+ }
1816
+ }
1817
+ if(!marker) {
1818
+ errors.push({line, why: `"${c.value.trim()}" is no i18n marker: ` +
1819
+ `\`i18n: data\` or \`i18n: <NAME>_I18N_KEYS[, ...]\``});
1820
+ continue;
1821
+ }
1822
+ let next = c.end;
1823
+ while(next < ast.code.length && /\s/.test(ast.code[next])) {
1824
+ next++;
1825
+ }
1826
+ marker.next = next;
1827
+ at.set(next, marker);
1828
+ all.push(marker);
1829
+ }
1830
+ return {at, all, errors};
1831
+ }
1832
+
1833
+ /***************************************************************
1834
+ * The marker of a leaf: the innermost one standing before a node of
1835
+ * the place it was read (its `via`), or null
1836
+ ***************************************************************/
1837
+ function marker_of(leaf, markers)
1838
+ {
1839
+ let via = leaf.via || [];
1840
+ for(let i = via.length - 1; i >= 0; i--) {
1841
+ let m = markers.at.get(via[i].start);
1842
+ if(m) {
1843
+ return m;
1844
+ }
1845
+ }
1846
+ return null;
1847
+ }
1848
+
1849
+
1850
+ /***************************
1851
+ * Key functions
1852
+ ***************************/
1853
+
1854
+
1855
+ /* The exported ones by name, every one by its module */
1856
+ const KEY_FN_EXPORTED = new Map();
1857
+ const KEY_FN_BY_MODULE = new Map();
1858
+ for(let entry of I18N_KEY_FUNCTIONS) {
1859
+ if(entry.exported) {
1860
+ KEY_FN_EXPORTED.set(entry.name, entry);
1861
+ }
1862
+ if(!KEY_FN_BY_MODULE.has(entry.module)) {
1863
+ KEY_FN_BY_MODULE.set(entry.module, new Map());
1864
+ }
1865
+ KEY_FN_BY_MODULE.get(entry.module).set(entry.name, entry);
1866
+ }
1867
+
1868
+ /***************************************************************
1869
+ * Does an import source name a gobj-ui module (the barrel, a deep
1870
+ * `src/` module, or, inside the library, a sibling)?
1871
+ ***************************************************************/
1872
+ function is_gobj_ui_source(src, lib_module)
1873
+ {
1874
+ if(/^@yuneta\/gobj-ui(?:\/index\.js|\/src\/[\w.-]+\.js)?$/.test(src)) {
1875
+ return true;
1876
+ }
1877
+ return !!lib_module && /^\.\/[\w.-]+\.js$/.test(src);
1878
+ }
1879
+
1880
+ /***************************************************************
1881
+ * The calls of a file to a key function, each with what the file
1882
+ * bound the callee to: an import of a gobj-ui function (by its
1883
+ * exported name), or, in the library module that defines it, the
1884
+ * function itself.
1885
+ *
1886
+ * -> [{entry, call, scope}], `scope` the ancestors of the call
1887
+ ***************************************************************/
1888
+ function key_calls(ast, lib_module)
1889
+ {
1890
+ let direct = new Map(); // local name -> entry
1891
+ let namespaces = new Set(); // `import * as ns` of a gobj-ui module
1892
+ for(let node of ast.body) {
1893
+ if(node.type !== "ImportDeclaration" || !is_gobj_ui_source(node.source.value, lib_module)) {
1894
+ continue;
1895
+ }
1896
+ for(let spec of node.specifiers) {
1897
+ if(spec.type === "ImportSpecifier") {
1898
+ let name = spec.imported.name || spec.imported.value;
1899
+ let entry = KEY_FN_EXPORTED.get(name);
1900
+ if(entry) {
1901
+ direct.set(spec.local.name, entry);
1902
+ }
1903
+ } else if(spec.type === "ImportNamespaceSpecifier") {
1904
+ namespaces.add(spec.local.name);
1905
+ }
1906
+ }
1907
+ }
1908
+ if(lib_module && KEY_FN_BY_MODULE.has(lib_module)) {
1909
+ for(let [name, entry] of KEY_FN_BY_MODULE.get(lib_module)) {
1910
+ direct.set(name, entry);
1911
+ }
1912
+ }
1913
+ if(direct.size === 0 && namespaces.size === 0) {
1914
+ return [];
1915
+ }
1916
+
1917
+ let out = [];
1918
+ walk(ast, (n, ancestors) => {
1919
+ if(n.type !== "CallExpression") {
1920
+ return;
1921
+ }
1922
+ let entry = null;
1923
+ let c = n.callee;
1924
+ if(c.type === "Identifier") {
1925
+ entry = direct.get(c.name) || null;
1926
+ } else if(c.type === "MemberExpression" && !c.computed
1927
+ && c.object.type === "Identifier" && namespaces.has(c.object.name)) {
1928
+ entry = KEY_FN_EXPORTED.get(c.property.name) || null;
1929
+ }
1930
+ if(entry) {
1931
+ out.push({entry, call: n, scope: ancestors.slice()});
1932
+ }
1933
+ });
1934
+ return out;
1935
+ }
1936
+
1937
+ /***************************************************************
1938
+ * Is a leaf a key position of the function it is in, handed on?
1939
+ * `message` where `message` is a key parameter of that function,
1940
+ * `opts.title` where it reads `opts.title`. Only a function of the
1941
+ * table forwards: its own callers are read where they call it.
1942
+ ***************************************************************/
1943
+ function is_forwarded(leaf, lib_module)
1944
+ {
1945
+ if(leaf.kind !== "param" || !lib_module || !KEY_FN_BY_MODULE.has(lib_module)) {
1946
+ return false;
1947
+ }
1948
+ let entry = KEY_FN_BY_MODULE.get(lib_module).get(leaf.binding.name);
1949
+ if(!entry) {
1950
+ return false;
1951
+ }
1952
+ return entry.keys.includes(`${leaf.binding.index}${leaf.path}`);
1953
+ }
1954
+
1955
+ /***************************************************************
1956
+ * The rule a gobj-ui module keeps at every key position of a key
1957
+ * function it calls (i18n_key_functions.test.js holds it): every
1958
+ * value the position can take is
1959
+ *
1960
+ * a) a string literal, or a `?:` / `||` / `??` of them, or nothing;
1961
+ * b) a `const` the reader follows to one of those (also through a
1962
+ * const object or array, and the element of a const array its
1963
+ * `.map()` / for...of iterates);
1964
+ * c) a key position of the enclosing table function, handed on;
1965
+ * d) MARKED where it is handed: `/\* i18n: <NAME>_I18N_KEYS *\/`
1966
+ * (the module publishes those keys) or `/\* i18n: data *\/`.
1967
+ *
1968
+ * -> [{line, call_line, name, path, text}]
1969
+ ***************************************************************/
1970
+ function call_rule_violations(ast, lib_module)
1971
+ {
1972
+ let markers = markers_of(ast);
1973
+ let out = [];
1974
+ for(let {entry, call, scope} of key_calls(ast, lib_module)) {
1975
+ for(let path of entry.keys) {
1976
+ for(let leaf of values_at(call, path, scope)) {
1977
+ if(leaf.kind === "key" || leaf.kind === "nothing"
1978
+ || is_forwarded(leaf, lib_module) || marker_of(leaf, markers)) {
1979
+ continue;
1980
+ }
1981
+ out.push({
1982
+ line: line_of(ast, leaf.node.start),
1983
+ call_line: line_of(ast, call.start),
1984
+ name: entry.name,
1985
+ path: path,
1986
+ text: ast.code.slice(leaf.node.start, leaf.node.end).slice(0, 120)
1987
+ });
1988
+ }
1989
+ }
1990
+ }
1991
+ return out;
1992
+ }
1993
+
1994
+
1995
+ /***************************
1996
+ * Published lists
1997
+ ***************************/
1998
+
1999
+
2000
+ /***************************************************************
2001
+ * The `<NAME>_I18N_KEYS` arrays a module exports, READ, never run:
2002
+ * `export const|let|var X = [...]`, `const X = [...]; export {X}`,
2003
+ * either wrapped in Object.freeze(). Each element is a string literal
2004
+ * or a template with no `${}`.
2005
+ *
2006
+ * -> {lists: [{name, keys}], errors: [why]}
2007
+ *
2008
+ * Anything else so named is an ERROR, never skipped: the keys it
2009
+ * holds would go unasked, and the build say OK. That covers an
2010
+ * export that is not an array of literals, and a list nobody can
2011
+ * read: one declared and not exported, one declared inside a
2012
+ * function or a block, one declared by destructuring, one exported
2013
+ * under a name that does not end in _I18N_KEYS. A re-export
2014
+ * (`export {X} from "./y.js"`) is read in its own module.
2015
+ ***************************************************************/
2016
+ function published_lists(ast)
2017
+ {
2018
+ let top = new Map(); // a top-level name -> its declaration
2019
+ for(let node of ast.body) {
2020
+ let decl = (node.type === "ExportNamedDeclaration")? node.declaration : node;
2021
+ if(!decl) {
2022
+ continue;
2023
+ }
2024
+ if(decl.type === "VariableDeclaration") {
2025
+ for(let d of decl.declarations) {
2026
+ if(d.id.type === "Identifier") {
2027
+ top.set(d.id.name, {init: d.init});
2028
+ }
2029
+ }
2030
+ } else if(decl.id && (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration")) {
2031
+ top.set(decl.id.name, {init: null, what: decl.type});
2032
+ }
2033
+ }
2034
+
2035
+ let lists = [];
2036
+ let errors = [];
2037
+ let read_as = new Set(); // local names exported under a list's name
2038
+ let read = (exported, local) => {
2039
+ read_as.add(local);
2040
+ let d = top.get(local);
2041
+ if(!d) {
2042
+ errors.push(`${exported}: not declared at the top of the module`);
2043
+ return;
2044
+ }
2045
+ if(!d.init) {
2046
+ errors.push(`${exported}: ${d.what? "a " + d.what : "declared with no value"}, not an array`);
2047
+ return;
2048
+ }
2049
+ let r = read_string_array(d.init);
2050
+ if(r.error) {
2051
+ errors.push(`${exported}: ${r.error}`);
2052
+ return;
2053
+ }
2054
+ lists.push({name: exported, keys: r.keys});
2055
+ };
2056
+ let exported_as = new Map(); // local name -> [exported names]
2057
+ for(let node of ast.body) {
2058
+ if(node.type !== "ExportNamedDeclaration") {
2059
+ continue;
2060
+ }
2061
+ if(node.declaration) {
2062
+ let decl = node.declaration;
2063
+ if(decl.type === "VariableDeclaration") {
2064
+ for(let d of decl.declarations) {
2065
+ if(d.id.type === "Identifier" && PUBLISHED_LIST_RE.test(d.id.name)) {
2066
+ read(d.id.name, d.id.name);
2067
+ }
2068
+ }
2069
+ } else if(decl.id && PUBLISHED_LIST_RE.test(decl.id.name)) {
2070
+ read(decl.id.name, decl.id.name);
2071
+ }
2072
+ continue;
2073
+ }
2074
+ if(node.source) {
2075
+ continue; // a re-export: read where it is declared
2076
+ }
2077
+ for(let spec of node.specifiers) {
2078
+ let exported = spec.exported.name || spec.exported.value;
2079
+ if(!exported_as.has(spec.local.name)) {
2080
+ exported_as.set(spec.local.name, []);
2081
+ }
2082
+ exported_as.get(spec.local.name).push(exported);
2083
+ if(PUBLISHED_LIST_RE.test(exported)) {
2084
+ read(exported, spec.local.name);
2085
+ }
2086
+ }
2087
+ }
2088
+
2089
+ /* A list that no export makes readable */
2090
+ walk(ast, (n, ancestors) => {
2091
+ if(n.type !== "VariableDeclarator") {
2092
+ return;
2093
+ }
2094
+ for(let name of pattern_names(n.id)) {
2095
+ if(!PUBLISHED_LIST_RE.test(name)) {
2096
+ continue;
2097
+ }
2098
+ let at_top = (ancestors.length === 2 && ancestors[1].type === "VariableDeclaration")
2099
+ || (ancestors.length === 3 && ancestors[1].type === "ExportNamedDeclaration");
2100
+ if(n.id.type !== "Identifier") {
2101
+ errors.push(`${name}: declared by destructuring, which no reader follows: ` +
2102
+ `write it as an array of literals`);
2103
+ } else if(!at_top) {
2104
+ errors.push(`${name}: declared inside a function or a block, where no consumer reads it`);
2105
+ } else if(!read_as.has(name)) {
2106
+ let as = exported_as.get(name);
2107
+ errors.push(as? `${name}: exported as ${as.join(", ")}, a name the validator does not read` :
2108
+ `${name}: not exported, so no consumer reads it`);
2109
+ }
2110
+ }
2111
+ });
2112
+ return {lists, errors};
2113
+ }
2114
+
2115
+ /***************************************************************
2116
+ * An array of string literals, through any Object.freeze()
2117
+ ***************************************************************/
2118
+ function read_string_array(init)
2119
+ {
2120
+ let n = init;
2121
+ while(is_object_call(n, "freeze") && n.arguments.length === 1) {
2122
+ n = n.arguments[0];
2123
+ }
2124
+ if(n.type !== "ArrayExpression") {
2125
+ return {error: `a ${n.type}, not an array of string literals`};
2126
+ }
2127
+ let keys = [];
2128
+ for(let i = 0; i < n.elements.length; i++) {
2129
+ let el = n.elements[i];
2130
+ let s = null;
2131
+ if(el && el.type === "Literal" && typeof el.value === "string") {
2132
+ s = el.value;
2133
+ } else if(el && el.type === "TemplateLiteral" && el.expressions.length === 0) {
2134
+ s = el.quasis[0].value.cooked;
2135
+ }
2136
+ if(!s) {
2137
+ return {error: `element ${i} is ${el? "a " + el.type : "a hole"}` +
2138
+ `${s === ""? " (an empty string)" : ""}, not a string literal`};
2139
+ }
2140
+ keys.push(s);
2141
+ }
2142
+ return {keys};
2143
+ }
2144
+
2145
+
2146
+ /***************************
2147
+ * The scan
2148
+ ***************************/
2149
+
2150
+
2151
+ /***************************************************************
2152
+ * The keys one parsed file asks for.
2153
+ *
2154
+ * ctx: {lib_module (its name in src/, or null for an app file),
2155
+ * attr_keys, array_keys (Sets), declared (Set)}
2156
+ * -> {keys: Set, read_keys: Set (those READ, not published by a
2157
+ * list), declared_seen: Set,
2158
+ * lists: [{name, keys}], list_errors: [why],
2159
+ * marker_refs: [{lists, keys, line}], marker_errors: [{line, why}]}
2160
+ *
2161
+ * Every place a key is read goes through leaves_of(), so a marker
2162
+ * standing there is found: one that marks no such place, or that is
2163
+ * not a marker at all, is an error.
2164
+ ***************************************************************/
2165
+ function scan_ast(ast, ctx)
2166
+ {
2167
+ let keys = new Set();
2168
+ let declared_seen = new Set();
2169
+ let attr_keys = ctx.attr_keys || new Set();
2170
+ let array_keys = ctx.array_keys || new Set();
2171
+ let declared = ctx.declared || new Set();
2172
+ let markers = markers_of(ast);
2173
+ let var_errors = [];
2174
+ let vars_checked = new Set();
2175
+
2176
+ /*
2177
+ * A marker says where the keys of what it marks come from, and a
2178
+ * `let` / `var` it marks -- itself, or a member or an element read
2179
+ * from it -- is held to it: everything the variable is given (and,
2180
+ * for a member, written into it) is read, each key of it is asked
2181
+ * for and must be in the lists the marker names. One that is only
2182
+ * ever given literals needs no marker at all: it is a const that
2183
+ * was not written as one. (What is WRITTEN into a table is not that
2184
+ * case: a const would not carry it to the reader.)
2185
+ */
2186
+ let check_marked_var = (leaf, m) => {
2187
+ let root = leaf.var_root;
2188
+ let b = root.binding;
2189
+ let id = `${m.line}:${b.declarators[0].start}:${JSON.stringify(root.steps)}`;
2190
+ if(vars_checked.has(id)) {
2191
+ return;
2192
+ }
2193
+ vars_checked.add(id);
2194
+ let r = var_leaves(root);
2195
+ let held = [...new Set(r.leaves.filter((l) => l.kind === "key").map((l) => l.value))];
2196
+ for(let k of held) {
2197
+ keys.add(k);
2198
+ }
2199
+ if(r.complete && !r.written && held.length > 0) {
2200
+ let decl_line = (x) => line_of(ast, x.declarators[0].start);
2201
+ let seen_lets = new Set([b.declarators[0]]);
2202
+ let others = r.lets.filter((x) => {
2203
+ let had = seen_lets.has(x.declarators[0]);
2204
+ seen_lets.add(x.declarators[0]);
2205
+ return !had;
2206
+ });
2207
+ let through = others.map((x) => `the ${x.decl} \`${x.name}\` (line ${decl_line(x)})`);
2208
+ var_errors.push({line: line_of(ast, leaf.node.start),
2209
+ why: `\`${b.name}\` is a ${b.decl} (line ${decl_line(b)}) that only ` +
2210
+ `ever holds literals (${held.map((k) => JSON.stringify(k)).join(", ")})` +
2211
+ (through.length? `, through ${through.join(", ")}` : "") +
2212
+ `: make ${through.length? "them consts" : "it a const"}, which the reader follows, ` +
2213
+ `and drop the marker`});
2214
+ return;
2215
+ }
2216
+ if(m.kind === "lists") {
2217
+ for(let k of held) {
2218
+ m.keys.add(k);
2219
+ }
2220
+ }
2221
+ };
2222
+
2223
+ let take = (leaves) => {
2224
+ for(let leaf of leaves) {
2225
+ if(leaf.kind === "key") {
2226
+ keys.add(leaf.value);
2227
+ }
2228
+ let m = marker_of(leaf, markers);
2229
+ if(m) {
2230
+ m.used = true;
2231
+ if(!m.keys) {
2232
+ m.keys = new Set();
2233
+ }
2234
+ if(leaf.kind === "key") {
2235
+ m.keys.add(leaf.value);
2236
+ }
2237
+ if(leaf.kind === "unknown" && leaf.var_root) {
2238
+ check_marked_var(leaf, m);
2239
+ }
2240
+ }
2241
+ }
2242
+ };
2243
+ let read = (node, scope, root) => {
2244
+ if(node) {
2245
+ take(leaves_of(node, scope, root));
2246
+ }
2247
+ };
2248
+ let add_markup = (text) => {
2249
+ if(typeof text !== "string" || !text.includes("data-i18n")) {
2250
+ return;
2251
+ }
2252
+ DATA_I18N_MARKUP_RE.lastIndex = 0;
2253
+ let m;
2254
+ while((m = DATA_I18N_MARKUP_RE.exec(text)) !== null) {
2255
+ if(!m[1].includes("${")) {
2256
+ keys.add(m[1]);
2257
+ }
2258
+ }
2259
+ };
2260
+
2261
+ walk(ast, (n, ancestors) => {
2262
+ switch(n.type) {
2263
+ case "CallExpression": {
2264
+ let name = callee_name(n.callee);
2265
+ let scope = [...ancestors, n];
2266
+ if(name === "t" || name === "tr") {
2267
+ read(n.arguments[0], scope, n);
2268
+ } else if(name === "add" && is_string_literal(n.arguments[0], /^(error|warning)$/)) {
2269
+ /* The codes the schema checks hand to `t(finding.code)` */
2270
+ read(n.arguments[1], scope, n);
2271
+ } else if(name === "setAttribute" && is_string_literal(n.arguments[0], DATA_I18N_ATTR_RE)) {
2272
+ read(n.arguments[1], scope, n);
2273
+ }
2274
+ break;
2275
+ }
2276
+ case "Property": {
2277
+ let name = prop_name(n);
2278
+ if(name === null) {
2279
+ break;
2280
+ }
2281
+ if(declared.has(name)) {
2282
+ declared_seen.add(name);
2283
+ }
2284
+ if(ancestors[ancestors.length - 1].type !== "ObjectExpression") {
2285
+ break; // a destructuring pattern
2286
+ }
2287
+ let scope = [...ancestors, n];
2288
+ if(name === "i18n" || name === "code" || DATA_I18N_ATTR_RE.test(name) || attr_keys.has(name)) {
2289
+ read(n.value, scope, n);
2290
+ }
2291
+ if(array_keys.has(name) && n.value.type === "ArrayExpression") {
2292
+ for(let el of n.value.elements) {
2293
+ read(el, [...scope, n.value], n);
2294
+ }
2295
+ }
2296
+ break;
2297
+ }
2298
+ case "Literal":
2299
+ add_markup(n.value);
2300
+ break;
2301
+ case "TemplateElement":
2302
+ add_markup(n.value.cooked);
2303
+ break;
2304
+ default:
2305
+ break;
2306
+ }
2307
+ });
2308
+
2309
+ /* The keys handed to a key function */
2310
+ for(let {entry, call, scope} of key_calls(ast, ctx.lib_module || null)) {
2311
+ for(let path of entry.keys) {
2312
+ take(values_at(call, path, scope));
2313
+ }
2314
+ }
2315
+
2316
+ /* The keys a module publishes as data */
2317
+ let read_keys = new Set(keys);
2318
+ let published = published_lists(ast);
2319
+ for(let list of published.lists) {
2320
+ for(let k of list.keys) {
2321
+ keys.add(k);
2322
+ }
2323
+ }
2324
+
2325
+ /* The markers: each one stands where a key is read, and is one */
2326
+ let marker_errors = [...markers.errors, ...var_errors];
2327
+ let marker_refs = [];
2328
+ for(let m of markers.all) {
2329
+ if(!m.used) {
2330
+ marker_errors.push({line: m.line, why: `the i18n marker stands before no place a key is read ` +
2331
+ `(a key position of a key function, t(), an i18n or data-i18n* value)`});
2332
+ } else if(m.kind === "lists") {
2333
+ marker_refs.push({lists: m.lists, keys: [...(m.keys || [])], line: m.line});
2334
+ }
2335
+ }
2336
+
2337
+ return {
2338
+ keys,
2339
+ read_keys,
2340
+ declared_seen,
2341
+ lists: published.lists,
2342
+ list_errors: published.errors,
2343
+ marker_refs,
2344
+ marker_errors
2345
+ };
2346
+ }
2347
+
2348
+ /***************************************************************
2349
+ * The i18n keys the source asks for.
2350
+ *
2351
+ * opts: {src: [dir URL], locales: dir URL (not scanned),
2352
+ * exclude: [file URL] (not scanned either),
2353
+ * config: file URL or [file URL] (the shell declarations),
2354
+ * config_key: (value) -> bool (a config value is a key?),
2355
+ * attr_keys, array_keys: [property name],
2356
+ * language_names: Set}
2357
+ * -> {keys: Set (all), lib_keys: Set (those of the gobj-ui
2358
+ * modules the app mounts)}
2359
+ *
2360
+ * A violation found on the way (a file that cannot be parsed, a list
2361
+ * that cannot be read, a marker that is wrong) is reported with
2362
+ * fail(): see violation_count().
2363
+ ***************************************************************/
2364
+ function collect_used_keys(opts)
2365
+ {
2366
+ let keys = new Set();
2367
+ let lib_keys = new Set();
2368
+ let attr_keys = opts.attr_keys || [];
2369
+ let array_keys = opts.array_keys || [];
2370
+ let ctx = {
2371
+ attr_keys: new Set(attr_keys),
2372
+ array_keys: new Set(array_keys),
2373
+ declared: new Set([...attr_keys, ...array_keys])
2374
+ };
2375
+ let declared_seen = new Set();
2376
+ let barrel = barrel_export_map();
2377
+ let published = new Map(); // list name -> Set of keys
2378
+ let refs = []; // [{label, line, lists, keys}]
2379
+
2380
+ let scan = (url, lib_module) => {
2381
+ let label = lib_module? `gobj-ui/src/${lib_module}` : relative(process.cwd(), fileURLToPath(url));
2382
+ let ast;
2383
+ try {
2384
+ ast = parse_source(readFileSync(url, "utf8"));
2385
+ } catch(e) {
2386
+ fail(`${label} cannot be parsed (${e.message}): the keys it asks for are NOT checked`);
2387
+ return [];
2388
+ }
2389
+ let r = scan_ast(ast, {...ctx, lib_module});
2390
+ for(let k of r.keys) {
2391
+ keys.add(k);
2392
+ if(lib_module) {
2393
+ lib_keys.add(k);
2394
+ }
2395
+ }
2396
+ for(let name of r.declared_seen) {
2397
+ declared_seen.add(name);
2398
+ }
2399
+ for(let why of r.list_errors) {
2400
+ fail(`${label}: the published list ${why}: its keys are NOT asked for`);
2401
+ }
2402
+ for(let list of r.lists) {
2403
+ if(!published.has(list.name)) {
2404
+ published.set(list.name, new Set());
2405
+ }
2406
+ for(let k of list.keys) {
2407
+ published.get(list.name).add(k);
2408
+ }
2409
+ }
2410
+ for(let e of r.marker_errors) {
2411
+ fail(`${label}:${e.line}: ${e.why}`);
2412
+ }
2413
+ for(let ref of r.marker_refs) {
2414
+ refs.push({label, ...ref});
2415
+ }
2416
+ return lib_imports(ast, barrel, lib_module);
2417
+ };
2418
+
2419
+ /* The app's own source, and the code it shares with others */
2420
+ let pending = [];
2421
+ let excluded = new Set((opts.exclude || []).map((u) => u.href));
2422
+ for(let dir of opts.src) {
2423
+ if(!existsSync(dir)) {
2424
+ abort(`no source directory at ${dir.pathname}`);
2425
+ }
2426
+ for(let url of source_files(dir, opts.locales)) {
2427
+ if(!excluded.has(url.href)) {
2428
+ pending.push(...scan(url, null));
2429
+ }
2430
+ }
2431
+ }
2432
+
2433
+ /* A name declared and found in no source is a typo that turns its check off */
2434
+ for(let name of ctx.declared) {
2435
+ if(!declared_seen.has(name)) {
2436
+ fail(`package.json "validate-locales" names ${JSON.stringify(name)}, which no source uses: a typo turns its check off`);
2437
+ }
2438
+ }
2439
+
2440
+ let configs = [].concat(opts.config || []);
2441
+ let is_key = opts.config_key || ((v) => v === v.toLowerCase() && !(opts.language_names || new Set()).has(v));
2442
+ for(let url of configs) {
2443
+ keys_from_shell_config(url, keys, is_key);
2444
+ }
2445
+
2446
+ /*
2447
+ * ...and the gobj-ui views it MOUNTS. Only the modules actually
2448
+ * imported are scanned, transitively: the library carries views the
2449
+ * app never mounts.
2450
+ */
2451
+ if(!existsSync(LIB_SRC)) {
2452
+ abort(`no gobj-ui source at ${LIB_SRC.pathname}`);
2453
+ }
2454
+ let missing = [];
2455
+ let seen = new Set();
2456
+ while(pending.length > 0) {
2457
+ let name = pending.pop();
2458
+ if(seen.has(name)) {
2459
+ continue;
2460
+ }
2461
+ seen.add(name);
2462
+ let url = new URL(name, LIB_SRC);
2463
+ if(!existsSync(url)) {
2464
+ missing.push(name);
2465
+ continue;
2466
+ }
2467
+ pending.push(...scan(url, name));
2468
+ }
2469
+ if(missing.length > 0) {
2470
+ abort(`modules of gobj-ui not found in ${LIB_SRC.pathname}: ${missing.join(", ")}`);
2471
+ }
2472
+
2473
+ /* A marker names lists that were read, and what it marks is in them */
2474
+ for(let ref of refs) {
2475
+ let names = ref.lists.filter((name) => published.has(name));
2476
+ for(let name of ref.lists) {
2477
+ if(!published.has(name)) {
2478
+ fail(`${ref.label}:${ref.line}: the i18n marker names ${name}, ` +
2479
+ `a list no module the app mounts publishes`);
2480
+ }
2481
+ }
2482
+ if(names.length < ref.lists.length) {
2483
+ continue;
2484
+ }
2485
+ for(let k of ref.keys) {
2486
+ if(!names.some((name) => published.get(name).has(k))) {
2487
+ fail(`${ref.label}:${ref.line}: ${JSON.stringify(k)} is marked as a key of ` +
2488
+ `${ref.lists.join(", ")}, and is not in it`);
2489
+ }
2490
+ }
2491
+ }
2492
+
2493
+ return {keys, lib_keys};
2494
+ }
2495
+
2496
+ /***************************************************************
2497
+ * The .js / .mjs files of a source directory, recursively: not the tests
2498
+ * (*.test.js, *.spec.js, __tests__/, test/, tests/), not a locales/
2499
+ * directory (its keys are definitions, not uses), not node_modules or a
2500
+ * build
2501
+ ***************************************************************/
2502
+ const SKIPPED_DIRS = new Set(["node_modules", "dist", "locales", "__tests__", "test", "tests"]);
2503
+
2504
+ function source_files(dir, locales_dir)
2505
+ {
2506
+ let out = [];
2507
+ let walk_dir = (d) => {
2508
+ if(locales_dir && d.href === locales_dir.href) {
2509
+ return;
2510
+ }
2511
+ for(let f of readdirSync(d).sort()) {
2512
+ let url = new URL(f, d);
2513
+ let st = statSync(url);
2514
+ if(st.isDirectory()) {
2515
+ if(SKIPPED_DIRS.has(f) || f.startsWith(".")) {
2516
+ continue;
2517
+ }
2518
+ walk_dir(new URL(f + "/", d));
2519
+ } else if(/\.m?js$/.test(f) && !/\.(test|spec)\.m?js$/.test(f)) {
2520
+ out.push(url);
2521
+ }
2522
+ }
2523
+ };
2524
+ walk_dir(dir);
2525
+ return out;
2526
+ }
2527
+
2528
+ /***************************************************************
2529
+ * The shell declaration (`app_config.json`) holds i18n keys in DATA:
2530
+ * every `name`, `aria_label` and `tooltip` of an item, the `label` of a
2531
+ * C_YUI_NODE node, the `title` / `subtitle` of a route's kw. No `t()`
2532
+ * names them, and a missing one renders in raw lower-case English for
2533
+ * ever -- which is how four toolbar buttons announced themselves as
2534
+ * "toggle language" in Spanish.
2535
+ *
2536
+ * `is_key(value)` says which values are keys. By default, a value that
2537
+ * is not lower-case is a literal the config carries on purpose ("ES/EN"
2538
+ * on a language button); so is the endonym of one of the app's locales
2539
+ * (its `name`: "中文", "español") in a language menu. Any other value is
2540
+ * a key -- "configuración" is a Spanish literal that forgot its key,
2541
+ * and it is reported, as an ASCII one would be. `wordmark` and `alt` are
2542
+ * deliberately out: a brand name is not translated. A key starting with
2543
+ * `_` is a comment.
2544
+ ***************************************************************/
2545
+ const CONFIG_I18N_FIELDS = ["name", "aria_label", "tooltip", "label", "title", "subtitle"];
2546
+
2547
+ function keys_from_shell_config(url, keys, is_key)
2548
+ {
2549
+ if(!existsSync(url)) {
2550
+ note(`no ${url.pathname}: no shell keys checked`);
2551
+ return;
2552
+ }
2553
+ let config;
2554
+ try {
2555
+ config = JSON.parse(readFileSync(url, "utf8"));
2556
+ } catch(e) {
2557
+ abort(`${url.pathname} is not valid json: ${e.message}`);
2558
+ }
2559
+ let walk_config = (node) => {
2560
+ if(Array.isArray(node)) {
2561
+ for(let item of node) {
2562
+ walk_config(item);
2563
+ }
2564
+ return;
2565
+ }
2566
+ if(!node || typeof node !== "object") {
2567
+ return;
2568
+ }
2569
+ for(let [k, v] of Object.entries(node)) {
2570
+ if(k.startsWith("_")) {
2571
+ continue;
2572
+ }
2573
+ if(CONFIG_I18N_FIELDS.includes(k) && typeof v === "string" && v && is_key(v)) {
2574
+ keys.add(v);
2575
+ }
2576
+ walk_config(v);
2577
+ }
2578
+ };
2579
+ walk_config(config);
2580
+ }
2581
+
2582
+ /***************************************************************
2583
+ * Drop /* ... *\/ and // ... comments (good enough for the duplicate
2584
+ * key scan of a locale file)
2585
+ ***************************************************************/
2586
+ function strip_comments(src)
2587
+ {
2588
+ return src
2589
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
2590
+ .replace(/(^|[^:"'`\\])\/\/[^\n]*/g, "$1");
2591
+ }
2592
+
2593
+ /***************************************************************
2594
+ * The barrel's public surface: exported symbol -> the src/ module that
2595
+ * defines it, read from index.js's re-exports
2596
+ * (`export {a, b as c} from "./src/x.js"`). A barrel import is resolved
2597
+ * to the modules it really pulls in, not to the whole library.
2598
+ ***************************************************************/
2599
+ function barrel_export_map()
2600
+ {
2601
+ let map = new Map();
2602
+ let ast;
2603
+ try {
2604
+ ast = parse_source(readFileSync(LIB_INDEX, "utf8"));
2605
+ } catch(e) {
2606
+ abort(`cannot read the gobj-ui barrel at ${LIB_INDEX.pathname}: ${e.message}`);
2607
+ }
2608
+ for(let node of ast.body) {
2609
+ if(node.type !== "ExportNamedDeclaration" || !node.source) {
2610
+ continue;
2611
+ }
2612
+ let m = String(node.source.value).match(/^\.\/src\/([\w.-]+\.js)$/);
2613
+ if(!m) {
2614
+ continue;
2615
+ }
2616
+ for(let spec of node.specifiers) {
2617
+ /* `a as b` is published as `b` */
2618
+ map.set(spec.exported.name || spec.exported.value, m[1]);
2619
+ }
2620
+ }
2621
+ return map;
2622
+ }
2623
+
2624
+ /***************************************************************
2625
+ * The gobj-ui source modules a parsed file imports (or re-exports):
2626
+ * - deep: "@yuneta/gobj-ui/src/x.js", also by a dynamic import()
2627
+ * - barrel: "@yuneta/gobj-ui[/index.js]", resolved per imported symbol
2628
+ * - inside the library: "./x.js" (in the app it names an app file,
2629
+ * scanned already)
2630
+ ***************************************************************/
2631
+ function lib_imports(ast, barrel, lib_module)
2632
+ {
2633
+ let out = [];
2634
+ let module_of = (src) => {
2635
+ if(typeof src !== "string") {
2636
+ return null;
2637
+ }
2638
+ let m = src.match(/^@yuneta\/gobj-ui\/src\/([\w.-]+\.js)$/);
2639
+ if(m) {
2640
+ return m[1];
2641
+ }
2642
+ if(lib_module) {
2643
+ m = src.match(/^\.\/([\w.-]+\.js)$/);
2644
+ if(m) {
2645
+ return m[1];
2646
+ }
2647
+ }
2648
+ return null;
2649
+ };
2650
+ let is_barrel = (src) => /^@yuneta\/gobj-ui(?:\/index\.js)?$/.test(src);
2651
+
2652
+ walk(ast, (n) => {
2653
+ let src = null;
2654
+ let names = null;
2655
+ if(n.type === "ImportDeclaration") {
2656
+ src = n.source.value;
2657
+ names = n.specifiers
2658
+ .filter((s) => s.type === "ImportSpecifier")
2659
+ .map((s) => s.imported.name || s.imported.value);
2660
+ } else if((n.type === "ExportNamedDeclaration" || n.type === "ExportAllDeclaration") && n.source) {
2661
+ src = n.source.value;
2662
+ names = (n.specifiers || []).map((s) => s.local.name || s.local.value);
2663
+ } else if(n.type === "ImportExpression" && n.source.type === "Literal") {
2664
+ src = n.source.value;
2665
+ }
2666
+ if(src === null) {
2667
+ return;
2668
+ }
2669
+ let mod = module_of(src);
2670
+ if(mod) {
2671
+ out.push(mod);
2672
+ } else if(is_barrel(src) && names) {
2673
+ for(let name of names) {
2674
+ let m = barrel.get(name);
2675
+ if(m) {
2676
+ out.push(m);
2677
+ }
2678
+ }
2679
+ }
2680
+ });
2681
+ return out;
2682
+ }
2683
+
2684
+ /* The readers, for a test of the library and for an app whose catalog
2685
+ * has another shape (gobj-ui's test-app) */
2686
+ export {
2687
+ parse_source,
2688
+ walk,
2689
+ line_of,
2690
+ prop_name,
2691
+ binding_of,
2692
+ leaves_of,
2693
+ static_keys,
2694
+ source_keys,
2695
+ values_at,
2696
+ markers_of,
2697
+ marker_of,
2698
+ key_calls,
2699
+ is_forwarded,
2700
+ call_rule_violations,
2701
+ published_lists,
2702
+ scan_ast,
2703
+ collect_used_keys,
2704
+ keys_from_shell_config,
2705
+ source_files,
2706
+ lib_imports,
2707
+ find_plural_groups,
2708
+ violation_count
2709
+ };
2710
+
2711
+ /*
2712
+ * Run when it is the script, not when a test or another script imports
2713
+ * it. Both sides are resolved: with --preserve-symlinks-main (also from
2714
+ * NODE_OPTIONS) and a symlinked package, argv[1] and import.meta.url
2715
+ * both keep the link, and only the real paths compare.
2716
+ */
2717
+ let is_main = false;
2718
+ try {
2719
+ is_main = !!process.argv[1]
2720
+ && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
2721
+ } catch(e) {
2722
+ is_main = false;
2723
+ }
2724
+ if(is_main) {
2725
+ await main();
2726
+ }