@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 +24 -3
- package/dist/gobj-ui.cjs.js +41 -20
- package/dist/gobj-ui.es.js +41 -20
- package/package.json +3 -2
- package/scripts/validate-locales.mjs +482 -0
- package/src/c_yui_schema_editor.js +7 -1
- package/src/schema_editor_reload.wiring.test.js +33 -0
- package/src/schema_import.js +7 -3
- package/src/schema_import.test.js +4 -4
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
|
|
3593
|
-
|
|
3594
|
-
|
|
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
|
|
package/dist/gobj-ui.cjs.js
CHANGED
|
@@ -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))
|
|
73655
|
-
|
|
73656
|
-
|
|
73657
|
-
|
|
73658
|
-
|
|
73659
|
-
|
|
73660
|
-
|
|
73661
|
-
|
|
73662
|
-
|
|
73663
|
-
|
|
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
|
-
|
|
76978
|
-
|
|
76979
|
-
|
|
76980
|
-
|
|
76981
|
-
|
|
76982
|
-
|
|
76983
|
-
|
|
76984
|
-
|
|
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
|
]));
|
package/dist/gobj-ui.es.js
CHANGED
|
@@ -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))
|
|
73650
|
-
|
|
73651
|
-
|
|
73652
|
-
|
|
73653
|
-
|
|
73654
|
-
|
|
73655
|
-
|
|
73656
|
-
|
|
73657
|
-
|
|
73658
|
-
|
|
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
|
-
|
|
76973
|
-
|
|
76974
|
-
|
|
76975
|
-
|
|
76976
|
-
|
|
76977
|
-
|
|
76978
|
-
|
|
76979
|
-
|
|
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.
|
|
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", () => {
|
package/src/schema_import.js
CHANGED
|
@@ -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),
|
|
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
|
});
|