jtlt 0.2.0 → 0.4.0

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.
Files changed (70) hide show
  1. package/CHANGES.md +27 -0
  2. package/README.md +61 -164
  3. package/demo/calltemplate-params-demo.js +138 -0
  4. package/demo/codemirror.esm.js +28242 -0
  5. package/demo/codemirror.js +94 -0
  6. package/demo/index.css +7 -0
  7. package/demo/index.html +28 -0
  8. package/demo/index.js +210 -0
  9. package/demo/vendor/jamilih/dist/jml.mjs +2341 -0
  10. package/demo/vendor/jhtml/src/SAJJ/SAJJ.ObjectArrayDelegator.js +356 -0
  11. package/demo/vendor/jhtml/src/SAJJ/SAJJ.Stringifier.js +186 -0
  12. package/demo/vendor/jhtml/src/SAJJ/SAJJ.js +746 -0
  13. package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.html +33 -0
  14. package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.testing.js +25 -0
  15. package/demo/vendor/jhtml/src/jhtml-browser.js +5 -0
  16. package/demo/vendor/jhtml/src/jhtml-node.cts +3 -0
  17. package/demo/vendor/jhtml/src/jhtml-node.js +8 -0
  18. package/demo/vendor/jhtml/src/jhtml-node.mts +1 -0
  19. package/demo/vendor/jhtml/src/jhtml.cts +3 -0
  20. package/demo/vendor/jhtml/src/jhtml.js +602 -0
  21. package/demo/vendor/jhtml/src/jhtml.mts +1 -0
  22. package/demo/vendor/jsonpath-plus/dist/index-browser-esm.js +2158 -0
  23. package/demo/vendor/simple-get-json/dist/index-es.js +151 -0
  24. package/demo/xpath2-placeholder.js +1 -0
  25. package/dist/AbstractJoiningTransformer.d.ts +83 -9
  26. package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
  27. package/dist/DOMJoiningTransformer.d.ts +85 -25
  28. package/dist/DOMJoiningTransformer.d.ts.map +1 -1
  29. package/dist/JSONJoiningTransformer.d.ts +159 -51
  30. package/dist/JSONJoiningTransformer.d.ts.map +1 -1
  31. package/dist/JSONPathTransformer.d.ts +37 -38
  32. package/dist/JSONPathTransformer.d.ts.map +1 -1
  33. package/dist/JSONPathTransformerContext.d.ts +334 -124
  34. package/dist/JSONPathTransformerContext.d.ts.map +1 -1
  35. package/dist/StringJoiningTransformer.d.ts +132 -41
  36. package/dist/StringJoiningTransformer.d.ts.map +1 -1
  37. package/dist/XPathTransformer.d.ts +35 -20
  38. package/dist/XPathTransformer.d.ts.map +1 -1
  39. package/dist/XPathTransformerContext.d.ts +281 -105
  40. package/dist/XPathTransformerContext.d.ts.map +1 -1
  41. package/dist/index-browser.d.ts +4 -0
  42. package/dist/index-browser.d.ts.map +1 -0
  43. package/dist/index-node.d.ts +4 -0
  44. package/dist/index-node.d.ts.map +1 -0
  45. package/dist/index.d.ts +330 -57
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/types.d.ts +204 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/docs/API.expanded.md +172 -5
  50. package/docs/API.md +92 -2
  51. package/docs/TO-DO.md +168 -0
  52. package/docs/calltemplate-params.md +251 -0
  53. package/eslint.config.js +11 -5
  54. package/package.json +35 -9
  55. package/pnpm-workspace.yaml +1 -0
  56. package/rollup.config.js +13 -0
  57. package/src/AbstractJoiningTransformer.js +54 -15
  58. package/src/DOMJoiningTransformer.js +275 -28
  59. package/src/JSONJoiningTransformer.js +351 -70
  60. package/src/JSONPathTransformer.js +48 -30
  61. package/src/JSONPathTransformerContext.js +729 -107
  62. package/src/StringJoiningTransformer.js +311 -57
  63. package/src/XPathTransformer.js +27 -12
  64. package/src/XPathTransformerContext.js +928 -99
  65. package/src/index-browser.js +5 -0
  66. package/src/index-node.js +7 -0
  67. package/src/index.js +502 -98
  68. package/tsconfig.json +5 -2
  69. package/typings/xpath2-js.d.ts +40 -1
  70. package/src/types/xpath2-js.d.ts +0 -2
@@ -1,51 +1,62 @@
1
1
  import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
2
- // xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js
2
+ // eslint-disable-next-line @stylistic/max-len -- Long
3
+ // xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js, 3 => fontoxpath
4
+ import fontoxpath from 'fontoxpath';
5
+
6
+ /**
7
+ * @typedef {object} XPathTransformerContextConfig
8
+ * @property {unknown} [data] - XML/DOM root to transform
9
+ * @property {number} [xpathVersion] - 1, 2, 3.1 (default 1)
10
+ * @property {import('./index.js').
11
+ * JoiningTransformer} joiningTransformer Joiner
12
+ * @property {boolean} [errorOnEqualPriority]
13
+ * @property {(path: string) => number} [specificityPriorityResolver]
14
+ */
3
15
 
4
16
  /**
5
17
  * Execution context for XPath-driven template application.
6
18
  *
7
19
  * Similar to JSONPathTransformerContext but uses XPath expressions on a
8
- * DOM/XML-like tree. Supports XPath 1.0 (default) or 2.0 when
9
- * `xpathVersion: 2`.
20
+ * DOM/XML-like tree. Supports XPath 1.0 (default), 2.0 when
21
+ * `xpathVersion: 2`, or 3.1 when `xpathVersion: 3.1`.
10
22
  *
11
23
  * Expected config:
12
24
  * - data: A Document, Element, or XML-like root node.
13
25
  * - joiningTransformer: joiner with append(), string(), object(), array(), etc.
14
- * - xpathVersion: 1|2 (default 1)
26
+ * - xpathVersion: 1|2|3.1 (default 1)
15
27
  * - errorOnEqualPriority, specificityPriorityResolver (same semantics).
16
28
  */
17
29
  class XPathTransformerContext {
18
30
  /**
19
- * @param {object} config - Configuration object
20
- * @param {Document|Element|any} config.data - XML/DOM root to transform
21
- * @param {number} [config.xpathVersion] - 1 or 2 (default 1)
22
- * @param {object} config.joiningTransformer Joiner
23
- * @param {Function} config.joiningTransformer.append Append output
24
- * @param {Function} config.joiningTransformer.get Get output
25
- * @param {Function} config.joiningTransformer.string Emit string
26
- * @param {Function} config.joiningTransformer.object Emit object
27
- * @param {Function} config.joiningTransformer.array Emit array
28
- * @param {boolean} [config.errorOnEqualPriority]
29
- * @param {Function} [config.specificityPriorityResolver]
30
- * @param {any[]} templates - Template objects
31
+ * @param {XPathTransformerContextConfig} config
32
+ * @param {import('./index.js').
33
+ * XPathTemplateObject<any>[]} templates - Template objects
31
34
  */
32
35
  constructor (config, templates) {
33
36
  this._config = config;
34
37
  this._templates = templates;
35
- this._contextNode = this._origNode = config.data;
36
- /** @type {Record<string, any>} */
38
+ if (!config.data) {
39
+ throw new Error('XPathTransformerContext requires config.data');
40
+ }
41
+ /** @type {Document|Element|Node} */
42
+ this._contextNode = this._origNode = /** @type {Document|Element|Node} */ (
43
+ config.data
44
+ );
45
+ /** @type {Record<string, unknown>} */
37
46
  this.vars = {};
38
- /** @type {Record<string, any>} */
47
+ /** @type {Record<string, Record<string, unknown>>} */
39
48
  this.propertySets = {};
40
- /** @type {Record<string, any>} */
49
+ /** @type {Record<string, {match: string, use: string}>} */
41
50
  this.keys = {};
42
51
  /** @type {boolean|undefined} */
43
52
  this._initialized = undefined;
44
53
  /** @type {string|undefined} */
45
54
  this._currPath = undefined; // XPath string of current context
55
+ /** @type {Record<string, any> | undefined} */
56
+ this._params = undefined;
46
57
  }
47
58
 
48
- /** @returns {any} */
59
+ /** @returns {import('./index.js').JoiningTransformer} */
49
60
  _getJoiningTransformer () {
50
61
  return this._config.joiningTransformer;
51
62
  }
@@ -53,24 +64,26 @@ class XPathTransformerContext {
53
64
  /**
54
65
  * Evaluate an XPath expression against the current context node.
55
66
  * @param {string} expr - XPath expression
56
- * @param {boolean} [asNodes] Return nodes (array) instead of scalar
57
- * @returns {any}
67
+ * @param {boolean} [asNodes] Return nodes (array) instead of scalar
68
+ * @returns {unknown}
58
69
  */
59
70
  _evalXPath (expr, asNodes) {
60
71
  if (!expr) {
61
72
  return this._contextNode;
62
73
  }
63
- const version = this._config.xpathVersion === 2 ? 2 : 1;
74
+ const version = this._config.xpathVersion ?? 1;
64
75
  if (version === 1) {
65
76
  // Use native XPath (browser-like); rely on DOM doc if available.
66
77
  const doc = this._contextNode && this._contextNode.ownerDocument
67
78
  ? this._contextNode.ownerDocument
68
79
  : (this._contextNode.nodeType === 9 ? this._contextNode : undefined);
69
- if (!doc || typeof doc.evaluate !== 'function') {
80
+ if (!doc || doc.nodeType !== 9) {
70
81
  throw new Error(
71
82
  'Native XPath unavailable for xpathVersion=1'
72
83
  );
73
84
  }
85
+ /** @type {Document} */
86
+ const docTyped = /** @type {Document} */ (doc);
74
87
  // Evaluate relative to current node. Namespace support optional.
75
88
  const resolver = null; // Placeholder for future namespaceResolver config
76
89
  /* c8 ignore start -- environment-dependent XPathResult availability */
@@ -86,31 +99,33 @@ class XPathTransformerContext {
86
99
  : 0
87
100
  );
88
101
  /* c8 ignore stop */
89
- const resultObj = doc.evaluate(
102
+ const resultObj = docTyped.evaluate(
90
103
  expr, this._contextNode, resolver, type, null
91
104
  );
92
105
  if (asNodes) {
106
+ /** @type {Node[]} */
93
107
  const arr = [];
94
108
  for (let i = 0; i < resultObj.snapshotLength; i++) {
95
- arr.push(resultObj.snapshotItem(i));
109
+ const item = resultObj.snapshotItem(i);
110
+ if (item) {
111
+ arr.push(item);
112
+ }
96
113
  }
97
114
  return arr;
98
115
  }
99
116
  // Handle primitive types from XPathResult
100
- const XR = globalThis.XPathResult || {};
101
- /* c8 ignore start -- JSDOM's XPath implementation does not properly
102
- * set resultType for STRING_TYPE, NUMBER_TYPE, or BOOLEAN_TYPE. These
103
- * branches work in real browsers but cannot be tested in JSDOM. */
117
+ const XR = docTyped.defaultView?.XPathResult ||
118
+ globalThis.XPathResult || {};
104
119
  switch (resultObj.resultType) {
105
120
  case XR.STRING_TYPE: return resultObj.stringValue;
106
121
  case XR.NUMBER_TYPE: return resultObj.numberValue;
107
- case XR.BOOLEAN_TYPE: return resultObj.booleanValue;
108
- /* c8 ignore stop */
109
- /* c8 ignore start -- iterator result branch env-dependent */
122
+ case XR.BOOLEAN_TYPE:
123
+ return resultObj.booleanValue;
110
124
  case XR.UNORDERED_NODE_ITERATOR_TYPE:
111
125
  case XR.ORDERED_NODE_ITERATOR_TYPE: {
112
126
  /* c8 ignore start -- jsdom yields snapshots; iterator traversal
113
127
  * validated logically but not triggered in this environment. */
128
+ /** @type {Node[]} */
114
129
  const nodes = [];
115
130
  let n = resultObj.iterateNext();
116
131
  while (n) {
@@ -118,8 +133,8 @@ class XPathTransformerContext {
118
133
  n = resultObj.iterateNext();
119
134
  }
120
135
  return nodes;
136
+ /* c8 ignore stop */
121
137
  }
122
- /* c8 ignore stop */
123
138
  /* c8 ignore start -- Default fallback for unsupported XPathResult
124
139
  * types; environment-dependent and not hit under jsdom. */
125
140
  default:
@@ -128,8 +143,25 @@ class XPathTransformerContext {
128
143
  /* c8 ignore stop */
129
144
  }
130
145
  }
131
- // Version 2: xpath2.js
132
- const result = xpath2.evaluate(expr, this._contextNode);
146
+ if (version === 2) {
147
+ // Version 2: xpath2.js
148
+ const result = xpath2.evaluate(expr, this._contextNode);
149
+ if (asNodes) {
150
+ // eslint-disable-next-line @stylistic/max-len -- Long
151
+ /* c8 ignore next -- array wrap/identity branch counted in other tests */
152
+ return Array.isArray(result) ? result : [result];
153
+ }
154
+ /* c8 ignore next -- scalar return trivial; wrap behavior tested */
155
+ return result;
156
+ }
157
+
158
+ // eslint-disable-next-line @stylistic/max-len -- Long
159
+ // eslint-disable-next-line import/no-named-as-default-member -- Only as default
160
+ const result = fontoxpath.evaluateXPath(
161
+ expr, this._contextNode, undefined, undefined,
162
+ // Non-deprecated, predictable all results
163
+ 14 // ReturnType.ALL_RESULTS
164
+ );
133
165
  if (asNodes) {
134
166
  /* c8 ignore next -- array wrap/identity branch counted in other tests */
135
167
  return Array.isArray(result) ? result : [result];
@@ -140,15 +172,18 @@ class XPathTransformerContext {
140
172
 
141
173
  /**
142
174
  * Append raw item to output.
143
- * @param {*} item
175
+ * @param {unknown} item
144
176
  * @returns {XPathTransformerContext}
145
177
  */
146
178
  appendOutput (item) {
147
- this._getJoiningTransformer().append(item);
179
+ // Cast item since we trust the caller provides valid append types
180
+ this._getJoiningTransformer().append(
181
+ /** @type {string | Node} */ (item)
182
+ );
148
183
  return this;
149
184
  }
150
185
 
151
- /** @returns {*} */
186
+ /** @returns {unknown} */
152
187
  getOutput () {
153
188
  return this._getJoiningTransformer().get();
154
189
  }
@@ -157,16 +192,16 @@ class XPathTransformerContext {
157
192
  * Get value(s) by XPath relative to current context.
158
193
  * @param {string} select - XPath expression
159
194
  * @param {boolean} [asNodes]
160
- * @returns {*}
195
+ * @returns {Node[]}
161
196
  */
162
197
  get (select, asNodes) {
163
- return this._evalXPath(select, Boolean(asNodes));
198
+ return /** @type {Node[]} */ (this._evalXPath(select, Boolean(asNodes)));
164
199
  }
165
200
 
166
201
  /**
167
202
  * Set current context's parent property (for parity with JSONPath context).
168
203
  * Mostly placeholder for object-mirroring behavior.
169
- * @param {*} v
204
+ * @param {Document|Element|Node} v
170
205
  * @returns {XPathTransformerContext}
171
206
  */
172
207
  set (v) {
@@ -176,7 +211,7 @@ class XPathTransformerContext {
176
211
 
177
212
  /**
178
213
  * Apply templates to nodes matched by an XPath expression.
179
- * @param {string} select - XPath expression (default '.')
214
+ * @param {string} [select] - XPath expression (default '.')
180
215
  * @param {string} [mode]
181
216
  * @returns {XPathTransformerContext}
182
217
  */
@@ -189,17 +224,28 @@ class XPathTransformerContext {
189
224
  } else {
190
225
  select = select || '*';
191
226
  }
192
- const nodes = this._evalXPath(select, true);
193
- const modeMatched = this._templates.filter((t) => (
194
- mode ? t.mode === mode : !t.mode
195
- ));
227
+ const nodesResult = this._evalXPath(select, true);
228
+ const nodes = /** @type {Node[]} */ (nodesResult);
229
+ const modeMatched = this._templates.filter((t) => {
230
+ // Exclude named-only templates (those with name but no path)
231
+ if (t.name && !t.path) {
232
+ return false;
233
+ }
234
+ return mode ? t.mode === mode : !t.mode;
235
+ });
196
236
  // Process each node
197
237
  for (const node of nodes) {
198
238
  // Path resolution simplified (could track full XPath if needed)
199
239
  const pathMatchedTemplates = modeMatched.filter((t) => {
200
240
  // Basic matching: template.path is XPath tested for existence
241
+ // At this point, we know t.path exists because we filtered
242
+ // out named-only templates in modeMatched
243
+ if (!t.path) {
244
+ return false;
245
+ }
201
246
  try {
202
- const res = this._evalXPath(t.path, true);
247
+ const resResult = this._evalXPath(t.path, true);
248
+ const res = /** @type {Node[]} */ (resResult);
203
249
  return res.includes(node);
204
250
  } catch {
205
251
  return false;
@@ -226,12 +272,12 @@ class XPathTransformerContext {
226
272
  pathMatchedTemplates.sort((a, b) => {
227
273
  const aPr = typeof a.priority === 'number'
228
274
  ? a.priority
229
- : (this._config.specificityPriorityResolver
275
+ : (this._config.specificityPriorityResolver && a.path
230
276
  ? this._config.specificityPriorityResolver(a.path)
231
277
  : 0);
232
278
  const bPr = typeof b.priority === 'number'
233
279
  ? b.priority
234
- : (this._config.specificityPriorityResolver
280
+ : (this._config.specificityPriorityResolver && b.path
235
281
  ? this._config.specificityPriorityResolver(b.path)
236
282
  : 0);
237
283
  if (aPr === bPr && this._config.errorOnEqualPriority) {
@@ -239,7 +285,12 @@ class XPathTransformerContext {
239
285
  }
240
286
  return aPr > bPr ? -1 : 1;
241
287
  });
242
- templateObj = pathMatchedTemplates.shift();
288
+ templateObj =
289
+ /**
290
+ * @type {import('./index.js').XPathTemplateObject<any>}
291
+ */ (
292
+ pathMatchedTemplates.shift()
293
+ );
243
294
  }
244
295
  this._contextNode = node;
245
296
  const ret = templateObj.template.call(this, node, {mode});
@@ -251,14 +302,74 @@ class XPathTransformerContext {
251
302
  return this;
252
303
  }
253
304
 
305
+ /**
306
+ * @param {string|
307
+ * {name: string, withParam?: any[]}} name - Template name or
308
+ * options object
309
+ * @param {any[]} [withParams] - Parameters to pass to template
310
+ * @returns {this}
311
+ */
312
+ callTemplate (name, withParams) {
313
+ // Invokes a named template, optionally passing values via withParam.
314
+ if (name && typeof name === 'object') {
315
+ withParams = name.withParam || withParams;
316
+ ({name} = name);
317
+ }
318
+ withParams = withParams || /* c8 ignore next */ [];
319
+
320
+ // Store parameters in a temporary context for valueOf() access
321
+ const prevParams = this._params;
322
+ /** @type {Record<string, any>} */
323
+ const params = {};
324
+ this._params = params;
325
+
326
+ withParams.forEach((withParam, index) => {
327
+ const value = withParam.value !== undefined
328
+ ? withParam.value
329
+ : this.get(withParam.select, false);
330
+
331
+ // Store by name if provided, otherwise by index
332
+ if (withParam.name) {
333
+ params[withParam.name] = value;
334
+ } else {
335
+ params[String(index)] = value;
336
+ }
337
+ });
338
+
339
+ const results = this._getJoiningTransformer();
340
+ const templateObj = this._templates.find((template) => {
341
+ return template.name === name;
342
+ });
343
+ if (!templateObj) {
344
+ throw new Error(
345
+ 'Template, ' + name + ', cannot be called as it was not found.'
346
+ );
347
+ }
348
+
349
+ // @ts-expect-error Todo: Fix
350
+ const result = templateObj.template.call(this, this._contextNode, {});
351
+ if (typeof result !== 'undefined') {
352
+ /** @type {any} */ (results).append(result);
353
+ }
354
+
355
+ // Restore previous parameter context
356
+ this._params = prevParams;
357
+
358
+ return this;
359
+ }
360
+
361
+
254
362
  /**
255
363
  * Iterate over nodes selected by XPath.
256
364
  * @param {string} select - XPath expression
257
- * @param {Function} cb - Callback invoked per node
365
+ * @param {(this: XPathTransformerContext,
366
+ * node: Node
367
+ * )=>void} cb - Callback invoked per node
258
368
  * @returns {XPathTransformerContext}
259
369
  */
260
370
  forEach (select, cb) {
261
- const nodes = this._evalXPath(select, true);
371
+ const nodesResult = this._evalXPath(select, true);
372
+ const nodes = /** @type {Node[]} */ (nodesResult);
262
373
  for (const n of nodes) {
263
374
  cb.call(this, n);
264
375
  }
@@ -273,19 +384,128 @@ class XPathTransformerContext {
273
384
  valueOf (select) {
274
385
  const jt = this._getJoiningTransformer();
275
386
  let val;
276
- if (!select || (
277
- typeof select === 'object' && /** @type {any} */ (select).select === '.'
278
- )) {
387
+
388
+ const selectStr = typeof select === 'object'
389
+ ? /** @type {{select?: string}} */ (select).select
390
+ : select;
391
+
392
+ // Check if this is a parameter reference (starts with $)
393
+ if (selectStr && selectStr.startsWith('$')) {
394
+ const paramName = selectStr.slice(1);
395
+ if (this._params && paramName in this._params) {
396
+ val = this._params[paramName];
397
+ } else {
398
+ // Fall back to normal XPath evaluation
399
+ const resResult = this._evalXPath(selectStr, true);
400
+ const res = /** @type {Node[]} */ (resResult);
401
+ const first = res[0];
402
+ /* c8 ignore start */
403
+ val = first && first.nodeType
404
+ ? first.textContent
405
+ : first;
406
+ /* c8 ignore stop */
407
+ }
408
+ } else if (!selectStr || selectStr === '.') {
279
409
  val = this._contextNode.nodeType === 3
280
410
  ? this._contextNode.nodeValue
281
411
  : this._contextNode.textContent;
282
412
  } else {
283
- const res = this._evalXPath(/** @type {string} */ (select), true);
413
+ const resResult = this._evalXPath(selectStr, true);
414
+ const res = /** @type {Node[]} */ (resResult);
284
415
  // Simplify: use textContent of first match if node, else raw
285
416
  const first = res[0];
286
417
  val = first && first.nodeType ? first.textContent : first;
287
418
  }
288
- jt.append(val);
419
+ // Ensure val is not null before appending
420
+ if (val !== null) {
421
+ jt.append(val);
422
+ }
423
+ return this;
424
+ }
425
+
426
+ /**
427
+ * Deep copy selection or current context when omitted.
428
+ * For DOM nodes uses cloneNode(true); for scalars copies the value.
429
+ * @param {string} [select] XPath expression selecting nodes (optional)
430
+ * @returns {XPathTransformerContext}
431
+ */
432
+ copyOf (select) {
433
+ /** @type {Node[]} */ let nodes = [];
434
+ if (select) {
435
+ try {
436
+ const res = this.get(select, true);
437
+ nodes = Array.isArray(res) ? res : /* c8 ignore next */ [];
438
+ } catch { /* c8 ignore next */
439
+ nodes = /* c8 ignore next */ [];
440
+ }
441
+ } else {
442
+ nodes = [this._contextNode];
443
+ }
444
+ if (nodes.length) {
445
+ for (const n of nodes) {
446
+ if (n && typeof n === 'object' && 'cloneNode' in n) {
447
+ let deep;
448
+ try {
449
+ deep = /** @type {Node} */ (n.cloneNode(true));
450
+ } catch { /* c8 ignore start */
451
+ deep = /** @type {Node} */ (n.cloneNode(false));
452
+ } /* c8 ignore stop */
453
+ this._getJoiningTransformer().append(/** @type {any} */ (deep));
454
+ } else { /* c8 ignore start */
455
+ this._getJoiningTransformer().append(/** @type {any} */ (n));
456
+ } /* c8 ignore stop */
457
+ }
458
+ } else if (select) { // Scalar path
459
+ const scalar = this._evalXPath(select, false);
460
+ // If scalar evaluation unexpectedly returns a Node/Document, use its
461
+ // textContent instead of attempting to append the Node itself (which
462
+ // can cause HierarchyRequestError for Document nodes).
463
+ if (
464
+ scalar &&
465
+ typeof scalar === 'object' &&
466
+ 'nodeType' in /** @type {any} */ (scalar)
467
+ ) {
468
+ const node = /** @type {Node} */ (scalar);
469
+ let txt = /** @type {any} */ (node.textContent);
470
+ if (/* c8 ignore start */
471
+ (txt === null || typeof txt === 'undefined') &&
472
+ /** @type {any} */ (node).nodeType === 9 // Document
473
+ ) {
474
+ // Fallback to documentElement textContent if available
475
+ const docEl = /** @type {any} */ (
476
+ /** @type {any} */ (node)
477
+ ).documentElement;
478
+ txt = /** @type {any} */ (docEl && docEl.textContent) || '';
479
+ } /* c8 ignore stop */
480
+ this._getJoiningTransformer().append(
481
+ /** @type {any} */ (txt || /* c8 ignore next */ '')
482
+ );
483
+ } else {
484
+ this._getJoiningTransformer().append(/** @type {any} */ (scalar));
485
+ }
486
+ }
487
+ return this;
488
+ }
489
+
490
+ /**
491
+ * Shallow copy current context node (cloneNode(false)); scalars copied
492
+ * directly. Provided for parity with JSONPath copy().
493
+ * @param {string[]} [_propertySets] Ignored in XPath variant (parity only)
494
+ * @returns {XPathTransformerContext}
495
+ */
496
+ copy (_propertySets) {
497
+ const target = this._contextNode;
498
+ let clone;
499
+ if (target && typeof target === 'object' && 'nodeType' in target) {
500
+ try {
501
+ clone = /** @type {Node} */ (target.cloneNode(false));
502
+ } catch { /* c8 ignore start */
503
+ clone = target;
504
+ } /* c8 ignore stop */
505
+ } else { /* c8 ignore start */
506
+ clone = target;
507
+ } /* c8 ignore stop */
508
+ this._getJoiningTransformer().append(/** @type {any} */ (clone));
289
509
  return this;
290
510
  }
291
511
 
@@ -301,32 +521,330 @@ class XPathTransformerContext {
301
521
  }
302
522
  /**
303
523
  * Log a message (for debugging).
304
- * @param {*} json Any value
524
+ * @param {unknown} json Any value
305
525
  * @returns {void}
306
526
  */
307
- static message (json) {
527
+ // eslint-disable-next-line class-methods-use-this -- Convenient
528
+ message (json) {
308
529
  /* eslint-disable-next-line no-console -- Debug output */
309
530
  console.log(json);
310
531
  }
311
532
  /**
312
533
  * Append string.
313
534
  * @param {string} str String to append
314
- * @param {Function} [cb] Callback
535
+ * @param {(this: XPathTransformerContext) => void} [cb] Callback
315
536
  * @returns {XPathTransformerContext}
316
537
  */
317
538
  string (str, cb) {
318
- this._getJoiningTransformer().string(str, cb);
539
+ // We don't pass the callback because it has incompatible 'this' type
540
+ // The callback is mainly used for context-building in string transformers
541
+ this._getJoiningTransformer().string(str);
319
542
  return this;
320
543
  }
321
544
  /**
322
- * Append number.
323
- * @param {number} num Number
545
+ * Append number with xsl:number-like formatting.
546
+ * @param {number|string|{
547
+ * value?: number|string,
548
+ * count?: string,
549
+ * level?: 'single'|'multiple'|'any',
550
+ * from?: string,
551
+ * format?: string,
552
+ * groupingSeparator?: string,
553
+ * groupingSize?: number
554
+ * }} num - Number value, "position()" string, or options object
324
555
  * @returns {XPathTransformerContext}
325
556
  */
326
557
  number (num) {
327
- this._getJoiningTransformer().number(num);
558
+ // Handle xsl:number-like functionality
559
+ if (typeof num === 'object' && num !== null) {
560
+ const opts = num;
561
+ let {value} = opts;
562
+
563
+ // Handle position() calculation
564
+ if (value === 'position()' || value === undefined) {
565
+ const {count} = opts;
566
+ const level = opts.level || 'single';
567
+ const {from} = opts;
568
+
569
+ switch (level) {
570
+ case 'single': {
571
+ value = this._calculatePosition(count, from);
572
+
573
+ break;
574
+ }
575
+ case 'multiple': {
576
+ // Hierarchical numbering: get position for each ancestor up to root
577
+ const positions = [];
578
+ let node = /** @type {any} */ (this._config).currentNode;
579
+ while (node) {
580
+ positions.unshift(this._calculatePosition(count, undefined));
581
+ node = node.parentNode;
582
+ if (from) {
583
+ const fromResult = /** @type {any} */ (
584
+ this._evalXPath(from, node)
585
+ );
586
+ if (fromResult && fromResult.length > 0) {
587
+ break;
588
+ }
589
+ }
590
+ }
591
+ value = positions.join('.');
592
+
593
+ break;
594
+ }
595
+ case 'any': {
596
+ value = this._calculatePositionAny(count, from);
597
+
598
+ break;
599
+ }
600
+ // No default
601
+ }
602
+ }
603
+
604
+ // Determine format string and locale
605
+ let format = opts.format || '1';
606
+ // @ts-expect-error: dynamic property access
607
+ const locale = opts.lang || 'en';
608
+ // @ts-expect-error: dynamic property access
609
+ const {letterValue} = opts;
610
+
611
+ // If letterValue is 'alphabetic', force alphabetic format
612
+ if (letterValue === 'alphabetic') {
613
+ format = (opts.format && (/^[aA]$/v).test(opts.format)) ? opts.format : 'a';
614
+ }
615
+
616
+ const numValue = typeof value === 'string' ? Number(value) : (value || 1);
617
+ const formatted = this._formatNumber(
618
+ numValue,
619
+ format,
620
+ opts.groupingSeparator,
621
+ opts.groupingSize,
622
+ locale
623
+ );
624
+ this._getJoiningTransformer().plainText(formatted);
625
+ } else if (num === 'position()') {
626
+ // Simple position() call
627
+ const pos = this._calculatePosition();
628
+ this._getJoiningTransformer().number(pos);
629
+ } else {
630
+ // Simple number
631
+ this._getJoiningTransformer().number(
632
+ typeof num === 'string' ? Number(num) : num
633
+ );
634
+ }
328
635
  return this;
329
636
  }
637
+
638
+ /**
639
+ * Calculate position of current node.
640
+ * @param {string} [count] - XPath pattern to match
641
+ * @param {string} [from] - XPath pattern for ancestor
642
+ * @returns {number}
643
+ * @private
644
+ */
645
+ _calculatePosition (count, from) {
646
+ // eslint-disable-next-line prefer-destructuring -- TS
647
+ const currentNode = /** @type {any} */ (this._config).currentNode;
648
+ if (!currentNode) {
649
+ return 1;
650
+ }
651
+
652
+ // Get parent node
653
+ const parent = currentNode.parentNode;
654
+ if (!parent) {
655
+ return 1;
656
+ }
657
+
658
+ // If from pattern specified, find that ancestor
659
+ let startNode = parent;
660
+ if (from) {
661
+ const fromResult = /** @type {any} */ (
662
+ this._evalXPath(from, currentNode)
663
+ );
664
+ if (fromResult && fromResult.length > 0) {
665
+ startNode = fromResult[0];
666
+ }
667
+ }
668
+
669
+ // Count preceding siblings
670
+ let position = 1;
671
+ let sibling = currentNode.previousSibling;
672
+
673
+ while (sibling) {
674
+ if (count) {
675
+ // Check if sibling matches count pattern
676
+ const matches = /** @type {any} */ (this._evalXPath(count, sibling));
677
+ if (matches && matches.length > 0) {
678
+ position++;
679
+ }
680
+ } else if (sibling.nodeType === currentNode.nodeType &&
681
+ (!currentNode.nodeName || sibling.nodeName === currentNode.nodeName)) {
682
+ position++;
683
+ }
684
+ sibling = sibling.previousSibling;
685
+ }
686
+
687
+ return position;
688
+ }
689
+
690
+ /**
691
+ * Calculate position counting all ancestors (level=any).
692
+ * @param {string} [count] - XPath pattern to match
693
+ * @param {string} [from] - XPath pattern for ancestor
694
+ * @returns {number}
695
+ * @private
696
+ */
697
+ _calculatePositionAny (count, from) {
698
+ // eslint-disable-next-line prefer-destructuring -- TS
699
+ const currentNode = /** @type {any} */ (this._config).currentNode;
700
+ if (!currentNode) {
701
+ return 1;
702
+ }
703
+
704
+ // Find root or 'from' node
705
+ let root = currentNode.ownerDocument || currentNode;
706
+ if (from) {
707
+ const fromResult = /** @type {any} */ (
708
+ this._evalXPath(from, currentNode)
709
+ );
710
+ if (fromResult && fromResult.length > 0) {
711
+ root = fromResult[0];
712
+ }
713
+ }
714
+
715
+ // Count all matching nodes in document order up to current
716
+ const pattern = count || 'node()';
717
+ const allNodes = /** @type {any[]} */ (
718
+ this._evalXPath('//' + pattern, root)
719
+ );
720
+
721
+ for (const [i, allNode] of allNodes.entries()) {
722
+ if (allNode === currentNode) {
723
+ return i + 1;
724
+ }
725
+ }
726
+
727
+ return 1;
728
+ }
729
+
730
+ /**
731
+ * Format a number according to format string.
732
+ * @param {number} num - Number to format
733
+ * @param {string} format - Format string (1, a, A, i, I, 01, etc.)
734
+ * @param {string} [groupingSeparator] - Separator for grouping (e.g., ',')
735
+ * @param {number} [groupingSize] - Size of groups (e.g., 3 for 1,000)
736
+ * @param {string} [locale]
737
+ * @returns {string}
738
+ * @private
739
+ */
740
+ _formatNumber (num, format, groupingSeparator, groupingSize, locale = 'en') {
741
+ if (Number.isNaN(num)) {
742
+ return String(num);
743
+ }
744
+
745
+ let result;
746
+ const formatChar = format.charAt(0);
747
+
748
+ switch (formatChar) {
749
+ case 'i': {
750
+ result = this._toRoman(num).toLowerCase();
751
+
752
+ break;
753
+ }
754
+ case 'I': {
755
+ result = this._toRoman(num);
756
+
757
+ break;
758
+ }
759
+ case 'a': {
760
+ result = this._toAlphabetic(num, false);
761
+
762
+ break;
763
+ }
764
+ case 'A': {
765
+ result = this._toAlphabetic(num, true);
766
+
767
+ break;
768
+ }
769
+ case '0': {
770
+ const width = format.length;
771
+ result = String(num).padStart(width, '0');
772
+
773
+ break;
774
+ }
775
+ default: {
776
+ // Use Intl.NumberFormat for decimal formatting if grouping/locale
777
+ // options are provided
778
+ let options = {};
779
+ if (groupingSeparator || groupingSize) {
780
+ options = {
781
+ useGrouping: true
782
+ };
783
+ }
784
+ try {
785
+ result = new Intl.NumberFormat(locale, options).format(num);
786
+ if (groupingSeparator) {
787
+ result = result.replaceAll(',', groupingSeparator);
788
+ }
789
+ } catch (e) {
790
+ result = String(num);
791
+ }
792
+ }
793
+ }
794
+ return result;
795
+ }
796
+
797
+ /**
798
+ * Convert number to Roman numerals.
799
+ * @param {number} num - Number to convert (1-3999)
800
+ * @returns {string}
801
+ * @private
802
+ */
803
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
804
+ _toRoman (num) {
805
+ if (num < 1 || num > 3999) {
806
+ return String(num);
807
+ }
808
+
809
+ const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
810
+ const syms = [
811
+ 'M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'
812
+ ];
813
+
814
+ let result = '';
815
+ for (const [i, val] of vals.entries()) {
816
+ while (num >= val) {
817
+ result += syms[i];
818
+ num -= val;
819
+ }
820
+ }
821
+ return result;
822
+ }
823
+
824
+ /**
825
+ * Convert number to alphabetic sequence.
826
+ * @param {number} num - Number to convert
827
+ * @param {boolean} uppercase - Use uppercase letters
828
+ * @returns {string}
829
+ * @private
830
+ */
831
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
832
+ _toAlphabetic (num, uppercase) {
833
+ if (num < 1) {
834
+ return String(num);
835
+ }
836
+
837
+ let result = '';
838
+ const base = uppercase ? 65 : 97; // 'A' or 'a'
839
+
840
+ while (num > 0) {
841
+ num--; // Make 0-indexed
842
+ result = String.fromCodePoint(base + (num % 26)) + result;
843
+ num = Math.floor(num / 26);
844
+ }
845
+
846
+ return result;
847
+ }
330
848
  /**
331
849
  * Append plain text (no escaping changes).
332
850
  * @param {string} str Text
@@ -339,7 +857,7 @@ class XPathTransformerContext {
339
857
  /**
340
858
  * Append property/value pair.
341
859
  * @param {string} prop Property name
342
- * @param {*} val Value
860
+ * @param {any} val Value
343
861
  * @returns {XPathTransformerContext}
344
862
  */
345
863
  propValue (prop, val) {
@@ -348,43 +866,82 @@ class XPathTransformerContext {
348
866
  }
349
867
  /**
350
868
  * Append object.
351
- * @param {...any} args Object args
869
+ * @param {Record<string, unknown>|
870
+ * ((this: XPathTransformerContext) => void)} objOrCb Object or callback
871
+ * @param {((this: XPathTransformerContext) => void)|
872
+ * any[]} [cbOrUsePropertySets] Callback or property sets
873
+ * @param {any[]|
874
+ * Record<string, unknown>} [usePropertySetsOrPropSets]
875
+ * Property sets or props
876
+ * @param {Record<string, unknown>} [propSets] Additional property sets
352
877
  * @returns {XPathTransformerContext}
353
878
  */
354
- object (...args) {
355
- this._getJoiningTransformer().object(...args);
879
+ object (objOrCb, cbOrUsePropertySets, usePropertySetsOrPropSets, propSets) {
880
+ const jt = this._getJoiningTransformer();
881
+ // Union of transformers creates intersection types
882
+ // @ts-expect-error
883
+ jt.object(objOrCb, cbOrUsePropertySets, usePropertySetsOrPropSets,
884
+ propSets);
356
885
  return this;
357
886
  }
358
887
  /**
359
888
  * Append array.
360
- * @param {...any} args Array args
889
+ * @param {any[]|
890
+ * ((this: XPathTransformerContext) => void)} [arrOrCb]
891
+ * Array or callback
892
+ * @param {(this: XPathTransformerContext) => void} [cb] Callback
361
893
  * @returns {XPathTransformerContext}
362
894
  */
363
- array (...args) {
364
- this._getJoiningTransformer().array(...args);
895
+ array (arrOrCb, cb) {
896
+ const jt = this._getJoiningTransformer();
897
+ // Union of transformers creates intersection types
898
+ // @ts-expect-error
899
+ jt.array(arrOrCb, cb);
365
900
  return this;
366
901
  }
902
+
903
+ /**
904
+ * Append text node content.
905
+ * @param {import('./StringJoiningTransformer.js').OutputConfig} cfg Text
906
+ * @returns {XPathTransformerContext}
907
+ */
908
+ output (cfg) {
909
+ this._getJoiningTransformer().output(cfg);
910
+ return this;
911
+ }
912
+
367
913
  /**
368
914
  * Append element.
369
915
  * @param {string} name Tag name
370
- * @param {object} [atts] Attributes
371
- * @param {any[]} [children] Children
372
- * @param {Function} [cb] Callback
916
+ * @param {Record<string, string>|any[]|
917
+ * ((this: XPathTransformerContext)=>void)} [atts] Attributes
918
+ * @param {any[]|((this: XPathTransformerContext)=>void)} [children]
919
+ * Children
920
+ * @param {(this: XPathTransformerContext)=>void} [cb] Callback
373
921
  * @returns {XPathTransformerContext}
374
922
  */
375
923
  element (name, atts, children, cb) {
924
+ // @ts-expect-error - Union of transformers creates intersection types
376
925
  this._getJoiningTransformer().element(name, atts, children, cb);
377
926
  return this;
378
927
  }
379
928
  /**
380
929
  * Append attribute.
381
930
  * @param {string} name Attribute name
382
- * @param {string|object} val Value
931
+ * @param {string|Record<string, unknown>} val Value
383
932
  * @param {boolean} [avoid] Avoid duplicates
384
933
  * @returns {XPathTransformerContext}
385
934
  */
386
935
  attribute (name, val, avoid) {
387
- this._getJoiningTransformer().attribute(name, val, avoid);
936
+ const jt = this._getJoiningTransformer();
937
+ // Only StringJoiningTransformer supports the third parameter
938
+ if (typeof avoid !== 'undefined') {
939
+ // Union of transformers creates intersection types
940
+ // @ts-expect-error
941
+ jt.attribute(name, /** @type {string} */ (val), avoid);
942
+ } else {
943
+ jt.attribute(name, /** @type {string} */ (val));
944
+ }
388
945
  return this;
389
946
  }
390
947
  /**
@@ -396,27 +953,59 @@ class XPathTransformerContext {
396
953
  this._getJoiningTransformer().text(txt);
397
954
  return this;
398
955
  }
956
+
957
+ /**
958
+ * Append a comment.
959
+ * @param {string} text - Comment text
960
+ * @returns {XPathTransformerContext}
961
+ */
962
+ comment (text) {
963
+ const jt = this._getJoiningTransformer();
964
+ if (jt.comment) {
965
+ jt.comment(text);
966
+ }
967
+ return this;
968
+ }
969
+
970
+ /**
971
+ * Append a processing instruction.
972
+ * @param {string} target - Processing instruction target
973
+ * @param {string} data - Processing instruction data
974
+ * @returns {XPathTransformerContext}
975
+ */
976
+ processingInstruction (target, data) {
977
+ const jt = this._getJoiningTransformer();
978
+ if (jt.processingInstruction) {
979
+ jt.processingInstruction(target, data);
980
+ }
981
+ return this;
982
+ }
983
+
399
984
  /**
400
985
  * Define a property set (optionally composed from other sets).
401
986
  * @param {string} name Property set name
402
- * @param {object} obj Base properties
987
+ * @param {Record<string, unknown>} obj Base properties
403
988
  * @param {string[]} [use] Property set names to merge
404
989
  * @returns {XPathTransformerContext}
405
990
  */
406
991
  propertySet (name, obj, use) {
407
- this.propertySets[name] = use
408
- ? ({
409
- ...obj,
410
- ...use.reduce((acc, psName) => this._usePropertySets(acc, psName), {})
411
- })
412
- : obj;
992
+ this.propertySets[name] = /** @type {Record<string, unknown>} */ (
993
+ use
994
+ ? ({
995
+ ...obj,
996
+ ...use.reduce(
997
+ (acc, psName) => this._usePropertySets(acc, psName), {}
998
+ )
999
+ })
1000
+ : obj
1001
+ );
413
1002
  return this;
414
1003
  }
415
1004
  /**
416
1005
  * Merge properties from a named property set into obj.
417
- * @param {object} obj Target object
1006
+ * @param {Record<string, unknown>} obj Target object
418
1007
  * @param {string} name Property set name
419
- * @returns {object}
1008
+ * @returns {Record<string, unknown>}
420
1009
  */
421
1010
  _usePropertySets (obj, name) {
422
1011
  return Object.assign(obj, this.propertySets[name]);
@@ -424,16 +1013,20 @@ class XPathTransformerContext {
424
1013
  /**
425
1014
  * Retrieve a key-mapped node matching a value or return context.
426
1015
  * @param {string} name Key name
427
- * @param {*} value Value to match
428
- * @returns {*}
1016
+ * @param {any} value Value to match
1017
+ * @returns {any}
429
1018
  */
430
1019
  getKey (name, value) {
431
1020
  const key = this.keys[name];
432
1021
  const matches = this.get(key.match, true);
433
- for (const m of matches) {
1022
+ // When asNodes=true, get() returns Node[]
1023
+ /** @type {Node[]} */
1024
+ const nodesArray = /** @type {Node[]} */ (matches);
1025
+ for (const m of nodesArray) {
434
1026
  if (m && m.nodeType === 1) { // Element
435
- if (m.getAttribute && m.getAttribute(key.use) === value) {
436
- return m;
1027
+ const elem = /** @type {Element} */ (m);
1028
+ if (elem.getAttribute(key.use) === value) {
1029
+ return elem;
437
1030
  }
438
1031
  }
439
1032
  }
@@ -451,27 +1044,260 @@ class XPathTransformerContext {
451
1044
  return this;
452
1045
  }
453
1046
 
1047
+ /**
1048
+ * Conditionally execute a callback when an XPath evaluates to a truthy
1049
+ * scalar or a non-empty node set (akin to xsl:if semantics).
1050
+ *
1051
+ * Truthiness rules:
1052
+ * - Node set: length > 0 passes.
1053
+ * - Scalar: Boolean(value) must be true.
1054
+ *
1055
+ * @param {string} select XPath expression
1056
+ * @param {(this: XPathTransformerContext)
1057
+ * => void} cb Callback invoked if condition passes
1058
+ * @returns {XPathTransformerContext}
1059
+ */
1060
+ if (select, cb) {
1061
+ const passes = this._passesIf(select);
1062
+ if (passes && typeof cb === 'function') {
1063
+ cb.call(this);
1064
+ }
1065
+ return this;
1066
+ }
1067
+
1068
+ /**
1069
+ * Internal helper: evaluate XPath truthiness like if().
1070
+ * @param {string} select
1071
+ * @returns {boolean}
1072
+ */
1073
+ _passesIf (select) {
1074
+ let passes = false;
1075
+ // Try scalar evaluation first (handles boolean/comparison expressions)
1076
+ try {
1077
+ const scalar = this.get(select, false);
1078
+ let normalized;
1079
+ // Unwrap single-item array if it contains a primitive
1080
+ /* c8 ignore next 6 -- Defensive code for edge case where _evalXPath
1081
+ * returns single-item array with asNodes=false; both native XPath v1
1082
+ * and xpath2.js v2 return scalars directly in standard usage. */
1083
+ if (
1084
+ Array.isArray(scalar) &&
1085
+ scalar.length === 1 &&
1086
+ ['boolean', 'number', 'string'].includes(typeof scalar[0])
1087
+ ) {
1088
+ normalized = scalar[0];
1089
+ } else if (['boolean', 'number', 'string'].includes(typeof scalar)) {
1090
+ normalized = scalar;
1091
+ }
1092
+ if (typeof normalized !== 'undefined') {
1093
+ passes = Boolean(normalized);
1094
+ }
1095
+ } catch {
1096
+ // Scalar eval failed; will try node selection
1097
+ }
1098
+ // If not yet truthy, attempt node selection (location paths)
1099
+ if (!passes && (/[\/@*]/v).test(select)) {
1100
+ try {
1101
+ const nodes = this.get(select, true);
1102
+ // eslint-disable-next-line unicorn/prefer-ternary -- for coverage
1103
+ if (Array.isArray(nodes)) {
1104
+ passes = nodes.length > 0;
1105
+ // Defensive for non-array nodes, but _evalXPath with asNodes=true
1106
+ // always returns arrays in both v1 and v2.
1107
+ /* c8 ignore start */
1108
+ } else {
1109
+ passes = Boolean(nodes);
1110
+ }
1111
+ /* c8 ignore stop */
1112
+ } catch {
1113
+ passes = false;
1114
+ }
1115
+ }
1116
+ return passes;
1117
+ }
1118
+
1119
+ /**
1120
+ * Conditional with optional fallback (like choose/otherwise).
1121
+ * Truthiness same as `if()`.
1122
+ * @param {string} select XPath expression
1123
+ * @param {(this: XPathTransformerContext)
1124
+ * => void} whenCb Callback when condition passes
1125
+ * @param {(this: XPathTransformerContext)
1126
+ * => void} [otherwiseCb] Callback when condition fails
1127
+ * @returns {XPathTransformerContext}
1128
+ */
1129
+ choose (select, whenCb, otherwiseCb) {
1130
+ const passes = this._passesIf(select);
1131
+ if (passes) {
1132
+ if (typeof whenCb === 'function') {
1133
+ whenCb.call(this);
1134
+ }
1135
+ } else if (typeof otherwiseCb === 'function') {
1136
+ otherwiseCb.call(this);
1137
+ }
1138
+ return this;
1139
+ }
1140
+
1141
+ /**
1142
+ * Analyze a string with a regular expression, equivalent to
1143
+ * xsl:analyze-string. Processes matching and non-matching substrings
1144
+ * with separate callbacks.
1145
+ * @param {string} str - The string to analyze
1146
+ * @param {string|RegExp} regex - Regular expression to match against
1147
+ * @param {{
1148
+ * matchingSubstring?: (
1149
+ * this: XPathTransformerContext,
1150
+ * substring: string,
1151
+ * groups: string[],
1152
+ * regexGroup: (n: number) => string
1153
+ * ) => void,
1154
+ * nonMatchingSubstring?: (
1155
+ * this: XPathTransformerContext,
1156
+ * substring: string
1157
+ * ) => void,
1158
+ * flags?: string
1159
+ * }} options - Options object
1160
+ * @returns {XPathTransformerContext}
1161
+ */
1162
+ analyzeString (str, regex, options = {}) {
1163
+ // Ensure we have a string
1164
+ const inputString = String(str || '');
1165
+
1166
+ // If empty string, do nothing
1167
+ if (inputString.length === 0) {
1168
+ return this;
1169
+ }
1170
+
1171
+ const {
1172
+ matchingSubstring,
1173
+ nonMatchingSubstring,
1174
+ flags = ''
1175
+ } = options;
1176
+
1177
+ // Convert regex to RegExp if it's a string
1178
+ let regexObj;
1179
+ if (typeof regex === 'string') {
1180
+ // Ensure 'g' flag is present for global matching
1181
+ const actualFlags = flags.includes('g') ? flags : flags + 'g';
1182
+ regexObj = new RegExp(regex, actualFlags);
1183
+ } else {
1184
+ regexObj = regex;
1185
+ // Ensure global flag is set
1186
+ if (!regexObj.global) {
1187
+ regexObj = new RegExp(
1188
+ regexObj.source,
1189
+ regexObj.flags + 'g'
1190
+ );
1191
+ }
1192
+ }
1193
+
1194
+ // Check for zero-length matches (error condition in XSLT)
1195
+ if (regexObj.test('')) {
1196
+ throw new Error(
1197
+ 'Regular expression matches zero-length string'
1198
+ );
1199
+ }
1200
+
1201
+ // Store captured groups for access during callback
1202
+ /** @type {string[] | undefined} */
1203
+ let currentCapturedGroups;
1204
+
1205
+ /**
1206
+ * Get captured group by index.
1207
+ * @param {number} groupNumber - Group index
1208
+ * @returns {string} - Captured group or empty string
1209
+ */
1210
+ const getRegexGroup = (groupNumber) => {
1211
+ if (!currentCapturedGroups ||
1212
+ groupNumber < 0 ||
1213
+ groupNumber >= currentCapturedGroups.length) {
1214
+ return '';
1215
+ }
1216
+ return currentCapturedGroups[groupNumber] || '';
1217
+ };
1218
+
1219
+ // Save previous context to restore later
1220
+ const prevContext = this._contextNode;
1221
+
1222
+ let lastIndex = 0;
1223
+ let match;
1224
+
1225
+ // Bind callbacks to this context
1226
+ const boundMatchingSubstring = matchingSubstring
1227
+ ? matchingSubstring.bind(this)
1228
+ : undefined;
1229
+ const boundNonMatchingSubstring = nonMatchingSubstring
1230
+ ? nonMatchingSubstring.bind(this)
1231
+ : undefined;
1232
+
1233
+ // Find all matches
1234
+ while ((match = regexObj.exec(inputString)) !== null) {
1235
+ // Process non-matching substring before this match
1236
+ if (match.index > lastIndex) {
1237
+ const nonMatchingStr = inputString.slice(lastIndex, match.index);
1238
+ if (boundNonMatchingSubstring) {
1239
+ boundNonMatchingSubstring(nonMatchingStr);
1240
+ }
1241
+ }
1242
+
1243
+ // Process matching substring
1244
+ if (boundMatchingSubstring) {
1245
+ const matchingStr = match[0];
1246
+ // Store captured groups: [full match, group1, group2, ...]
1247
+ currentCapturedGroups = [...match];
1248
+ boundMatchingSubstring(
1249
+ matchingStr, currentCapturedGroups, getRegexGroup
1250
+ );
1251
+ currentCapturedGroups = undefined;
1252
+ }
1253
+
1254
+ const {lastIndex: newLastIndex} = regexObj;
1255
+ lastIndex = newLastIndex;
1256
+
1257
+ // Prevent infinite loop on zero-length matches (shouldn't happen
1258
+ // due to earlier check, but defensive)
1259
+ if (match.index === regexObj.lastIndex) {
1260
+ regexObj.lastIndex++;
1261
+ }
1262
+ }
1263
+
1264
+ // Process final non-matching substring
1265
+ if (lastIndex < inputString.length) {
1266
+ const nonMatchingStr = inputString.slice(lastIndex);
1267
+ if (boundNonMatchingSubstring) {
1268
+ boundNonMatchingSubstring(nonMatchingStr);
1269
+ }
1270
+ }
1271
+
1272
+ // Restore previous context
1273
+ this._contextNode = prevContext;
1274
+
1275
+ return this;
1276
+ }
1277
+
454
1278
  /* c8 ignore start -- static default rules object has spotty function
455
1279
  * attribution under coverage; behavior is exercised via applyTemplates */
456
1280
  static DefaultTemplateRules = {
457
1281
  transformRoot: {
458
1282
  /**
459
- * @param {*} node Root node
1283
+ * @this {XPathTransformerContext}
1284
+ * @param {unknown} node Root node
460
1285
  * @param {{mode:string}} cfg Config
461
1286
  * @returns {void}
462
1287
  */
463
1288
  template (node, cfg) {
464
- /** @type {any} */ (this).applyTemplates('.', cfg.mode);
1289
+ this.applyTemplates('.', cfg.mode);
465
1290
  }
466
1291
  },
467
1292
  transformElements: {
468
1293
  /**
469
- * @param {*} node Element node
470
- * @param {{mode:string}} cfg Config
1294
+ * @this {XPathTransformerContext}
1295
+ * @param {unknown} node Element node
1296
+ * @param {{mode?:string}} cfg Config
471
1297
  * @returns {void}
472
1298
  */
473
1299
  template (node, cfg) {
474
- /** @type {any} */ (this).applyTemplates('*', cfg.mode);
1300
+ this.applyTemplates('*', cfg.mode);
475
1301
  }
476
1302
  },
477
1303
  transformTextNodes: {
@@ -484,9 +1310,12 @@ class XPathTransformerContext {
484
1310
  }
485
1311
  },
486
1312
  transformScalars: {
487
- /** @returns {*} */
1313
+ /**
1314
+ * @this {XPathTransformerContext}
1315
+ * @returns {XPathTransformerContext}
1316
+ */
488
1317
  template () {
489
- return /** @type {any} */ (this).valueOf({select: '.'});
1318
+ return this.valueOf({select: '.'});
490
1319
  }
491
1320
  }
492
1321
  };