jtlt 0.4.0 → 0.6.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 +16 -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 +35 -0
  10. package/dist/AbstractJoiningTransformer.d.ts.map +1 -1
  11. package/dist/DOMJoiningTransformer.d.ts +85 -8
  12. package/dist/DOMJoiningTransformer.d.ts.map +1 -1
  13. package/dist/JSONJoiningTransformer.d.ts +28 -10
  14. package/dist/JSONJoiningTransformer.d.ts.map +1 -1
  15. package/dist/JSONPathTransformer.d.ts.map +1 -1
  16. package/dist/JSONPathTransformerContext.d.ts +168 -3
  17. package/dist/JSONPathTransformerContext.d.ts.map +1 -1
  18. package/dist/StringJoiningTransformer.d.ts +25 -8
  19. package/dist/StringJoiningTransformer.d.ts.map +1 -1
  20. package/dist/XPathTransformer.d.ts.map +1 -1
  21. package/dist/XPathTransformerContext.d.ts +107 -1
  22. package/dist/XPathTransformerContext.d.ts.map +1 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/docs/TO-DO.md +38 -36
  25. package/package.json +6 -2
  26. package/src/AbstractJoiningTransformer.js +53 -0
  27. package/src/DOMJoiningTransformer.js +199 -24
  28. package/src/JSONJoiningTransformer.js +89 -27
  29. package/src/JSONPathTransformer.js +2 -0
  30. package/src/JSONPathTransformerContext.js +572 -9
  31. package/src/StringJoiningTransformer.js +60 -22
  32. package/src/XPathTransformer.js +2 -0
  33. package/src/XPathTransformerContext.js +487 -11
  34. package/src/index.js +7 -1
@@ -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
+ }
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
+ }
375
630
  }
631
+
376
632
  return this;
377
633
  }
378
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) {
@@ -864,6 +1233,15 @@ class XPathTransformerContext {
864
1233
  this._getJoiningTransformer().propValue(prop, val);
865
1234
  return this;
866
1235
  }
1236
+ /**
1237
+ * Alias for propValue(). Append a key-value pair to the current map/object.
1238
+ * @param {string} prop Property name
1239
+ * @param {any} val Value
1240
+ * @returns {XPathTransformerContext}
1241
+ */
1242
+ mapEntry (prop, val) {
1243
+ return this.propValue(prop, val);
1244
+ }
867
1245
  /**
868
1246
  * Append object.
869
1247
  * @param {Record<string, unknown>|
@@ -884,6 +1262,23 @@ class XPathTransformerContext {
884
1262
  propSets);
885
1263
  return this;
886
1264
  }
1265
+ /**
1266
+ * Alias for object(). Append an object/map.
1267
+ * @param {Record<string, unknown>|
1268
+ * ((this: XPathTransformerContext) => void)} objOrCb Object or callback
1269
+ * @param {((this: XPathTransformerContext) => void)|
1270
+ * any[]} [cbOrUsePropertySets] Callback or property sets
1271
+ * @param {any[]|
1272
+ * Record<string, unknown>} [usePropertySetsOrPropSets]
1273
+ * Property sets or props
1274
+ * @param {Record<string, unknown>} [propSets] Additional property sets
1275
+ * @returns {XPathTransformerContext}
1276
+ */
1277
+ map (objOrCb, cbOrUsePropertySets, usePropertySetsOrPropSets, propSets) {
1278
+ return this.object(
1279
+ objOrCb, cbOrUsePropertySets, usePropertySetsOrPropSets, propSets
1280
+ );
1281
+ }
887
1282
  /**
888
1283
  * Append array.
889
1284
  * @param {any[]|
@@ -910,6 +1305,27 @@ class XPathTransformerContext {
910
1305
  return this;
911
1306
  }
912
1307
 
1308
+ /**
1309
+ * @param {string} name
1310
+ * @param {import('./AbstractJoiningTransformer.js').
1311
+ * OutputCharacters} outputCharacters
1312
+ * @returns {this}
1313
+ */
1314
+ characterMap (name, outputCharacters) {
1315
+ this._getJoiningTransformer().characterMap(name, outputCharacters);
1316
+ return this;
1317
+ }
1318
+
1319
+ /**
1320
+ * @param {string} name
1321
+ * @param {Record<string, string>} attributes
1322
+ * @returns {this}
1323
+ */
1324
+ attributeSet (name, attributes) {
1325
+ this._getJoiningTransformer().attributeSet(name, attributes);
1326
+ return this;
1327
+ }
1328
+
913
1329
  /**
914
1330
  * Append element.
915
1331
  * @param {string} name Tag name
@@ -918,13 +1334,53 @@ class XPathTransformerContext {
918
1334
  * @param {any[]|((this: XPathTransformerContext)=>void)} [children]
919
1335
  * Children
920
1336
  * @param {(this: XPathTransformerContext)=>void} [cb] Callback
1337
+ * @param {string[]} [useAttributeSets] - Attribute set names to apply
921
1338
  * @returns {XPathTransformerContext}
922
1339
  */
923
- element (name, atts, children, cb) {
924
- // @ts-expect-error - Union of transformers creates intersection types
925
- this._getJoiningTransformer().element(name, atts, children, cb);
1340
+ element (name, atts, children, cb, useAttributeSets) {
1341
+ /** @type {any} */ (this._getJoiningTransformer()).element(
1342
+ name, atts, children, cb, useAttributeSets
1343
+ );
926
1344
  return this;
927
1345
  }
1346
+
1347
+ /**
1348
+ * Adds a prefixed namespace declaration to the most recently opened
1349
+ * element. Mirrors the joining
1350
+ * transformer API so templates can call `this.attribute()`.
1351
+ * @param {string} prefix - Prefix
1352
+ * @param {string} namespaceURI - Namespace
1353
+ * @returns {this}
1354
+ */
1355
+ namespace (prefix, namespaceURI) {
1356
+ /** @type {any} */ (this._getJoiningTransformer()).namespace(
1357
+ prefix, namespaceURI
1358
+ );
1359
+ return this;
1360
+ }
1361
+
1362
+ /**
1363
+ * Define a decimal format with custom symbols for number formatting.
1364
+ * Equivalent to xsl:decimal-format. If no name is provided, defines
1365
+ * the default format.
1366
+ * @param {string|import('./JSONPathTransformerContext.js').
1367
+ * DecimalFormatSymbols} nameOrSymbols - Format name or symbols object
1368
+ * if defining default
1369
+ * @param {import('./JSONPathTransformerContext.js').
1370
+ * DecimalFormatSymbols} [symbols] - Format symbols
1371
+ * @returns {this}
1372
+ */
1373
+ decimalFormat (nameOrSymbols, symbols) {
1374
+ if (typeof nameOrSymbols === 'string') {
1375
+ // Named format
1376
+ this.decimalFormats[nameOrSymbols] = symbols || {};
1377
+ } else {
1378
+ // Default format (unnamed)
1379
+ this.decimalFormats[''] = nameOrSymbols;
1380
+ }
1381
+ return this;
1382
+ }
1383
+
928
1384
  /**
929
1385
  * Append attribute.
930
1386
  * @param {string} name Attribute name
@@ -1138,6 +1594,26 @@ class XPathTransformerContext {
1138
1594
  return this;
1139
1595
  }
1140
1596
 
1597
+ /**
1598
+ * Assert that a test condition is true, throwing an error if it fails.
1599
+ * Equivalent to xsl:assert. Evaluates an XPath expression using the
1600
+ * same truthiness rules as if() and choose().
1601
+ * @param {string} test - XPath expression to test
1602
+ * @param {string} [message] - Optional error message to include
1603
+ * @returns {XPathTransformerContext}
1604
+ * @throws {Error} When the test expression evaluates to false
1605
+ */
1606
+ assert (test, message) {
1607
+ const passes = this._passesIf(test);
1608
+ if (!passes) {
1609
+ const errorMsg = message
1610
+ ? `Assertion failed: ${message}`
1611
+ : `Assertion failed: ${test}`;
1612
+ throw new Error(errorMsg);
1613
+ }
1614
+ return this;
1615
+ }
1616
+
1141
1617
  /**
1142
1618
  * Analyze a string with a regular expression, equivalent to
1143
1619
  * xsl:analyze-string. Processes matching and non-matching substrings
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'