jtlt 0.4.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 (34) hide show
  1. package/CHANGES.md +9 -0
  2. package/demo/index.css +4 -0
  3. package/demo/index.html +4 -4
  4. package/demo/index.js +6 -2
  5. package/demo/vendor/fontoxpath/dist/fontoxpath.esm.js +600 -0
  6. package/demo/vendor/prsc/dist/prsc.esm.js +2 -0
  7. package/demo/vendor/whynot/dist/whynot.esm.js +2 -0
  8. package/demo/vendor/xspattern/dist/xspattern.esm.js +2 -0
  9. package/dist/AbstractJoiningTransformer.d.ts +27 -0
  10. package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
  11. package/dist/DOMJoiningTransformer.d.ts +60 -8
  12. package/dist/DOMJoiningTransformer.d.ts.map +1 -1
  13. package/dist/JSONJoiningTransformer.d.ts +8 -9
  14. package/dist/JSONJoiningTransformer.d.ts.map +1 -1
  15. package/dist/JSONPathTransformer.d.ts.map +1 -1
  16. package/dist/JSONPathTransformerContext.d.ts +137 -2
  17. package/dist/JSONPathTransformerContext.d.ts.map +1 -1
  18. package/dist/StringJoiningTransformer.d.ts +7 -7
  19. package/dist/StringJoiningTransformer.d.ts.map +1 -1
  20. package/dist/XPathTransformer.d.ts.map +1 -1
  21. package/dist/XPathTransformerContext.d.ts +69 -0
  22. package/dist/XPathTransformerContext.d.ts.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/docs/TO-DO.md +8 -6
  25. package/package.json +6 -2
  26. package/src/AbstractJoiningTransformer.js +42 -0
  27. package/src/DOMJoiningTransformer.js +115 -23
  28. package/src/JSONJoiningTransformer.js +50 -26
  29. package/src/JSONPathTransformer.js +2 -0
  30. package/src/JSONPathTransformerContext.js +520 -7
  31. package/src/StringJoiningTransformer.js +25 -21
  32. package/src/XPathTransformer.js +2 -0
  33. package/src/XPathTransformerContext.js +426 -8
  34. package/src/index.js +7 -1
@@ -13,7 +13,8 @@ import AbstractJoiningTransformer from './AbstractJoiningTransformer.js';
13
13
  * mediaType?: string,
14
14
  * version?: string,
15
15
  * standalone?: boolean,
16
- * method?: "xml"|"html"|"text"|"json"|"xhtml"
16
+ * method?: "xml"|"html"|"text"|"json"|"xhtml",
17
+ * useCharacterMaps?: string[]
17
18
  * }} OutputConfig
18
19
  */
19
20
 
@@ -434,20 +435,6 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
434
435
  return this;
435
436
  }
436
437
 
437
- /**
438
- * @param {OutputConfig} cfg
439
- * @returns {StringJoiningTransformer}
440
- */
441
- output (cfg) {
442
- // We wait until first element is set in `element()` to add
443
- // XML declaration and DOCTYPE as latter depends on root element
444
- this._outputConfig = cfg;
445
-
446
- // Use for file extension if making downloadable?
447
- this.mediaType = cfg.mediaType;
448
- return this;
449
- }
450
-
451
438
  /**
452
439
  * @param {string|Element} elName - Element name or element object
453
440
  * @param {ElementAttributes} [atts] - Element attributes
@@ -570,7 +557,9 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
570
557
  }
571
558
  if (childNodes && childNodes.length) {
572
559
  this._openTagState = false;
573
- this.append(jml[method]({'#': childNodes}));
560
+ this.append(this._replaceCharacterMaps(
561
+ jml[method]({'#': childNodes})
562
+ ));
574
563
  }
575
564
  cb.call(this);
576
565
 
@@ -585,6 +574,17 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
585
574
  return this;
586
575
  }
587
576
 
577
+ /**
578
+ * @param {string} prefix
579
+ * @param {string} namespaceURI
580
+ * @returns {StringJoiningTransformer}
581
+ */
582
+ namespace (prefix, namespaceURI) {
583
+ this.append(' xmlns:' + prefix + '="' +
584
+ this._replaceCharacterMaps(namespaceURI) + '"');
585
+ return this;
586
+ }
587
+
588
588
  /**
589
589
  * @param {string} name - Attribute name
590
590
  * @param {string|Record<string, unknown>|
@@ -648,7 +648,7 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
648
648
  val = (this._cfg.preEscapedAttributes || avoidAttEscape)
649
649
  ? valStr
650
650
  : valStr.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
651
- this.append(' ' + name + '="' + val + '"');
651
+ this.append(' ' + name + '="' + this._replaceCharacterMaps(val) + '"');
652
652
  return this;
653
653
  }
654
654
 
@@ -663,7 +663,11 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
663
663
  this.append('>');
664
664
  this._openTagState = false;
665
665
  }
666
- this.append(txt.replaceAll('&', '&amp;').replaceAll('<', '&lt;'));
666
+ this.append(
667
+ this._replaceCharacterMaps(
668
+ txt
669
+ ).replaceAll('&', '&amp;').replaceAll('<', '&lt;')
670
+ );
667
671
  return this;
668
672
  }
669
673
 
@@ -734,7 +738,7 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
734
738
  // Save current state
735
739
  /** @type {any} */
736
740
  const oldRoot = this.root;
737
- /** @type {any} */
741
+ /** @type {OutputConfig|undefined} */
738
742
  const oldOutputConfig = this._outputConfig;
739
743
  const oldStr = this._str;
740
744
  /** @type {any} */
@@ -742,7 +746,7 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
742
746
 
743
747
  // Reset state for new document
744
748
  this.root = undefined;
745
- /** @type {any} */
749
+ /** @type {OutputConfig} */
746
750
  this._outputConfig = cfg;
747
751
  this._str = '';
748
752
  this._openTagState = false;
@@ -795,7 +799,7 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
795
799
 
796
800
  // Reset state for new document
797
801
  this.root = undefined;
798
- /** @type {any} */
802
+ /** @type {OutputConfig} */
799
803
  this._outputConfig = cfg;
800
804
  this._str = '';
801
805
  this._openTagState = false;
@@ -83,6 +83,8 @@ class XPathTransformer {
83
83
  if (len > 1) {
84
84
  this._triggerEqualPriorityError();
85
85
  }
86
+ // Set up parameter context for valueOf() access in root template
87
+ xte._params = {0: xte._contextNode};
86
88
  const ret = templateObj.template.call(xte, undefined, {mode});
87
89
  if (typeof ret !== 'undefined') {
88
90
  /** @type {any} */ (xte)._getJoiningTransformer().append(ret);
@@ -48,6 +48,11 @@ class XPathTransformerContext {
48
48
  this.propertySets = {};
49
49
  /** @type {Record<string, {match: string, use: string}>} */
50
50
  this.keys = {};
51
+ /**
52
+ * @type {Record<string,
53
+ * import('./JSONPathTransformerContext.js').DecimalFormatSymbols>}
54
+ */
55
+ this.decimalFormats = {};
51
56
  /** @type {boolean|undefined} */
52
57
  this._initialized = undefined;
53
58
  /** @type {string|undefined} */
@@ -293,7 +298,16 @@ class XPathTransformerContext {
293
298
  );
294
299
  }
295
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
+
296
306
  const ret = templateObj.template.call(this, node, {mode});
307
+
308
+ // Restore previous parameter context
309
+ this._params = prevTemplateParams;
310
+
297
311
  if (typeof ret !== 'undefined') {
298
312
  this._getJoiningTransformer().append(ret);
299
313
  }
@@ -371,11 +385,269 @@ class XPathTransformerContext {
371
385
  const nodesResult = this._evalXPath(select, true);
372
386
  const nodes = /** @type {Node[]} */ (nodesResult);
373
387
  for (const n of nodes) {
374
- 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
+ }
375
400
  }
376
401
  return this;
377
402
  }
378
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
+ }
630
+ }
631
+
632
+ return this;
633
+ }
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
+
379
651
  /**
380
652
  * Append the value from an XPath expression or the context node text.
381
653
  * @param {string|object} [select]
@@ -389,11 +661,66 @@ class XPathTransformerContext {
389
661
  ? /** @type {{select?: string}} */ (select).select
390
662
  : select;
391
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
+
392
703
  // Check if this is a parameter reference (starts with $)
393
704
  if (selectStr && selectStr.startsWith('$')) {
394
705
  const paramName = selectStr.slice(1);
395
706
  if (this._params && paramName in this._params) {
396
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
+ }
397
724
  } else {
398
725
  // Fall back to normal XPath evaluation
399
726
  const resResult = this._evalXPath(selectStr, true);
@@ -406,9 +733,19 @@ class XPathTransformerContext {
406
733
  /* c8 ignore stop */
407
734
  }
408
735
  } else if (!selectStr || selectStr === '.') {
409
- val = this._contextNode.nodeType === 3
410
- ? this._contextNode.nodeValue
411
- : 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
+ }
412
749
  } else {
413
750
  const resResult = this._evalXPath(selectStr, true);
414
751
  const res = /** @type {Node[]} */ (resResult);
@@ -549,6 +886,7 @@ class XPathTransformerContext {
549
886
  * level?: 'single'|'multiple'|'any',
550
887
  * from?: string,
551
888
  * format?: string,
889
+ * decimalFormat?: string,
552
890
  * groupingSeparator?: string,
553
891
  * groupingSize?: number
554
892
  * }} num - Number value, "position()" string, or options object
@@ -733,15 +1071,27 @@ class XPathTransformerContext {
733
1071
  * @param {string} format - Format string (1, a, A, i, I, 01, etc.)
734
1072
  * @param {string} [groupingSeparator] - Separator for grouping (e.g., ',')
735
1073
  * @param {number} [groupingSize] - Size of groups (e.g., 3 for 1,000)
1074
+ * @param {string} [decimalFormatName] - Named decimal format to use
736
1075
  * @param {string} [locale]
737
1076
  * @returns {string}
738
1077
  * @private
739
1078
  */
740
- _formatNumber (num, format, groupingSeparator, groupingSize, locale = 'en') {
1079
+ _formatNumber (
1080
+ num, format, groupingSeparator, groupingSize, decimalFormatName, locale
1081
+ ) {
741
1082
  if (Number.isNaN(num)) {
742
1083
  return String(num);
743
1084
  }
744
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
+
745
1095
  let result;
746
1096
  const formatChar = format.charAt(0);
747
1097
 
@@ -768,7 +1118,8 @@ class XPathTransformerContext {
768
1118
  }
769
1119
  case '0': {
770
1120
  const width = format.length;
771
- result = String(num).padStart(width, '0');
1121
+ const zeroDigit = symbols?.zeroDigit || '0';
1122
+ result = String(num).padStart(width, zeroDigit);
772
1123
 
773
1124
  break;
774
1125
  }
@@ -782,8 +1133,26 @@ class XPathTransformerContext {
782
1133
  };
783
1134
  }
784
1135
  try {
785
- result = new Intl.NumberFormat(locale, options).format(num);
786
- if (groupingSeparator) {
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) {
787
1156
  result = result.replaceAll(',', groupingSeparator);
788
1157
  }
789
1158
  } catch (e) {
@@ -910,6 +1279,17 @@ class XPathTransformerContext {
910
1279
  return this;
911
1280
  }
912
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
+
913
1293
  /**
914
1294
  * Append element.
915
1295
  * @param {string} name Tag name
@@ -925,6 +1305,44 @@ class XPathTransformerContext {
925
1305
  this._getJoiningTransformer().element(name, atts, children, cb);
926
1306
  return this;
927
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
+
928
1346
  /**
929
1347
  * Append attribute.
930
1348
  * @param {string} name Attribute name
package/src/index.js CHANGED
@@ -380,7 +380,13 @@ class JTLT {
380
380
  );
381
381
  // eslint-disable-next-line @stylistic/max-len -- Long
382
382
  // eslint-disable-next-line unicorn/no-array-method-this-argument -- Not array
383
- this.forEach(path, fn);
383
+ this.forEach(path, (arg) => {
384
+ const ret = fn.call(this, arg);
385
+ if (typeof ret !== 'undefined') {
386
+ // @ts-expect-error Ok?
387
+ this._getJoiningTransformer().append(ret);
388
+ }
389
+ });
384
390
  }
385
391
  : cfg.query || (
386
392
  typeof cfg.templates === 'function'