@yuneta/gobj-ui 7.26.24 → 7.26.25

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 CHANGED
@@ -3589,9 +3589,30 @@ makes Tabulator re-run a title formatter on the EXISTING header cell, which
3589
3589
  **A missing key is invisible:** i18next answers an unknown key **with the key
3590
3590
  itself**, so it renders (lower-case English) and simply never changes language.
3591
3591
  A **duplicate** key in a locale file is silent too — an object literal keeps the
3592
- last one. Both are caught by the apps' `scripts/validate-locales.mjs`, which
3593
- also scans the gobj-ui modules the app mounts: **the library translates through
3594
- the APP's i18next**, so every key it asks for must be defined by the app.
3592
+ last one. Both are caught by `validate-locales`, which this package publishes
3593
+ (since `7.26.25`) and every app runs from its `prebuild`, from its own
3594
+ directory:
3595
+
3596
+ ```json
3597
+ "scripts": {
3598
+ "validate-locales": "node node_modules/@yuneta/gobj-ui/scripts/validate-locales.mjs",
3599
+ "prebuild": "npm run validate-locales && npm run check-markup"
3600
+ }
3601
+ ```
3602
+
3603
+ It also scans the gobj-ui modules the app mounts, deep or through the barrel:
3604
+ **the library translates through the APP's i18next**, so every key it asks for
3605
+ must be defined by the app. The library it scans is the one it ships in, the
3606
+ package the app builds with. Code and catalogs shared by several apps are
3607
+ named with `--src <dir>` (repeatable) and `--locales <dir>` (yunovatios:
3608
+ `--src ../gui-common/src --locales ../gui-common/src/locales`); `--config`
3609
+ names a shell declaration other than `src/app_config.json`.
3610
+
3611
+ Up to `7.26.24` each app carried its own copy, and the copies had drifted into
3612
+ three families: only one read the codes of the schema editor, only the other
3613
+ two the barrel imports, and two read `t('topic') + ':'` as a key built by
3614
+ concatenation and skipped it -- so the gobj tree's *"topic:"* went untranslated
3615
+ in wattyzer and yunovatios. An app keeps no copy now.
3595
3616
 
3596
3617
  ### Dates: never hand-roll them again
3597
3618
 
@@ -73651,17 +73651,22 @@ function plan_import(treedb, incoming, options) {
73651
73651
  col: inner_id
73652
73652
  });
73653
73653
  let hook = (in_col.col || {}).hook;
73654
- if (hook && typeof hook === "object" && !Array.isArray(hook)) for (let [child_topic, fkey_col] of Object.entries(hook)) {
73655
- if (topic_name_breaks_the_rule(child_topic)) conflicts.push({
73656
- code: "topic name breaks the name rule",
73657
- topic: child_topic,
73658
- col: ""
73659
- });
73660
- if (col_name_breaks_the_rule(fkey_col)) conflicts.push({
73661
- code: "column name breaks the name rule",
73662
- topic: child_topic,
73663
- col: String(fkey_col)
73664
- });
73654
+ if (hook && typeof hook === "object" && !Array.isArray(hook)) {
73655
+ let from_hook = `${name || ""}.${in_col.name}`;
73656
+ for (let [child_topic, fkey_col] of Object.entries(hook)) {
73657
+ if (topic_name_breaks_the_rule(child_topic)) conflicts.push({
73658
+ code: "topic name breaks the name rule",
73659
+ topic: child_topic,
73660
+ col: "",
73661
+ hook: from_hook
73662
+ });
73663
+ if (col_name_breaks_the_rule(fkey_col)) conflicts.push({
73664
+ code: "column name breaks the name rule",
73665
+ topic: child_topic,
73666
+ col: String(fkey_col),
73667
+ hook: from_hook
73668
+ });
73669
+ }
73665
73670
  }
73666
73671
  }
73667
73672
  }
@@ -76973,15 +76978,31 @@ function render_import_plan(gobj, plan) {
76973
76978
  return [
76974
76979
  "p",
76975
76980
  { class: "SCHEMA_IMPORT_CONFLICT is-size-7" },
76976
- [[
76977
- "span",
76978
- { i18n: conflict.code },
76979
- (0, i18next.t)(conflict.code)
76980
- ], where ? [
76981
- "code",
76982
- { class: "SCHEMA_IMPORT_CONFLICT_NAME ml-2" },
76983
- text_node(where)
76984
- ] : null].filter(Boolean)
76981
+ [
76982
+ [
76983
+ "span",
76984
+ { i18n: conflict.code },
76985
+ (0, i18next.t)(conflict.code)
76986
+ ],
76987
+ where ? [
76988
+ "code",
76989
+ { class: "SCHEMA_IMPORT_CONFLICT_NAME ml-2" },
76990
+ text_node(where)
76991
+ ] : null,
76992
+ conflict.hook ? [
76993
+ "span",
76994
+ {
76995
+ class: "SCHEMA_IMPORT_CONFLICT_HOOK ml-2",
76996
+ i18n: "in the hook map of"
76997
+ },
76998
+ (0, i18next.t)("in the hook map of")
76999
+ ] : null,
77000
+ conflict.hook ? [
77001
+ "code",
77002
+ { class: "SCHEMA_IMPORT_CONFLICT_HOOK_NAME ml-1" },
77003
+ text_node(conflict.hook)
77004
+ ] : null
77005
+ ].filter(Boolean)
76985
77006
  ];
76986
77007
  })
76987
77008
  ]));
@@ -73646,17 +73646,22 @@ function plan_import(treedb, incoming, options) {
73646
73646
  col: inner_id
73647
73647
  });
73648
73648
  let hook = (in_col.col || {}).hook;
73649
- if (hook && typeof hook === "object" && !Array.isArray(hook)) for (let [child_topic, fkey_col] of Object.entries(hook)) {
73650
- if (topic_name_breaks_the_rule(child_topic)) conflicts.push({
73651
- code: "topic name breaks the name rule",
73652
- topic: child_topic,
73653
- col: ""
73654
- });
73655
- if (col_name_breaks_the_rule(fkey_col)) conflicts.push({
73656
- code: "column name breaks the name rule",
73657
- topic: child_topic,
73658
- col: String(fkey_col)
73659
- });
73649
+ if (hook && typeof hook === "object" && !Array.isArray(hook)) {
73650
+ let from_hook = `${name || ""}.${in_col.name}`;
73651
+ for (let [child_topic, fkey_col] of Object.entries(hook)) {
73652
+ if (topic_name_breaks_the_rule(child_topic)) conflicts.push({
73653
+ code: "topic name breaks the name rule",
73654
+ topic: child_topic,
73655
+ col: "",
73656
+ hook: from_hook
73657
+ });
73658
+ if (col_name_breaks_the_rule(fkey_col)) conflicts.push({
73659
+ code: "column name breaks the name rule",
73660
+ topic: child_topic,
73661
+ col: String(fkey_col),
73662
+ hook: from_hook
73663
+ });
73664
+ }
73660
73665
  }
73661
73666
  }
73662
73667
  }
@@ -76968,15 +76973,31 @@ function render_import_plan(gobj, plan) {
76968
76973
  return [
76969
76974
  "p",
76970
76975
  { class: "SCHEMA_IMPORT_CONFLICT is-size-7" },
76971
- [[
76972
- "span",
76973
- { i18n: conflict.code },
76974
- t(conflict.code)
76975
- ], where ? [
76976
- "code",
76977
- { class: "SCHEMA_IMPORT_CONFLICT_NAME ml-2" },
76978
- text_node(where)
76979
- ] : null].filter(Boolean)
76976
+ [
76977
+ [
76978
+ "span",
76979
+ { i18n: conflict.code },
76980
+ t(conflict.code)
76981
+ ],
76982
+ where ? [
76983
+ "code",
76984
+ { class: "SCHEMA_IMPORT_CONFLICT_NAME ml-2" },
76985
+ text_node(where)
76986
+ ] : null,
76987
+ conflict.hook ? [
76988
+ "span",
76989
+ {
76990
+ class: "SCHEMA_IMPORT_CONFLICT_HOOK ml-2",
76991
+ i18n: "in the hook map of"
76992
+ },
76993
+ t("in the hook map of")
76994
+ ] : null,
76995
+ conflict.hook ? [
76996
+ "code",
76997
+ { class: "SCHEMA_IMPORT_CONFLICT_HOOK_NAME ml-1" },
76998
+ text_node(conflict.hook)
76999
+ ] : null
77000
+ ].filter(Boolean)
76980
77001
  ];
76981
77002
  })
76982
77003
  ]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.26.24",
3
+ "version": "7.26.25",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -19,7 +19,8 @@
19
19
  "index.js",
20
20
  "src/",
21
21
  "vite-plugin-yuneta-html.js",
22
- "scripts/check-markup-strings.mjs"
22
+ "scripts/check-markup-strings.mjs",
23
+ "scripts/validate-locales.mjs"
23
24
  ],
24
25
  "description": "Yuneta UI Library — the active line: declarative shell (C_YUI_SHELL+NAV+PAGER+WIZARD) + windows, TreeDB views, forms, charts and maps. Consumed from the npm registry by the Yuneta SPAs (gui_agent, gui_treedb, wattyzer, yunovatios). The frozen legacy GClass GUI stack (C_YUI_MAIN/TABS/ROUTING) lives on the v1 branch, npm dist-tag 'legacy' (consumed by estadodelaire and hidraulia).",
25
26
  "scripts": {
@@ -0,0 +1,482 @@
1
+ #!/usr/bin/env node
2
+ /***********************************************************************
3
+ * validate-locales.mjs
4
+ *
5
+ * The i18n check of every app that builds on gobj-ui, run from the
6
+ * APP's directory (its `prebuild`):
7
+ *
8
+ * node node_modules/@yuneta/gobj-ui/scripts/validate-locales.mjs
9
+ *
10
+ * It enforces, on every locale of the app:
11
+ *
12
+ * 1. Keys are ASCII (no em-dash, accents, etc.).
13
+ * 2. Keys are lower-case.
14
+ * 3. All locales carry the same key set (symmetric).
15
+ * 4. No duplicate key in a locale file (an object literal keeps
16
+ * the LAST one and says nothing -- a stale entry silently
17
+ * overrides a new one; it happened to "last" and "loading").
18
+ * 5. Every key the SOURCE uses is defined (i18next answers an
19
+ * unknown key with the key ITSELF, so a missing entry renders
20
+ * fine and simply never changes language -- invisible by
21
+ * construction, and it shipped: a Settings `label` column).
22
+ *
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.
31
+ *
32
+ * Options (paths relative to the app's directory):
33
+ *
34
+ * --src <dir> a source directory to scan, besides src/
35
+ * (repeatable: code shared by several apps)
36
+ * --locales <dir> where the locales are (default src/locales/)
37
+ * --config <file> the shell declaration
38
+ * (default src/app_config.json)
39
+ *
40
+ * A locale is a file <code>.js of the locales directory (en.js,
41
+ * es.js; flags.js and locales.js are not) exporting
42
+ * `{<code>}` with a `translation` dict. It is imported directly,
43
+ * not through locales.js, which drags in gobj-js and i18next and
44
+ * expects a browser.
45
+ *
46
+ * Exits 1 on any violation, so the build fails loudly.
47
+ *
48
+ * 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.
53
+ *
54
+ * Copyright (c) 2025-2026, ArtGins.
55
+ * All Rights Reserved.
56
+ ***********************************************************************/
57
+ import {readdirSync, readFileSync, existsSync} from "fs";
58
+ import {resolve} from "path";
59
+ import {pathToFileURL} from "url";
60
+
61
+ const ASCII_RE = /^[\x20-\x7e]+$/;
62
+ const LOCALE_FILE_RE = /^([a-z]{2,3})\.js$/;
63
+
64
+ /* This package: the library the app builds with. */
65
+ const LIB = new URL("../", import.meta.url);
66
+ const LIB_SRC = new URL("src/", LIB);
67
+ const LIB_INDEX = new URL("index.js", LIB);
68
+
69
+ /* The app: the directory it is run from. */
70
+ const APP_DIR = pathToFileURL(process.cwd() + "/");
71
+
72
+ let errors = 0;
73
+
74
+ /***************************************************************
75
+ *
76
+ ***************************************************************/
77
+ function fail(msg)
78
+ {
79
+ process.stderr.write(`validate-locales: ${msg}\n`);
80
+ errors++;
81
+ }
82
+
83
+ /***************************************************************
84
+ * A fatal problem of the setup, not a violation: said, exit 1
85
+ ***************************************************************/
86
+ function abort(msg)
87
+ {
88
+ process.stderr.write(`validate-locales: ${msg}\n`);
89
+ process.exit(1);
90
+ }
91
+
92
+ /***************************************************************
93
+ * The directory a path names, as a URL ending in '/'
94
+ ***************************************************************/
95
+ function dir_url(path)
96
+ {
97
+ let p = resolve(process.cwd(), path);
98
+ return pathToFileURL(p.endsWith("/") ? p : p + "/");
99
+ }
100
+
101
+ /***************************************************************
102
+ *
103
+ ***************************************************************/
104
+ function parse_args(argv)
105
+ {
106
+ let opts = {
107
+ src: [new URL("src/", APP_DIR)],
108
+ locales: new URL("src/locales/", APP_DIR),
109
+ config: new URL("src/app_config.json", APP_DIR)
110
+ };
111
+ for(let i = 0; i < argv.length; i++) {
112
+ let a = argv[i];
113
+ let v = argv[i + 1];
114
+ if(a === "--src" || a === "--locales" || a === "--config") {
115
+ if(!v) {
116
+ abort(`${a} needs a value`);
117
+ }
118
+ i++;
119
+ }
120
+ if(a === "--src") {
121
+ opts.src.push(dir_url(v));
122
+ } else if(a === "--locales") {
123
+ opts.locales = dir_url(v);
124
+ } else if(a === "--config") {
125
+ opts.config = pathToFileURL(resolve(process.cwd(), v));
126
+ } else {
127
+ abort(`unknown argument: ${a} (--src <dir>, --locales <dir>, --config <file>)`);
128
+ }
129
+ }
130
+ return opts;
131
+ }
132
+
133
+ /***************************************************************
134
+ * The locales of the app: {code: translation}
135
+ ***************************************************************/
136
+ async function load_locales(locales_dir)
137
+ {
138
+ if(!existsSync(locales_dir)) {
139
+ abort(`no locales directory at ${locales_dir.pathname}`);
140
+ }
141
+ let codes = readdirSync(locales_dir)
142
+ .map((f) => (f.match(LOCALE_FILE_RE) || [])[1])
143
+ .filter(Boolean)
144
+ .sort();
145
+ /* The reference is English when there is one */
146
+ if(codes.includes("en")) {
147
+ codes = ["en", ...codes.filter((c) => c !== "en")];
148
+ }
149
+ if(codes.length === 0) {
150
+ abort(`no locale (<code>.js) in ${locales_dir.pathname}`);
151
+ }
152
+ let locales = {};
153
+ for(let code of codes) {
154
+ let mod = await import(new URL(`${code}.js`, locales_dir).href);
155
+ let locale = mod[code] || mod.default;
156
+ if(!locale || !locale.translation || typeof locale.translation !== "object") {
157
+ abort(`${code}.js does not export {${code}} with a translation dict`);
158
+ }
159
+ locales[code] = locale.translation;
160
+ }
161
+ return locales;
162
+ }
163
+
164
+ /***************************************************************
165
+ *
166
+ ***************************************************************/
167
+ async function main()
168
+ {
169
+ let opts = parse_args(process.argv.slice(2));
170
+ let locales = await load_locales(opts.locales);
171
+ let codes = Object.keys(locales);
172
+
173
+ /* Per-locale key checks (ASCII + lower-case) */
174
+ let key_sets = {};
175
+ for(let code of codes) {
176
+ let keys = Object.keys(locales[code]);
177
+ key_sets[code] = new Set(keys);
178
+ for(let k of keys) {
179
+ if(!ASCII_RE.test(k)) {
180
+ fail(`[${code}] non-ASCII key: ${JSON.stringify(k)}`);
181
+ }
182
+ if(k !== k.toLowerCase()) {
183
+ fail(`[${code}] non-lower-case key: ${JSON.stringify(k)}`);
184
+ }
185
+ }
186
+ }
187
+
188
+ /* Symmetry: every locale carries the same keys as the first */
189
+ let ref = codes[0];
190
+ let ref_keys = key_sets[ref];
191
+ for(let code of codes.slice(1)) {
192
+ let cur = key_sets[code];
193
+ for(let k of ref_keys) {
194
+ if(!cur.has(k)) {
195
+ fail(`[${code}] missing key present in [${ref}]: ${JSON.stringify(k)}`);
196
+ }
197
+ }
198
+ for(let k of cur) {
199
+ if(!ref_keys.has(k)) {
200
+ fail(`[${code}] extra key not in [${ref}]: ${JSON.stringify(k)}`);
201
+ }
202
+ }
203
+ }
204
+
205
+ /*
206
+ * No DUPLICATE key in a locale file. A JS object literal keeps the LAST
207
+ * of two identical keys and says nothing, so the imported dict looks
208
+ * fine while the file lies. Only the raw text can see it.
209
+ */
210
+ for(let code of codes) {
211
+ let src = strip_comments(readFileSync(new URL(`${code}.js`, opts.locales), "utf8"));
212
+ let seen = new Set();
213
+ let re = /^\s*"([^"]+)":/gm;
214
+ let m;
215
+ while((m = re.exec(src)) !== null) {
216
+ if(seen.has(m[1])) {
217
+ fail(`[${code}] DUPLICATE key (the later one silently wins): ${JSON.stringify(m[1])}`);
218
+ }
219
+ seen.add(m[1]);
220
+ }
221
+ }
222
+
223
+ /* Every key the source asks for exists: a missing one is a violation, not a fallback */
224
+ let used = collect_used_keys(opts);
225
+ for(let k of used) {
226
+ if(!ref_keys.has(k)) {
227
+ fail(`key used in the source but defined in NO locale: ${JSON.stringify(k)}`);
228
+ }
229
+ }
230
+
231
+ if(errors > 0) {
232
+ process.stderr.write(`validate-locales: ${errors} violation(s), see above\n`);
233
+ process.exit(1);
234
+ }
235
+
236
+ process.stdout.write(
237
+ `validate-locales: OK (${codes.length} locales x ${ref_keys.size} keys, ` +
238
+ `${used.size} used in the source)\n`);
239
+ }
240
+
241
+ /***************************************************************
242
+ * The i18n keys the source asks for
243
+ ***************************************************************/
244
+ 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,
254
+ /* A local ALIAS of `t` is a blind spot: `yui_tabulator_i18n.js` asks
255
+ * for its whole chrome through `tr(key, default)`. */
256
+ /\btr\(\s*['"]([^'"]+)['"](?!\s*\+)/g,
257
+ /* The codes the schema checks hand to `t(finding.code)` /
258
+ * `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,
261
+ /* 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
268
+ ];
269
+
270
+ function collect_used_keys(opts)
271
+ {
272
+ let keys = new Set();
273
+
274
+ let scan = (url) => {
275
+ /* Comments are not code: the library documents the contract with
276
+ * examples like `data-i18n="<key>"`. */
277
+ let code = strip_comments(readFileSync(url, "utf8"));
278
+ for(let re of PATTERNS) {
279
+ re.lastIndex = 0;
280
+ let m;
281
+ while((m = re.exec(code)) !== null) {
282
+ /* A key INTERPOLATED in a template (`data-i18n="${key}"`)
283
+ * cannot be known statically either */
284
+ if(m[1].includes("${")) {
285
+ continue;
286
+ }
287
+ keys.add(m[1]);
288
+ }
289
+ }
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));
296
+ }
297
+ }
298
+ return code;
299
+ };
300
+
301
+ let barrel = barrel_export_map();
302
+
303
+ /* The app's own source, and the code it shares with others */
304
+ let pending = [];
305
+ for(let dir of opts.src) {
306
+ if(!existsSync(dir)) {
307
+ abort(`no source directory at ${dir.pathname}`);
308
+ }
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));
314
+ pending.push(...lib_imports(src, barrel, false));
315
+ }
316
+ }
317
+
318
+ keys_from_shell_config(opts.config, keys);
319
+
320
+ /*
321
+ * ...and the gobj-ui views it MOUNTS. Only the modules actually
322
+ * imported are scanned, transitively: the library carries views the
323
+ * app never mounts.
324
+ */
325
+ if(!existsSync(LIB_SRC)) {
326
+ abort(`no gobj-ui source at ${LIB_SRC.pathname}`);
327
+ }
328
+ let missing = [];
329
+ let seen = new Set();
330
+ while(pending.length > 0) {
331
+ let name = pending.pop();
332
+ if(seen.has(name)) {
333
+ continue;
334
+ }
335
+ seen.add(name);
336
+ let url = new URL(name, LIB_SRC);
337
+ if(!existsSync(url)) {
338
+ missing.push(name);
339
+ continue;
340
+ }
341
+ let src = scan(url);
342
+ pending.push(...lib_imports(src, barrel, true));
343
+ }
344
+ if(missing.length > 0) {
345
+ abort(`modules of gobj-ui not found in ${LIB_SRC.pathname}: ${missing.join(", ")}`);
346
+ }
347
+
348
+ return keys;
349
+ }
350
+
351
+ /***************************************************************
352
+ * The shell declaration (`app_config.json`) holds i18n keys in DATA:
353
+ * every `name`, `aria_label` and `tooltip` of an item, the `label` of a
354
+ * C_YUI_NODE node, the `title` / `subtitle` of a route's kw. No `t()`
355
+ * names them, and a missing one renders in raw lower-case English for
356
+ * ever -- which is how four toolbar buttons announced themselves as
357
+ * "toggle language" in Spanish.
358
+ *
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.
363
+ ***************************************************************/
364
+ const CONFIG_I18N_FIELDS = ["name", "aria_label", "tooltip", "label", "title", "subtitle"];
365
+
366
+ function keys_from_shell_config(url, keys)
367
+ {
368
+ if(!existsSync(url)) {
369
+ process.stdout.write(`validate-locales: no ${url.pathname}: no shell keys checked\n`);
370
+ return;
371
+ }
372
+ let config;
373
+ try {
374
+ config = JSON.parse(readFileSync(url, "utf8"));
375
+ } catch(e) {
376
+ abort(`${url.pathname} is not valid json: ${e.message}`);
377
+ }
378
+ let walk = (node) => {
379
+ if(Array.isArray(node)) {
380
+ for(let item of node) {
381
+ walk(item);
382
+ }
383
+ return;
384
+ }
385
+ if(!node || typeof node !== "object") {
386
+ return;
387
+ }
388
+ for(let [k, v] of Object.entries(node)) {
389
+ if(k.startsWith("_")) {
390
+ continue;
391
+ }
392
+ if(CONFIG_I18N_FIELDS.includes(k) && typeof v === "string" && v
393
+ && v === v.toLowerCase()) {
394
+ keys.add(v);
395
+ }
396
+ walk(v);
397
+ }
398
+ };
399
+ walk(config);
400
+ }
401
+
402
+ /***************************************************************
403
+ * Drop /* ... *\/ and // ... comments (good enough for a key scan: a "//"
404
+ * or "/*" inside a string would only ever cost a key we then report)
405
+ ***************************************************************/
406
+ function strip_comments(src)
407
+ {
408
+ return src
409
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
410
+ .replace(/(^|[^:"'`\\])\/\/[^\n]*/g, "$1");
411
+ }
412
+
413
+ /***************************************************************
414
+ * The barrel's public surface: exported symbol -> the src/ module that
415
+ * defines it, read from index.js's re-exports
416
+ * (`export {a, b as c} from "./src/x.js"`). A barrel import is resolved
417
+ * to the modules it really pulls in, not to the whole library.
418
+ ***************************************************************/
419
+ function barrel_export_map()
420
+ {
421
+ let map = new Map();
422
+ let src;
423
+ try {
424
+ src = strip_comments(readFileSync(LIB_INDEX, "utf8"));
425
+ } catch(e) {
426
+ abort(`cannot read the gobj-ui barrel at ${LIB_INDEX.pathname}: ${e.message}`);
427
+ }
428
+ let re = /export\s*\{([^}]*)\}\s*from\s*["']\.\/src\/([\w.-]+\.js)["']/g;
429
+ let m;
430
+ while((m = re.exec(src)) !== null) {
431
+ let mod = m[2];
432
+ for(let sym of m[1].split(",")) {
433
+ sym = sym.trim();
434
+ if(!sym) {
435
+ continue;
436
+ }
437
+ /* `a as b` is published as `b` */
438
+ let parts = sym.split(/\s+as\s+/);
439
+ map.set(parts[parts.length - 1].trim(), mod);
440
+ }
441
+ }
442
+ return map;
443
+ }
444
+
445
+ /***************************************************************
446
+ * 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
449
+ * - inside the library: "./x.js" (in the app it names an app file,
450
+ * scanned already)
451
+ ***************************************************************/
452
+ function lib_imports(src, barrel, inside_lib)
453
+ {
454
+ let out = [];
455
+ let m;
456
+
457
+ let from_app = /from\s+["']@yuneta\/gobj-ui\/src\/([\w.-]+\.js)["']/g;
458
+ while((m = from_app.exec(src)) !== null) {
459
+ out.push(m[1]);
460
+ }
461
+
462
+ let from_barrel = /import\s*\{([^}]*)\}\s*from\s*["']@yuneta\/gobj-ui(?:\/index\.js)?["']/g;
463
+ while((m = from_barrel.exec(src)) !== null) {
464
+ for(let sym of m[1].split(",")) {
465
+ sym = sym.trim().split(/\s+as\s+/)[0].trim();
466
+ let mod = barrel.get(sym);
467
+ if(mod) {
468
+ out.push(mod);
469
+ }
470
+ }
471
+ }
472
+
473
+ if(inside_lib) {
474
+ let inside = /from\s+["']\.\/([\w.-]+\.js)["']/g;
475
+ while((m = inside.exec(src)) !== null) {
476
+ out.push(m[1]);
477
+ }
478
+ }
479
+ return out;
480
+ }
481
+
482
+ await main();
@@ -2689,9 +2689,15 @@ function render_import_plan(gobj, plan)
2689
2689
  let where = conflict.col
2690
2690
  ? `${conflict.topic}.${conflict.col}`
2691
2691
  : conflict.topic;
2692
+ /* ...and, for a name of a hook map, the hook column it
2693
+ * is written in: where to fix it. */
2692
2694
  return ["p", {class: "SCHEMA_IMPORT_CONFLICT is-size-7"}, [
2693
2695
  ["span", {i18n: conflict.code}, t(conflict.code)],
2694
- where ? ["code", {class: "SCHEMA_IMPORT_CONFLICT_NAME ml-2"}, text_node(where)] : null
2696
+ where ? ["code", {class: "SCHEMA_IMPORT_CONFLICT_NAME ml-2"}, text_node(where)] : null,
2697
+ conflict.hook ? ["span", {class: "SCHEMA_IMPORT_CONFLICT_HOOK ml-2",
2698
+ i18n: "in the hook map of"}, t("in the hook map of")] : null,
2699
+ conflict.hook ? ["code", {class: "SCHEMA_IMPORT_CONFLICT_HOOK_NAME ml-1"},
2700
+ text_node(conflict.hook)] : null
2695
2701
  ].filter(Boolean)];
2696
2702
  })]
2697
2703
  ));
@@ -1056,6 +1056,39 @@ describe("the import plan", () => {
1056
1056
  });
1057
1057
  });
1058
1058
 
1059
+ describe("the import panel says what each conflict is about (REV-CLOUD V17.3.5)", () => {
1060
+
1061
+ test("the bad name of a hook map is printed, and the hook column it is written in", () => {
1062
+ const {editor, host} = build("i3", "db");
1063
+ gobj_send_event(editor, "EV_IMPORT", {}, host);
1064
+ const dialog = modals[modals.length - 1];
1065
+ gobj_send_event(editor, "EV_PREVIEW_IMPORT", {prune: false, model_gen: editor.priv.model_gen,
1066
+ text: JSON.stringify({id: "db", topics: [{id: "p", pkey: "id", cols: {
1067
+ "id": {id: "id", type: "string"},
1068
+ "kids": {id: "kids", type: "dict", flag: ["hook"], hook: {"a/b": "x`y"}},
1069
+ "good": {id: "go`od", type: "string"}
1070
+ }}]})}, editor);
1071
+
1072
+ const lines = [...dialog.$content.querySelectorAll(".SCHEMA_IMPORT_CONFLICT")];
1073
+ const said = lines.map((p) => {
1074
+ const name = p.querySelector(".SCHEMA_IMPORT_CONFLICT_NAME");
1075
+ const hook = p.querySelector(".SCHEMA_IMPORT_CONFLICT_HOOK_NAME");
1076
+ return `${p.querySelector("span").textContent}|${name ? name.textContent : ""}|${hook ? hook.textContent : ""}`;
1077
+ });
1078
+ expect(said).toContain("topic name breaks the name rule|a/b|p.kids");
1079
+ expect(said).toContain("column name breaks the name rule|a/b.x`y|p.kids");
1080
+ /* Not from a hook map: no hook column said */
1081
+ expect(said).toContain("column name breaks the name rule|p.go`od|");
1082
+ /* The names are DATA, text and not translated: no key on them */
1083
+ for(const code of dialog.$content.querySelectorAll(".SCHEMA_IMPORT_CONFLICT_NAME, .SCHEMA_IMPORT_CONFLICT_HOOK_NAME")) {
1084
+ expect(code.getAttribute("i18n")).toBe(null);
1085
+ expect(code.getAttribute("data-i18n")).toBe(null);
1086
+ }
1087
+ expect(dialog.$content.querySelector(".SCHEMA_IMPORT_RUN").disabled).toBe(true);
1088
+ expect(errors()).toEqual([]);
1089
+ });
1090
+ });
1091
+
1059
1092
  describe("a confirmation answered where no treedb is open", () => {
1060
1093
 
1061
1094
  test("after a reload that landed on zero treedbs: refused as stale, not NOT DEFINED", () => {
@@ -245,16 +245,20 @@ function plan_import(treedb, incoming, options)
245
245
  * the treedb refuses too, even when no column declares them. */
246
246
  let hook = (in_col.col || {}).hook;
247
247
  if(hook && typeof hook === "object" && !Array.isArray(hook)) {
248
+ /* The parent's hook column the bad name comes from: a
249
+ * conflict that said only `child.fkey` did not say where
250
+ * to fix it (REV-CLOUD V17.3.5). */
251
+ let from_hook = `${name || ""}.${in_col.name}`;
248
252
  for(let [child_topic, fkey_col] of Object.entries(hook)) {
249
253
  /* The conflict names the BAD name (the child topic, its
250
- * fkey), not the hook column that maps to it. */
254
+ * fkey), and `hook` the column that maps to it. */
251
255
  if(topic_name_breaks_the_rule(child_topic)) {
252
256
  conflicts.push({code: "topic name breaks the name rule",
253
- topic: child_topic, col: ""});
257
+ topic: child_topic, col: "", hook: from_hook});
254
258
  }
255
259
  if(col_name_breaks_the_rule(fkey_col)) {
256
260
  conflicts.push({code: "column name breaks the name rule",
257
- topic: child_topic, col: String(fkey_col)});
261
+ topic: child_topic, col: String(fkey_col), hook: from_hook});
258
262
  }
259
263
  }
260
264
  }
@@ -323,9 +323,9 @@ describe("a conflict names the bad name (REV-CLOUD V16.5)", () => {
323
323
  }}]
324
324
  });
325
325
  expect(plan.writes).toEqual([]);
326
- let named = plan.conflicts.map((c) => `${c.code}|${c.topic}|${c.col}`);
327
- expect(named).toContain("topic name breaks the name rule|a/b|");
328
- expect(named).toContain("column name breaks the name rule|a/b|x`y");
329
- expect(named).toContain("column name breaks the name rule|p|go`od");
326
+ let named = plan.conflicts.map((c) => `${c.code}|${c.topic}|${c.col}|${c.hook || ""}`);
327
+ expect(named).toContain("topic name breaks the name rule|a/b||p.kids");
328
+ expect(named).toContain("column name breaks the name rule|a/b|x`y|p.kids");
329
+ expect(named).toContain("column name breaks the name rule|p|go`od|");
330
330
  });
331
331
  });