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 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?)` | Returns the translated string without logging |
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?)` | Acoustic signal in the dashboard: `'success'` \| `'error'` \| `'attention'` (default) |
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 (`<context="task log"/>` usw.) |
277
- | `Translate(text, values?)` | Gibt den übersetzten String zurück, ohne zu loggen |
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?)` | Akustisches Signal im Dashboard: `'success'` \| `'error'` \| `'attention'` (Default) |
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.4",
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
- /** Formats milliseconds as M:SS or H:MM:SS (progress bar durations). */
219
- function _FormatDuration(_ms) {
220
- let ms = Math.max(0, Number(_ms) || 0);
221
- let totalSeconds = Math.round(ms / 1000);
222
- let seconds = totalSeconds % 60;
223
- let minutes = Math.floor(totalSeconds / 60) % 60;
224
- let hours = Math.floor(totalSeconds / 3600);
225
- let pad = (_n) => String(_n).padStart(2, '0');
226
- return hours > 0 ? hours + ':' + pad(minutes) + ':' + pad(seconds) : minutes + ':' + pad(seconds);
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 lid = _lid ?? GetLid();
239
- let tz = GetTimeZone();
240
- if (_formatName === 'progressDuration') return _FormatDuration(Number(_value));
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 resolves placeholders. Unknown phrases fall back to the en-US
301
- * source text with its tags stripped.
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
- return _ApplyPlaceholders(translated, _values);
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,