@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.
- package/README.md +49 -6
- package/dist/gobj-ui.cjs.js +73 -31
- package/dist/gobj-ui.es.js +73 -31
- package/package.json +1 -1
- package/scripts/validate-locales.mjs +375 -84
- package/src/c_g6_nodes_tree.js +100 -28
- package/src/g6_nodes_tree_i18n_keys.test.js +39 -0
- package/src/yui_dev.js +28 -30
- package/src/yui_file_field.js +2 -2
|
@@ -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,
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* (
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
|
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 =
|
|
193
|
-
for(let k of
|
|
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(!
|
|
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(!
|
|
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 ${
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
279
|
-
|
|
512
|
+
for(let r of patterns) {
|
|
513
|
+
r.lastIndex = 0;
|
|
280
514
|
let m;
|
|
281
|
-
while((m =
|
|
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(
|
|
519
|
+
if(!k || k.includes("${")) {
|
|
285
520
|
continue;
|
|
286
521
|
}
|
|
287
|
-
keys.add(
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
310
|
-
|
|
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
|
-
|
|
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
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
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
|
-
|
|
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
|
|
719
|
+
let r = /export\s*\{([^}]*)\}\s*from\s*["']\.\/src\/([\w.-]+\.js)["']/g;
|
|
429
720
|
let m;
|
|
430
|
-
while((m =
|
|
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:
|
|
448
|
-
* - barrel:
|
|
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
|
|
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
|
|
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
|
}
|