@yuneta/gobj-ui 7.26.25 → 7.26.26

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
@@ -3600,19 +3600,44 @@ directory:
3600
3600
  }
3601
3601
  ```
3602
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:
3603
+ It also scans the gobj-ui modules the app mounts -- deep, through the barrel,
3604
+ or by a dynamic `import()`: **the library translates through the APP's
3605
+ i18next**, so every key it asks for must be defined by the app. The library it
3606
+ scans is the one it ships in, the package the app builds with. The app's own
3607
+ source is read recursively. Code and catalogs shared by several apps are named
3608
+ with `--src <dir>` (repeatable) and `--locales <dir>` (yunovatios:
3608
3609
  `--src ../gui-common/src --locales ../gui-common/src/locales`); `--config`
3609
3610
  names a shell declaration other than `src/app_config.json`.
3610
3611
 
3612
+ What is particular to an app goes in ITS `package.json`, not in the script:
3613
+ properties whose value is a key, and properties whose value is a list of keys
3614
+ (yunovatios hands its orchestrator `app_title: "central"` and
3615
+ `welcome_features: [...]`):
3616
+
3617
+ ```json
3618
+ "validate-locales": {
3619
+ "attr_keys": ["app_title", "app_subtitle", "welcome_title", "welcome_lead"],
3620
+ "array_keys": ["welcome_features"]
3621
+ }
3622
+ ```
3623
+
3624
+ Plurals are compared by language (since `7.26.26`): `songs_one` /
3625
+ `songs_few` / `songs_many` / `songs_other` are one key, and each locale must
3626
+ carry the categories of ITS language (`Intl.PluralRules`: English `one`,
3627
+ `other`; Russian `one`, `few`, `many`, `other`; Chinese `other`). The key with
3628
+ no suffix serves `other` and `many`, as i18next falls back to it; `zero`,
3629
+ `one`, `two` and `few` need their own form. A value of the shell declaration
3630
+ that is not lower-case ASCII (an endonym, `"中文"`, in a language menu) is a
3631
+ literal, not a key.
3632
+
3611
3633
  Up to `7.26.24` each app carried its own copy, and the copies had drifted into
3612
3634
  three families: only one read the codes of the schema editor, only the other
3613
3635
  two the barrel imports, and two read `t('topic') + ':'` as a key built by
3614
3636
  concatenation and skipped it -- so the gobj tree's *"topic:"* went untranslated
3615
- in wattyzer and yunovatios. An app keeps no copy now.
3637
+ in wattyzer and yunovatios. No app keeps a copy now, but one: `test-app`, this
3638
+ library's own demo, whose catalog has English as the source (a key IS the
3639
+ English text, Title Case included) and so follows other rules, in its own
3640
+ `test-app/scripts/validate-locales.mjs`.
3616
3641
 
3617
3642
  ### Dates: never hand-roll them again
3618
3643
 
@@ -7181,11 +7181,11 @@ function yui_file_control(gobj, { name, value, readonly, accept, on_pick }) {
7181
7181
  if (picked) {
7182
7182
  $state.classList.add("is-picked");
7183
7183
  $state.setAttribute("data-i18n", "not saved yet");
7184
- $state.textContent = "not saved yet";
7184
+ $state.textContent = (0, i18next.t)("not saved yet");
7185
7185
  } else if (!kept) {
7186
7186
  $state.classList.add("is-empty");
7187
7187
  $state.setAttribute("data-i18n", "no file");
7188
- $state.textContent = "no file";
7188
+ $state.textContent = (0, i18next.t)("no file");
7189
7189
  }
7190
7190
  $clear.hidden = readonly || !picked && !kept;
7191
7191
  $pick.hidden = !!readonly;
@@ -63836,9 +63836,14 @@ var BTN_STYLE_CANCEL = "flex:1;padding:6px;background:var(--bulma-scheme-main-bi
63836
63836
  function create_form_button_row(parent, buttons) {
63837
63837
  let btnRow = document.createElement("div");
63838
63838
  btnRow.style.cssText = "display:flex;gap:8px;";
63839
- for (let { text, style, onClick } of buttons) {
63839
+ for (let { i18n, style, onClick } of buttons) {
63840
63840
  let btn = document.createElement("button");
63841
- btn.textContent = (0, i18next.t)(text);
63841
+ btn.textContent = (0, i18next.t)(i18n);
63842
+ btn.setAttribute("data-i18n", i18n);
63843
+ btn.setAttribute("title", (0, i18next.t)(i18n));
63844
+ btn.setAttribute("data-i18n-title", i18n);
63845
+ btn.setAttribute("aria-label", (0, i18next.t)(i18n));
63846
+ btn.setAttribute("data-i18n-aria-label", i18n);
63842
63847
  btn.style.cssText = style + touch_control_css();
63843
63848
  btn.addEventListener("click", (e) => {
63844
63849
  e.stopPropagation();
@@ -63872,11 +63877,11 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
63872
63877
  msg.textContent = message;
63873
63878
  popover.appendChild(msg);
63874
63879
  create_form_button_row(popover, [{
63875
- text: "cancel",
63880
+ i18n: "cancel",
63876
63881
  style: BTN_STYLE_CANCEL,
63877
63882
  onClick: () => hide_overlay(gobj, priv_key)
63878
63883
  }, {
63879
- text: confirm_text,
63884
+ i18n: confirm_text,
63880
63885
  style: "flex:1;padding:6px;background:" + confirm_color + ";color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
63881
63886
  onClick: onConfirm
63882
63887
  }]);
@@ -64605,13 +64610,13 @@ function show_port_popover(gobj) {
64605
64610
  }
64606
64611
  ]);
64607
64612
  create_form_button_row(popover, [{
64608
- text: "cancel",
64613
+ i18n: "cancel",
64609
64614
  style: BTN_STYLE_CANCEL,
64610
64615
  onClick: () => {
64611
64616
  hide_port_popover(gobj);
64612
64617
  }
64613
64618
  }, {
64614
- text: "apply",
64619
+ i18n: "apply",
64615
64620
  style: "flex:1;padding:6px;background:#1890ff;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
64616
64621
  onClick: () => {
64617
64622
  drop_preview_undo(gobj, "_port_preview_undo");
@@ -65301,13 +65306,13 @@ function show_edge_popover(gobj) {
65301
65306
  }
65302
65307
  ]);
65303
65308
  create_form_button_row(popover, [{
65304
- text: "cancel",
65309
+ i18n: "cancel",
65305
65310
  style: BTN_STYLE_CANCEL,
65306
65311
  onClick: () => {
65307
65312
  deselect_edge(gobj);
65308
65313
  }
65309
65314
  }, {
65310
- text: "apply",
65315
+ i18n: "apply",
65311
65316
  style: "flex:1;padding:6px;background:#52c41a;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
65312
65317
  onClick: () => {
65313
65318
  drop_preview_undo(gobj, "_edge_preview_undo");
@@ -65462,13 +65467,13 @@ function show_node_popover(gobj) {
65462
65467
  }
65463
65468
  ]);
65464
65469
  create_form_button_row(popover, [{
65465
- text: "cancel",
65470
+ i18n: "cancel",
65466
65471
  style: BTN_STYLE_CANCEL,
65467
65472
  onClick: () => {
65468
65473
  hide_node_popover(gobj);
65469
65474
  }
65470
65475
  }, {
65471
- text: "apply",
65476
+ i18n: "apply",
65472
65477
  style: "flex:1;padding:6px;background:#1890ff;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
65473
65478
  onClick: () => {
65474
65479
  drop_preview_undo(gobj, "_node_preview_undo");
@@ -66122,12 +66127,21 @@ function show_create_popover(gobj) {
66122
66127
  let titleEl = document.createElement("div");
66123
66128
  titleEl.style.cssText = "margin-bottom:10px;font-weight:600;font-size:14px;";
66124
66129
  titleEl.textContent = (0, i18next.t)("create node");
66130
+ titleEl.setAttribute("data-i18n", "create node");
66125
66131
  popover.appendChild(titleEl);
66126
66132
  let topicLabel = document.createElement("div");
66127
66133
  topicLabel.style.cssText = "margin-bottom:4px;font-weight:500;";
66128
- topicLabel.textContent = (0, i18next.t)("topic") + ":";
66134
+ let topicWord = document.createElement("span");
66135
+ topicWord.textContent = (0, i18next.t)("topic");
66136
+ topicWord.setAttribute("data-i18n", "topic");
66137
+ topicLabel.appendChild(topicWord);
66138
+ topicLabel.appendChild(document.createTextNode(":"));
66129
66139
  popover.appendChild(topicLabel);
66130
66140
  let topicSelect = document.createElement("select");
66141
+ topicSelect.setAttribute("title", (0, i18next.t)("topic"));
66142
+ topicSelect.setAttribute("data-i18n-title", "topic");
66143
+ topicSelect.setAttribute("aria-label", (0, i18next.t)("topic"));
66144
+ topicSelect.setAttribute("data-i18n-aria-label", "topic");
66131
66145
  topicSelect.style.cssText = "width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;font-size:13px;margin-bottom:8px;" + touch_control_css();
66132
66146
  for (let i = 0; i < topics.length; i++) {
66133
66147
  let opt = document.createElement("option");
@@ -66161,6 +66175,11 @@ function show_create_popover(gobj) {
66161
66175
  let idInput = document.createElement("input");
66162
66176
  idInput.type = "text";
66163
66177
  idInput.placeholder = (0, i18next.t)("node id");
66178
+ idInput.setAttribute("data-i18n-placeholder", "node id");
66179
+ idInput.setAttribute("title", (0, i18next.t)("node id"));
66180
+ idInput.setAttribute("data-i18n-title", "node id");
66181
+ idInput.setAttribute("aria-label", (0, i18next.t)("node id"));
66182
+ idInput.setAttribute("data-i18n-aria-label", "node id");
66164
66183
  idInput.style.cssText = "width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;font-size:13px;margin-bottom:10px;box-sizing:border-box;" + touch_control_css();
66165
66184
  popover.appendChild(idInput);
66166
66185
  let errorEl = document.createElement("div");
@@ -66168,16 +66187,17 @@ function show_create_popover(gobj) {
66168
66187
  popover.appendChild(errorEl);
66169
66188
  let createBtn;
66170
66189
  create_form_button_row(popover, [{
66171
- text: "cancel",
66190
+ i18n: "cancel",
66172
66191
  style: BTN_STYLE_CANCEL,
66173
66192
  onClick: () => hide_create_popover(gobj)
66174
66193
  }, {
66175
- text: "create",
66194
+ i18n: "create",
66176
66195
  style: "flex:1;padding:6px;background:#1890ff;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
66177
66196
  onClick: () => {
66178
66197
  let node_id = idInput.value.trim();
66179
66198
  if (!node_id) {
66180
- errorEl.textContent = (0, i18next.t)("node id") + " required";
66199
+ errorEl.textContent = (0, i18next.t)("node id required");
66200
+ errorEl.setAttribute("data-i18n", "node id required");
66181
66201
  errorEl.style.display = "block";
66182
66202
  idInput.focus();
66183
66203
  return;
@@ -7176,11 +7176,11 @@ function yui_file_control(gobj, { name, value, readonly, accept, on_pick }) {
7176
7176
  if (picked) {
7177
7177
  $state.classList.add("is-picked");
7178
7178
  $state.setAttribute("data-i18n", "not saved yet");
7179
- $state.textContent = "not saved yet";
7179
+ $state.textContent = t("not saved yet");
7180
7180
  } else if (!kept) {
7181
7181
  $state.classList.add("is-empty");
7182
7182
  $state.setAttribute("data-i18n", "no file");
7183
- $state.textContent = "no file";
7183
+ $state.textContent = t("no file");
7184
7184
  }
7185
7185
  $clear.hidden = readonly || !picked && !kept;
7186
7186
  $pick.hidden = !!readonly;
@@ -63831,9 +63831,14 @@ var BTN_STYLE_CANCEL = "flex:1;padding:6px;background:var(--bulma-scheme-main-bi
63831
63831
  function create_form_button_row(parent, buttons) {
63832
63832
  let btnRow = document.createElement("div");
63833
63833
  btnRow.style.cssText = "display:flex;gap:8px;";
63834
- for (let { text, style, onClick } of buttons) {
63834
+ for (let { i18n, style, onClick } of buttons) {
63835
63835
  let btn = document.createElement("button");
63836
- btn.textContent = t(text);
63836
+ btn.textContent = t(i18n);
63837
+ btn.setAttribute("data-i18n", i18n);
63838
+ btn.setAttribute("title", t(i18n));
63839
+ btn.setAttribute("data-i18n-title", i18n);
63840
+ btn.setAttribute("aria-label", t(i18n));
63841
+ btn.setAttribute("data-i18n-aria-label", i18n);
63837
63842
  btn.style.cssText = style + touch_control_css();
63838
63843
  btn.addEventListener("click", (e) => {
63839
63844
  e.stopPropagation();
@@ -63867,11 +63872,11 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
63867
63872
  msg.textContent = message;
63868
63873
  popover.appendChild(msg);
63869
63874
  create_form_button_row(popover, [{
63870
- text: "cancel",
63875
+ i18n: "cancel",
63871
63876
  style: BTN_STYLE_CANCEL,
63872
63877
  onClick: () => hide_overlay(gobj, priv_key)
63873
63878
  }, {
63874
- text: confirm_text,
63879
+ i18n: confirm_text,
63875
63880
  style: "flex:1;padding:6px;background:" + confirm_color + ";color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
63876
63881
  onClick: onConfirm
63877
63882
  }]);
@@ -64600,13 +64605,13 @@ function show_port_popover(gobj) {
64600
64605
  }
64601
64606
  ]);
64602
64607
  create_form_button_row(popover, [{
64603
- text: "cancel",
64608
+ i18n: "cancel",
64604
64609
  style: BTN_STYLE_CANCEL,
64605
64610
  onClick: () => {
64606
64611
  hide_port_popover(gobj);
64607
64612
  }
64608
64613
  }, {
64609
- text: "apply",
64614
+ i18n: "apply",
64610
64615
  style: "flex:1;padding:6px;background:#1890ff;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
64611
64616
  onClick: () => {
64612
64617
  drop_preview_undo(gobj, "_port_preview_undo");
@@ -65296,13 +65301,13 @@ function show_edge_popover(gobj) {
65296
65301
  }
65297
65302
  ]);
65298
65303
  create_form_button_row(popover, [{
65299
- text: "cancel",
65304
+ i18n: "cancel",
65300
65305
  style: BTN_STYLE_CANCEL,
65301
65306
  onClick: () => {
65302
65307
  deselect_edge(gobj);
65303
65308
  }
65304
65309
  }, {
65305
- text: "apply",
65310
+ i18n: "apply",
65306
65311
  style: "flex:1;padding:6px;background:#52c41a;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
65307
65312
  onClick: () => {
65308
65313
  drop_preview_undo(gobj, "_edge_preview_undo");
@@ -65457,13 +65462,13 @@ function show_node_popover(gobj) {
65457
65462
  }
65458
65463
  ]);
65459
65464
  create_form_button_row(popover, [{
65460
- text: "cancel",
65465
+ i18n: "cancel",
65461
65466
  style: BTN_STYLE_CANCEL,
65462
65467
  onClick: () => {
65463
65468
  hide_node_popover(gobj);
65464
65469
  }
65465
65470
  }, {
65466
- text: "apply",
65471
+ i18n: "apply",
65467
65472
  style: "flex:1;padding:6px;background:#1890ff;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
65468
65473
  onClick: () => {
65469
65474
  drop_preview_undo(gobj, "_node_preview_undo");
@@ -66117,12 +66122,21 @@ function show_create_popover(gobj) {
66117
66122
  let titleEl = document.createElement("div");
66118
66123
  titleEl.style.cssText = "margin-bottom:10px;font-weight:600;font-size:14px;";
66119
66124
  titleEl.textContent = t("create node");
66125
+ titleEl.setAttribute("data-i18n", "create node");
66120
66126
  popover.appendChild(titleEl);
66121
66127
  let topicLabel = document.createElement("div");
66122
66128
  topicLabel.style.cssText = "margin-bottom:4px;font-weight:500;";
66123
- topicLabel.textContent = t("topic") + ":";
66129
+ let topicWord = document.createElement("span");
66130
+ topicWord.textContent = t("topic");
66131
+ topicWord.setAttribute("data-i18n", "topic");
66132
+ topicLabel.appendChild(topicWord);
66133
+ topicLabel.appendChild(document.createTextNode(":"));
66124
66134
  popover.appendChild(topicLabel);
66125
66135
  let topicSelect = document.createElement("select");
66136
+ topicSelect.setAttribute("title", t("topic"));
66137
+ topicSelect.setAttribute("data-i18n-title", "topic");
66138
+ topicSelect.setAttribute("aria-label", t("topic"));
66139
+ topicSelect.setAttribute("data-i18n-aria-label", "topic");
66126
66140
  topicSelect.style.cssText = "width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;font-size:13px;margin-bottom:8px;" + touch_control_css();
66127
66141
  for (let i = 0; i < topics.length; i++) {
66128
66142
  let opt = document.createElement("option");
@@ -66156,6 +66170,11 @@ function show_create_popover(gobj) {
66156
66170
  let idInput = document.createElement("input");
66157
66171
  idInput.type = "text";
66158
66172
  idInput.placeholder = t("node id");
66173
+ idInput.setAttribute("data-i18n-placeholder", "node id");
66174
+ idInput.setAttribute("title", t("node id"));
66175
+ idInput.setAttribute("data-i18n-title", "node id");
66176
+ idInput.setAttribute("aria-label", t("node id"));
66177
+ idInput.setAttribute("data-i18n-aria-label", "node id");
66159
66178
  idInput.style.cssText = "width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;font-size:13px;margin-bottom:10px;box-sizing:border-box;" + touch_control_css();
66160
66179
  popover.appendChild(idInput);
66161
66180
  let errorEl = document.createElement("div");
@@ -66163,16 +66182,17 @@ function show_create_popover(gobj) {
66163
66182
  popover.appendChild(errorEl);
66164
66183
  let createBtn;
66165
66184
  create_form_button_row(popover, [{
66166
- text: "cancel",
66185
+ i18n: "cancel",
66167
66186
  style: BTN_STYLE_CANCEL,
66168
66187
  onClick: () => hide_create_popover(gobj)
66169
66188
  }, {
66170
- text: "create",
66189
+ i18n: "create",
66171
66190
  style: "flex:1;padding:6px;background:#1890ff;color:#fff;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;",
66172
66191
  onClick: () => {
66173
66192
  let node_id = idInput.value.trim();
66174
66193
  if (!node_id) {
66175
- errorEl.textContent = t("node id") + " required";
66194
+ errorEl.textContent = t("node id required");
66195
+ errorEl.setAttribute("data-i18n", "node id required");
66176
66196
  errorEl.style.display = "block";
66177
66197
  idInput.focus();
66178
66198
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yuneta/gobj-ui",
3
- "version": "7.26.25",
3
+ "version": "7.26.26",
4
4
  "type": "module",
5
5
  "main": "dist/gobj-ui.cjs.js",
6
6
  "module": "dist/gobj-ui.es.js",
@@ -11,7 +11,18 @@
11
11
  *
12
12
  * 1. Keys are ASCII (no em-dash, accents, etc.).
13
13
  * 2. Keys are lower-case.
14
- * 3. All locales carry the same key set (symmetric).
14
+ * 3. All locales carry the same key set (symmetric). A PLURAL key
15
+ * (`songs_one`, `songs_few`, ...: i18next's `_zero` `_one`
16
+ * `_two` `_few` `_many` `_other`) is compared by its base, and
17
+ * each locale carries the plural categories of ITS language
18
+ * (Intl.PluralRules): English `one`/`other`, Spanish
19
+ * `one`/`many`/`other`, Russian `one`/`few`/`many`/`other`,
20
+ * Chinese `other`. The key with no suffix (`songs`) serves
21
+ * `other` and `many` -- i18next falls back to it -- but
22
+ * `zero`, `one`, `two` and `few` change the grammar and need
23
+ * their own form when the language has them. A form the
24
+ * language never uses is a violation; `_zero` is always
25
+ * allowed (i18next uses it for a count of 0).
15
26
  * 4. No duplicate key in a locale file (an object literal keeps
16
27
  * the LAST one and says nothing -- a stale entry silently
17
28
  * overrides a new one; it happened to "last" and "loading").
@@ -20,14 +31,15 @@
20
31
  * fine and simply never changes language -- invisible by
21
32
  * construction, and it shipped: a Settings `label` column).
22
33
  *
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.
34
+ * Rule 5 reads the app's own source (recursively), the shell
35
+ * declaration (`src/app_config.json`) and the gobj-ui modules the
36
+ * app MOUNTS, transitively -- deep, through the barrel, or by a
37
+ * dynamic import(): the library translates through the APP's
38
+ * i18next, so a key it asks for and the app does not define
39
+ * renders raw (that is how the treedb editor shipped reading
40
+ * "edit", "new", "delete" in every language). The library scanned
41
+ * is THIS one -- the package the app installed and builds with,
42
+ * the one this script ships in.
31
43
  *
32
44
  * Options (paths relative to the app's directory):
33
45
  *
@@ -37,29 +49,42 @@
37
49
  * --config <file> the shell declaration
38
50
  * (default src/app_config.json)
39
51
  *
52
+ * And, in the app's package.json, what is particular to it:
53
+ *
54
+ * "validate-locales": {
55
+ * "attr_keys": ["app_title", "welcome_lead"],
56
+ * "array_keys": ["welcome_features"]
57
+ * }
58
+ *
59
+ * `attr_keys` names properties whose string value is an i18n key
60
+ * (`app_title: "central"`), `array_keys` properties whose value is
61
+ * an array of keys. A key the app hands over that way reaches no
62
+ * t() the scan can read.
63
+ *
40
64
  * A locale is a file <code>.js of the locales directory (en.js,
41
- * es.js; flags.js and locales.js are not) exporting
65
+ * es.js, pt-br.js; flags.js and locales.js are not) exporting
42
66
  * `{<code>}` with a `translation` dict. It is imported directly,
43
67
  * not through locales.js, which drags in gobj-js and i18next and
44
- * expects a browser.
68
+ * expects a browser. A file with a locale's name and no such
69
+ * export is said and skipped.
45
70
  *
46
71
  * Exits 1 on any violation, so the build fails loudly.
47
72
  *
48
73
  * 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.
74
+ * copies drifted into three families with different rules
75
+ * (REV-CLOUD V17.3.4). This one is their union, and an app keeps
76
+ * no copy.
53
77
  *
54
78
  * Copyright (c) 2025-2026, ArtGins.
55
79
  * All Rights Reserved.
56
80
  ***********************************************************************/
57
- import {readdirSync, readFileSync, existsSync} from "fs";
81
+ import {readdirSync, readFileSync, existsSync, statSync} from "fs";
58
82
  import {resolve} from "path";
59
83
  import {pathToFileURL} from "url";
60
84
 
61
85
  const ASCII_RE = /^[\x20-\x7e]+$/;
62
- const LOCALE_FILE_RE = /^([a-z]{2,3})\.js$/;
86
+ const LOCALE_FILE_RE = /^([a-z]{2,3}(?:[-_][a-zA-Z]{2,4})?)\.js$/;
87
+ const PLURAL_RE = /^(.+)_(zero|one|two|few|many|other)$/;
63
88
 
64
89
  /* This package: the library the app builds with. */
65
90
  const LIB = new URL("../", import.meta.url);
@@ -89,6 +114,14 @@ function abort(msg)
89
114
  process.exit(1);
90
115
  }
91
116
 
117
+ /***************************************************************
118
+ * Said, not a violation
119
+ ***************************************************************/
120
+ function note(msg)
121
+ {
122
+ process.stdout.write(`validate-locales: ${msg}\n`);
123
+ }
124
+
92
125
  /***************************************************************
93
126
  * The directory a path names, as a URL ending in '/'
94
127
  ***************************************************************/
@@ -106,7 +139,9 @@ function parse_args(argv)
106
139
  let opts = {
107
140
  src: [new URL("src/", APP_DIR)],
108
141
  locales: new URL("src/locales/", APP_DIR),
109
- config: new URL("src/app_config.json", APP_DIR)
142
+ config: new URL("src/app_config.json", APP_DIR),
143
+ attr_keys: [],
144
+ array_keys: []
110
145
  };
111
146
  for(let i = 0; i < argv.length; i++) {
112
147
  let a = argv[i];
@@ -127,6 +162,25 @@ function parse_args(argv)
127
162
  abort(`unknown argument: ${a} (--src <dir>, --locales <dir>, --config <file>)`);
128
163
  }
129
164
  }
165
+
166
+ /* What is particular to the app, in its package.json */
167
+ let pkg_url = new URL("package.json", APP_DIR);
168
+ if(existsSync(pkg_url)) {
169
+ let pkg;
170
+ try {
171
+ pkg = JSON.parse(readFileSync(pkg_url, "utf8"));
172
+ } catch(e) {
173
+ abort(`${pkg_url.pathname} is not valid json: ${e.message}`);
174
+ }
175
+ let cfg = pkg["validate-locales"] || {};
176
+ for(let name of ["attr_keys", "array_keys"]) {
177
+ let list = cfg[name] || [];
178
+ if(!Array.isArray(list) || !list.every((x) => /^[A-Za-z_$][\w$]*$/.test(x))) {
179
+ abort(`package.json "validate-locales".${name} must be a list of property names`);
180
+ }
181
+ opts[name] = list;
182
+ }
183
+ }
130
184
  return opts;
131
185
  }
132
186
 
@@ -146,21 +200,55 @@ async function load_locales(locales_dir)
146
200
  if(codes.includes("en")) {
147
201
  codes = ["en", ...codes.filter((c) => c !== "en")];
148
202
  }
149
- if(codes.length === 0) {
150
- abort(`no locale (<code>.js) in ${locales_dir.pathname}`);
151
- }
152
203
  let locales = {};
153
204
  for(let code of codes) {
154
205
  let mod = await import(new URL(`${code}.js`, locales_dir).href);
155
- let locale = mod[code] || mod.default;
206
+ let locale = mod[code] || mod[code.replace(/-/g, "_")] || mod.default;
156
207
  if(!locale || !locale.translation || typeof locale.translation !== "object") {
157
- abort(`${code}.js does not export {${code}} with a translation dict`);
208
+ note(`${code}.js exports no {${code}} with a translation dict: not a locale, skipped`);
209
+ continue;
158
210
  }
159
211
  locales[code] = locale.translation;
160
212
  }
213
+ if(Object.keys(locales).length === 0) {
214
+ abort(`no locale (<code>.js exporting {<code>: {translation}}) in ${locales_dir.pathname}`);
215
+ }
161
216
  return locales;
162
217
  }
163
218
 
219
+ /***************************************************************
220
+ * The plural categories of a language (Intl.PluralRules)
221
+ ***************************************************************/
222
+ function plural_categories(code)
223
+ {
224
+ try {
225
+ return new Set(new Intl.PluralRules(code.replace(/_/g, "-")).resolvedOptions().pluralCategories);
226
+ } catch(e) {
227
+ return new Set(["one", "other"]);
228
+ }
229
+ }
230
+
231
+ /***************************************************************
232
+ * A locale's keys split: plain ones, and {base: Set(categories)}
233
+ ***************************************************************/
234
+ function split_plurals(keys)
235
+ {
236
+ let plain = new Set();
237
+ let plurals = new Map();
238
+ for(let k of keys) {
239
+ let m = k.match(PLURAL_RE);
240
+ if(m) {
241
+ if(!plurals.has(m[1])) {
242
+ plurals.set(m[1], new Set());
243
+ }
244
+ plurals.get(m[1]).add(m[2]);
245
+ } else {
246
+ plain.add(k);
247
+ }
248
+ }
249
+ return {plain, plurals};
250
+ }
251
+
164
252
  /***************************************************************
165
253
  *
166
254
  ***************************************************************/
@@ -171,10 +259,10 @@ async function main()
171
259
  let codes = Object.keys(locales);
172
260
 
173
261
  /* Per-locale key checks (ASCII + lower-case) */
174
- let key_sets = {};
262
+ let split = {};
175
263
  for(let code of codes) {
176
264
  let keys = Object.keys(locales[code]);
177
- key_sets[code] = new Set(keys);
265
+ split[code] = split_plurals(keys);
178
266
  for(let k of keys) {
179
267
  if(!ASCII_RE.test(k)) {
180
268
  fail(`[${code}] non-ASCII key: ${JSON.stringify(k)}`);
@@ -185,23 +273,60 @@ async function main()
185
273
  }
186
274
  }
187
275
 
188
- /* Symmetry: every locale carries the same keys as the first */
276
+ /*
277
+ * The plural bases, of any locale: a plain key that is one of them
278
+ * belongs to its plural, and is checked with it
279
+ */
280
+ let bases = new Set();
281
+ for(let code of codes) {
282
+ for(let base of split[code].plurals.keys()) {
283
+ bases.add(base);
284
+ }
285
+ }
286
+
287
+ /* Symmetry of the plain keys: every locale carries those of the first */
189
288
  let ref = codes[0];
190
- let ref_keys = key_sets[ref];
289
+ let plain_of = (code) => new Set([...split[code].plain].filter((k) => !bases.has(k)));
290
+ let ref_plain = plain_of(ref);
191
291
  for(let code of codes.slice(1)) {
192
- let cur = key_sets[code];
193
- for(let k of ref_keys) {
292
+ let cur = plain_of(code);
293
+ for(let k of ref_plain) {
194
294
  if(!cur.has(k)) {
195
295
  fail(`[${code}] missing key present in [${ref}]: ${JSON.stringify(k)}`);
196
296
  }
197
297
  }
198
298
  for(let k of cur) {
199
- if(!ref_keys.has(k)) {
299
+ if(!ref_plain.has(k)) {
200
300
  fail(`[${code}] extra key not in [${ref}]: ${JSON.stringify(k)}`);
201
301
  }
202
302
  }
203
303
  }
204
304
 
305
+ /*
306
+ * The plural keys: every base in every locale, with the categories of
307
+ * ITS language -- not the same suffixes everywhere. The key with no
308
+ * suffix serves `other` and `many`.
309
+ */
310
+ const SERVED_BY_THE_BASE = new Set(["other", "many"]);
311
+ for(let code of codes) {
312
+ let wanted = plural_categories(code);
313
+ for(let base of bases) {
314
+ let have = split[code].plurals.get(base) || new Set();
315
+ let has_base = split[code].plain.has(base);
316
+ for(let c of wanted) {
317
+ if(!have.has(c) && !(has_base && SERVED_BY_THE_BASE.has(c))) {
318
+ fail(`[${code}] plural ${JSON.stringify(base)} lacks the form "${c}" of its language` +
319
+ (SERVED_BY_THE_BASE.has(c)? ` (or the key ${JSON.stringify(base)} itself)` : ""));
320
+ }
321
+ }
322
+ for(let c of have) {
323
+ if(!wanted.has(c) && c !== "zero") {
324
+ fail(`[${code}] plural ${JSON.stringify(base)} has a form "${c}" its language never uses`);
325
+ }
326
+ }
327
+ }
328
+ }
329
+
205
330
  /*
206
331
  * No DUPLICATE key in a locale file. A JS object literal keeps the LAST
207
332
  * of two identical keys and says nothing, so the imported dict looks
@@ -223,7 +348,7 @@ async function main()
223
348
  /* Every key the source asks for exists: a missing one is a violation, not a fallback */
224
349
  let used = collect_used_keys(opts);
225
350
  for(let k of used) {
226
- if(!ref_keys.has(k)) {
351
+ if(!ref_plain.has(k) && !bases.has(k)) {
227
352
  fail(`key used in the source but defined in NO locale: ${JSON.stringify(k)}`);
228
353
  }
229
354
  }
@@ -233,66 +358,99 @@ async function main()
233
358
  process.exit(1);
234
359
  }
235
360
 
361
+ let n_keys = ref_plain.size + bases.size;
236
362
  process.stdout.write(
237
- `validate-locales: OK (${codes.length} locales x ${ref_keys.size} keys, ` +
238
- `${used.size} used in the source)\n`);
363
+ `validate-locales: OK (${codes.length} locales x ${n_keys} keys` +
364
+ (bases.size? `, ${bases.size} plural` : "") +
365
+ `, ${used.size} used in the source)\n`);
239
366
  }
240
367
 
241
368
  /***************************************************************
242
369
  * The i18n keys the source asks for
370
+ *
371
+ * A string literal is matched by its own quote: an apostrophe inside
372
+ * double quotes ("don't") is part of the key.
373
+ *
374
+ * The (?!\s*\+) guards skip a key BUILT BY CONCATENATION
375
+ * (`i18n: "kind_" + t.kind`): the literal is only a prefix, the real key
376
+ * cannot be known statically, and demanding "kind_" is a false positive.
377
+ * Such keys are not validated: keep them in sync by hand. The guard is
378
+ * right after the literal, INSIDE the call: `t('topic') + ':'`
379
+ * concatenates the result, and its key is asked for.
243
380
  ***************************************************************/
381
+ const STR = String.raw`(?:"([^"\n]+)"|'([^'\n]+)')`;
382
+ const STR_OR_TEMPLATE = String.raw`(?:"([^"\n]+)"|'([^'\n]+)'|` + "`" + String.raw`([^` + "`" + String.raw`$\n]+)` + "`" + ")";
383
+ const NOT_CONCAT = String.raw`(?!\s*\+)`;
384
+
385
+ function re(source)
386
+ {
387
+ return new RegExp(source, "g");
388
+ }
389
+
244
390
  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,
391
+ re(String.raw`\bt\(\s*` + STR_OR_TEMPLATE + NOT_CONCAT),
254
392
  /* A local ALIAS of `t` is a blind spot: `yui_tabulator_i18n.js` asks
255
393
  * for its whole chrome through `tr(key, default)`. */
256
- /\btr\(\s*['"]([^'"]+)['"](?!\s*\+)/g,
394
+ re(String.raw`\btr\(\s*` + STR_OR_TEMPLATE + NOT_CONCAT),
257
395
  /* The codes the schema checks hand to `t(finding.code)` /
258
396
  * `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,
397
+ re(String.raw`\badd\(\s*['"](?:error|warning)['"]\s*,\s*` + STR),
398
+ re(String.raw`\bcode:\s*` + STR + NOT_CONCAT),
261
399
  /* 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
400
+ re(String.raw`\bi18n:\s*` + STR + NOT_CONCAT),
401
+ re(String.raw`["']data-i18n(?:-title|-aria-label|-placeholder)?["']:\s*` + STR + NOT_CONCAT),
402
+ re(String.raw`\bsetAttribute\(\s*["']data-i18n(?:-title|-aria-label|-placeholder)?["']\s*,\s*` + STR + NOT_CONCAT),
403
+ /data-i18n(?:-title|-aria-label|-placeholder)?=["']([^"']+)["']/g
268
404
  ];
269
405
 
406
+ function literal_of(m)
407
+ {
408
+ for(let i = 1; i < m.length; i++) {
409
+ if(m[i] !== undefined) {
410
+ return m[i];
411
+ }
412
+ }
413
+ return undefined;
414
+ }
415
+
270
416
  function collect_used_keys(opts)
271
417
  {
272
418
  let keys = new Set();
273
419
 
420
+ let patterns = [...PATTERNS];
421
+ if(opts.attr_keys.length > 0) {
422
+ /* The app's own: properties whose value is a key */
423
+ patterns.push(re(String.raw`\b(?:` + opts.attr_keys.join("|") + String.raw`)\s*:\s*` + STR));
424
+ }
425
+ let array_re = opts.array_keys.length > 0?
426
+ re(String.raw`\b(?:` + opts.array_keys.join("|") + String.raw`)\s*:\s*\[([^\]]*)\]`) : null;
427
+
274
428
  let scan = (url) => {
275
429
  /* Comments are not code: the library documents the contract with
276
430
  * examples like `data-i18n="<key>"`. */
277
431
  let code = strip_comments(readFileSync(url, "utf8"));
278
- for(let re of PATTERNS) {
279
- re.lastIndex = 0;
432
+ for(let r of patterns) {
433
+ r.lastIndex = 0;
280
434
  let m;
281
- while((m = re.exec(code)) !== null) {
435
+ while((m = r.exec(code)) !== null) {
436
+ let k = literal_of(m);
282
437
  /* A key INTERPOLATED in a template (`data-i18n="${key}"`)
283
438
  * cannot be known statically either */
284
- if(m[1].includes("${")) {
439
+ if(!k || k.includes("${")) {
285
440
  continue;
286
441
  }
287
- keys.add(m[1]);
442
+ keys.add(k);
288
443
  }
289
444
  }
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));
445
+ if(array_re) {
446
+ array_re.lastIndex = 0;
447
+ let f;
448
+ while((f = array_re.exec(code)) !== null) {
449
+ let lit_re = re(STR);
450
+ let l;
451
+ while((l = lit_re.exec(f[1])) !== null) {
452
+ keys.add(literal_of(l));
453
+ }
296
454
  }
297
455
  }
298
456
  return code;
@@ -306,11 +464,8 @@ function collect_used_keys(opts)
306
464
  if(!existsSync(dir)) {
307
465
  abort(`no source directory at ${dir.pathname}`);
308
466
  }
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));
467
+ for(let url of source_files(dir, opts.locales)) {
468
+ let src = scan(url);
314
469
  pending.push(...lib_imports(src, barrel, false));
315
470
  }
316
471
  }
@@ -348,6 +503,35 @@ function collect_used_keys(opts)
348
503
  return keys;
349
504
  }
350
505
 
506
+ /***************************************************************
507
+ * The .js files of a source directory, recursively: not the tests, not
508
+ * the locales (their keys are definitions, not uses), not node_modules
509
+ * or a build
510
+ ***************************************************************/
511
+ function source_files(dir, locales_dir)
512
+ {
513
+ let out = [];
514
+ let walk = (d) => {
515
+ if(d.href === locales_dir.href) {
516
+ return;
517
+ }
518
+ for(let f of readdirSync(d).sort()) {
519
+ let url = new URL(f, d);
520
+ let st = statSync(url);
521
+ if(st.isDirectory()) {
522
+ if(f === "node_modules" || f === "dist" || f.startsWith(".")) {
523
+ continue;
524
+ }
525
+ walk(new URL(f + "/", d));
526
+ } else if(f.endsWith(".js") && !f.endsWith(".test.js")) {
527
+ out.push(url);
528
+ }
529
+ }
530
+ };
531
+ walk(dir);
532
+ return out;
533
+ }
534
+
351
535
  /***************************************************************
352
536
  * The shell declaration (`app_config.json`) holds i18n keys in DATA:
353
537
  * every `name`, `aria_label` and `tooltip` of an item, the `label` of a
@@ -356,17 +540,18 @@ function collect_used_keys(opts)
356
540
  * ever -- which is how four toolbar buttons announced themselves as
357
541
  * "toggle language" in Spanish.
358
542
  *
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.
543
+ * A value that is not a KEY -- not lower-case ASCII -- is a literal the
544
+ * config carries on purpose: "ES/EN" on a language button, an endonym
545
+ * ("中文", "Español") in a language menu. `wordmark` and `alt` are
546
+ * deliberately out: a brand name is not translated. A key starting with
547
+ * `_` is a comment.
363
548
  ***************************************************************/
364
549
  const CONFIG_I18N_FIELDS = ["name", "aria_label", "tooltip", "label", "title", "subtitle"];
365
550
 
366
551
  function keys_from_shell_config(url, keys)
367
552
  {
368
553
  if(!existsSync(url)) {
369
- process.stdout.write(`validate-locales: no ${url.pathname}: no shell keys checked\n`);
554
+ note(`no ${url.pathname}: no shell keys checked`);
370
555
  return;
371
556
  }
372
557
  let config;
@@ -390,7 +575,7 @@ function keys_from_shell_config(url, keys)
390
575
  continue;
391
576
  }
392
577
  if(CONFIG_I18N_FIELDS.includes(k) && typeof v === "string" && v
393
- && v === v.toLowerCase()) {
578
+ && ASCII_RE.test(v) && v === v.toLowerCase()) {
394
579
  keys.add(v);
395
580
  }
396
581
  walk(v);
@@ -425,9 +610,9 @@ function barrel_export_map()
425
610
  } catch(e) {
426
611
  abort(`cannot read the gobj-ui barrel at ${LIB_INDEX.pathname}: ${e.message}`);
427
612
  }
428
- let re = /export\s*\{([^}]*)\}\s*from\s*["']\.\/src\/([\w.-]+\.js)["']/g;
613
+ let r = /export\s*\{([^}]*)\}\s*from\s*["']\.\/src\/([\w.-]+\.js)["']/g;
429
614
  let m;
430
- while((m = re.exec(src)) !== null) {
615
+ while((m = r.exec(src)) !== null) {
431
616
  let mod = m[2];
432
617
  for(let sym of m[1].split(",")) {
433
618
  sym = sym.trim();
@@ -444,8 +629,8 @@ function barrel_export_map()
444
629
 
445
630
  /***************************************************************
446
631
  * 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
632
+ * - deep: "@yuneta/gobj-ui/src/x.js", also by a dynamic import()
633
+ * - barrel: "@yuneta/gobj-ui[/index.js]", resolved per imported symbol
449
634
  * - inside the library: "./x.js" (in the app it names an app file,
450
635
  * scanned already)
451
636
  ***************************************************************/
@@ -454,7 +639,7 @@ function lib_imports(src, barrel, inside_lib)
454
639
  let out = [];
455
640
  let m;
456
641
 
457
- let from_app = /from\s+["']@yuneta\/gobj-ui\/src\/([\w.-]+\.js)["']/g;
642
+ let from_app = /(?:from\s+|import\(\s*)["']@yuneta\/gobj-ui\/src\/([\w.-]+\.js)["']/g;
458
643
  while((m = from_app.exec(src)) !== null) {
459
644
  out.push(m[1]);
460
645
  }
@@ -471,7 +656,7 @@ function lib_imports(src, barrel, inside_lib)
471
656
  }
472
657
 
473
658
  if(inside_lib) {
474
- let inside = /from\s+["']\.\/([\w.-]+\.js)["']/g;
659
+ let inside = /(?:from\s+|import\(\s*)["']\.\/([\w.-]+\.js)["']/g;
475
660
  while((m = inside.exec(src)) !== null) {
476
661
  out.push(m[1]);
477
662
  }
@@ -5010,9 +5010,16 @@ function create_form_button_row(parent, buttons)
5010
5010
  {
5011
5011
  let btnRow = document.createElement('div');
5012
5012
  btnRow.style.cssText = 'display:flex;gap:8px;';
5013
- for(let {text, style, onClick} of buttons) {
5013
+ for(let {i18n, style, onClick} of buttons) {
5014
+ /* `i18n` is the key (named so that validate-locales sees a literal
5015
+ * one): the button carries it, and its name */
5014
5016
  let btn = document.createElement('button');
5015
- btn.textContent = t(text);
5017
+ btn.textContent = t(i18n);
5018
+ btn.setAttribute('data-i18n', i18n);
5019
+ btn.setAttribute('title', t(i18n));
5020
+ btn.setAttribute('data-i18n-title', i18n);
5021
+ btn.setAttribute('aria-label', t(i18n));
5022
+ btn.setAttribute('data-i18n-aria-label', i18n);
5016
5023
  btn.style.cssText = style + touch_control_css();
5017
5024
  btn.addEventListener('click', (e) => {
5018
5025
  e.stopPropagation();
@@ -5059,12 +5066,12 @@ function show_confirm_popover(gobj, target_el, message, confirm_text, confirm_co
5059
5066
 
5060
5067
  create_form_button_row(popover, [
5061
5068
  {
5062
- text: 'cancel',
5069
+ i18n: 'cancel',
5063
5070
  style: BTN_STYLE_CANCEL,
5064
5071
  onClick: () => hide_overlay(gobj, priv_key),
5065
5072
  },
5066
5073
  {
5067
- text: confirm_text,
5074
+ i18n: confirm_text,
5068
5075
  style: 'flex:1;padding:6px;background:' + confirm_color + ';color:#fff;border:none;' +
5069
5076
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
5070
5077
  onClick: onConfirm,
@@ -6148,14 +6155,14 @@ function show_port_popover(gobj)
6148
6155
 
6149
6156
  create_form_button_row(popover, [
6150
6157
  {
6151
- text: 'cancel',
6158
+ i18n: 'cancel',
6152
6159
  style: BTN_STYLE_CANCEL,
6153
6160
  onClick: () => {
6154
6161
  hide_port_popover(gobj); /* which undoes the preview */
6155
6162
  },
6156
6163
  },
6157
6164
  {
6158
- text: 'apply',
6165
+ i18n: 'apply',
6159
6166
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
6160
6167
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
6161
6168
  onClick: () => {
@@ -7100,14 +7107,14 @@ function show_edge_popover(gobj)
7100
7107
 
7101
7108
  create_form_button_row(popover, [
7102
7109
  {
7103
- text: 'cancel',
7110
+ i18n: 'cancel',
7104
7111
  style: BTN_STYLE_CANCEL,
7105
7112
  onClick: () => {
7106
7113
  deselect_edge(gobj); /* which undoes the preview */
7107
7114
  },
7108
7115
  },
7109
7116
  {
7110
- text: 'apply',
7117
+ i18n: 'apply',
7111
7118
  style: 'flex:1;padding:6px;background:#52c41a;color:#fff;border:none;' +
7112
7119
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
7113
7120
  onClick: () => {
@@ -7349,14 +7356,14 @@ function show_node_popover(gobj)
7349
7356
 
7350
7357
  create_form_button_row(popover, [
7351
7358
  {
7352
- text: 'cancel',
7359
+ i18n: 'cancel',
7353
7360
  style: BTN_STYLE_CANCEL,
7354
7361
  onClick: () => {
7355
7362
  hide_node_popover(gobj); /* which undoes the preview */
7356
7363
  },
7357
7364
  },
7358
7365
  {
7359
- text: 'apply',
7366
+ i18n: 'apply',
7360
7367
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
7361
7368
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
7362
7369
  onClick: () => {
@@ -8431,15 +8438,24 @@ function show_create_popover(gobj)
8431
8438
  let titleEl = document.createElement('div');
8432
8439
  titleEl.style.cssText = 'margin-bottom:10px;font-weight:600;font-size:14px;';
8433
8440
  titleEl.textContent = t('create node');
8441
+ titleEl.setAttribute('data-i18n', 'create node');
8434
8442
  popover.appendChild(titleEl);
8435
8443
 
8436
- // Topic selector
8444
+ // Topic selector: the word carries its key, the colon is not translated
8437
8445
  let topicLabel = document.createElement('div');
8438
8446
  topicLabel.style.cssText = 'margin-bottom:4px;font-weight:500;';
8439
- topicLabel.textContent = t('topic') + ':';
8447
+ let topicWord = document.createElement('span');
8448
+ topicWord.textContent = t('topic');
8449
+ topicWord.setAttribute('data-i18n', 'topic');
8450
+ topicLabel.appendChild(topicWord);
8451
+ topicLabel.appendChild(document.createTextNode(':'));
8440
8452
  popover.appendChild(topicLabel);
8441
8453
 
8442
8454
  let topicSelect = document.createElement('select');
8455
+ topicSelect.setAttribute('title', t('topic'));
8456
+ topicSelect.setAttribute('data-i18n-title', 'topic');
8457
+ topicSelect.setAttribute('aria-label', t('topic'));
8458
+ topicSelect.setAttribute('data-i18n-aria-label', 'topic');
8443
8459
  topicSelect.style.cssText =
8444
8460
  'width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;' +
8445
8461
  'font-size:13px;margin-bottom:8px;' + touch_control_css();
@@ -8483,6 +8499,11 @@ function show_create_popover(gobj)
8483
8499
  let idInput = document.createElement('input');
8484
8500
  idInput.type = 'text';
8485
8501
  idInput.placeholder = t('node id');
8502
+ idInput.setAttribute('data-i18n-placeholder', 'node id');
8503
+ idInput.setAttribute('title', t('node id'));
8504
+ idInput.setAttribute('data-i18n-title', 'node id');
8505
+ idInput.setAttribute('aria-label', t('node id'));
8506
+ idInput.setAttribute('data-i18n-aria-label', 'node id');
8486
8507
  idInput.style.cssText =
8487
8508
  'width:100%;padding:6px;border:1px solid #d9d9d9;border-radius:4px;' +
8488
8509
  'font-size:13px;margin-bottom:10px;box-sizing:border-box;' +
@@ -8499,18 +8520,19 @@ function show_create_popover(gobj)
8499
8520
  let createBtn; // need reference for Enter key handler
8500
8521
  create_form_button_row(popover, [
8501
8522
  {
8502
- text: 'cancel',
8523
+ i18n: 'cancel',
8503
8524
  style: BTN_STYLE_CANCEL,
8504
8525
  onClick: () => hide_create_popover(gobj),
8505
8526
  },
8506
8527
  {
8507
- text: 'create',
8528
+ i18n: 'create',
8508
8529
  style: 'flex:1;padding:6px;background:#1890ff;color:#fff;border:none;' +
8509
8530
  'border-radius:4px;cursor:pointer;font-size:13px;font-weight:500;',
8510
8531
  onClick: () => {
8511
8532
  let node_id = idInput.value.trim();
8512
8533
  if(!node_id) {
8513
- errorEl.textContent = t('node id') + ' required';
8534
+ errorEl.textContent = t('node id required');
8535
+ errorEl.setAttribute('data-i18n', 'node id required');
8514
8536
  errorEl.style.display = 'block';
8515
8537
  idInput.focus();
8516
8538
  return;
@@ -328,11 +328,11 @@ function yui_file_control(gobj, {name, value, readonly, accept, on_pick})
328
328
  if(picked) {
329
329
  $state.classList.add("is-picked");
330
330
  $state.setAttribute("data-i18n", "not saved yet");
331
- $state.textContent = "not saved yet";
331
+ $state.textContent = t("not saved yet");
332
332
  } else if(!kept) {
333
333
  $state.classList.add("is-empty");
334
334
  $state.setAttribute("data-i18n", "no file");
335
- $state.textContent = "no file";
335
+ $state.textContent = t("no file");
336
336
  }
337
337
 
338
338
  $clear.hidden = readonly || (!picked && !kept);