jtlt 0.3.0 → 0.5.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 (59) hide show
  1. package/CHANGES.md +18 -0
  2. package/README.md +46 -78
  3. package/demo/codemirror.esm.js +28242 -0
  4. package/demo/codemirror.js +94 -0
  5. package/demo/index.css +11 -0
  6. package/demo/index.html +11 -14
  7. package/demo/index.js +210 -26
  8. package/demo/vendor/fontoxpath/dist/fontoxpath.esm.js +600 -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/prsc/dist/prsc.esm.js +2 -0
  24. package/demo/vendor/simple-get-json/dist/index-es.js +151 -0
  25. package/demo/vendor/whynot/dist/whynot.esm.js +2 -0
  26. package/demo/vendor/xspattern/dist/xspattern.esm.js +2 -0
  27. package/dist/AbstractJoiningTransformer.d.ts +27 -0
  28. package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
  29. package/dist/DOMJoiningTransformer.d.ts +60 -8
  30. package/dist/DOMJoiningTransformer.d.ts.map +1 -1
  31. package/dist/JSONJoiningTransformer.d.ts +8 -9
  32. package/dist/JSONJoiningTransformer.d.ts.map +1 -1
  33. package/dist/JSONPathTransformer.d.ts.map +1 -1
  34. package/dist/JSONPathTransformerContext.d.ts +223 -4
  35. package/dist/JSONPathTransformerContext.d.ts.map +1 -1
  36. package/dist/StringJoiningTransformer.d.ts +7 -7
  37. package/dist/StringJoiningTransformer.d.ts.map +1 -1
  38. package/dist/XPathTransformer.d.ts +2 -2
  39. package/dist/XPathTransformer.d.ts.map +1 -1
  40. package/dist/XPathTransformerContext.d.ts +161 -8
  41. package/dist/XPathTransformerContext.d.ts.map +1 -1
  42. package/dist/index.d.ts +4 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/docs/API.expanded.md +5 -3
  45. package/docs/API.md +1 -1
  46. package/docs/TO-DO.md +62 -36
  47. package/eslint.config.js +3 -1
  48. package/package.json +28 -4
  49. package/rollup.config.js +13 -0
  50. package/src/AbstractJoiningTransformer.js +42 -0
  51. package/src/DOMJoiningTransformer.js +115 -23
  52. package/src/JSONJoiningTransformer.js +50 -26
  53. package/src/JSONPathTransformer.js +2 -0
  54. package/src/JSONPathTransformerContext.js +936 -5
  55. package/src/StringJoiningTransformer.js +25 -21
  56. package/src/XPathTransformer.js +3 -1
  57. package/src/XPathTransformerContext.js +884 -15
  58. package/src/index.js +16 -7
  59. package/tsconfig.json +5 -2
@@ -1,10 +1,12 @@
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';
3
5
 
4
6
  /**
5
7
  * @typedef {object} XPathTransformerContextConfig
6
8
  * @property {unknown} [data] - XML/DOM root to transform
7
- * @property {number} [xpathVersion] - 1 or 2 (default 1)
9
+ * @property {number} [xpathVersion] - 1, 2, 3.1 (default 1)
8
10
  * @property {import('./index.js').
9
11
  * JoiningTransformer} joiningTransformer Joiner
10
12
  * @property {boolean} [errorOnEqualPriority]
@@ -15,13 +17,13 @@ import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
15
17
  * Execution context for XPath-driven template application.
16
18
  *
17
19
  * Similar to JSONPathTransformerContext but uses XPath expressions on a
18
- * DOM/XML-like tree. Supports XPath 1.0 (default) or 2.0 when
19
- * `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`.
20
22
  *
21
23
  * Expected config:
22
24
  * - data: A Document, Element, or XML-like root node.
23
25
  * - joiningTransformer: joiner with append(), string(), object(), array(), etc.
24
- * - xpathVersion: 1|2 (default 1)
26
+ * - xpathVersion: 1|2|3.1 (default 1)
25
27
  * - errorOnEqualPriority, specificityPriorityResolver (same semantics).
26
28
  */
27
29
  class XPathTransformerContext {
@@ -46,6 +48,11 @@ class XPathTransformerContext {
46
48
  this.propertySets = {};
47
49
  /** @type {Record<string, {match: string, use: string}>} */
48
50
  this.keys = {};
51
+ /**
52
+ * @type {Record<string,
53
+ * import('./JSONPathTransformerContext.js').DecimalFormatSymbols>}
54
+ */
55
+ this.decimalFormats = {};
49
56
  /** @type {boolean|undefined} */
50
57
  this._initialized = undefined;
51
58
  /** @type {string|undefined} */
@@ -69,7 +76,7 @@ class XPathTransformerContext {
69
76
  if (!expr) {
70
77
  return this._contextNode;
71
78
  }
72
- const version = this._config.xpathVersion === 2 ? 2 : 1;
79
+ const version = this._config.xpathVersion ?? 1;
73
80
  if (version === 1) {
74
81
  // Use native XPath (browser-like); rely on DOM doc if available.
75
82
  const doc = this._contextNode && this._contextNode.ownerDocument
@@ -141,8 +148,25 @@ class XPathTransformerContext {
141
148
  /* c8 ignore stop */
142
149
  }
143
150
  }
144
- // Version 2: xpath2.js
145
- const result = xpath2.evaluate(expr, this._contextNode);
151
+ if (version === 2) {
152
+ // Version 2: xpath2.js
153
+ const result = xpath2.evaluate(expr, this._contextNode);
154
+ if (asNodes) {
155
+ // eslint-disable-next-line @stylistic/max-len -- Long
156
+ /* c8 ignore next -- array wrap/identity branch counted in other tests */
157
+ return Array.isArray(result) ? result : [result];
158
+ }
159
+ /* c8 ignore next -- scalar return trivial; wrap behavior tested */
160
+ return result;
161
+ }
162
+
163
+ // eslint-disable-next-line @stylistic/max-len -- Long
164
+ // eslint-disable-next-line import/no-named-as-default-member -- Only as default
165
+ const result = fontoxpath.evaluateXPath(
166
+ expr, this._contextNode, undefined, undefined,
167
+ // Non-deprecated, predictable all results
168
+ 14 // ReturnType.ALL_RESULTS
169
+ );
146
170
  if (asNodes) {
147
171
  /* c8 ignore next -- array wrap/identity branch counted in other tests */
148
172
  return Array.isArray(result) ? result : [result];
@@ -274,7 +298,16 @@ class XPathTransformerContext {
274
298
  );
275
299
  }
276
300
  this._contextNode = node;
301
+
302
+ // Set up parameter context for valueOf() access in templates
303
+ const prevTemplateParams = this._params;
304
+ this._params = {0: node};
305
+
277
306
  const ret = templateObj.template.call(this, node, {mode});
307
+
308
+ // Restore previous parameter context
309
+ this._params = prevTemplateParams;
310
+
278
311
  if (typeof ret !== 'undefined') {
279
312
  this._getJoiningTransformer().append(ret);
280
313
  }
@@ -352,11 +385,269 @@ class XPathTransformerContext {
352
385
  const nodesResult = this._evalXPath(select, true);
353
386
  const nodes = /** @type {Node[]} */ (nodesResult);
354
387
  for (const n of nodes) {
355
- cb.call(this, n);
388
+ // Set up parameter context for valueOf() access
389
+ const prevParams = this._params;
390
+ const prevContext = this._contextNode;
391
+ this._params = {0: n};
392
+ this._contextNode = n;
393
+ try {
394
+ cb.call(this, n);
395
+ } finally {
396
+ // Restore previous parameter context
397
+ this._params = prevParams;
398
+ this._contextNode = prevContext;
399
+ }
400
+ }
401
+ return this;
402
+ }
403
+
404
+ /**
405
+ * Groups items and executes callback for each group.
406
+ * Equivalent to XSLT's xsl:for-each-group.
407
+ * @param {string} select - XPath selector for items to group
408
+ * @param {object} options - Grouping options
409
+ * @param {string} [options.groupBy] - XPath expression to group by value
410
+ * @param {string} [options.groupAdjacent] - Groups adjacent items with
411
+ * same value
412
+ * @param {string} [options.groupStartingWith] - Starts new group when
413
+ * expression matches
414
+ * @param {string} [options.groupEndingWith] - Ends group when expression
415
+ * matches
416
+ * @param {(
417
+ * this: XPathTransformerContext, key: any, items: Node[], ctx: any
418
+ * ) => void} cb - Callback receives (groupingKey, groupItems, context)
419
+ * @returns {this}
420
+ */
421
+ forEachGroup (select, options, cb) {
422
+ // eslint-disable-next-line unicorn/no-this-assignment -- Temporary
423
+ const that = this;
424
+ const {groupBy, groupAdjacent, groupStartingWith, groupEndingWith} =
425
+ options;
426
+
427
+ const nodesResult = this._evalXPath(select, true);
428
+ const nodes = /** @type {Node[]} */ (nodesResult);
429
+
430
+ /**
431
+ * @param {string} expr
432
+ * @param {Node} node
433
+ * @returns {any}
434
+ */
435
+ function evalInContext (expr, node) {
436
+ if (expr === '.' || expr === '@') {
437
+ return node.textContent;
438
+ }
439
+ // Temporarily set context to evaluate expression
440
+ const prevNode = that._contextNode;
441
+ that._contextNode = node;
442
+ try {
443
+ const result = that._evalXPath(expr, false);
444
+ // XPath v2/v3 always return arrays; handle NodeList/Array results
445
+ // by taking first item or its text content
446
+ const resultWithLength = /** @type {{length: number}} */ (result);
447
+ if (resultWithLength.length > 0) {
448
+ const firstItem = /** @type {any} */ (result)[0];
449
+ return firstItem?.textContent ?? firstItem;
450
+ }
451
+ return undefined;
452
+ } finally {
453
+ that._contextNode = prevNode;
454
+ }
455
+ }
456
+
457
+ /** @type {Map<string, Node[]>} */
458
+ const groups = new Map();
459
+
460
+ if (groupBy) {
461
+ // Group by computed value
462
+ for (const node of nodes) {
463
+ const key = evalInContext(groupBy, node);
464
+ // Handle undefined by converting to null for JSON serialization
465
+ const keyStr = JSON.stringify(key === undefined ? null : key);
466
+ if (!groups.has(keyStr)) {
467
+ groups.set(keyStr, []);
468
+ }
469
+ /** @type {Node[]} */ (groups.get(keyStr)).push(node);
470
+ }
471
+
472
+ for (const [keyStr, items] of groups) {
473
+ const key = JSON.parse(keyStr);
474
+ // Convert null back to undefined if that was the original value
475
+ const actualKey = key === null && keyStr === 'null' ? undefined : key;
476
+ const prevParams = this._params;
477
+ const prevContext = this._contextNode;
478
+ try {
479
+ this._contextNode = items[0];
480
+ /** @type {any} */ (this)._currentGroup = items;
481
+ /** @type {any} */ (this)._currentGroupingKey = actualKey;
482
+ cb.call(this, actualKey, items, this);
483
+ } finally {
484
+ this._params = prevParams;
485
+ this._contextNode = prevContext;
486
+ delete /** @type {any} */ (this)._currentGroup;
487
+ delete /** @type {any} */ (this)._currentGroupingKey;
488
+ }
489
+ }
490
+ } else if (groupAdjacent) {
491
+ // Group adjacent items with same value
492
+ /** @type {string|null} */
493
+ let currentKey = null;
494
+ /** @type {Node[]} */
495
+ let currentGroup = [];
496
+
497
+ for (const node of nodes) {
498
+ const key = evalInContext(groupAdjacent, node);
499
+ const keyStr = JSON.stringify(key);
500
+
501
+ if (currentKey === null || currentKey !== keyStr) {
502
+ if (currentGroup.length > 0) {
503
+ const prevParams = this._params;
504
+ const prevContext = this._contextNode;
505
+ try {
506
+ this._contextNode = currentGroup[0];
507
+ /** @type {any} */ (this)._currentGroup = currentGroup;
508
+ /** @type {any} */ (this)._currentGroupingKey =
509
+ JSON.parse(/** @type {string} */ (currentKey));
510
+ cb.call(
511
+ this,
512
+ JSON.parse(/** @type {string} */ (currentKey)),
513
+ currentGroup,
514
+ this
515
+ );
516
+ } finally {
517
+ this._params = prevParams;
518
+ this._contextNode = prevContext;
519
+ delete /** @type {any} */ (this)._currentGroup;
520
+ delete /** @type {any} */ (this)._currentGroupingKey;
521
+ }
522
+ }
523
+ currentKey = keyStr;
524
+ currentGroup = [node];
525
+ } else {
526
+ currentGroup.push(node);
527
+ }
528
+ }
529
+
530
+ // Process last group
531
+ if (currentGroup.length > 0) {
532
+ const prevParams = this._params;
533
+ const prevContext = this._contextNode;
534
+ try {
535
+ this._contextNode = currentGroup[0];
536
+ /** @type {any} */ (this)._currentGroup = currentGroup;
537
+ /** @type {any} */ (this)._currentGroupingKey =
538
+ JSON.parse(/** @type {string} */ (currentKey));
539
+ cb.call(
540
+ this,
541
+ JSON.parse(/** @type {string} */ (currentKey)),
542
+ currentGroup,
543
+ this
544
+ );
545
+ } finally {
546
+ this._params = prevParams;
547
+ this._contextNode = prevContext;
548
+ delete /** @type {any} */ (this)._currentGroup;
549
+ delete /** @type {any} */ (this)._currentGroupingKey;
550
+ }
551
+ }
552
+ } else if (groupStartingWith) {
553
+ // Start new group when expression matches
554
+ /** @type {Node[]} */
555
+ let currentGroup = [];
556
+
557
+ for (const node of nodes) {
558
+ const startMatch = evalInContext(groupStartingWith, node);
559
+
560
+ if (startMatch && currentGroup.length > 0) {
561
+ const prevParams = this._params;
562
+ const prevContext = this._contextNode;
563
+ try {
564
+ this._contextNode = currentGroup[0];
565
+ /** @type {any} */ (this)._currentGroup = currentGroup;
566
+ cb.call(this, null, currentGroup, this);
567
+ } finally {
568
+ this._params = prevParams;
569
+ this._contextNode = prevContext;
570
+ delete /** @type {any} */ (this)._currentGroup;
571
+ }
572
+ currentGroup = [];
573
+ }
574
+ currentGroup.push(node);
575
+ }
576
+
577
+ // Process last group
578
+ if (currentGroup.length > 0) {
579
+ const prevParams = this._params;
580
+ const prevContext = this._contextNode;
581
+ try {
582
+ this._contextNode = currentGroup[0];
583
+ /** @type {any} */ (this)._currentGroup = currentGroup;
584
+ cb.call(this, null, currentGroup, this);
585
+ } finally {
586
+ this._params = prevParams;
587
+ this._contextNode = prevContext;
588
+ delete /** @type {any} */ (this)._currentGroup;
589
+ }
590
+ }
591
+ } else if (groupEndingWith) {
592
+ // End group when expression matches
593
+ /** @type {Node[]} */
594
+ let currentGroup = [];
595
+
596
+ for (const node of nodes) {
597
+ currentGroup.push(node);
598
+ const endMatch = evalInContext(groupEndingWith, node);
599
+
600
+ if (endMatch) {
601
+ const prevParams = this._params;
602
+ const prevContext = this._contextNode;
603
+ try {
604
+ this._contextNode = currentGroup[0];
605
+ /** @type {any} */ (this)._currentGroup = currentGroup;
606
+ cb.call(this, null, currentGroup, this);
607
+ } finally {
608
+ this._params = prevParams;
609
+ this._contextNode = prevContext;
610
+ delete /** @type {any} */ (this)._currentGroup;
611
+ }
612
+ currentGroup = [];
613
+ }
614
+ }
615
+
616
+ // Process last group if not ended
617
+ if (currentGroup.length > 0) {
618
+ const prevParams = this._params;
619
+ const prevContext = this._contextNode;
620
+ try {
621
+ this._contextNode = currentGroup[0];
622
+ /** @type {any} */ (this)._currentGroup = currentGroup;
623
+ cb.call(this, null, currentGroup, this);
624
+ } finally {
625
+ this._params = prevParams;
626
+ this._contextNode = prevContext;
627
+ delete /** @type {any} */ (this)._currentGroup;
628
+ }
629
+ }
356
630
  }
631
+
357
632
  return this;
358
633
  }
359
634
 
635
+ /**
636
+ * Returns the current group (for use within forEachGroup callback).
637
+ * @returns {Node[]|undefined}
638
+ */
639
+ currentGroup () {
640
+ return /** @type {any} */ (this)._currentGroup;
641
+ }
642
+
643
+ /**
644
+ * Returns the current grouping key (for use within forEachGroup callback).
645
+ * @returns {any}
646
+ */
647
+ currentGroupingKey () {
648
+ return /** @type {any} */ (this)._currentGroupingKey;
649
+ }
650
+
360
651
  /**
361
652
  * Append the value from an XPath expression or the context node text.
362
653
  * @param {string|object} [select]
@@ -370,11 +661,66 @@ class XPathTransformerContext {
370
661
  ? /** @type {{select?: string}} */ (select).select
371
662
  : select;
372
663
 
664
+ // Check for format-number() function call
665
+ if (selectStr && selectStr.includes('format-number(')) {
666
+ const match = (/format-number\((?<value>[^,\)]+)(?:,\s*["'](?<format>[^"']+)["'])?(?:,\s*["'](?<decimalFormat>[^"']*)["'])?\)/v).exec(selectStr);
667
+ if (match && match.groups) {
668
+ const {
669
+ value: valueExpr,
670
+ format: formatStr,
671
+ decimalFormat: decimalFormatName
672
+ } = match.groups;
673
+ // Evaluate the value expression
674
+ let numValue;
675
+ if (valueExpr.trim().startsWith('$')) {
676
+ // Parameter reference
677
+ const paramName = valueExpr.trim().slice(1);
678
+ numValue = this._params && paramName in this._params
679
+ ? this._params[paramName]
680
+ : 0;
681
+ } else {
682
+ // Try to parse as number or evaluate as XPath
683
+ const trimmed = valueExpr.trim();
684
+ numValue = Number.isNaN(Number(trimmed))
685
+ ? this._evalXPath(trimmed, false)
686
+ : Number(trimmed);
687
+ }
688
+ const num = typeof numValue === 'string' ? Number(numValue) : numValue;
689
+ const format = formatStr || '1';
690
+ const formatted = this._formatNumber(
691
+ num,
692
+ format,
693
+ undefined,
694
+ undefined,
695
+ decimalFormatName || '',
696
+ 'en'
697
+ );
698
+ jt.append(formatted);
699
+ return this;
700
+ }
701
+ }
702
+
373
703
  // Check if this is a parameter reference (starts with $)
374
704
  if (selectStr && selectStr.startsWith('$')) {
375
705
  const paramName = selectStr.slice(1);
376
706
  if (this._params && paramName in this._params) {
377
707
  val = this._params[paramName];
708
+ // If val is a Node, extract its text content
709
+ if (val && typeof val === 'object' && 'nodeType' in val) {
710
+ if (val.nodeType === 3) {
711
+ // Text node: use nodeValue
712
+ val = val.nodeValue;
713
+ } else if (val.nodeType === 9) {
714
+ // Document node: use documentElement.textContent
715
+ const doc = /** @type {Document} */ (val);
716
+ val = doc.documentElement
717
+ ? doc.documentElement.textContent
718
+ : null;
719
+ } else {
720
+ // Other nodes: use textContent
721
+ val = val.textContent;
722
+ }
723
+ }
378
724
  } else {
379
725
  // Fall back to normal XPath evaluation
380
726
  const resResult = this._evalXPath(selectStr, true);
@@ -387,9 +733,19 @@ class XPathTransformerContext {
387
733
  /* c8 ignore stop */
388
734
  }
389
735
  } else if (!selectStr || selectStr === '.') {
390
- val = this._contextNode.nodeType === 3
391
- ? this._contextNode.nodeValue
392
- : this._contextNode.textContent;
736
+ if (this._contextNode.nodeType === 3) {
737
+ // Text node: use nodeValue
738
+ val = this._contextNode.nodeValue;
739
+ } else if (this._contextNode.nodeType === 9) {
740
+ // Document node: use documentElement.textContent
741
+ const doc = /** @type {Document} */ (this._contextNode);
742
+ val = doc.documentElement
743
+ ? doc.documentElement.textContent
744
+ : null;
745
+ } else {
746
+ // Other nodes: use textContent
747
+ val = this._contextNode.textContent;
748
+ }
393
749
  } else {
394
750
  const resResult = this._evalXPath(selectStr, true);
395
751
  const res = /** @type {Node[]} */ (resResult);
@@ -523,14 +879,341 @@ class XPathTransformerContext {
523
879
  return this;
524
880
  }
525
881
  /**
526
- * Append number.
527
- * @param {number} num Number
882
+ * Append number with xsl:number-like formatting.
883
+ * @param {number|string|{
884
+ * value?: number|string,
885
+ * count?: string,
886
+ * level?: 'single'|'multiple'|'any',
887
+ * from?: string,
888
+ * format?: string,
889
+ * decimalFormat?: string,
890
+ * groupingSeparator?: string,
891
+ * groupingSize?: number
892
+ * }} num - Number value, "position()" string, or options object
528
893
  * @returns {XPathTransformerContext}
529
894
  */
530
895
  number (num) {
531
- this._getJoiningTransformer().number(num);
896
+ // Handle xsl:number-like functionality
897
+ if (typeof num === 'object' && num !== null) {
898
+ const opts = num;
899
+ let {value} = opts;
900
+
901
+ // Handle position() calculation
902
+ if (value === 'position()' || value === undefined) {
903
+ const {count} = opts;
904
+ const level = opts.level || 'single';
905
+ const {from} = opts;
906
+
907
+ switch (level) {
908
+ case 'single': {
909
+ value = this._calculatePosition(count, from);
910
+
911
+ break;
912
+ }
913
+ case 'multiple': {
914
+ // Hierarchical numbering: get position for each ancestor up to root
915
+ const positions = [];
916
+ let node = /** @type {any} */ (this._config).currentNode;
917
+ while (node) {
918
+ positions.unshift(this._calculatePosition(count, undefined));
919
+ node = node.parentNode;
920
+ if (from) {
921
+ const fromResult = /** @type {any} */ (
922
+ this._evalXPath(from, node)
923
+ );
924
+ if (fromResult && fromResult.length > 0) {
925
+ break;
926
+ }
927
+ }
928
+ }
929
+ value = positions.join('.');
930
+
931
+ break;
932
+ }
933
+ case 'any': {
934
+ value = this._calculatePositionAny(count, from);
935
+
936
+ break;
937
+ }
938
+ // No default
939
+ }
940
+ }
941
+
942
+ // Determine format string and locale
943
+ let format = opts.format || '1';
944
+ // @ts-expect-error: dynamic property access
945
+ const locale = opts.lang || 'en';
946
+ // @ts-expect-error: dynamic property access
947
+ const {letterValue} = opts;
948
+
949
+ // If letterValue is 'alphabetic', force alphabetic format
950
+ if (letterValue === 'alphabetic') {
951
+ format = (opts.format && (/^[aA]$/v).test(opts.format)) ? opts.format : 'a';
952
+ }
953
+
954
+ const numValue = typeof value === 'string' ? Number(value) : (value || 1);
955
+ const formatted = this._formatNumber(
956
+ numValue,
957
+ format,
958
+ opts.groupingSeparator,
959
+ opts.groupingSize,
960
+ locale
961
+ );
962
+ this._getJoiningTransformer().plainText(formatted);
963
+ } else if (num === 'position()') {
964
+ // Simple position() call
965
+ const pos = this._calculatePosition();
966
+ this._getJoiningTransformer().number(pos);
967
+ } else {
968
+ // Simple number
969
+ this._getJoiningTransformer().number(
970
+ typeof num === 'string' ? Number(num) : num
971
+ );
972
+ }
532
973
  return this;
533
974
  }
975
+
976
+ /**
977
+ * Calculate position of current node.
978
+ * @param {string} [count] - XPath pattern to match
979
+ * @param {string} [from] - XPath pattern for ancestor
980
+ * @returns {number}
981
+ * @private
982
+ */
983
+ _calculatePosition (count, from) {
984
+ // eslint-disable-next-line prefer-destructuring -- TS
985
+ const currentNode = /** @type {any} */ (this._config).currentNode;
986
+ if (!currentNode) {
987
+ return 1;
988
+ }
989
+
990
+ // Get parent node
991
+ const parent = currentNode.parentNode;
992
+ if (!parent) {
993
+ return 1;
994
+ }
995
+
996
+ // If from pattern specified, find that ancestor
997
+ let startNode = parent;
998
+ if (from) {
999
+ const fromResult = /** @type {any} */ (
1000
+ this._evalXPath(from, currentNode)
1001
+ );
1002
+ if (fromResult && fromResult.length > 0) {
1003
+ startNode = fromResult[0];
1004
+ }
1005
+ }
1006
+
1007
+ // Count preceding siblings
1008
+ let position = 1;
1009
+ let sibling = currentNode.previousSibling;
1010
+
1011
+ while (sibling) {
1012
+ if (count) {
1013
+ // Check if sibling matches count pattern
1014
+ const matches = /** @type {any} */ (this._evalXPath(count, sibling));
1015
+ if (matches && matches.length > 0) {
1016
+ position++;
1017
+ }
1018
+ } else if (sibling.nodeType === currentNode.nodeType &&
1019
+ (!currentNode.nodeName || sibling.nodeName === currentNode.nodeName)) {
1020
+ position++;
1021
+ }
1022
+ sibling = sibling.previousSibling;
1023
+ }
1024
+
1025
+ return position;
1026
+ }
1027
+
1028
+ /**
1029
+ * Calculate position counting all ancestors (level=any).
1030
+ * @param {string} [count] - XPath pattern to match
1031
+ * @param {string} [from] - XPath pattern for ancestor
1032
+ * @returns {number}
1033
+ * @private
1034
+ */
1035
+ _calculatePositionAny (count, from) {
1036
+ // eslint-disable-next-line prefer-destructuring -- TS
1037
+ const currentNode = /** @type {any} */ (this._config).currentNode;
1038
+ if (!currentNode) {
1039
+ return 1;
1040
+ }
1041
+
1042
+ // Find root or 'from' node
1043
+ let root = currentNode.ownerDocument || currentNode;
1044
+ if (from) {
1045
+ const fromResult = /** @type {any} */ (
1046
+ this._evalXPath(from, currentNode)
1047
+ );
1048
+ if (fromResult && fromResult.length > 0) {
1049
+ root = fromResult[0];
1050
+ }
1051
+ }
1052
+
1053
+ // Count all matching nodes in document order up to current
1054
+ const pattern = count || 'node()';
1055
+ const allNodes = /** @type {any[]} */ (
1056
+ this._evalXPath('//' + pattern, root)
1057
+ );
1058
+
1059
+ for (const [i, allNode] of allNodes.entries()) {
1060
+ if (allNode === currentNode) {
1061
+ return i + 1;
1062
+ }
1063
+ }
1064
+
1065
+ return 1;
1066
+ }
1067
+
1068
+ /**
1069
+ * Format a number according to format string.
1070
+ * @param {number} num - Number to format
1071
+ * @param {string} format - Format string (1, a, A, i, I, 01, etc.)
1072
+ * @param {string} [groupingSeparator] - Separator for grouping (e.g., ',')
1073
+ * @param {number} [groupingSize] - Size of groups (e.g., 3 for 1,000)
1074
+ * @param {string} [decimalFormatName] - Named decimal format to use
1075
+ * @param {string} [locale]
1076
+ * @returns {string}
1077
+ * @private
1078
+ */
1079
+ _formatNumber (
1080
+ num, format, groupingSeparator, groupingSize, decimalFormatName, locale
1081
+ ) {
1082
+ if (Number.isNaN(num)) {
1083
+ return String(num);
1084
+ }
1085
+
1086
+ // Get decimal format symbols if specified
1087
+ const symbols = decimalFormatName !== undefined &&
1088
+ decimalFormatName in this.decimalFormats
1089
+ ? this.decimalFormats[decimalFormatName]
1090
+ : undefined;
1091
+
1092
+ // Default locale
1093
+ const loc = locale || 'en';
1094
+
1095
+ let result;
1096
+ const formatChar = format.charAt(0);
1097
+
1098
+ switch (formatChar) {
1099
+ case 'i': {
1100
+ result = this._toRoman(num).toLowerCase();
1101
+
1102
+ break;
1103
+ }
1104
+ case 'I': {
1105
+ result = this._toRoman(num);
1106
+
1107
+ break;
1108
+ }
1109
+ case 'a': {
1110
+ result = this._toAlphabetic(num, false);
1111
+
1112
+ break;
1113
+ }
1114
+ case 'A': {
1115
+ result = this._toAlphabetic(num, true);
1116
+
1117
+ break;
1118
+ }
1119
+ case '0': {
1120
+ const width = format.length;
1121
+ const zeroDigit = symbols?.zeroDigit || '0';
1122
+ result = String(num).padStart(width, zeroDigit);
1123
+
1124
+ break;
1125
+ }
1126
+ default: {
1127
+ // Use Intl.NumberFormat for decimal formatting if grouping/locale
1128
+ // options are provided
1129
+ let options = {};
1130
+ if (groupingSeparator || groupingSize) {
1131
+ options = {
1132
+ useGrouping: true
1133
+ };
1134
+ }
1135
+ try {
1136
+ result = new Intl.NumberFormat(loc, options).format(num);
1137
+
1138
+ // Apply decimal format symbols if specified
1139
+ if (symbols) {
1140
+ // Use placeholders to avoid conflicts during replacement
1141
+ const TEMP_GROUP = '\u0000GROUPSEP\u0000';
1142
+ const TEMP_DECIMAL = '\u0000DECIMALSEP\u0000';
1143
+
1144
+ // Replace with temporary placeholders first
1145
+ result = result.replaceAll(',', TEMP_GROUP);
1146
+ result = result.replaceAll('.', TEMP_DECIMAL);
1147
+
1148
+ // Now replace with actual symbols
1149
+ const effectiveGroupingSep = groupingSeparator ||
1150
+ symbols.groupingSeparator || ',';
1151
+ const effectiveDecimalSep = symbols.decimalSeparator || '.';
1152
+
1153
+ result = result.replaceAll(TEMP_GROUP, effectiveGroupingSep);
1154
+ result = result.replaceAll(TEMP_DECIMAL, effectiveDecimalSep);
1155
+ } else if (groupingSeparator) {
1156
+ result = result.replaceAll(',', groupingSeparator);
1157
+ }
1158
+ } catch (e) {
1159
+ result = String(num);
1160
+ }
1161
+ }
1162
+ }
1163
+ return result;
1164
+ }
1165
+
1166
+ /**
1167
+ * Convert number to Roman numerals.
1168
+ * @param {number} num - Number to convert (1-3999)
1169
+ * @returns {string}
1170
+ * @private
1171
+ */
1172
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
1173
+ _toRoman (num) {
1174
+ if (num < 1 || num > 3999) {
1175
+ return String(num);
1176
+ }
1177
+
1178
+ const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
1179
+ const syms = [
1180
+ 'M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'
1181
+ ];
1182
+
1183
+ let result = '';
1184
+ for (const [i, val] of vals.entries()) {
1185
+ while (num >= val) {
1186
+ result += syms[i];
1187
+ num -= val;
1188
+ }
1189
+ }
1190
+ return result;
1191
+ }
1192
+
1193
+ /**
1194
+ * Convert number to alphabetic sequence.
1195
+ * @param {number} num - Number to convert
1196
+ * @param {boolean} uppercase - Use uppercase letters
1197
+ * @returns {string}
1198
+ * @private
1199
+ */
1200
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
1201
+ _toAlphabetic (num, uppercase) {
1202
+ if (num < 1) {
1203
+ return String(num);
1204
+ }
1205
+
1206
+ let result = '';
1207
+ const base = uppercase ? 65 : 97; // 'A' or 'a'
1208
+
1209
+ while (num > 0) {
1210
+ num--; // Make 0-indexed
1211
+ result = String.fromCodePoint(base + (num % 26)) + result;
1212
+ num = Math.floor(num / 26);
1213
+ }
1214
+
1215
+ return result;
1216
+ }
534
1217
  /**
535
1218
  * Append plain text (no escaping changes).
536
1219
  * @param {string} str Text
@@ -596,6 +1279,17 @@ class XPathTransformerContext {
596
1279
  return this;
597
1280
  }
598
1281
 
1282
+ /**
1283
+ * @param {string} name
1284
+ * @param {import('./AbstractJoiningTransformer.js').
1285
+ * OutputCharacters} outputCharacters
1286
+ * @returns {this}
1287
+ */
1288
+ characterMap (name, outputCharacters) {
1289
+ this._getJoiningTransformer().characterMap(name, outputCharacters);
1290
+ return this;
1291
+ }
1292
+
599
1293
  /**
600
1294
  * Append element.
601
1295
  * @param {string} name Tag name
@@ -611,6 +1305,44 @@ class XPathTransformerContext {
611
1305
  this._getJoiningTransformer().element(name, atts, children, cb);
612
1306
  return this;
613
1307
  }
1308
+
1309
+ /**
1310
+ * Adds a prefixed namespace declaration to the most recently opened
1311
+ * element. Mirrors the joining
1312
+ * transformer API so templates can call `this.attribute()`.
1313
+ * @param {string} prefix - Prefix
1314
+ * @param {string} namespaceURI - Namespace
1315
+ * @returns {this}
1316
+ */
1317
+ namespace (prefix, namespaceURI) {
1318
+ /** @type {any} */ (this._getJoiningTransformer()).namespace(
1319
+ prefix, namespaceURI
1320
+ );
1321
+ return this;
1322
+ }
1323
+
1324
+ /**
1325
+ * Define a decimal format with custom symbols for number formatting.
1326
+ * Equivalent to xsl:decimal-format. If no name is provided, defines
1327
+ * the default format.
1328
+ * @param {string|import('./JSONPathTransformerContext.js').
1329
+ * DecimalFormatSymbols} nameOrSymbols - Format name or symbols object
1330
+ * if defining default
1331
+ * @param {import('./JSONPathTransformerContext.js').
1332
+ * DecimalFormatSymbols} [symbols] - Format symbols
1333
+ * @returns {this}
1334
+ */
1335
+ decimalFormat (nameOrSymbols, symbols) {
1336
+ if (typeof nameOrSymbols === 'string') {
1337
+ // Named format
1338
+ this.decimalFormats[nameOrSymbols] = symbols || {};
1339
+ } else {
1340
+ // Default format (unnamed)
1341
+ this.decimalFormats[''] = nameOrSymbols;
1342
+ }
1343
+ return this;
1344
+ }
1345
+
614
1346
  /**
615
1347
  * Append attribute.
616
1348
  * @param {string} name Attribute name
@@ -824,6 +1556,143 @@ class XPathTransformerContext {
824
1556
  return this;
825
1557
  }
826
1558
 
1559
+ /**
1560
+ * Analyze a string with a regular expression, equivalent to
1561
+ * xsl:analyze-string. Processes matching and non-matching substrings
1562
+ * with separate callbacks.
1563
+ * @param {string} str - The string to analyze
1564
+ * @param {string|RegExp} regex - Regular expression to match against
1565
+ * @param {{
1566
+ * matchingSubstring?: (
1567
+ * this: XPathTransformerContext,
1568
+ * substring: string,
1569
+ * groups: string[],
1570
+ * regexGroup: (n: number) => string
1571
+ * ) => void,
1572
+ * nonMatchingSubstring?: (
1573
+ * this: XPathTransformerContext,
1574
+ * substring: string
1575
+ * ) => void,
1576
+ * flags?: string
1577
+ * }} options - Options object
1578
+ * @returns {XPathTransformerContext}
1579
+ */
1580
+ analyzeString (str, regex, options = {}) {
1581
+ // Ensure we have a string
1582
+ const inputString = String(str || '');
1583
+
1584
+ // If empty string, do nothing
1585
+ if (inputString.length === 0) {
1586
+ return this;
1587
+ }
1588
+
1589
+ const {
1590
+ matchingSubstring,
1591
+ nonMatchingSubstring,
1592
+ flags = ''
1593
+ } = options;
1594
+
1595
+ // Convert regex to RegExp if it's a string
1596
+ let regexObj;
1597
+ if (typeof regex === 'string') {
1598
+ // Ensure 'g' flag is present for global matching
1599
+ const actualFlags = flags.includes('g') ? flags : flags + 'g';
1600
+ regexObj = new RegExp(regex, actualFlags);
1601
+ } else {
1602
+ regexObj = regex;
1603
+ // Ensure global flag is set
1604
+ if (!regexObj.global) {
1605
+ regexObj = new RegExp(
1606
+ regexObj.source,
1607
+ regexObj.flags + 'g'
1608
+ );
1609
+ }
1610
+ }
1611
+
1612
+ // Check for zero-length matches (error condition in XSLT)
1613
+ if (regexObj.test('')) {
1614
+ throw new Error(
1615
+ 'Regular expression matches zero-length string'
1616
+ );
1617
+ }
1618
+
1619
+ // Store captured groups for access during callback
1620
+ /** @type {string[] | undefined} */
1621
+ let currentCapturedGroups;
1622
+
1623
+ /**
1624
+ * Get captured group by index.
1625
+ * @param {number} groupNumber - Group index
1626
+ * @returns {string} - Captured group or empty string
1627
+ */
1628
+ const getRegexGroup = (groupNumber) => {
1629
+ if (!currentCapturedGroups ||
1630
+ groupNumber < 0 ||
1631
+ groupNumber >= currentCapturedGroups.length) {
1632
+ return '';
1633
+ }
1634
+ return currentCapturedGroups[groupNumber] || '';
1635
+ };
1636
+
1637
+ // Save previous context to restore later
1638
+ const prevContext = this._contextNode;
1639
+
1640
+ let lastIndex = 0;
1641
+ let match;
1642
+
1643
+ // Bind callbacks to this context
1644
+ const boundMatchingSubstring = matchingSubstring
1645
+ ? matchingSubstring.bind(this)
1646
+ : undefined;
1647
+ const boundNonMatchingSubstring = nonMatchingSubstring
1648
+ ? nonMatchingSubstring.bind(this)
1649
+ : undefined;
1650
+
1651
+ // Find all matches
1652
+ while ((match = regexObj.exec(inputString)) !== null) {
1653
+ // Process non-matching substring before this match
1654
+ if (match.index > lastIndex) {
1655
+ const nonMatchingStr = inputString.slice(lastIndex, match.index);
1656
+ if (boundNonMatchingSubstring) {
1657
+ boundNonMatchingSubstring(nonMatchingStr);
1658
+ }
1659
+ }
1660
+
1661
+ // Process matching substring
1662
+ if (boundMatchingSubstring) {
1663
+ const matchingStr = match[0];
1664
+ // Store captured groups: [full match, group1, group2, ...]
1665
+ currentCapturedGroups = [...match];
1666
+ boundMatchingSubstring(
1667
+ matchingStr, currentCapturedGroups, getRegexGroup
1668
+ );
1669
+ currentCapturedGroups = undefined;
1670
+ }
1671
+
1672
+ const {lastIndex: newLastIndex} = regexObj;
1673
+ lastIndex = newLastIndex;
1674
+
1675
+ // Prevent infinite loop on zero-length matches (shouldn't happen
1676
+ // due to earlier check, but defensive)
1677
+ if (match.index === regexObj.lastIndex) {
1678
+ regexObj.lastIndex++;
1679
+ }
1680
+ }
1681
+
1682
+ // Process final non-matching substring
1683
+ if (lastIndex < inputString.length) {
1684
+ const nonMatchingStr = inputString.slice(lastIndex);
1685
+ if (boundNonMatchingSubstring) {
1686
+ boundNonMatchingSubstring(nonMatchingStr);
1687
+ }
1688
+ }
1689
+
1690
+ // Restore previous context
1691
+ this._contextNode = prevContext;
1692
+
1693
+ return this;
1694
+ }
1695
+
827
1696
  /* c8 ignore start -- static default rules object has spotty function
828
1697
  * attribution under coverage; behavior is exercised via applyTemplates */
829
1698
  static DefaultTemplateRules = {