gulp-mu-gulp-api 0.3.4 → 0.3.6
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 +63 -10
- package/package.json +1 -1
- package/src/i18x-engine.mjs +636 -0
- package/src/i18x.mjs +30 -73
- package/src/index.mjs +44 -0
package/README.md
CHANGED
|
@@ -32,6 +32,11 @@ The module is deliberately **dependency-free** and knows nothing about µGulp™
|
|
|
32
32
|
npm install gulp-mu-gulp-api
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
### 0.3.5
|
|
36
|
+
|
|
37
|
+
- **Added** `PlaySound(sample, { loop? })` and `StopSound(sample)` — play named samples from the dashboard µAU sound atlas; `loop: true` repeats until `StopSound`.
|
|
38
|
+
- **Changed** npm README: clearer i18x section (`Log`/`Warn` vs `.i18xTrans()` / `.i18xRegister()`).
|
|
39
|
+
|
|
35
40
|
### 0.3.3
|
|
36
41
|
|
|
37
42
|
- **Changed** npm README: centered µGulp logo and µGulp ready badge (absolute raw URLs from the µGulp repo).
|
|
@@ -50,7 +55,7 @@ npm install gulp-mu-gulp-api
|
|
|
50
55
|
## Usage
|
|
51
56
|
|
|
52
57
|
```javascript
|
|
53
|
-
import { ReportProgress, CreateProgress, PlaySignal, Speak, RequestTextInput, RequestColorInput, RequestFontInput, RequestSelectInput, RequestMultiSelectInput, RequestForm, IsMicroGulp, IsµGulp, InstallStringExtensions } from 'gulp-mu-gulp-api';
|
|
58
|
+
import { ReportProgress, CreateProgress, PlaySignal, PlaySound, StopSound, Speak, RequestTextInput, RequestColorInput, RequestFontInput, RequestSelectInput, RequestMultiSelectInput, RequestForm, IsMicroGulp, IsµGulp, InstallStringExtensions } from 'gulp-mu-gulp-api';
|
|
54
59
|
|
|
55
60
|
// Enables "phrase<context=\"…\"/>".i18xRegister()/.i18xTrans() on strings.
|
|
56
61
|
InstallStringExtensions();
|
|
@@ -77,7 +82,10 @@ export async function BUILD_THEME() {
|
|
|
77
82
|
progress.Done();
|
|
78
83
|
|
|
79
84
|
// sound + speech (rendered by the dashboard, configured in its settings)
|
|
80
|
-
PlaySignal('success');
|
|
85
|
+
PlaySignal('success'); // preset: success | error | attention
|
|
86
|
+
PlaySound('success'); // named atlas sample
|
|
87
|
+
PlaySound('engine', { loop: true }); // loop until StopSound('engine')
|
|
88
|
+
StopSound('engine');
|
|
81
89
|
Speak('Theme build finished.');
|
|
82
90
|
}
|
|
83
91
|
BUILD_THEME.µDisplayName = 'Build Theme<context="µDisplayName"/>'.i18xRegister();
|
|
@@ -106,12 +114,16 @@ let values = await RequestForm({
|
|
|
106
114
|
| :--- | :--- |
|
|
107
115
|
| `IsMicroGulp()` / `IsµGulp()` | `true` when the task runs under µGulp™ with an attached dashboard |
|
|
108
116
|
| `InstallStringExtensions()` | Installs µLib-compatible `String.prototype.i18xTrans()`/`i18xRegister()` (auto-run on import) |
|
|
109
|
-
| `Log(text, values?)` / `Warn` / `LogError` | Localized `console.*` via i18x (`<context="task log"/>` etc.) |
|
|
110
|
-
| `Translate(text, values?)` |
|
|
117
|
+
| `Log(text, values?)` / `Warn` / `LogError` | Localized `console.*` via i18x (`<context="task log"/>` etc.) — preferred for task log output |
|
|
118
|
+
| `Translate(text, values?)` | Low-level: returns the translated string without logging (rarely needed directly) |
|
|
119
|
+
| `"phrase".i18xTrans(values?)` | Prototype sugar for `Translate` — same result, ergonomic in expressions |
|
|
120
|
+
| `"phrase".i18xRegister()` | Registers metadata text (returns the source phrase unchanged as the i18x key) |
|
|
111
121
|
| `GetLid()` / `SetLid(lid?)` | Active language id; `SetLid` clears to automatic when omitted |
|
|
112
122
|
| `ReportProgress(value, label?)` | Report progress `0..1` to the task progress bar |
|
|
113
123
|
| `CreateProgress(label?)` | Reporter object with `Update(value, stepLabel?)` and `Done()` |
|
|
114
|
-
| `PlaySignal(signal?)` |
|
|
124
|
+
| `PlaySignal(signal?)` | Preset acoustic signal: `'success'` \| `'error'` \| `'attention'` (default) |
|
|
125
|
+
| `PlaySound(sample, options?)` | Named sample from the dashboard sound atlas; `options.loop` repeats until `StopSound` |
|
|
126
|
+
| `StopSound(sample)` | Stops a looping sample started with `PlaySound(sample, { loop: true })` |
|
|
115
127
|
| `Speak(text, options?)` | Speech output in the dashboard; `options`: `{ rate?, pitch?, volume? }` per-call overrides |
|
|
116
128
|
| `RequestForm(form)` | Request a complete form; resolves with `{ fieldId: value, … }` |
|
|
117
129
|
| `RequestTextInput(options)` | Single text field (with `validate` rules) |
|
|
@@ -172,6 +184,7 @@ A field descriptor is `{ id, type, label, default?, options?, validate?, min?, m
|
|
|
172
184
|
| :--- | :--- | :--- |
|
|
173
185
|
| `ReportProgress` | updating progress line | log line every 10% step |
|
|
174
186
|
| `PlaySignal` | terminal bell (`attention`/`error`) | silent |
|
|
187
|
+
| `PlaySound` / `StopSound` | `[sound] <name>` log line | silent |
|
|
175
188
|
| `Speak` | `[speech]` log line | `[speech]` log line |
|
|
176
189
|
| text/password/number/range input | readline prompt (Enter = default) | default value |
|
|
177
190
|
| font/select/radio input | numbered list + readline | default value |
|
|
@@ -183,6 +196,33 @@ Sound and speech playback (mute, volume, speech rate/pitch) is configured centra
|
|
|
183
196
|
|
|
184
197
|
MIT — © 2026 Meinolf Amekudzi
|
|
185
198
|
|
|
199
|
+
### Publishing (maintainers)
|
|
200
|
+
|
|
201
|
+
npm accounts with 2FA reject `npm publish` with `EOTP` when `~/.npmrc` holds a
|
|
202
|
+
web-login session token (`npm login --auth-type=web`). The reliable fix is a
|
|
203
|
+
**Granular Access Token** with **Bypass 2FA** and **write** access to this package:
|
|
204
|
+
|
|
205
|
+
1. npmjs.com → Access Tokens → Generate → Granular
|
|
206
|
+
2. Permissions: **Read and write**, Packages: `gulp-mu-gulp-api` (or All packages)
|
|
207
|
+
3. Enable **Bypass 2FA**, copy the token, then:
|
|
208
|
+
|
|
209
|
+
```powershell
|
|
210
|
+
npm config set //registry.npmjs.org/:_authToken <TOKEN>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
After that, publish without any OTP:
|
|
214
|
+
|
|
215
|
+
```powershell
|
|
216
|
+
npm run publish:api
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Bump `gulp-mu-gulp-api/package.json` before publishing. The task runs the module
|
|
220
|
+
tests first. As a one-off, a 2FA code still works via `NPM_OTP`:
|
|
221
|
+
|
|
222
|
+
```powershell
|
|
223
|
+
$env:NPM_OTP="123456"; npm run publish:api
|
|
224
|
+
```
|
|
225
|
+
|
|
186
226
|
---
|
|
187
227
|
|
|
188
228
|
# Deutsch
|
|
@@ -199,6 +239,11 @@ Das Modul ist bewusst **abhängigkeitsfrei** und kennt keine µGulp™-Interna:
|
|
|
199
239
|
npm install gulp-mu-gulp-api
|
|
200
240
|
```
|
|
201
241
|
|
|
242
|
+
### 0.3.5
|
|
243
|
+
|
|
244
|
+
- **Neu** `PlaySound(sample, { loop? })` und `StopSound(sample)` — benannte Samples aus dem Dashboard-µAU-Sound-Atlas; `loop: true` wiederholt bis `StopSound`.
|
|
245
|
+
- **Geändert** npm-README: klarere i18x-Sektion (`Log`/`Warn` vs. `.i18xTrans()` / `.i18xRegister()`).
|
|
246
|
+
|
|
202
247
|
### 0.3.3
|
|
203
248
|
|
|
204
249
|
- **Geändert** npm-README: zentriertes µGulp-Logo und µGulp-ready-Badge (absolute raw-URLs aus dem µGulp-Repo).
|
|
@@ -217,7 +262,7 @@ npm install gulp-mu-gulp-api
|
|
|
217
262
|
## Verwendung
|
|
218
263
|
|
|
219
264
|
```javascript
|
|
220
|
-
import { ReportProgress, CreateProgress, PlaySignal, Speak, RequestTextInput, RequestColorInput, RequestFontInput, RequestSelectInput, RequestMultiSelectInput, RequestForm, IsMicroGulp, IsµGulp, InstallStringExtensions } from 'gulp-mu-gulp-api';
|
|
265
|
+
import { ReportProgress, CreateProgress, PlaySignal, PlaySound, StopSound, Speak, RequestTextInput, RequestColorInput, RequestFontInput, RequestSelectInput, RequestMultiSelectInput, RequestForm, IsMicroGulp, IsµGulp, InstallStringExtensions } from 'gulp-mu-gulp-api';
|
|
221
266
|
|
|
222
267
|
// Enables "phrase<context=\"…\"/>".i18xRegister()/.i18xTrans() on strings.
|
|
223
268
|
InstallStringExtensions();
|
|
@@ -244,7 +289,10 @@ export async function BUILD_THEME() {
|
|
|
244
289
|
progress.Done();
|
|
245
290
|
|
|
246
291
|
// sound + speech (rendered by the dashboard, configured in its settings)
|
|
247
|
-
PlaySignal('success');
|
|
292
|
+
PlaySignal('success'); // preset: success | error | attention
|
|
293
|
+
PlaySound('success'); // named atlas sample
|
|
294
|
+
PlaySound('engine', { loop: true }); // loop until StopSound('engine')
|
|
295
|
+
StopSound('engine');
|
|
248
296
|
Speak('Theme build finished.');
|
|
249
297
|
}
|
|
250
298
|
BUILD_THEME.µDisplayName = 'Build Theme<context="µDisplayName"/>'.i18xRegister();
|
|
@@ -273,12 +321,16 @@ let values = await RequestForm({
|
|
|
273
321
|
| :--- | :--- |
|
|
274
322
|
| `IsMicroGulp()` / `IsµGulp()` | `true`, wenn der Task unter µGulp™ mit angebundenem Dashboard läuft |
|
|
275
323
|
| `InstallStringExtensions()` | Installiert µLib-kompatible `String.prototype.i18xTrans()`/`i18xRegister()` (läuft beim Import automatisch) |
|
|
276
|
-
| `Log(text, values?)` / `Warn` / `LogError` | Lokalisierte `console.*`-Ausgabe über i18x
|
|
277
|
-
| `Translate(text, values?)` |
|
|
324
|
+
| `Log(text, values?)` / `Warn` / `LogError` | Lokalisierte `console.*`-Ausgabe über i18x — bevorzugt für Task-Logs |
|
|
325
|
+
| `Translate(text, values?)` | Low-Level: übersetzter String ohne Log (selten direkt nötig) |
|
|
326
|
+
| `"phrase".i18xTrans(values?)` | Prototype-Kurzform für `Translate` |
|
|
327
|
+
| `"phrase".i18xRegister()` | Metadaten-Text registrieren (gibt die Quellphrase unverändert zurück) |
|
|
278
328
|
| `GetLid()` / `SetLid(lid?)` | Aktive Sprach-ID; `SetLid` ohne Argument = automatische Auflösung |
|
|
279
329
|
| `ReportProgress(value, label?)` | Fortschritt `0..1` an die Task-Progressbar melden |
|
|
280
330
|
| `CreateProgress(label?)` | Reporter-Objekt mit `Update(value, stepLabel?)` und `Done()` |
|
|
281
|
-
| `PlaySignal(signal?)` |
|
|
331
|
+
| `PlaySignal(signal?)` | Vorgabe-Signal: `'success'` \| `'error'` \| `'attention'` (Default) |
|
|
332
|
+
| `PlaySound(sample, options?)` | Benanntes Sample aus dem Sound-Atlas; `options.loop` wiederholt bis `StopSound` |
|
|
333
|
+
| `StopSound(sample)` | Stoppt eine Schleife von `PlaySound(sample, { loop: true })` |
|
|
282
334
|
| `Speak(text, options?)` | Sprachausgabe im Dashboard; `options`: `{ rate?, pitch?, volume? }` je Aufruf |
|
|
283
335
|
| `RequestForm(form)` | Komplettes Formular anfordern; löst mit `{ fieldId: value, … }` auf |
|
|
284
336
|
| `RequestTextInput(options)` | Einzelnes Textfeld (mit `validate`-Regeln) |
|
|
@@ -339,6 +391,7 @@ Ein Feld-Deskriptor ist `{ id, type, label, default?, options?, validate?, min?,
|
|
|
339
391
|
| :--- | :--- | :--- |
|
|
340
392
|
| `ReportProgress` | aktualisierende Fortschrittszeile | Log-Zeile je 10-%-Schritt |
|
|
341
393
|
| `PlaySignal` | Terminal-Glocke (`attention`/`error`) | stumm |
|
|
394
|
+
| `PlaySound` / `StopSound` | `[sound]`-Log-Zeile | stumm |
|
|
342
395
|
| `Speak` | `[speech]`-Log-Zeile | `[speech]`-Log-Zeile |
|
|
343
396
|
| Text-/Passwort-/Zahlen-/Range-Eingabe | readline-Prompt (Enter = Default) | Default-Wert |
|
|
344
397
|
| Font-/Select-/Radio-Eingabe | nummerierte Liste + readline | Default-Wert |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gulp-mu-gulp-api",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Public task API for the µGulp orchestrator: progress reporting, sound signals, speech output, localized console output (i18x) and interactive UI inputs (text, password, number, textarea, color, font, select, radio, multi-select checkbox, range slider, date/time, file) from within gulp tasks — with graceful CLI fallbacks when running without µGulp.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.mjs",
|
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
// ===========================================
|
|
2
|
+
// i18x-engine.mjs — eval-free i18x runtime core
|
|
3
|
+
// © 1996-2026 Meinolf Amekudzi
|
|
4
|
+
// (published under MIT license)
|
|
5
|
+
// ===========================================
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Dependency-free, eval-free implementation of the i18x rendering engine.
|
|
9
|
+
*
|
|
10
|
+
* This is a faithful port of the canonical i18x runtime (µLib `i18x.mjs` and
|
|
11
|
+
* the i18xe C# `i18x.Trans`/`CreateFormatPlaceholders`), rebuilt as a pure
|
|
12
|
+
* interpreter so it runs unchanged in a Node process and inside a
|
|
13
|
+
* Content-Security-Policy-restricted webview (no `eval`, no `new Function`).
|
|
14
|
+
*
|
|
15
|
+
* The engine renders the i18x XML notation:
|
|
16
|
+
* - placeholder tags: `<name/>`, `<name format="int"/>`
|
|
17
|
+
* - conditionals: `<x if="0-">-</x>`, `<n if="1~">s</n>` (`if`/`ifnot`/`ifin`/`ifnotin`)
|
|
18
|
+
* - inline formats: `<format name="…" …>…</format>` (also nested)
|
|
19
|
+
* - value transforms: `expression="=round(x*100)/100"`
|
|
20
|
+
* - enumerations: `<hour enumeration="AM|PM"/>`
|
|
21
|
+
* - digit tags: `<i0/>`…`<iN/>` (integer), `<f0/>`… (fixed fraction),
|
|
22
|
+
* `<g0/>` (grouped fraction), `<r0/>` (roman), `<i/>`/`<f/>` (whole part)
|
|
23
|
+
* - date/time tags: `<day/>`, `<month/>` (0-based), `<year/>`, `<hour/>`, `<minute/>`,
|
|
24
|
+
* `<second/>`, `<weekday/>` and their `utc…` variants
|
|
25
|
+
* - entities: `<lt/>`, `<gt/>`, `<space/>`, `<nbsp/>`, `<br/>`, `<newline/>`, …
|
|
26
|
+
*
|
|
27
|
+
* NOTE: The `<m…/>` (zero-cut fraction) tags render empty here, mirroring the
|
|
28
|
+
* canonical µLib runtime. They are slated for a rework together with the i18x
|
|
29
|
+
* format catalog; do not rely on `<m…/>` output until then.
|
|
30
|
+
*
|
|
31
|
+
* @module gulp-mu-gulp-api/i18x-engine
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// -------------------------------------------------
|
|
35
|
+
// expression evaluator (recursive descent, no eval)
|
|
36
|
+
// -------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const EXPRESSION_FUNCTIONS = {
|
|
39
|
+
abs: (_a) => Math.abs(_a),
|
|
40
|
+
acos: (_a) => Math.acos(_a),
|
|
41
|
+
asin: (_a) => Math.asin(_a),
|
|
42
|
+
atan: (_a) => Math.atan(_a),
|
|
43
|
+
atan2: (_a, _b) => Math.atan2(_a, _b),
|
|
44
|
+
ceil: (_a) => Math.ceil(_a),
|
|
45
|
+
ceiling: (_a) => Math.ceil(_a),
|
|
46
|
+
cos: (_a) => Math.cos(_a),
|
|
47
|
+
cosh: (_a) => Math.cosh(_a),
|
|
48
|
+
exp: (_a) => Math.exp(_a),
|
|
49
|
+
fact: (_a) => {
|
|
50
|
+
let n = Math.floor(_a);
|
|
51
|
+
if (n < 0) return NaN;
|
|
52
|
+
let result = 1;
|
|
53
|
+
for (let k = 2; k <= n; k++) result *= k;
|
|
54
|
+
return result;
|
|
55
|
+
},
|
|
56
|
+
floor: (_a) => Math.floor(_a),
|
|
57
|
+
frac: (_a) => _a - Math.trunc(_a),
|
|
58
|
+
int: (_a) => Math.trunc(_a),
|
|
59
|
+
ln: (_a) => Math.log(_a),
|
|
60
|
+
log: (_a, _b) => (_b === undefined ? Math.log10(_a) : Math.log(_a) / Math.log(_b)),
|
|
61
|
+
max: (..._args) => Math.max(..._args),
|
|
62
|
+
min: (..._args) => Math.min(..._args),
|
|
63
|
+
pow: (_a, _b) => Math.pow(_a, _b),
|
|
64
|
+
round: (_a) => Math.round(_a),
|
|
65
|
+
sign: (_a) => Math.sign(_a),
|
|
66
|
+
sin: (_a) => Math.sin(_a),
|
|
67
|
+
sinh: (_a) => Math.sinh(_a),
|
|
68
|
+
sqr: (_a) => _a * _a,
|
|
69
|
+
sqrt: (_a) => Math.sqrt(_a),
|
|
70
|
+
tan: (_a) => Math.tan(_a),
|
|
71
|
+
tanh: (_a) => Math.tanh(_a),
|
|
72
|
+
trunc: (_a) => Math.trunc(_a),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const EXPRESSION_CACHE = new Map();
|
|
76
|
+
|
|
77
|
+
/** Tokenizes an i18x expression string into numbers, identifiers and operators. */
|
|
78
|
+
function _TokenizeExpression(_source) {
|
|
79
|
+
let tokens = [];
|
|
80
|
+
let index = 0;
|
|
81
|
+
let length = _source.length;
|
|
82
|
+
while (index < length) {
|
|
83
|
+
let ch = _source[index];
|
|
84
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
85
|
+
index++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if ((ch >= '0' && ch <= '9') || ch === '.') {
|
|
89
|
+
let start = index;
|
|
90
|
+
while (index < length && ((_source[index] >= '0' && _source[index] <= '9') || _source[index] === '.')) index++;
|
|
91
|
+
tokens.push({ type: 'number', value: parseFloat(_source.slice(start, index)) });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_') {
|
|
95
|
+
let start = index;
|
|
96
|
+
while (index < length && ((_source[index] >= 'a' && _source[index] <= 'z') || (_source[index] >= 'A' && _source[index] <= 'Z') || (_source[index] >= '0' && _source[index] <= '9') || _source[index] === '_')) index++;
|
|
97
|
+
tokens.push({ type: 'ident', value: _source.slice(start, index) });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
tokens.push({ type: 'op', value: ch });
|
|
101
|
+
index++;
|
|
102
|
+
}
|
|
103
|
+
return tokens;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Evaluates an i18x `expression="…"` value against the bound variable `x`.
|
|
108
|
+
* The leading `=` (if present) is stripped. Supports `+ - * / %`, unary minus,
|
|
109
|
+
* parentheses and the math functions listed in EXPRESSION_FUNCTIONS.
|
|
110
|
+
* @param {string} _source expression source (with or without leading `=`)
|
|
111
|
+
* @param {number} _x value bound to the variable `x`
|
|
112
|
+
* @returns {number}
|
|
113
|
+
*/
|
|
114
|
+
export function EvalExpression(_source, _x) {
|
|
115
|
+
let source = String(_source);
|
|
116
|
+
if (source.charAt(0) === '=') source = source.slice(1);
|
|
117
|
+
let tokens = EXPRESSION_CACHE.get(source);
|
|
118
|
+
if (!tokens) {
|
|
119
|
+
tokens = _TokenizeExpression(source);
|
|
120
|
+
EXPRESSION_CACHE.set(source, tokens);
|
|
121
|
+
}
|
|
122
|
+
let position = 0;
|
|
123
|
+
|
|
124
|
+
function _Peek() { return tokens[position]; }
|
|
125
|
+
function _Next() { return tokens[position++]; }
|
|
126
|
+
|
|
127
|
+
function _ParsePrimary() {
|
|
128
|
+
let token = _Peek();
|
|
129
|
+
if (!token) return 0;
|
|
130
|
+
if (token.type === 'op' && token.value === '(') {
|
|
131
|
+
_Next();
|
|
132
|
+
let value = _ParseExpression();
|
|
133
|
+
if (_Peek() && _Peek().value === ')') _Next();
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
if (token.type === 'op' && token.value === '-') {
|
|
137
|
+
_Next();
|
|
138
|
+
return -_ParsePrimary();
|
|
139
|
+
}
|
|
140
|
+
if (token.type === 'op' && token.value === '+') {
|
|
141
|
+
_Next();
|
|
142
|
+
return _ParsePrimary();
|
|
143
|
+
}
|
|
144
|
+
if (token.type === 'number') {
|
|
145
|
+
_Next();
|
|
146
|
+
return token.value;
|
|
147
|
+
}
|
|
148
|
+
if (token.type === 'ident') {
|
|
149
|
+
_Next();
|
|
150
|
+
let name = token.value;
|
|
151
|
+
if (_Peek() && _Peek().value === '(') {
|
|
152
|
+
_Next();
|
|
153
|
+
let args = [];
|
|
154
|
+
if (!(_Peek() && _Peek().value === ')')) {
|
|
155
|
+
args.push(_ParseExpression());
|
|
156
|
+
while (_Peek() && _Peek().value === ',') {
|
|
157
|
+
_Next();
|
|
158
|
+
args.push(_ParseExpression());
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (_Peek() && _Peek().value === ')') _Next();
|
|
162
|
+
let fn = EXPRESSION_FUNCTIONS[name];
|
|
163
|
+
return fn ? Number(fn(...args)) : 0;
|
|
164
|
+
}
|
|
165
|
+
if (name === 'x') return Number(_x);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
_Next();
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _ParseTerm() {
|
|
173
|
+
let value = _ParsePrimary();
|
|
174
|
+
while (_Peek() && _Peek().type === 'op' && (_Peek().value === '*' || _Peek().value === '/' || _Peek().value === '%')) {
|
|
175
|
+
let op = _Next().value;
|
|
176
|
+
let right = _ParsePrimary();
|
|
177
|
+
if (op === '*') value *= right;
|
|
178
|
+
else if (op === '/') value /= right;
|
|
179
|
+
else value %= right;
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _ParseExpression() {
|
|
185
|
+
let value = _ParseTerm();
|
|
186
|
+
while (_Peek() && _Peek().type === 'op' && (_Peek().value === '+' || _Peek().value === '-')) {
|
|
187
|
+
let op = _Next().value;
|
|
188
|
+
let right = _ParseTerm();
|
|
189
|
+
if (op === '+') value += right;
|
|
190
|
+
else value -= right;
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let result = _ParseExpression();
|
|
196
|
+
return Number.isFinite(result) ? result : 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// -------------------------------------------------
|
|
200
|
+
// roman numerals
|
|
201
|
+
// -------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const ROMAN_TABLE = [
|
|
204
|
+
[1000, 'M'], [900, 'CM'], [500, 'D'], [400, 'CD'], [100, 'C'], [90, 'XC'],
|
|
205
|
+
[50, 'L'], [40, 'XL'], [10, 'X'], [9, 'IX'], [5, 'V'], [4, 'IV'], [1, 'I'],
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/** Converts a non-negative integer to its Roman-numeral string. */
|
|
209
|
+
export function ToRoman(_value) {
|
|
210
|
+
let n = Math.abs(Math.round(Number(_value) || 0));
|
|
211
|
+
let out = '';
|
|
212
|
+
for (let [amount, symbol] of ROMAN_TABLE) {
|
|
213
|
+
while (n >= amount) {
|
|
214
|
+
out += symbol;
|
|
215
|
+
n -= amount;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// -------------------------------------------------
|
|
222
|
+
// format definitions
|
|
223
|
+
// -------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Parses a `<format …>…</format>` definition into a reusable descriptor:
|
|
227
|
+
* name, attribute map, inner XML body and the pre-scanned maximum digit index
|
|
228
|
+
* per digit class (needed for the fill/padding logic).
|
|
229
|
+
* @param {string} _def full `<format …>…</format>` source
|
|
230
|
+
* @returns {{ name: string, attrs: object, xml: string, maxes: object, flags: object, hasDateTags: boolean }}
|
|
231
|
+
*/
|
|
232
|
+
export function ParseFormatDef(_def) {
|
|
233
|
+
let def = String(_def);
|
|
234
|
+
let start = def.indexOf('<format');
|
|
235
|
+
let headerEnd = def.indexOf('>', start);
|
|
236
|
+
let attrsText = def.slice(start + 7, headerEnd);
|
|
237
|
+
let bodyStart = headerEnd + 1;
|
|
238
|
+
let bodyEnd = def.lastIndexOf('</format>');
|
|
239
|
+
let xml = bodyEnd >= 0 ? def.slice(bodyStart, bodyEnd) : def.slice(bodyStart);
|
|
240
|
+
let attrs = _ParseAttributes(attrsText);
|
|
241
|
+
return {
|
|
242
|
+
name: attrs.name ?? '',
|
|
243
|
+
attrs,
|
|
244
|
+
xml,
|
|
245
|
+
maxes: _ScanDigitMaxes(xml),
|
|
246
|
+
hasDateTags: /<\/?(?:utc)?(?:day|month|year|hour|minute|second|millisecond|weekday|weekofyear|dayofyear|timezone)[\s/>]/.test(xml),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Parses an XML attribute list (`name="value" …`) into a plain object. */
|
|
251
|
+
function _ParseAttributes(_attrsText) {
|
|
252
|
+
let attrs = {};
|
|
253
|
+
let regex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*"([^"]*)"/g;
|
|
254
|
+
let match;
|
|
255
|
+
while ((match = regex.exec(_attrsText)) !== null) attrs[match[1]] = match[2];
|
|
256
|
+
return attrs;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Pre-scans a format body for the highest `<iN/>`, `<fN/>`, `<gN/>`, `<rN/>` index. */
|
|
260
|
+
function _ScanDigitMaxes(_xml) {
|
|
261
|
+
let maxes = { i: 0, f: 0, g: 0, r: 0 };
|
|
262
|
+
let regex = /<([ifgr])(\d+)\s*\/?>/g;
|
|
263
|
+
let match;
|
|
264
|
+
while ((match = regex.exec(_xml)) !== null) {
|
|
265
|
+
let cls = match[1];
|
|
266
|
+
let idx = parseInt(match[2], 10);
|
|
267
|
+
if (idx + 1 > maxes[cls]) maxes[cls] = idx + 1;
|
|
268
|
+
}
|
|
269
|
+
return maxes;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Registers a `<format …>` definition into a name-keyed map.
|
|
274
|
+
* @param {Object<string, object>} _formats target map (mutated)
|
|
275
|
+
* @param {string} _def format source
|
|
276
|
+
* @param {boolean} [_onlyIfNotExists] keep an existing entry with the same name
|
|
277
|
+
*/
|
|
278
|
+
export function RegisterFormat(_formats, _def, _onlyIfNotExists) {
|
|
279
|
+
let parsed = ParseFormatDef(_def);
|
|
280
|
+
if (!parsed.name) return;
|
|
281
|
+
if (_onlyIfNotExists && Object.prototype.hasOwnProperty.call(_formats, parsed.name)) return;
|
|
282
|
+
_formats[parsed.name] = parsed;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// -------------------------------------------------
|
|
286
|
+
// value decomposition (CreateFormatPlaceholders port)
|
|
287
|
+
// -------------------------------------------------
|
|
288
|
+
|
|
289
|
+
/** Builds the digit-fill array exactly as the canonical engine does. */
|
|
290
|
+
function _BuildFill(_count, _chr, _maxNo) {
|
|
291
|
+
let ret = [];
|
|
292
|
+
if (_maxNo > 0) {
|
|
293
|
+
let maxNo = _maxNo > _count ? _count : _maxNo;
|
|
294
|
+
for (let n = 0; n < maxNo; n++) ret.push(_chr);
|
|
295
|
+
for (let n = maxNo; n < _count; n++) ret.push('');
|
|
296
|
+
ret.reverse();
|
|
297
|
+
} else {
|
|
298
|
+
for (let n = 0; n < _count; n++) ret.push(_chr);
|
|
299
|
+
}
|
|
300
|
+
return ret;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Applies a `0=零|1=一`-style digit replacement map to a numeric string. */
|
|
304
|
+
function _ApplyDigitMap(_string, _digitsAttr) {
|
|
305
|
+
if (!_digitsAttr) return _string;
|
|
306
|
+
let map = {};
|
|
307
|
+
for (let pair of _digitsAttr.split('|')) {
|
|
308
|
+
let [key, value] = pair.split('=');
|
|
309
|
+
if (key !== undefined && value !== undefined) map[key] = value;
|
|
310
|
+
}
|
|
311
|
+
let out = '';
|
|
312
|
+
for (let ch of _string) out += Object.prototype.hasOwnProperty.call(map, ch) ? map[ch] : ch;
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Decomposes a numeric value into the placeholder set consumed by a number
|
|
318
|
+
* format body: `x`, `xa`, whole-part `i`/`f`, indexed `i0…`, `f0…`, `g0…`,
|
|
319
|
+
* roman `r0…`.
|
|
320
|
+
*/
|
|
321
|
+
function _NumberPlaceholders(_value, _def) {
|
|
322
|
+
let attrs = _def.attrs;
|
|
323
|
+
let base = parseInt(attrs.base ?? '10', 10) || 10;
|
|
324
|
+
let value = Number(_value) || 0;
|
|
325
|
+
if (attrs.expression) value = EvalExpression(attrs.expression, value);
|
|
326
|
+
let xa = Math.abs(value);
|
|
327
|
+
let numericString = base === 10 ? xa.toString() : Math.trunc(xa).toString(base);
|
|
328
|
+
numericString = _ApplyDigitMap(numericString, attrs.digits);
|
|
329
|
+
let parts = numericString.split('.');
|
|
330
|
+
let intString = parts[0] ?? '';
|
|
331
|
+
let fracString = parts[1] ?? '';
|
|
332
|
+
|
|
333
|
+
let placeholders = { x: value, xa, i: intString, f: fracString };
|
|
334
|
+
|
|
335
|
+
let iMax = _def.maxes.i;
|
|
336
|
+
if (iMax > 0) {
|
|
337
|
+
let fill = _BuildFill(iMax, attrs.ifillchr ?? '', parseInt(attrs.ifillmax ?? '0', 10) || 0);
|
|
338
|
+
let digits = intString.split('').reverse().concat(fill);
|
|
339
|
+
for (let n = 0; n < iMax; n++) placeholders['i' + n] = digits[n] ?? '';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
let fMax = _def.maxes.f;
|
|
343
|
+
if (fMax > 0) {
|
|
344
|
+
let fill = _BuildFill(fMax, attrs.ffillchr ?? '', parseInt(attrs.ffillmax ?? '0', 10) || 0);
|
|
345
|
+
let digits = fracString.split('').concat(fill);
|
|
346
|
+
for (let n = 0; n < fMax; n++) placeholders['f' + n] = digits[n] ?? '';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let gMax = _def.maxes.g;
|
|
350
|
+
if (gMax > 0) {
|
|
351
|
+
let fill = _BuildFill(gMax, attrs.gfillchr ?? '', parseInt(attrs.gfillmax ?? '0', 10) || 0);
|
|
352
|
+
let digits = fracString.split('').concat(fill);
|
|
353
|
+
for (let n = 0; n < gMax; n++) placeholders['g' + n] = digits[n] ?? '';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let rMax = _def.maxes.r;
|
|
357
|
+
if (rMax > 0) {
|
|
358
|
+
let roman = ToRoman(value).split('').reverse();
|
|
359
|
+
let fill = _BuildFill(rMax, attrs.rfillchr ?? '', parseInt(attrs.rfillmax ?? '0', 10) || 0);
|
|
360
|
+
let digits = roman.concat(fill);
|
|
361
|
+
for (let n = 0; n < rMax; n++) placeholders['r' + n] = digits[n] ?? '';
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return placeholders;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const WALL_CLOCK_FORMATTERS = new Map();
|
|
368
|
+
|
|
369
|
+
/** Extracts wall-clock components for a timestamp in a given IANA timezone. */
|
|
370
|
+
function _WallClockParts(_timestampMs, _timeZone) {
|
|
371
|
+
let tz = _timeZone || 'UTC';
|
|
372
|
+
let formatter = WALL_CLOCK_FORMATTERS.get(tz);
|
|
373
|
+
if (!formatter) {
|
|
374
|
+
try {
|
|
375
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
376
|
+
timeZone: tz, hour12: false, weekday: 'short',
|
|
377
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
378
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
379
|
+
});
|
|
380
|
+
} catch {
|
|
381
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
382
|
+
hour12: false, weekday: 'short',
|
|
383
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
384
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
WALL_CLOCK_FORMATTERS.set(tz, formatter);
|
|
388
|
+
}
|
|
389
|
+
let values = {};
|
|
390
|
+
for (let part of formatter.formatToParts(new Date(Number(_timestampMs) || 0))) values[part.type] = part.value;
|
|
391
|
+
let weekdayIndex = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }[values.weekday] ?? 0;
|
|
392
|
+
let hour = parseInt(values.hour, 10) % 24;
|
|
393
|
+
return {
|
|
394
|
+
day: parseInt(values.day, 10),
|
|
395
|
+
month: parseInt(values.month, 10) - 1,
|
|
396
|
+
year: parseInt(values.year, 10),
|
|
397
|
+
hour,
|
|
398
|
+
minute: parseInt(values.minute, 10),
|
|
399
|
+
second: parseInt(values.second, 10),
|
|
400
|
+
weekday: weekdayIndex,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Decomposes a timestamp into date/time placeholders (local tz + utc variants). */
|
|
405
|
+
function _DatePlaceholders(_value, _timeZone) {
|
|
406
|
+
let ms = Number(_value) || 0;
|
|
407
|
+
let local = _WallClockParts(ms, _timeZone);
|
|
408
|
+
let utcDate = new Date(ms);
|
|
409
|
+
return {
|
|
410
|
+
day: local.day, month: local.month, year: local.year,
|
|
411
|
+
hour: local.hour, minute: local.minute, second: local.second,
|
|
412
|
+
weekday: local.weekday, millisecond: utcDate.getUTCMilliseconds(),
|
|
413
|
+
timezone: _timeZone || 'UTC',
|
|
414
|
+
utcday: utcDate.getUTCDate(), utcmonth: utcDate.getUTCMonth(), utcyear: utcDate.getUTCFullYear(),
|
|
415
|
+
utchour: utcDate.getUTCHours(), utcminute: utcDate.getUTCMinutes(), utcsecond: utcDate.getUTCSeconds(),
|
|
416
|
+
utcweekday: utcDate.getUTCDay(), utcmillisecond: utcDate.getUTCMilliseconds(),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// -------------------------------------------------
|
|
421
|
+
// named-format rendering
|
|
422
|
+
// -------------------------------------------------
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Formats a value with a named format definition.
|
|
426
|
+
* @param {number} _value
|
|
427
|
+
* @param {string} _formatName
|
|
428
|
+
* @param {object} _ctx render context `{ lid, timeZone, formats }`
|
|
429
|
+
* @returns {string}
|
|
430
|
+
*/
|
|
431
|
+
export function FormatValue(_value, _formatName, _ctx) {
|
|
432
|
+
let def = _ResolveFormat(_formatName, _ctx);
|
|
433
|
+
if (!def) return String(_value);
|
|
434
|
+
let placeholders = _NumberPlaceholders(_value, def);
|
|
435
|
+
if (def.hasDateTags) Object.assign(placeholders, _DatePlaceholders(_value, _ctx.timeZone), { x: Number(_value) || 0 });
|
|
436
|
+
return Trans(def.xml, placeholders, _ctx);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Resolves a format by name from the local defs, then the context registry. */
|
|
440
|
+
function _ResolveFormat(_name, _ctx, _localFormats) {
|
|
441
|
+
if (_localFormats && Object.prototype.hasOwnProperty.call(_localFormats, _name)) return _localFormats[_name];
|
|
442
|
+
let formats = _ctx.formats;
|
|
443
|
+
if (formats && Object.prototype.hasOwnProperty.call(formats, _name)) return formats[_name];
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// -------------------------------------------------
|
|
448
|
+
// entity tags
|
|
449
|
+
// -------------------------------------------------
|
|
450
|
+
|
|
451
|
+
const ENTITY_TAGS = {
|
|
452
|
+
lt: '<', gt: '>', obrc: '{', cbrc: '}', brl: '\u25C4', brr: '\u25BA',
|
|
453
|
+
br: '<br/>', newline: '\n', slashnewline: '\\n', quot: '"', quote: '"',
|
|
454
|
+
squot: "'", apos: "'", singlequote: "'", space: ' ', whitespace: ' ',
|
|
455
|
+
amp: '&', nbsp: '\u00A0', tab: '\t', hyphen: '\u00AD', '-': '\u00AD',
|
|
456
|
+
newpage: '\u21A1',
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
// -------------------------------------------------
|
|
460
|
+
// core interpreter (i18x.Trans port)
|
|
461
|
+
// -------------------------------------------------
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Renders an i18x XML string against a placeholder set. This is the shared
|
|
465
|
+
* interpreter used both for translated phrases and for format bodies.
|
|
466
|
+
* @param {string} _text i18x XML source (already looked up / translated)
|
|
467
|
+
* @param {object} [_placeholders] values for `<name/>` tags
|
|
468
|
+
* @param {object} [_ctx] render context `{ lid, timeZone, formats }`
|
|
469
|
+
* @returns {string}
|
|
470
|
+
*/
|
|
471
|
+
export function Trans(_text, _placeholders, _ctx) {
|
|
472
|
+
if (_text == null) return '';
|
|
473
|
+
let text = String(_text);
|
|
474
|
+
if (text.indexOf('<') === -1) return text;
|
|
475
|
+
|
|
476
|
+
let placeholders = _placeholders ?? {};
|
|
477
|
+
let ctx = _ctx ?? { lid: 'en-US', timeZone: 'UTC', formats: {} };
|
|
478
|
+
let localFormats = null;
|
|
479
|
+
|
|
480
|
+
let tokens = ('<i18x>' + text + '</i18x>').split('<');
|
|
481
|
+
let level = 0;
|
|
482
|
+
let txts = [''];
|
|
483
|
+
let visibles = [true];
|
|
484
|
+
let length = tokens.length;
|
|
485
|
+
|
|
486
|
+
for (let i = 0; i < length; i++) {
|
|
487
|
+
let token = tokens[i];
|
|
488
|
+
let closeIndex = token.indexOf('>');
|
|
489
|
+
if (closeIndex === -1) {
|
|
490
|
+
txts[level] += token;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let trailing = token.slice(closeIndex + 1);
|
|
495
|
+
let isClosingTag = token.charAt(0) === '/';
|
|
496
|
+
let isClosedTag = token.charAt(closeIndex - 1) === '/';
|
|
497
|
+
let sliceStart = 0;
|
|
498
|
+
let sliceEnd = closeIndex;
|
|
499
|
+
if (isClosedTag) sliceEnd -= 1;
|
|
500
|
+
if (isClosingTag) { sliceStart = 1; sliceEnd -= 1; }
|
|
501
|
+
|
|
502
|
+
let head = token.slice(sliceStart, sliceEnd);
|
|
503
|
+
let spaceIndex = head.indexOf(' ');
|
|
504
|
+
let tag = spaceIndex === -1 ? head : head.slice(0, spaceIndex);
|
|
505
|
+
let attrs = spaceIndex === -1 ? {} : _ParseAttributes(head.slice(spaceIndex + 1));
|
|
506
|
+
|
|
507
|
+
// inline <format> definitions: collect the raw block (honoring nesting),
|
|
508
|
+
// register it locally and skip over it without rendering its body.
|
|
509
|
+
if (tag === 'format' && !isClosingTag && !isClosedTag) {
|
|
510
|
+
let depth = 1;
|
|
511
|
+
let j = i;
|
|
512
|
+
while (depth > 0 && ++j < length) {
|
|
513
|
+
let inner = tokens[j];
|
|
514
|
+
let innerClose = inner.indexOf('>');
|
|
515
|
+
if (innerClose === -1) continue;
|
|
516
|
+
let innerClosing = inner.charAt(0) === '/';
|
|
517
|
+
let innerClosed = inner.charAt(innerClose - 1) === '/';
|
|
518
|
+
let innerHead = inner.slice(innerClosing ? 1 : 0, innerClose);
|
|
519
|
+
let innerSpace = innerHead.indexOf(' ');
|
|
520
|
+
let innerTag = innerSpace === -1 ? innerHead.replace(/\/$/, '') : innerHead.slice(0, innerSpace);
|
|
521
|
+
if (innerTag === 'format') {
|
|
522
|
+
if (innerClosing) depth--;
|
|
523
|
+
else if (!innerClosed) depth++;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
let defSource = '<' + tokens.slice(i, j + 1).join('<');
|
|
527
|
+
if (!localFormats) localFormats = Object.create(null);
|
|
528
|
+
RegisterFormat(localFormats, defSource, true);
|
|
529
|
+
if (j < length) txts[level] += tokens[j].slice(tokens[j].indexOf('>') + 1);
|
|
530
|
+
i = j;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let value = '';
|
|
535
|
+
let doInsertValue = false;
|
|
536
|
+
|
|
537
|
+
if (isClosingTag) {
|
|
538
|
+
level--;
|
|
539
|
+
txts[level] += visibles[level + 1] ? txts[level + 1] : '';
|
|
540
|
+
txts[level] += trailing;
|
|
541
|
+
continue;
|
|
542
|
+
} else if (isClosedTag) {
|
|
543
|
+
doInsertValue = true;
|
|
544
|
+
} else {
|
|
545
|
+
level++;
|
|
546
|
+
txts[level] = '';
|
|
547
|
+
visibles[level] = true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (Object.prototype.hasOwnProperty.call(placeholders, tag)) {
|
|
551
|
+
let raw = placeholders[tag];
|
|
552
|
+
value = raw instanceof Date ? raw.getTime() : raw;
|
|
553
|
+
}
|
|
554
|
+
if (Object.prototype.hasOwnProperty.call(ENTITY_TAGS, tag)) value = ENTITY_TAGS[tag];
|
|
555
|
+
|
|
556
|
+
// process attributes in document order: value, expression, format, enumeration, if.
|
|
557
|
+
for (let attrKey of Object.keys(attrs)) {
|
|
558
|
+
let attrValue = attrs[attrKey];
|
|
559
|
+
if (attrKey === 'value') {
|
|
560
|
+
value = attrValue;
|
|
561
|
+
} else if (attrKey === 'expression') {
|
|
562
|
+
value = EvalExpression(attrValue, Number(value) || 0);
|
|
563
|
+
} else if (attrKey === 'format') {
|
|
564
|
+
let def = _ResolveFormat(attrValue, ctx, localFormats);
|
|
565
|
+
if (def) {
|
|
566
|
+
let subContext = localFormats ? { ...ctx, formats: { ...(ctx.formats ?? {}), ...localFormats } } : ctx;
|
|
567
|
+
value = FormatValue(Number(value) || 0, attrValue, subContext);
|
|
568
|
+
} else {
|
|
569
|
+
value = '';
|
|
570
|
+
}
|
|
571
|
+
} else if (attrKey === 'enumeration') {
|
|
572
|
+
let options = attrValue.split('|');
|
|
573
|
+
let index = parseInt(value, 10);
|
|
574
|
+
value = Number.isFinite(index) && index >= 0 && index < options.length ? options[index] : '';
|
|
575
|
+
} else if (attrKey === 'if' || attrKey === 'ifnot' || attrKey === 'ifin' || attrKey === 'ifnotin') {
|
|
576
|
+
visibles[level] = _EvaluateCondition(value, attrValue, attrKey);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
txts[level] += (doInsertValue ? value : '') + trailing;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// flush any levels left open by unbalanced tags (e.g. the malformed roman format).
|
|
584
|
+
while (level > 0) {
|
|
585
|
+
txts[level - 1] += visibles[level] ? txts[level] : '';
|
|
586
|
+
level--;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return txts[0].replace(/\0/g, '');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Evaluates an `if`/`ifnot`/`ifin`/`ifnotin` condition against a value. */
|
|
593
|
+
function _EvaluateCondition(_value, _condition, _attrKey) {
|
|
594
|
+
let numeric = Number(_value);
|
|
595
|
+
let isNumber = _value !== '' && _value !== null && Number.isFinite(numeric);
|
|
596
|
+
let compareNumber = isNumber ? numeric : NaN;
|
|
597
|
+
let compareString = String(_value);
|
|
598
|
+
let isIn = _attrKey === 'ifin' || _attrKey === 'ifnotin';
|
|
599
|
+
|
|
600
|
+
let matched = false;
|
|
601
|
+
for (let clause of _condition.split('|')) {
|
|
602
|
+
let operator = clause.charAt(clause.length - 1);
|
|
603
|
+
let operand = clause;
|
|
604
|
+
if (operator === '+' || operator === '-' || operator === '=' || operator === '~' || operator === '*') {
|
|
605
|
+
operand = clause.slice(0, -1);
|
|
606
|
+
} else {
|
|
607
|
+
operator = '';
|
|
608
|
+
}
|
|
609
|
+
let operandNumber = Number(operand);
|
|
610
|
+
let operandIsNumber = operand !== '' && Number.isFinite(operandNumber);
|
|
611
|
+
|
|
612
|
+
if (isNumber && operandIsNumber) {
|
|
613
|
+
if (operator === '+') matched = matched || compareNumber > operandNumber;
|
|
614
|
+
else if (operator === '-') matched = matched || compareNumber < operandNumber;
|
|
615
|
+
else if (operator === '=') matched = matched || compareNumber === operandNumber;
|
|
616
|
+
else if (operator === '~') matched = matched || compareNumber !== operandNumber;
|
|
617
|
+
else if (operator === '*') matched = matched || compareString.indexOf(operand) >= 0;
|
|
618
|
+
else matched = matched || compareNumber === operandNumber;
|
|
619
|
+
} else {
|
|
620
|
+
if (operator === '+') matched = matched || compareString > operand;
|
|
621
|
+
else if (operator === '-') matched = matched || compareString < operand;
|
|
622
|
+
else if (operator === '=') matched = matched || compareString === operand;
|
|
623
|
+
else if (operator === '~') matched = matched || compareString !== operand;
|
|
624
|
+
else if (operator === '*') matched = matched || compareString.indexOf(operand) >= 0;
|
|
625
|
+
else if (isIn) matched = matched || compareString.indexOf(operand) >= 0;
|
|
626
|
+
else matched = matched || compareString === operand;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (_attrKey === 'ifnot' || _attrKey === 'ifnotin') matched = !matched;
|
|
630
|
+
return matched;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Removes any remaining i18x/XML tags from a string (after placeholder substitution). */
|
|
634
|
+
export function StripTags(_text) {
|
|
635
|
+
return String(_text).replace(/<[^>]*>/g, '');
|
|
636
|
+
}
|
package/src/i18x.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
|
|
32
32
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
33
33
|
import { join } from 'node:path';
|
|
34
|
+
import { Trans as EngineTrans, FormatValue as EngineFormatValue, RegisterFormat } from './i18x-engine.mjs';
|
|
34
35
|
|
|
35
36
|
const DEFAULT_LID = 'en-US';
|
|
36
37
|
|
|
@@ -51,6 +52,7 @@ const LIDS_MAIN_CULTURES = {
|
|
|
51
52
|
|
|
52
53
|
let dictionaryCache = new Map();
|
|
53
54
|
let formatDefinitionCache = new Map();
|
|
55
|
+
let formatRegistryCache = new Map();
|
|
54
56
|
let availableLids = null;
|
|
55
57
|
let activeLid = null;
|
|
56
58
|
const timeZoneFormatters = {};
|
|
@@ -177,6 +179,7 @@ export function GetLid() {
|
|
|
177
179
|
export function SetLid(_lid) {
|
|
178
180
|
activeLid = _lid ? _BestAvailableLid(_lid, _AvailableLids()) : null;
|
|
179
181
|
formatDefinitionCache.clear();
|
|
182
|
+
formatRegistryCache.clear();
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
/** Loads and caches `<dir>/<lid>.json`; a missing/broken file yields source text. */
|
|
@@ -215,90 +218,42 @@ function _LoadFormatDefinitions(_lid) {
|
|
|
215
218
|
return formats;
|
|
216
219
|
}
|
|
217
220
|
|
|
218
|
-
/**
|
|
219
|
-
function
|
|
220
|
-
|
|
221
|
-
let
|
|
222
|
-
let
|
|
223
|
-
let
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
221
|
+
/** Builds (and caches) the name→parsed-definition format registry for a language. */
|
|
222
|
+
function _FormatRegistry(_lid) {
|
|
223
|
+
if (formatRegistryCache.has(_lid)) return formatRegistryCache.get(_lid);
|
|
224
|
+
let registry = Object.create(null);
|
|
225
|
+
let definitions = _LoadFormatDefinitions(_lid);
|
|
226
|
+
for (let xml of Object.values(definitions)) RegisterFormat(registry, xml, false);
|
|
227
|
+
formatRegistryCache.set(_lid, registry);
|
|
228
|
+
return registry;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Assembles the engine render context (language, timezone, format registry). */
|
|
232
|
+
function _RenderContext(_lid) {
|
|
233
|
+
let lid = _lid ?? GetLid();
|
|
234
|
+
return { lid, timeZone: GetTimeZone(), formats: _FormatRegistry(lid) };
|
|
227
235
|
}
|
|
228
236
|
|
|
229
237
|
/**
|
|
230
|
-
* Formats a numeric value with a named i18xe format definition from the
|
|
231
|
-
* project's i18x/gulp dictionaries (synced from i18xe prod).
|
|
238
|
+
* Formats a numeric/date value with a named i18xe format definition from the
|
|
239
|
+
* project's i18x/gulp dictionaries (synced from i18xe prod). Delegates to the
|
|
240
|
+
* eval-free i18x engine, so the full i18x notation (fill/padding, `if`
|
|
241
|
+
* conditions, nested formats, expressions, enumerations, date parts) is honored.
|
|
232
242
|
* @param {number|string} _value
|
|
233
|
-
* @param {string} _formatName e.g. 'int', 'floatFix2', 'byteSize'
|
|
243
|
+
* @param {string} _formatName e.g. 'int', 'floatFix2', 'byteSize', 'stdDateTime'
|
|
234
244
|
* @param {string} [_lid]
|
|
235
245
|
* @returns {string}
|
|
236
246
|
*/
|
|
237
247
|
export function FormatValue(_value, _formatName, _lid) {
|
|
238
|
-
let
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (_formatName === 'stdTime') {
|
|
242
|
-
let date = new Date(Number(_value) || 0);
|
|
243
|
-
try {
|
|
244
|
-
return new Intl.DateTimeFormat(lid, { hour: '2-digit', minute: '2-digit', timeZone: tz }).format(date);
|
|
245
|
-
} catch {
|
|
246
|
-
return date.toISOString();
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
if (_formatName === 'stdDateTime' || _formatName === 'fullDateTime') {
|
|
250
|
-
let date = new Date(Number(_value) || 0);
|
|
251
|
-
try {
|
|
252
|
-
return new Intl.DateTimeFormat(lid, {
|
|
253
|
-
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
254
|
-
hour: '2-digit', minute: '2-digit', timeZone: tz,
|
|
255
|
-
}).format(date);
|
|
256
|
-
} catch {
|
|
257
|
-
return date.toISOString();
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
if (_formatName === 'byteSize') {
|
|
261
|
-
let bytes = Number(_value);
|
|
262
|
-
if (!Number.isFinite(bytes)) return String(_value);
|
|
263
|
-
if (bytes >= 1073741824) return FormatValue(bytes / 1073741824, 'floatFix2', lid) + ' GB';
|
|
264
|
-
if (bytes >= 1048576) return FormatValue(bytes / 1048576, 'floatFix2', lid) + ' MB';
|
|
265
|
-
if (bytes >= 1024) return FormatValue(bytes / 1024, 'floatFix2', lid) + ' KB';
|
|
266
|
-
return FormatValue(bytes, 'int', lid) + ' Bytes';
|
|
267
|
-
}
|
|
268
|
-
let number = Number(_value);
|
|
269
|
-
if (!Number.isFinite(number)) return String(_value);
|
|
270
|
-
let definition = _LoadFormatDefinitions(lid)[_formatName];
|
|
271
|
-
if (!definition) return String(_value);
|
|
272
|
-
let groupSeparator = definition.match(/<xa if="1000\|1000\+">([^<]*)<\/xa>/)?.[1] ?? ',';
|
|
273
|
-
let decimalSeparator = definition.match(/if="0~">([^<]*)<m0\/>/)?.[1]
|
|
274
|
-
?? definition.match(/<\/xa>([^<]*)<f0\/>/)?.[1] ?? '.';
|
|
275
|
-
let fractionDigits = _formatName === 'floatFix1' ? 1
|
|
276
|
-
: (_formatName.startsWith('floatFix') ? 2 : 0);
|
|
277
|
-
if (_formatName === 'int') number = Math.round(number);
|
|
278
|
-
let negative = number < 0;
|
|
279
|
-
let absolute = Math.abs(number);
|
|
280
|
-
let integerPart = String(Math.trunc(fractionDigits ? absolute : Math.round(absolute)));
|
|
281
|
-
let grouped = integerPart.replace(/\B(?=(\d{3})+(?!\d))/g, groupSeparator);
|
|
282
|
-
let fraction = fractionDigits ? decimalSeparator + absolute.toFixed(fractionDigits).split('.')[1] : '';
|
|
283
|
-
return (negative ? '-' : '') + grouped + fraction;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Substitutes `<name/>` placeholders; honors `format="…"` via FormatValue. */
|
|
287
|
-
function _ApplyPlaceholders(_text, _values) {
|
|
288
|
-
let text = String(_text);
|
|
289
|
-
let lid = GetLid();
|
|
290
|
-
for (let key of Object.keys(_values ?? {})) {
|
|
291
|
-
let pattern = new RegExp('<' + key + '(?:\\s+format="([^"]*)")?\\s*/>', 'g');
|
|
292
|
-
text = text.replace(pattern, (_match, _formatName) =>
|
|
293
|
-
_formatName ? FormatValue(_values[key], _formatName, lid) : String(_values[key]));
|
|
294
|
-
}
|
|
295
|
-
return text.replace(/<[^>]*>/g, '');
|
|
248
|
+
let context = _RenderContext(_lid);
|
|
249
|
+
if (!Object.prototype.hasOwnProperty.call(context.formats, _formatName)) return String(_value);
|
|
250
|
+
return EngineFormatValue(_value, _formatName, context);
|
|
296
251
|
}
|
|
297
252
|
|
|
298
253
|
/**
|
|
299
254
|
* Translates a source phrase (including its context tag) into the active
|
|
300
|
-
* language and
|
|
301
|
-
*
|
|
255
|
+
* language and renders it through the i18x engine (placeholders, `format="…"`,
|
|
256
|
+
* `if` conditions, …). Unknown phrases fall back to the en-US source text.
|
|
302
257
|
* @param {string} _text source phrase, e.g. 'Build ready<context="task log"/>'
|
|
303
258
|
* @param {object} [_values] placeholder values for `<name/>` tags
|
|
304
259
|
* @returns {string}
|
|
@@ -309,7 +264,9 @@ export function Translate(_text, _values) {
|
|
|
309
264
|
let lid = GetLid();
|
|
310
265
|
let dictionary = lid === DEFAULT_LID ? null : _LoadDictionary(lid);
|
|
311
266
|
let translated = (dictionary && Object.prototype.hasOwnProperty.call(dictionary, source)) ? dictionary[source] : source;
|
|
312
|
-
|
|
267
|
+
// The context tag is part of the dictionary key only; strip it before rendering.
|
|
268
|
+
translated = translated.replace(/<context=[^>]*>/g, '');
|
|
269
|
+
return EngineTrans(translated, _values ?? {}, _RenderContext(lid));
|
|
313
270
|
}
|
|
314
271
|
|
|
315
272
|
/**
|
package/src/index.mjs
CHANGED
|
@@ -228,6 +228,48 @@ export function PlaySignal(_signal = 'attention') {
|
|
|
228
228
|
}
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Plays a named sample from the dashboard sound atlas (µAU). Sample names match
|
|
233
|
+
* the files bundled with µGulp (`success`, `error`, `attention`, or custom
|
|
234
|
+
* atlas entries in the consumer skin). When `loop` is true the sample repeats
|
|
235
|
+
* until `StopSound(sample)` is called.
|
|
236
|
+
*
|
|
237
|
+
* CLI fallback: logs `[sound] <name>` on a TTY, silent otherwise.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} _sample atlas sample name (e.g. `'success'`)
|
|
240
|
+
* @param {object} [_options]
|
|
241
|
+
* @param {boolean} [_options.loop] repeat until stopped (default `false`)
|
|
242
|
+
*/
|
|
243
|
+
export function PlaySound(_sample, _options = {}) {
|
|
244
|
+
let sample = String(_sample ?? '').trim();
|
|
245
|
+
if (!sample) return;
|
|
246
|
+
if (IsMicroGulp()) {
|
|
247
|
+
process.send({ type: 'sound', sample, loop: !!_options.loop });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (process.stdout.isTTY) {
|
|
251
|
+
console.log(`[sound] ${sample}${_options.loop ? ' (loop)' : ''}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Stops a looping sample started with `PlaySound(name, { loop: true })`.
|
|
257
|
+
* No-op for one-shot samples and outside µGulp (CLI logs `[sound-stop]` on TTY).
|
|
258
|
+
*
|
|
259
|
+
* @param {string} _sample atlas sample name passed to `PlaySound`
|
|
260
|
+
*/
|
|
261
|
+
export function StopSound(_sample) {
|
|
262
|
+
let sample = String(_sample ?? '').trim();
|
|
263
|
+
if (!sample) return;
|
|
264
|
+
if (IsMicroGulp()) {
|
|
265
|
+
process.send({ type: 'sound-stop', sample });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (process.stdout.isTTY) {
|
|
269
|
+
console.log(`[sound-stop] ${sample}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
231
273
|
/**
|
|
232
274
|
* Speaks a text through the µGulp dashboard speech output (Web Speech API,
|
|
233
275
|
* respects the dashboard speech settings: enabled/volume/rate/pitch).
|
|
@@ -517,6 +559,8 @@ export default {
|
|
|
517
559
|
ReportProgress,
|
|
518
560
|
CreateProgress,
|
|
519
561
|
PlaySignal,
|
|
562
|
+
PlaySound,
|
|
563
|
+
StopSound,
|
|
520
564
|
Speak,
|
|
521
565
|
RequestForm,
|
|
522
566
|
RequestTextInput,
|