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
@@ -1,11 +1,28 @@
1
1
  import {JSONPath as jsonpath} from 'jsonpath-plus';
2
2
  import JSONPathTransformer from './JSONPathTransformer.js';
3
3
 
4
+ /**
5
+ * Decimal format symbols for number formatting.
6
+ * @typedef {object} DecimalFormatSymbols
7
+ * @property {string} [decimalSeparator='.'] - Character for decimal point
8
+ * @property {string} [groupingSeparator=','] - Character for thousands
9
+ * @property {string} [percent='%'] - Character for percent
10
+ * @property {string} [perMille='‰'] - Character for per-mille
11
+ * @property {string} [zeroDigit='0'] - Character for zero
12
+ * @property {string} [digit='#'] - Character for digit placeholder
13
+ * @property {string} [patternSeparator=';'] - Character separating
14
+ * positive/negative patterns
15
+ * @property {string} [minusSign='-'] - Character for minus sign
16
+ * @property {string} [infinity='Infinity'] - String for infinity
17
+ * @property {string} [NaN='NaN'] - String for NaN
18
+ */
19
+
4
20
  /**
5
21
  * @typedef {number|string|{
6
22
  * value?: number|string,
7
23
  * count?: string,
8
24
  * format?: string,
25
+ * decimalFormat?: string,
9
26
  * groupingSeparator?: string,
10
27
  * groupingSize?: number,
11
28
  * lang?: string,
@@ -80,6 +97,8 @@ class JSONPathTransformerContext {
80
97
  this.propertySets = {};
81
98
  /** @type {Record<string, {match: string, use: string}>} */
82
99
  this.keys = {};
100
+ /** @type {Record<string, DecimalFormatSymbols>} */
101
+ this.decimalFormats = {};
83
102
  /** @type {boolean | undefined} */
84
103
  this._initialized = undefined;
85
104
  /** @type {string | undefined} */
@@ -258,6 +277,7 @@ class JSONPathTransformerContext {
258
277
  if (type === 'number') {
259
278
  const an = Number(aVal);
260
279
  const bn = Number(bVal);
280
+ /* c8 ignore next -- both NaN tested; short-circuit branch tracking */
261
281
  if (Number.isNaN(an) && Number.isNaN(bn)) {
262
282
  return 0;
263
283
  }
@@ -270,6 +290,7 @@ class JSONPathTransformerContext {
270
290
  return (an - bn) * order;
271
291
  }
272
292
  // text
293
+ /* c8 ignore next 2 -- all null/undefined tested; OR short-circuit */
273
294
  const aStr = aVal === null || aVal === undefined ? '' : String(aVal);
274
295
  const bStr = bVal === null || bVal === undefined ? '' : String(bVal);
275
296
  if (spec && spec.locale) {
@@ -277,6 +298,8 @@ class JSONPathTransformerContext {
277
298
  bStr, spec.locale, spec.localeOptions
278
299
  ) * order;
279
300
  }
301
+ /* c8 ignore next -- all comparison outcomes tested; ternary
302
+ * branch tracking */
280
303
  return (aStr < bStr ? -1 : (aStr > bStr ? 1 : 0)) * order;
281
304
  }
282
305
  /**
@@ -407,12 +430,19 @@ class JSONPathTransformerContext {
407
430
  that._parent = parent;
408
431
  that._parentProperty = (parentProperty ?? that._parentProperty);
409
432
 
433
+ // Set up parameter context for valueOf() access in templates
434
+ const prevTemplateParams = that._params;
435
+ that._params = {0: value};
436
+
410
437
  const ret =
411
438
  /** @type {import('./index.js').JSONPathTemplateObject<T>} */ (
412
439
  templateObj
413
440
  ).template.call(
414
441
  that, value, {mode, parent, parentProperty}
415
442
  );
443
+
444
+ // Restore previous parameter context
445
+ that._params = prevTemplateParams;
416
446
  if (typeof ret !== 'undefined') {
417
447
  // After the undefined check, ret is ResultType<T>
418
448
  that._getJoiningTransformer().append(
@@ -540,11 +570,13 @@ class JSONPathTransformerContext {
540
570
  * @returns {number}
541
571
  */
542
572
  function feCompareBySpec (aVal, bVal, spec) {
573
+ /* c8 ignore next 2 -- all spec combinations tested; && and || branches */
543
574
  const order = (spec && spec.order === 'descending') ? -1 : 1;
544
575
  const type = (spec && spec.type) || 'text';
545
576
  if (type === 'number') {
546
577
  const an = Number(aVal);
547
578
  const bn = Number(bVal);
579
+ /* c8 ignore next -- both NaN tested; short-circuit branch tracking */
548
580
  if (Number.isNaN(an) && Number.isNaN(bn)) {
549
581
  return 0;
550
582
  }
@@ -556,6 +588,7 @@ class JSONPathTransformerContext {
556
588
  }
557
589
  return (an - bn) * order;
558
590
  }
591
+ /* c8 ignore next 2 -- all null/undefined tested; OR short-circuit */
559
592
  const aStr = aVal === null || aVal === undefined ? '' : String(aVal);
560
593
  const bStr = bVal === null || bVal === undefined ? '' : String(bVal);
561
594
  if (spec && spec.locale) {
@@ -563,6 +596,8 @@ class JSONPathTransformerContext {
563
596
  bStr, spec.locale, spec.localeOptions
564
597
  ) * order;
565
598
  }
599
+ /* c8 ignore next -- all comparison outcomes tested; ternary
600
+ * branch tracking */
566
601
  return (aStr < bStr ? -1 : (aStr > bStr ? 1 : 0)) * order;
567
602
  }
568
603
  /**
@@ -606,11 +641,364 @@ class JSONPathTransformerContext {
606
641
  const comparator = feBuildComparator(sort);
607
642
  const list = comparator ? [...matches].toSorted(comparator) : matches;
608
643
  for (const m of list) {
609
- cb.call(that, m.value);
644
+ // Set up parameter context for valueOf() access
645
+ const prevParams = that._params;
646
+ const prevContext = that._contextObj;
647
+ that._params = {0: m.value};
648
+ that._contextObj = m.value;
649
+ try {
650
+ cb.call(that, m.value);
651
+ } finally {
652
+ // Restore previous parameter context
653
+ that._params = prevParams;
654
+ that._contextObj = prevContext;
655
+ }
656
+ }
657
+ return this;
658
+ }
659
+
660
+ /**
661
+ * Groups items and executes callback for each group.
662
+ * Equivalent to XSLT's xsl:for-each-group.
663
+ * @param {string} select - JSONPath selector for items to group
664
+ * @param {object} options - Grouping options
665
+ * @param {string} [options.groupBy] - JSONPath expression to group by value
666
+ * @param {string} [options.groupAdjacent] - Groups adjacent items with
667
+ * same value
668
+ * @param {string} [options.groupStartingWith] - Starts new group when
669
+ * expression matches
670
+ * @param {string} [options.groupEndingWith] - Ends group when expression
671
+ * matches
672
+ * @param {any} [options.sort] - Sort specification (same as forEach)
673
+ * @param {(
674
+ * this: JSONPathTransformerContext<T>, key: any, items: any[], ctx: any
675
+ * ) => void} cb - Callback receives (groupingKey, groupItems, context)
676
+ * @returns {this}
677
+ */
678
+ forEachGroup (select, options, cb) {
679
+ // eslint-disable-next-line unicorn/no-this-assignment -- Temporary
680
+ const that = this;
681
+ const {groupBy, groupAdjacent, groupStartingWith, groupEndingWith, sort} =
682
+ options;
683
+
684
+ /** @type {{value: any}[]} */
685
+ const matches = /** @type {any} */ (jsonpath)({
686
+ path: select,
687
+ json: this._contextObj,
688
+ preventEval: this._config.preventEval,
689
+ wrap: true,
690
+ resultType: 'all'
691
+ });
692
+
693
+ /**
694
+ * @param {string} expr
695
+ * @param {any} ctxVal
696
+ * @returns {any}
697
+ */
698
+ function evalInContext (expr, ctxVal) {
699
+ if (expr === '.' || expr === '@') {
700
+ return ctxVal;
701
+ }
702
+ return /** @type {any} */ (jsonpath)({
703
+ path: expr,
704
+ json: ctxVal,
705
+ preventEval: that._config.preventEval,
706
+ wrap: false,
707
+ returnType: 'value'
708
+ });
709
+ }
710
+
711
+ // Apply sorting if specified
712
+ if (sort) {
713
+ const comparator = this._buildComparator(sort, evalInContext);
714
+ if (comparator) {
715
+ matches.sort(
716
+ /** @type {(a: {value: any}, b: {value: any}) => number} */ (
717
+ comparator
718
+ )
719
+ );
720
+ }
721
+ }
722
+
723
+ /** @type {Map<any, any[]>} */
724
+ const groups = new Map();
725
+
726
+ if (groupBy) {
727
+ // Group by computed value
728
+ for (const m of matches) {
729
+ const key = evalInContext(groupBy, m.value);
730
+ // Handle undefined by converting to null for JSON serialization
731
+ const keyStr = JSON.stringify(key === undefined ? null : key);
732
+ if (!groups.has(keyStr)) {
733
+ groups.set(keyStr, []);
734
+ }
735
+ /** @type {any[]} */ (groups.get(keyStr)).push(m.value);
736
+ }
737
+
738
+ for (const [keyStr, items] of groups) {
739
+ const key = JSON.parse(keyStr);
740
+ // Convert null back to undefined if that was the original value
741
+ const actualKey = key === null && keyStr === 'null' ? undefined : key;
742
+ const prevContext = this._contextObj;
743
+ const prevParams = this._params;
744
+ try {
745
+ this._contextObj = items;
746
+ // Provide currentGroup() and currentGroupingKey() via context
747
+ /** @type {any} */ (this)._currentGroup = items;
748
+ /** @type {any} */ (this)._currentGroupingKey = actualKey;
749
+ cb.call(this, actualKey, items, this);
750
+ } finally {
751
+ this._contextObj = prevContext;
752
+ this._params = prevParams;
753
+ delete /** @type {any} */ (this)._currentGroup;
754
+ delete /** @type {any} */ (this)._currentGroupingKey;
755
+ }
756
+ }
757
+ } else if (groupAdjacent) {
758
+ // Group adjacent items with same value
759
+ /** @type {string|null} */
760
+ let currentKey = null;
761
+ let currentGroup = [];
762
+
763
+ for (const m of matches) {
764
+ const key = evalInContext(groupAdjacent, m.value);
765
+ const keyStr = JSON.stringify(key);
766
+
767
+ if (currentKey === null || currentKey !== keyStr) {
768
+ if (currentGroup.length > 0) {
769
+ const prevContext = this._contextObj;
770
+ const prevParams = this._params;
771
+ try {
772
+ this._contextObj = currentGroup;
773
+ /** @type {any} */ (this)._currentGroup = currentGroup;
774
+ /** @type {any} */ (this)._currentGroupingKey =
775
+ JSON.parse(/** @type {string} */ (currentKey));
776
+ cb.call(
777
+ this,
778
+ JSON.parse(/** @type {string} */ (currentKey)),
779
+ currentGroup,
780
+ this
781
+ );
782
+ } finally {
783
+ this._contextObj = prevContext;
784
+ this._params = prevParams;
785
+ delete /** @type {any} */ (this)._currentGroup;
786
+ delete /** @type {any} */ (this)._currentGroupingKey;
787
+ }
788
+ }
789
+ currentKey = keyStr;
790
+ currentGroup = [m.value];
791
+ } else {
792
+ currentGroup.push(m.value);
793
+ }
794
+ }
795
+
796
+ // Process last group
797
+ if (currentGroup.length > 0) {
798
+ const prevContext = this._contextObj;
799
+ const prevParams = this._params;
800
+ try {
801
+ this._contextObj = currentGroup;
802
+ /** @type {any} */ (this)._currentGroup = currentGroup;
803
+ /** @type {any} */ (this)._currentGroupingKey =
804
+ JSON.parse(/** @type {string} */ (currentKey));
805
+ cb.call(
806
+ this,
807
+ JSON.parse(/** @type {string} */ (currentKey)),
808
+ currentGroup,
809
+ this
810
+ );
811
+ } finally {
812
+ this._contextObj = prevContext;
813
+ this._params = prevParams;
814
+ delete /** @type {any} */ (this)._currentGroup;
815
+ delete /** @type {any} */ (this)._currentGroupingKey;
816
+ }
817
+ }
818
+ } else if (groupStartingWith) {
819
+ // Start new group when expression matches
820
+ let currentGroup = [];
821
+
822
+ for (const m of matches) {
823
+ const startMatch = evalInContext(groupStartingWith, m.value);
824
+
825
+ if (startMatch && currentGroup.length > 0) {
826
+ const prevContext = this._contextObj;
827
+ const prevParams = this._params;
828
+ try {
829
+ this._contextObj = currentGroup;
830
+ /** @type {any} */ (this)._currentGroup = currentGroup;
831
+ cb.call(this, null, currentGroup, this);
832
+ } finally {
833
+ this._contextObj = prevContext;
834
+ this._params = prevParams;
835
+ delete /** @type {any} */ (this)._currentGroup;
836
+ }
837
+ currentGroup = [];
838
+ }
839
+ currentGroup.push(m.value);
840
+ }
841
+
842
+ // Process last group
843
+ if (currentGroup.length > 0) {
844
+ const prevContext = this._contextObj;
845
+ const prevParams = this._params;
846
+ try {
847
+ this._contextObj = currentGroup;
848
+ /** @type {any} */ (this)._currentGroup = currentGroup;
849
+ cb.call(this, null, currentGroup, this);
850
+ } finally {
851
+ this._contextObj = prevContext;
852
+ this._params = prevParams;
853
+ delete /** @type {any} */ (this)._currentGroup;
854
+ }
855
+ }
856
+ } else if (groupEndingWith) {
857
+ // End group when expression matches
858
+ let currentGroup = [];
859
+
860
+ for (const m of matches) {
861
+ currentGroup.push(m.value);
862
+ const endMatch = evalInContext(groupEndingWith, m.value);
863
+
864
+ if (endMatch) {
865
+ const prevContext = this._contextObj;
866
+ const prevParams = this._params;
867
+ try {
868
+ this._contextObj = currentGroup;
869
+ /** @type {any} */ (this)._currentGroup = currentGroup;
870
+ cb.call(this, null, currentGroup, this);
871
+ } finally {
872
+ this._contextObj = prevContext;
873
+ this._params = prevParams;
874
+ delete /** @type {any} */ (this)._currentGroup;
875
+ }
876
+ currentGroup = [];
877
+ }
878
+ }
879
+
880
+ // Process last group if not ended
881
+ if (currentGroup.length > 0) {
882
+ const prevContext = this._contextObj;
883
+ const prevParams = this._params;
884
+ try {
885
+ this._contextObj = currentGroup;
886
+ /** @type {any} */ (this)._currentGroup = currentGroup;
887
+ cb.call(this, null, currentGroup, this);
888
+ } finally {
889
+ this._contextObj = prevContext;
890
+ this._params = prevParams;
891
+ delete /** @type {any} */ (this)._currentGroup;
892
+ }
893
+ }
610
894
  }
895
+
611
896
  return this;
612
897
  }
613
898
 
899
+ /**
900
+ * Helper to build comparator for sorting.
901
+ * @param {any} sortSpec
902
+ * @param {(expr: string, ctxVal: any) => any} evalFn
903
+ * @returns {((a: {value: any}, b: {value: any}) => number)|null}
904
+ * @private
905
+ */
906
+ _buildComparator (sortSpec, evalFn) {
907
+ // eslint-disable-next-line unicorn/no-this-assignment -- Temporary
908
+ const that = this;
909
+
910
+ if (typeof sortSpec === 'function') {
911
+ return function (
912
+ /** @type {{value: any}} */ a,
913
+ /** @type {{value: any}} */ b
914
+ ) {
915
+ return sortSpec(a.value, b.value, that);
916
+ };
917
+ }
918
+
919
+ /**
920
+ * @param {any} aVal
921
+ * @param {any} bVal
922
+ * @param {{
923
+ * order?: 'ascending'|'descending', type?: 'text'|'number',
924
+ * locale?: string, localeOptions?: any
925
+ * }|undefined} spec
926
+ * @returns {number}
927
+ */
928
+ function compareBySpec (aVal, bVal, spec) {
929
+ /* c8 ignore next 2 -- all spec combinations tested; && and || branches */
930
+ const order = (spec && spec.order === 'descending') ? -1 : 1;
931
+ const type = (spec && spec.type) || 'text';
932
+ if (type === 'number') {
933
+ const an = Number(aVal);
934
+ const bn = Number(bVal);
935
+ /* c8 ignore next -- both NaN tested; short-circuit branch tracking */
936
+ if (Number.isNaN(an) && Number.isNaN(bn)) {
937
+ return 0;
938
+ }
939
+ if (Number.isNaN(an)) {
940
+ return Number(order);
941
+ }
942
+ if (Number.isNaN(bn)) {
943
+ return -1 * order;
944
+ }
945
+ return (an - bn) * order;
946
+ }
947
+ /* c8 ignore next 2 -- all null/undefined tested; OR short-circuit */
948
+ const aStr = aVal === null || aVal === undefined ? '' : String(aVal);
949
+ const bStr = bVal === null || bVal === undefined ? '' : String(bVal);
950
+ if (spec && spec.locale) {
951
+ return aStr.localeCompare(
952
+ bStr, spec.locale, spec.localeOptions
953
+ ) * order;
954
+ }
955
+ /* c8 ignore next 2 -- all comparison outcomes tested; ternary
956
+ * branch tracking */
957
+ return (aStr < bStr ? -1 : (aStr > bStr ? 1 : 0)) * order;
958
+ }
959
+
960
+ const specs = Array.isArray(sortSpec) ? sortSpec : [sortSpec];
961
+ return function (
962
+ /** @type {{value: any}} */ a,
963
+ /** @type {{value: any}} */ b
964
+ ) {
965
+ for (const s of specs) {
966
+ if (typeof s === 'string') {
967
+ const av = evalFn(s, a.value);
968
+ const bv = evalFn(s, b.value);
969
+ const c = compareBySpec(av, bv, {type: 'text', order: 'ascending'});
970
+ if (c !== 0) {
971
+ return c;
972
+ }
973
+ } else if (s && typeof s === 'object') {
974
+ const av = evalFn(s.select, a.value);
975
+ const bv = evalFn(s.select, b.value);
976
+ const c = compareBySpec(av, bv, s);
977
+ if (c !== 0) {
978
+ return c;
979
+ }
980
+ }
981
+ }
982
+ return 0;
983
+ };
984
+ }
985
+
986
+ /**
987
+ * Returns the current group (for use within forEachGroup callback).
988
+ * @returns {any[]|undefined}
989
+ */
990
+ currentGroup () {
991
+ return /** @type {any} */ (this)._currentGroup;
992
+ }
993
+
994
+ /**
995
+ * Returns the current grouping key (for use within forEachGroup callback).
996
+ * @returns {any}
997
+ */
998
+ currentGroupingKey () {
999
+ return /** @type {any} */ (this)._currentGroupingKey;
1000
+ }
1001
+
614
1002
  /**
615
1003
  * @param {string|object} [select] - JSONPath selector
616
1004
  * @returns {this}
@@ -629,6 +1017,48 @@ class JSONPathTransformerContext {
629
1017
  ? /** @type {{select?: string}} */ (select).select
630
1018
  : select;
631
1019
 
1020
+ // Check for format-number() function call
1021
+ if (selectStr && selectStr.includes('format-number(')) {
1022
+ const match = (/format-number\((?<value>[^,\)]+)(?:,\s*["'](?<format>[^"']+)["'])?(?:,\s*["'](?<decimalFormat>[^"']*)["'])?\)/v).exec(selectStr);
1023
+ if (match && match.groups) {
1024
+ const {
1025
+ value: valueExpr,
1026
+ format: formatStr,
1027
+ decimalFormat: decimalFormatName
1028
+ } = match.groups;
1029
+ // Evaluate the value expression
1030
+ let numValue;
1031
+ if (valueExpr.trim().startsWith('$') &&
1032
+ !valueExpr.includes('.') && !valueExpr.includes('[')) {
1033
+ // Parameter reference (no path components)
1034
+ const paramName = valueExpr.trim().slice(1);
1035
+ numValue = this._params && paramName in this._params
1036
+ ? this._params[paramName]
1037
+ : 0;
1038
+ } else {
1039
+ // Try to parse as number or evaluate as JSONPath
1040
+ const trimmed = valueExpr.trim();
1041
+ numValue = Number.isNaN(Number(trimmed))
1042
+ ? this.get(trimmed, false)
1043
+ : Number(trimmed);
1044
+ }
1045
+ const num = typeof numValue === 'string'
1046
+ ? Number(numValue)
1047
+ : numValue;
1048
+ const format = formatStr || '1';
1049
+ const formatted = this._formatNumber(
1050
+ num,
1051
+ format,
1052
+ undefined,
1053
+ undefined,
1054
+ decimalFormatName || '',
1055
+ 'en'
1056
+ );
1057
+ /** @type {any} */ (results).append(formatted);
1058
+ return this;
1059
+ }
1060
+ }
1061
+
632
1062
  // Check if this is a parameter reference (starts with $)
633
1063
  if (selectStr && selectStr.startsWith('$')) {
634
1064
  const paramName = selectStr.slice(1);
@@ -987,7 +1417,8 @@ class JSONPathTransformerContext {
987
1417
  format,
988
1418
  opts.groupingSeparator,
989
1419
  opts.groupingSize,
990
- locale
1420
+ locale,
1421
+ opts.decimalFormat
991
1422
  );
992
1423
 
993
1424
  // Output as string if formatted, otherwise as number
@@ -1050,14 +1481,31 @@ class JSONPathTransformerContext {
1050
1481
  * @param {string} format - Format string (1, a, A, i, I, 01, etc.)
1051
1482
  * @param {string} [groupingSeparator] - Separator for grouping
1052
1483
  * @param {number} [groupingSize] - Size of groups
1053
- * @param {string} [locale]
1484
+ * @param {string} [decimalFormatName] - Name of decimal format to use
1485
+ * @param {string} [locale] - Locale for formatting
1054
1486
  * @returns {string}
1055
1487
  */
1056
- _formatNumber (num, format, groupingSeparator, groupingSize, locale = 'en') {
1488
+ _formatNumber (
1489
+ num,
1490
+ format,
1491
+ groupingSeparator,
1492
+ groupingSize,
1493
+ decimalFormatName,
1494
+ locale = 'en'
1495
+ ) {
1057
1496
  if (Number.isNaN(num)) {
1058
- return String(num);
1497
+ // Check for custom NaN string in decimal format
1498
+ const fmt = decimalFormatName
1499
+ ? this.decimalFormats[decimalFormatName]
1500
+ : this.decimalFormats[''];
1501
+ return fmt?.NaN || String(num);
1059
1502
  }
1060
1503
 
1504
+ // Get decimal format if specified
1505
+ const decimalFormat = decimalFormatName
1506
+ ? this.decimalFormats[decimalFormatName]
1507
+ : this.decimalFormats[''];
1508
+
1061
1509
  let result;
1062
1510
  const formatChar = format.charAt(0);
1063
1511
 
@@ -1084,7 +1532,8 @@ class JSONPathTransformerContext {
1084
1532
  }
1085
1533
  case '0': {
1086
1534
  const width = format.length;
1087
- result = String(num).padStart(width, '0');
1535
+ const zeroDigit = decimalFormat?.zeroDigit || '0';
1536
+ result = String(num).padStart(width, zeroDigit);
1088
1537
 
1089
1538
  break;
1090
1539
  }
@@ -1100,7 +1549,25 @@ class JSONPathTransformerContext {
1100
1549
 
1101
1550
  try {
1102
1551
  result = new Intl.NumberFormat(locale, options).format(num);
1103
- if (groupingSeparator) {
1552
+
1553
+ // Apply decimal format symbols if specified
1554
+ if (decimalFormat) {
1555
+ // Use placeholders to avoid conflicts during replacement
1556
+ const TEMP_GROUP = '\u0000GROUPSEP\u0000';
1557
+ const TEMP_DECIMAL = '\u0000DECIMALSEP\u0000';
1558
+
1559
+ // Replace with temporary placeholders first
1560
+ result = result.replaceAll(',', TEMP_GROUP);
1561
+ result = result.replaceAll('.', TEMP_DECIMAL);
1562
+
1563
+ // Now replace with actual symbols
1564
+ const effectiveGroupingSep = groupingSeparator ||
1565
+ decimalFormat.groupingSeparator || ',';
1566
+ const effectiveDecimalSep = decimalFormat.decimalSeparator || '.';
1567
+
1568
+ result = result.replaceAll(TEMP_GROUP, effectiveGroupingSep);
1569
+ result = result.replaceAll(TEMP_DECIMAL, effectiveDecimalSep);
1570
+ } else if (groupingSeparator) {
1104
1571
  result = result.replaceAll(',', groupingSeparator);
1105
1572
  }
1106
1573
  } catch (e) {
@@ -1187,6 +1654,16 @@ class JSONPathTransformerContext {
1187
1654
  return this;
1188
1655
  }
1189
1656
 
1657
+ /**
1658
+ * Alias for propValue(). Set a key-value pair in the current map/object.
1659
+ * @param {string} prop - Property name
1660
+ * @param {any} val - Property value
1661
+ * @returns {this}
1662
+ */
1663
+ mapEntry (prop, val) {
1664
+ return this.propValue(prop, val);
1665
+ }
1666
+
1190
1667
  /**
1191
1668
  * Build an object. Mirrors the joining transformer API. All joiners now
1192
1669
  * support both signatures: (obj, cb, usePropertySets, propSets) with seed
@@ -1199,6 +1676,15 @@ class JSONPathTransformerContext {
1199
1676
  return this;
1200
1677
  }
1201
1678
 
1679
+ /**
1680
+ * Alias for object(). Build an object/map.
1681
+ * @param {...any} args - Arguments to pass to joiner
1682
+ * @returns {this}
1683
+ */
1684
+ map (...args) {
1685
+ return this.object(...args);
1686
+ }
1687
+
1202
1688
  /**
1203
1689
  * Build an array. Mirrors the joining transformer API. All joiners now
1204
1690
  * support both signatures: (arr, cb) with seed array or (cb) without.
@@ -1220,6 +1706,27 @@ class JSONPathTransformerContext {
1220
1706
  return this;
1221
1707
  }
1222
1708
 
1709
+ /**
1710
+ * @param {string} name
1711
+ * @param {import('./AbstractJoiningTransformer.js').
1712
+ * OutputCharacters} outputCharacters
1713
+ * @returns {this}
1714
+ */
1715
+ characterMap (name, outputCharacters) {
1716
+ this._getJoiningTransformer().characterMap(name, outputCharacters);
1717
+ return this;
1718
+ }
1719
+
1720
+ /**
1721
+ * @param {string} name
1722
+ * @param {Record<string, string>} attributes
1723
+ * @returns {this}
1724
+ */
1725
+ attributeSet (name, attributes) {
1726
+ this._getJoiningTransformer().attributeSet(name, attributes);
1727
+ return this;
1728
+ }
1729
+
1223
1730
  /**
1224
1731
  * Create an element. Mirrors the joining transformer API so templates can
1225
1732
  * call `this.element()`.
@@ -1228,15 +1735,51 @@ class JSONPathTransformerContext {
1228
1735
  * @param {any[]} [children] - Child nodes
1229
1736
  * @param {import('./JSONJoiningTransformer.js').
1230
1737
  * SimpleCallback<T>} [cb] - Callback function
1738
+ * @param {string[]} [useAttributeSets] - Attribute set names to apply
1231
1739
  * @returns {this}
1232
1740
  */
1233
- element (name, atts, children, cb) {
1741
+ element (name, atts, children, cb, useAttributeSets) {
1234
1742
  /** @type {any} */ (this._getJoiningTransformer()).element(
1235
- name, atts, children, cb
1743
+ name, atts, children, cb, useAttributeSets
1744
+ );
1745
+ return this;
1746
+ }
1747
+
1748
+ /**
1749
+ * Adds a prefixed namespace declaration to the most recently opened
1750
+ * element. Mirrors the joining
1751
+ * transformer API so templates can call `this.attribute()`.
1752
+ * @param {string} prefix - Prefix
1753
+ * @param {string} namespaceURI - Namespace
1754
+ * @returns {this}
1755
+ */
1756
+ namespace (prefix, namespaceURI) {
1757
+ /** @type {any} */ (this._getJoiningTransformer()).namespace(
1758
+ prefix, namespaceURI
1236
1759
  );
1237
1760
  return this;
1238
1761
  }
1239
1762
 
1763
+ /**
1764
+ * Define a decimal format with custom symbols for number formatting.
1765
+ * Equivalent to xsl:decimal-format. If no name is provided, defines
1766
+ * the default format.
1767
+ * @param {string|DecimalFormatSymbols} nameOrSymbols - Format name or
1768
+ * symbols object if defining default
1769
+ * @param {DecimalFormatSymbols} [symbols] - Format symbols
1770
+ * @returns {this}
1771
+ */
1772
+ decimalFormat (nameOrSymbols, symbols) {
1773
+ if (typeof nameOrSymbols === 'string') {
1774
+ // Named format
1775
+ this.decimalFormats[nameOrSymbols] = symbols || {};
1776
+ } else {
1777
+ // Default format (unnamed)
1778
+ this.decimalFormats[''] = nameOrSymbols;
1779
+ }
1780
+ return this;
1781
+ }
1782
+
1240
1783
  /**
1241
1784
  * Add an attribute to the most recently opened element. Mirrors the joining
1242
1785
  * transformer API so templates can call `this.attribute()`.
@@ -1424,6 +1967,26 @@ class JSONPathTransformerContext {
1424
1967
  }
1425
1968
  return this;
1426
1969
  }
1970
+
1971
+ /**
1972
+ * Assert that a test condition is true, throwing an error if it fails.
1973
+ * Equivalent to xsl:assert. Evaluates a JSONPath expression using the
1974
+ * same truthiness rules as if() and choose().
1975
+ * @param {string} test - JSONPath expression to test
1976
+ * @param {string} [message] - Optional error message to include
1977
+ * @returns {this}
1978
+ * @throws {Error} When the test expression evaluates to false
1979
+ */
1980
+ assert (test, message) {
1981
+ const passes = this._passesIf(test);
1982
+ if (!passes) {
1983
+ const errorMsg = message
1984
+ ? `Assertion failed: ${message}`
1985
+ : `Assertion failed: ${test}`;
1986
+ throw new Error(errorMsg);
1987
+ }
1988
+ return this;
1989
+ }
1427
1990
  }
1428
1991
 
1429
1992
  export default JSONPathTransformerContext;