@yuneta/gobj-ui 7.26.25 → 7.26.27

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