@yuneta/gobj-ui 7.26.23 → 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
 
@@ -73644,18 +73644,29 @@ function plan_import(treedb, incoming, options) {
73644
73644
  topic: name || "",
73645
73645
  col: in_col.name
73646
73646
  });
73647
+ let inner_id = (in_col.col || {}).id;
73648
+ if (typeof inner_id === "string" && inner_id !== in_col.name && col_name_breaks_the_rule(inner_id)) conflicts.push({
73649
+ code: "column name breaks the name rule",
73650
+ topic: name || "",
73651
+ col: inner_id
73652
+ });
73647
73653
  let hook = (in_col.col || {}).hook;
73648
- if (hook && typeof hook === "object" && !Array.isArray(hook)) for (let [child_topic, fkey_col] of Object.entries(hook)) {
73649
- if (topic_name_breaks_the_rule(child_topic)) conflicts.push({
73650
- code: "topic name breaks the name rule",
73651
- topic: name || "",
73652
- col: in_col.name
73653
- });
73654
- if (col_name_breaks_the_rule(fkey_col)) conflicts.push({
73655
- code: "column name breaks the name rule",
73656
- topic: name || "",
73657
- col: in_col.name
73658
- });
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
+ }
73659
73670
  }
73660
73671
  }
73661
73672
  }
@@ -76963,13 +76974,35 @@ function render_import_plan(gobj, plan) {
76963
76974
  "div",
76964
76975
  { class: "SCHEMA_IMPORT_CONFLICTS notification is-danger is-light p-3" },
76965
76976
  plan.conflicts.map((conflict) => {
76977
+ let where = conflict.col ? `${conflict.topic}.${conflict.col}` : conflict.topic;
76966
76978
  return [
76967
76979
  "p",
76968
- {
76969
- class: "SCHEMA_IMPORT_CONFLICT is-size-7",
76970
- i18n: conflict.code
76971
- },
76972
- (0, i18next.t)(conflict.code)
76980
+ { class: "SCHEMA_IMPORT_CONFLICT is-size-7" },
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)
76973
77006
  ];
76974
77007
  })
76975
77008
  ]));
@@ -73639,18 +73639,29 @@ function plan_import(treedb, incoming, options) {
73639
73639
  topic: name || "",
73640
73640
  col: in_col.name
73641
73641
  });
73642
+ let inner_id = (in_col.col || {}).id;
73643
+ if (typeof inner_id === "string" && inner_id !== in_col.name && col_name_breaks_the_rule(inner_id)) conflicts.push({
73644
+ code: "column name breaks the name rule",
73645
+ topic: name || "",
73646
+ col: inner_id
73647
+ });
73642
73648
  let hook = (in_col.col || {}).hook;
73643
- if (hook && typeof hook === "object" && !Array.isArray(hook)) for (let [child_topic, fkey_col] of Object.entries(hook)) {
73644
- if (topic_name_breaks_the_rule(child_topic)) conflicts.push({
73645
- code: "topic name breaks the name rule",
73646
- topic: name || "",
73647
- col: in_col.name
73648
- });
73649
- if (col_name_breaks_the_rule(fkey_col)) conflicts.push({
73650
- code: "column name breaks the name rule",
73651
- topic: name || "",
73652
- col: in_col.name
73653
- });
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
+ }
73654
73665
  }
73655
73666
  }
73656
73667
  }
@@ -76958,13 +76969,35 @@ function render_import_plan(gobj, plan) {
76958
76969
  "div",
76959
76970
  { class: "SCHEMA_IMPORT_CONFLICTS notification is-danger is-light p-3" },
76960
76971
  plan.conflicts.map((conflict) => {
76972
+ let where = conflict.col ? `${conflict.topic}.${conflict.col}` : conflict.topic;
76961
76973
  return [
76962
76974
  "p",
76963
- {
76964
- class: "SCHEMA_IMPORT_CONFLICT is-size-7",
76965
- i18n: conflict.code
76966
- },
76967
- t(conflict.code)
76975
+ { class: "SCHEMA_IMPORT_CONFLICT is-size-7" },
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)
76968
77001
  ];
76969
77002
  })
76970
77003
  ]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.26.23",
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();
@@ -2684,8 +2684,21 @@ function render_import_plan(gobj, plan)
2684
2684
  $pane.appendChild(createElement2(
2685
2685
  ["div", {class: "SCHEMA_IMPORT_CONFLICTS notification is-danger is-light p-3"},
2686
2686
  plan.conflicts.map((conflict) => {
2687
- return ["p", {class: "SCHEMA_IMPORT_CONFLICT is-size-7", i18n: conflict.code},
2688
- t(conflict.code)];
2687
+ /* What the conflict is about, said: two bad entries of
2688
+ * one hook map were two identical lines. */
2689
+ let where = conflict.col
2690
+ ? `${conflict.topic}.${conflict.col}`
2691
+ : conflict.topic;
2692
+ /* ...and, for a name of a hook map, the hook column it
2693
+ * is written in: where to fix it. */
2694
+ return ["p", {class: "SCHEMA_IMPORT_CONFLICT is-size-7"}, [
2695
+ ["span", {i18n: conflict.code}, t(conflict.code)],
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
2701
+ ].filter(Boolean)];
2689
2702
  })]
2690
2703
  ));
2691
2704
  if(priv.import_run) {
@@ -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", () => {
@@ -1768,6 +1801,8 @@ describe("aria-invalid of the name field", () => {
1768
1801
  };
1769
1802
  save("a/b");
1770
1803
  expect($value.getAttribute("aria-invalid")).toBe("true");
1804
+ save("users"); // a duplicate: the topic is there
1805
+ expect($value.getAttribute("aria-invalid")).toBe("true");
1771
1806
  save("ok_name");
1772
1807
  expect($value.getAttribute("aria-invalid")).toBe(null);
1773
1808
  take("update-node");
@@ -233,18 +233,32 @@ function plan_import(treedb, incoming, options)
233
233
  if(col_name_breaks_the_rule(in_col.name)) {
234
234
  conflicts.push({code: "column name breaks the name rule", topic: name || "", col: in_col.name});
235
235
  }
236
+ /* A dict column carries its id twice: the key (what is
237
+ * imported) and its own `id`. One that breaks the rule is said
238
+ * too, not imported under the key in silence. */
239
+ let inner_id = (in_col.col || {}).id;
240
+ if(typeof inner_id === "string" && inner_id !== in_col.name &&
241
+ col_name_breaks_the_rule(inner_id)) {
242
+ conflicts.push({code: "column name breaks the name rule", topic: name || "", col: inner_id});
243
+ }
236
244
  /* The hook map names a child topic and its fkey column: names
237
245
  * the treedb refuses too, even when no column declares them. */
238
246
  let hook = (in_col.col || {}).hook;
239
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}`;
240
252
  for(let [child_topic, fkey_col] of Object.entries(hook)) {
253
+ /* The conflict names the BAD name (the child topic, its
254
+ * fkey), and `hook` the column that maps to it. */
241
255
  if(topic_name_breaks_the_rule(child_topic)) {
242
256
  conflicts.push({code: "topic name breaks the name rule",
243
- topic: name || "", col: in_col.name});
257
+ topic: child_topic, col: "", hook: from_hook});
244
258
  }
245
259
  if(col_name_breaks_the_rule(fkey_col)) {
246
260
  conflicts.push({code: "column name breaks the name rule",
247
- topic: name || "", col: in_col.name});
261
+ topic: child_topic, col: String(fkey_col), hook: from_hook});
248
262
  }
249
263
  }
250
264
  }
@@ -310,3 +310,22 @@ describe("the import reads the hook map and the treedb id (REV-CLOUD V15.4)", ()
310
310
  expect(codes).toContain("column has no name");
311
311
  });
312
312
  });
313
+
314
+
315
+ describe("a conflict names the bad name (REV-CLOUD V16.5)", () => {
316
+ test("the child topic and the fkey of a hook map, and a dict column's inner id", () => {
317
+ let plan = plan_import(null, {
318
+ id: "db", schema_version: "1",
319
+ topics: [{id: "p", pkey: "id", cols: {
320
+ "id": {id: "id", type: "string"},
321
+ "kids": {id: "kids", type: "dict", flag: ["hook"], hook: {"a/b": "x`y"}},
322
+ "good": {id: "go`od", type: "string"},
323
+ }}]
324
+ });
325
+ expect(plan.writes).toEqual([]);
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
+ });
331
+ });