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,6 +1,35 @@
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
+
20
+ /**
21
+ * @typedef {number|string|{
22
+ * value?: number|string,
23
+ * count?: string,
24
+ * format?: string,
25
+ * decimalFormat?: string,
26
+ * groupingSeparator?: string,
27
+ * groupingSize?: number,
28
+ * lang?: string,
29
+ * letterValue?: string
30
+ * }} NumberValue
31
+ */
32
+
4
33
  /**
5
34
  * Sort spec types used by applyTemplates() and forEach().
6
35
  * @typedef {{
@@ -46,6 +75,11 @@ import JSONPathTransformer from './JSONPathTransformer.js';
46
75
  * @template [T = "json"]
47
76
  */
48
77
  class JSONPathTransformerContext {
78
+ /**
79
+ * Holds the current iteration state (for position calculations).
80
+ * @type {{ index?: number } | undefined}
81
+ */
82
+ iterationState;
49
83
  /**
50
84
  * @param {JSONPathTransformerContextConfig<T>} config
51
85
  * @param {import('./index.js').JSONPathTemplateObject<T>[]} templates - Array
@@ -63,6 +97,8 @@ class JSONPathTransformerContext {
63
97
  this.propertySets = {};
64
98
  /** @type {Record<string, {match: string, use: string}>} */
65
99
  this.keys = {};
100
+ /** @type {Record<string, DecimalFormatSymbols>} */
101
+ this.decimalFormats = {};
66
102
  /** @type {boolean | undefined} */
67
103
  this._initialized = undefined;
68
104
  /** @type {string | undefined} */
@@ -241,6 +277,7 @@ class JSONPathTransformerContext {
241
277
  if (type === 'number') {
242
278
  const an = Number(aVal);
243
279
  const bn = Number(bVal);
280
+ /* c8 ignore next -- both NaN tested; short-circuit branch tracking */
244
281
  if (Number.isNaN(an) && Number.isNaN(bn)) {
245
282
  return 0;
246
283
  }
@@ -253,6 +290,7 @@ class JSONPathTransformerContext {
253
290
  return (an - bn) * order;
254
291
  }
255
292
  // text
293
+ /* c8 ignore next 2 -- all null/undefined tested; OR short-circuit */
256
294
  const aStr = aVal === null || aVal === undefined ? '' : String(aVal);
257
295
  const bStr = bVal === null || bVal === undefined ? '' : String(bVal);
258
296
  if (spec && spec.locale) {
@@ -260,6 +298,8 @@ class JSONPathTransformerContext {
260
298
  bStr, spec.locale, spec.localeOptions
261
299
  ) * order;
262
300
  }
301
+ /* c8 ignore next -- all comparison outcomes tested; ternary
302
+ * branch tracking */
263
303
  return (aStr < bStr ? -1 : (aStr > bStr ? 1 : 0)) * order;
264
304
  }
265
305
  /**
@@ -390,12 +430,19 @@ class JSONPathTransformerContext {
390
430
  that._parent = parent;
391
431
  that._parentProperty = (parentProperty ?? that._parentProperty);
392
432
 
433
+ // Set up parameter context for valueOf() access in templates
434
+ const prevTemplateParams = that._params;
435
+ that._params = {0: value};
436
+
393
437
  const ret =
394
438
  /** @type {import('./index.js').JSONPathTemplateObject<T>} */ (
395
439
  templateObj
396
440
  ).template.call(
397
441
  that, value, {mode, parent, parentProperty}
398
442
  );
443
+
444
+ // Restore previous parameter context
445
+ that._params = prevTemplateParams;
399
446
  if (typeof ret !== 'undefined') {
400
447
  // After the undefined check, ret is ResultType<T>
401
448
  that._getJoiningTransformer().append(
@@ -523,11 +570,13 @@ class JSONPathTransformerContext {
523
570
  * @returns {number}
524
571
  */
525
572
  function feCompareBySpec (aVal, bVal, spec) {
573
+ /* c8 ignore next 2 -- all spec combinations tested; && and || branches */
526
574
  const order = (spec && spec.order === 'descending') ? -1 : 1;
527
575
  const type = (spec && spec.type) || 'text';
528
576
  if (type === 'number') {
529
577
  const an = Number(aVal);
530
578
  const bn = Number(bVal);
579
+ /* c8 ignore next -- both NaN tested; short-circuit branch tracking */
531
580
  if (Number.isNaN(an) && Number.isNaN(bn)) {
532
581
  return 0;
533
582
  }
@@ -539,6 +588,7 @@ class JSONPathTransformerContext {
539
588
  }
540
589
  return (an - bn) * order;
541
590
  }
591
+ /* c8 ignore next 2 -- all null/undefined tested; OR short-circuit */
542
592
  const aStr = aVal === null || aVal === undefined ? '' : String(aVal);
543
593
  const bStr = bVal === null || bVal === undefined ? '' : String(bVal);
544
594
  if (spec && spec.locale) {
@@ -546,6 +596,8 @@ class JSONPathTransformerContext {
546
596
  bStr, spec.locale, spec.localeOptions
547
597
  ) * order;
548
598
  }
599
+ /* c8 ignore next -- all comparison outcomes tested; ternary
600
+ * branch tracking */
549
601
  return (aStr < bStr ? -1 : (aStr > bStr ? 1 : 0)) * order;
550
602
  }
551
603
  /**
@@ -589,11 +641,364 @@ class JSONPathTransformerContext {
589
641
  const comparator = feBuildComparator(sort);
590
642
  const list = comparator ? [...matches].toSorted(comparator) : matches;
591
643
  for (const m of list) {
592
- 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
+ }
593
894
  }
895
+
594
896
  return this;
595
897
  }
596
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
+
597
1002
  /**
598
1003
  * @param {string|object} [select] - JSONPath selector
599
1004
  * @returns {this}
@@ -612,6 +1017,48 @@ class JSONPathTransformerContext {
612
1017
  ? /** @type {{select?: string}} */ (select).select
613
1018
  : select;
614
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
+
615
1062
  // Check if this is a parameter reference (starts with $)
616
1063
  if (selectStr && selectStr.startsWith('$')) {
617
1064
  const paramName = selectStr.slice(1);
@@ -627,6 +1074,146 @@ class JSONPathTransformerContext {
627
1074
  return this;
628
1075
  }
629
1076
 
1077
+ /**
1078
+ * Analyze a string with a regular expression, equivalent to
1079
+ * xsl:analyze-string. Processes matching and non-matching substrings
1080
+ * with separate callbacks.
1081
+ * @param {string} str - The string to analyze
1082
+ * @param {string|RegExp} regex - Regular expression to match against
1083
+ * @param {{
1084
+ * matchingSubstring?: (
1085
+ * this: JSONPathTransformerContext<T>,
1086
+ * substring: string,
1087
+ * groups: string[],
1088
+ * regexGroup: (n: number) => string
1089
+ * ) => void,
1090
+ * nonMatchingSubstring?: (
1091
+ * this: JSONPathTransformerContext<T>,
1092
+ * substring: string
1093
+ * ) => void,
1094
+ * flags?: string
1095
+ * }} options - Options object
1096
+ * @returns {this}
1097
+ */
1098
+ analyzeString (str, regex, options = {}) {
1099
+ // Ensure we have a string
1100
+ const inputString = String(str || '');
1101
+
1102
+ // If empty string, do nothing
1103
+ if (inputString.length === 0) {
1104
+ return this;
1105
+ }
1106
+
1107
+ const {
1108
+ matchingSubstring,
1109
+ nonMatchingSubstring,
1110
+ flags = ''
1111
+ } = options;
1112
+
1113
+ // Convert regex to RegExp if it's a string
1114
+ let regexObj;
1115
+ if (typeof regex === 'string') {
1116
+ // Ensure 'g' flag is present for global matching
1117
+ const actualFlags = flags.includes('g') ? flags : flags + 'g';
1118
+ regexObj = new RegExp(regex, actualFlags);
1119
+ } else {
1120
+ regexObj = regex;
1121
+ // Ensure global flag is set
1122
+ if (!regexObj.global) {
1123
+ regexObj = new RegExp(
1124
+ regexObj.source,
1125
+ regexObj.flags + 'g'
1126
+ );
1127
+ }
1128
+ }
1129
+
1130
+ // Check for zero-length matches (error condition in XSLT)
1131
+ if (regexObj.test('')) {
1132
+ throw new Error(
1133
+ 'Regular expression matches zero-length string'
1134
+ );
1135
+ }
1136
+
1137
+ // Store captured groups for access during callback
1138
+ /** @type {string[] | undefined} */
1139
+ let currentCapturedGroups;
1140
+
1141
+ /**
1142
+ * Get captured group by index.
1143
+ * @param {number} groupNumber - Group index
1144
+ * @returns {string} - Captured group or empty string
1145
+ */
1146
+ const getRegexGroup = (groupNumber) => {
1147
+ if (!currentCapturedGroups ||
1148
+ groupNumber < 0 ||
1149
+ groupNumber >= currentCapturedGroups.length) {
1150
+ return '';
1151
+ }
1152
+ return currentCapturedGroups[groupNumber] || '';
1153
+ };
1154
+
1155
+ // Save previous context to restore later
1156
+ const prevContext = this._contextObj;
1157
+
1158
+ let lastIndex = 0;
1159
+ let match;
1160
+
1161
+ // Bind callbacks to this context
1162
+ const boundMatchingSubstring = matchingSubstring
1163
+ ? matchingSubstring.bind(this)
1164
+ : undefined;
1165
+ const boundNonMatchingSubstring = nonMatchingSubstring
1166
+ ? nonMatchingSubstring.bind(this)
1167
+ : undefined;
1168
+
1169
+ // Find all matches
1170
+ while ((match = regexObj.exec(inputString)) !== null) {
1171
+ // Process non-matching substring before this match
1172
+ if (match.index > lastIndex) {
1173
+ const nonMatchingStr = inputString.slice(lastIndex, match.index);
1174
+ if (boundNonMatchingSubstring) {
1175
+ this._contextObj = nonMatchingStr;
1176
+ boundNonMatchingSubstring(nonMatchingStr);
1177
+ }
1178
+ }
1179
+
1180
+ // Process matching substring
1181
+ if (boundMatchingSubstring) {
1182
+ const matchingStr = match[0];
1183
+ // Store captured groups: [full match, group1, group2, ...]
1184
+ currentCapturedGroups = [...match];
1185
+ this._contextObj = matchingStr;
1186
+ boundMatchingSubstring(
1187
+ matchingStr, currentCapturedGroups, getRegexGroup
1188
+ );
1189
+ currentCapturedGroups = undefined;
1190
+ }
1191
+
1192
+ const {lastIndex: newLastIndex} = regexObj;
1193
+ lastIndex = newLastIndex;
1194
+
1195
+ // Prevent infinite loop on zero-length matches (shouldn't happen
1196
+ // due to earlier check, but defensive)
1197
+ if (match.index === regexObj.lastIndex) {
1198
+ regexObj.lastIndex++;
1199
+ }
1200
+ }
1201
+
1202
+ // Process final non-matching substring
1203
+ if (lastIndex < inputString.length) {
1204
+ const nonMatchingStr = inputString.slice(lastIndex);
1205
+ if (boundNonMatchingSubstring) {
1206
+ this._contextObj = nonMatchingStr;
1207
+ boundNonMatchingSubstring(nonMatchingStr);
1208
+ }
1209
+ }
1210
+
1211
+ // Restore previous context
1212
+ this._contextObj = prevContext;
1213
+
1214
+ return this;
1215
+ }
1216
+
630
1217
  /**
631
1218
  * Deep copy selection or current context when omitted.
632
1219
  * @param {string} [select] - JSONPath selector
@@ -735,16 +1322,314 @@ class JSONPathTransformerContext {
735
1322
  }
736
1323
 
737
1324
  /**
738
- * Append a number to JSON output. Mirrors the joining transformer API so
739
- * templates can call `this.number()`.
740
- * @param {number} num - Number value to append
1325
+ * Append a number to JSON output with xsl:number-like formatting.
1326
+ * @param {NumberValue} num - Number value, "position()" string, or
1327
+ * options object
741
1328
  * @returns {this}
742
1329
  */
743
1330
  number (num) {
744
- this._getJoiningTransformer().number(num);
1331
+ // Handle xsl:number-like functionality
1332
+ if (typeof num === 'object' && num !== null) {
1333
+ const opts = num;
1334
+ let {value} = opts;
1335
+
1336
+ // Handle position() and hierarchical numbering
1337
+ if (value === 'position()' || value === undefined) {
1338
+ const {count} = opts;
1339
+ // @ts-expect-error: dynamic property access
1340
+ const level = opts.level || 'single';
1341
+
1342
+ switch (level) {
1343
+ case 'single': {
1344
+ value = this.calculatePosition(count);
1345
+ // If count is set, simulate count by returning total items
1346
+ // in current context
1347
+ if (count) {
1348
+ const arr = Array.isArray(this.get(count, true))
1349
+ ? this.get(count, true)
1350
+ /* c8 ignore next -- defensive: get(wrap) always returns array */
1351
+ : [];
1352
+ value = arr.length;
1353
+ }
1354
+
1355
+ break;
1356
+ }
1357
+ case 'multiple': {
1358
+ // Hierarchical numbering: get position for each ancestor up to root
1359
+ const positions = [];
1360
+ let state = /** @type {any} */ (this._config).iterationState;
1361
+ while (state) {
1362
+ // If count is set, use count for each ancestor if possible
1363
+ if (count) {
1364
+ const arr = Array.isArray(this.get(count, true))
1365
+ ? this.get(count, true)
1366
+ /* c8 ignore next -- defensive: get(wrap) returns array */
1367
+ : [];
1368
+ positions.unshift(arr.length);
1369
+ } else {
1370
+ positions.unshift(
1371
+ state.index !== undefined
1372
+ ? state.index + 1
1373
+ /* c8 ignore next -- defensive: state always has index */
1374
+ : 1
1375
+ );
1376
+ }
1377
+ state = state.parentState;
1378
+ }
1379
+ value = positions.join('.');
1380
+
1381
+ break;
1382
+ }
1383
+ case 'any': {
1384
+ // Count all matching items up to current
1385
+ value = this.calculatePosition(count);
1386
+
1387
+ break;
1388
+ }
1389
+ // No default
1390
+ }
1391
+ }
1392
+
1393
+ // Determine format string and locale
1394
+ let format = opts.format || '1';
1395
+ const locale = opts.lang || 'en';
1396
+ const {letterValue} = opts;
1397
+
1398
+ // If letterValue is 'alphabetic', force alphabetic format
1399
+ if (letterValue === 'alphabetic') {
1400
+ format = (opts.format && (/^[aA]$/v).test(opts.format)) ? opts.format : 'a';
1401
+ }
1402
+
1403
+ // Ensure value is a number or string for formatting
1404
+ let numValue = value;
1405
+ // If value is undefined, fallback to opts.value
1406
+ if (typeof numValue === 'undefined') {
1407
+ numValue = opts.value;
1408
+ }
1409
+ if (typeof numValue === 'string') {
1410
+ numValue = Number(numValue);
1411
+ }
1412
+ if (typeof numValue !== 'number' || Number.isNaN(numValue)) {
1413
+ numValue = 1;
1414
+ }
1415
+ const formatted = this._formatNumber(
1416
+ numValue,
1417
+ format,
1418
+ opts.groupingSeparator,
1419
+ opts.groupingSize,
1420
+ locale,
1421
+ opts.decimalFormat
1422
+ );
1423
+
1424
+ // Output as string if formatted, otherwise as number
1425
+ if (format && format !== '1') {
1426
+ this._getJoiningTransformer().plainText(formatted);
1427
+ } else {
1428
+ this._getJoiningTransformer().number(Number(formatted));
1429
+ }
1430
+ } else if (num === 'position()') {
1431
+ // Simple position() call
1432
+ const pos = this.calculatePosition();
1433
+ this._getJoiningTransformer().number(pos);
1434
+ } else {
1435
+ // Simple number
1436
+ this._getJoiningTransformer().number(
1437
+ typeof num === 'string' ? Number(num) : num
1438
+ );
1439
+ }
745
1440
  return this;
746
1441
  }
747
1442
 
1443
+ /**
1444
+ * Calculate position in current iteration context.
1445
+ * @param {string} [count] - JSONPath expression to match
1446
+ * @returns {number}
1447
+ */
1448
+ calculatePosition (count) {
1449
+ // If count is provided, return the length of the matched array from
1450
+ // the root data
1451
+ if (count) {
1452
+ const result = jsonpath({
1453
+ path: count, json: this._origObj, resultType: 'value', wrap: true
1454
+ });
1455
+ if (Array.isArray(result)) {
1456
+ if (result.length === 0) {
1457
+ return 0;
1458
+ }
1459
+ // If the first item is an array, return its length
1460
+ if (Array.isArray(result[0])) {
1461
+ return result[0].length;
1462
+ }
1463
+ // Otherwise, return the number of matches
1464
+ return result.length;
1465
+ }
1466
+ /* c8 ignore next 3 -- defensive:
1467
+ jsonpath-plus with wrap:true always returns arrays */
1468
+ return 0;
1469
+ }
1470
+ // Get current index from iteration state
1471
+ const state = this.iterationState;
1472
+ if (state && typeof state.index === 'number') {
1473
+ return state.index + 1; // 1-indexed
1474
+ }
1475
+ return 1;
1476
+ }
1477
+
1478
+ /**
1479
+ * Format a number according to format string.
1480
+ * @param {number} num - Number to format
1481
+ * @param {string} format - Format string (1, a, A, i, I, 01, etc.)
1482
+ * @param {string} [groupingSeparator] - Separator for grouping
1483
+ * @param {number} [groupingSize] - Size of groups
1484
+ * @param {string} [decimalFormatName] - Name of decimal format to use
1485
+ * @param {string} [locale] - Locale for formatting
1486
+ * @returns {string}
1487
+ */
1488
+ _formatNumber (
1489
+ num,
1490
+ format,
1491
+ groupingSeparator,
1492
+ groupingSize,
1493
+ decimalFormatName,
1494
+ locale = 'en'
1495
+ ) {
1496
+ if (Number.isNaN(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);
1502
+ }
1503
+
1504
+ // Get decimal format if specified
1505
+ const decimalFormat = decimalFormatName
1506
+ ? this.decimalFormats[decimalFormatName]
1507
+ : this.decimalFormats[''];
1508
+
1509
+ let result;
1510
+ const formatChar = format.charAt(0);
1511
+
1512
+ switch (formatChar) {
1513
+ case 'i': {
1514
+ result = this._toRoman(num).toLowerCase();
1515
+
1516
+ break;
1517
+ }
1518
+ case 'I': {
1519
+ result = this._toRoman(num);
1520
+
1521
+ break;
1522
+ }
1523
+ case 'a': {
1524
+ result = this._toAlphabetic(num, false);
1525
+
1526
+ break;
1527
+ }
1528
+ case 'A': {
1529
+ result = this._toAlphabetic(num, true);
1530
+
1531
+ break;
1532
+ }
1533
+ case '0': {
1534
+ const width = format.length;
1535
+ const zeroDigit = decimalFormat?.zeroDigit || '0';
1536
+ result = String(num).padStart(width, zeroDigit);
1537
+
1538
+ break;
1539
+ }
1540
+ default: {
1541
+ // Use Intl.NumberFormat for decimal formatting if grouping/locale
1542
+ // options are provided
1543
+ let options = {};
1544
+ if (groupingSeparator || groupingSize) {
1545
+ options = {
1546
+ useGrouping: true
1547
+ };
1548
+ }
1549
+
1550
+ try {
1551
+ result = new Intl.NumberFormat(locale, options).format(num);
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) {
1571
+ result = result.replaceAll(',', groupingSeparator);
1572
+ }
1573
+ } catch (e) {
1574
+ result = String(num);
1575
+ }
1576
+ }
1577
+ }
1578
+ return result;
1579
+ }
1580
+
1581
+ /**
1582
+ * Convert number to Roman numerals.
1583
+ * @param {number} num - Number to convert (1-3999)
1584
+ * @returns {string}
1585
+ * @private
1586
+ */
1587
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
1588
+ _toRoman (num) {
1589
+ if (num < 1 || num > 3999) {
1590
+ return String(num);
1591
+ }
1592
+
1593
+ const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
1594
+ const syms = [
1595
+ 'M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'
1596
+ ];
1597
+
1598
+ let result = '';
1599
+ for (const [i, val] of vals.entries()) {
1600
+ while (num >= val) {
1601
+ result += syms[i];
1602
+ num -= val;
1603
+ }
1604
+ }
1605
+ return result;
1606
+ }
1607
+
1608
+ /**
1609
+ * Convert number to alphabetic sequence.
1610
+ * @param {number} num - Number to convert
1611
+ * @param {boolean} uppercase - Use uppercase letters
1612
+ * @returns {string}
1613
+ * @private
1614
+ */
1615
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
1616
+ _toAlphabetic (num, uppercase) {
1617
+ if (num < 1) {
1618
+ return String(num);
1619
+ }
1620
+
1621
+ let result = '';
1622
+ const base = uppercase ? 65 : 97; // 'A' or 'a'
1623
+
1624
+ while (num > 0) {
1625
+ num--; // Make 0-indexed
1626
+ result = String.fromCodePoint(base + (num % 26)) + result;
1627
+ num = Math.floor(num / 26);
1628
+ }
1629
+
1630
+ return result;
1631
+ }
1632
+
748
1633
  /**
749
1634
  * Append plain text directly to the output without escaping or JSON
750
1635
  * stringification. Mirrors the joining transformer API so templates can
@@ -802,6 +1687,17 @@ class JSONPathTransformerContext {
802
1687
  return this;
803
1688
  }
804
1689
 
1690
+ /**
1691
+ * @param {string} name
1692
+ * @param {import('./AbstractJoiningTransformer.js').
1693
+ * OutputCharacters} outputCharacters
1694
+ * @returns {this}
1695
+ */
1696
+ characterMap (name, outputCharacters) {
1697
+ this._getJoiningTransformer().characterMap(name, outputCharacters);
1698
+ return this;
1699
+ }
1700
+
805
1701
  /**
806
1702
  * Create an element. Mirrors the joining transformer API so templates can
807
1703
  * call `this.element()`.
@@ -819,6 +1715,41 @@ class JSONPathTransformerContext {
819
1715
  return this;
820
1716
  }
821
1717
 
1718
+ /**
1719
+ * Adds a prefixed namespace declaration to the most recently opened
1720
+ * element. Mirrors the joining
1721
+ * transformer API so templates can call `this.attribute()`.
1722
+ * @param {string} prefix - Prefix
1723
+ * @param {string} namespaceURI - Namespace
1724
+ * @returns {this}
1725
+ */
1726
+ namespace (prefix, namespaceURI) {
1727
+ /** @type {any} */ (this._getJoiningTransformer()).namespace(
1728
+ prefix, namespaceURI
1729
+ );
1730
+ return this;
1731
+ }
1732
+
1733
+ /**
1734
+ * Define a decimal format with custom symbols for number formatting.
1735
+ * Equivalent to xsl:decimal-format. If no name is provided, defines
1736
+ * the default format.
1737
+ * @param {string|DecimalFormatSymbols} nameOrSymbols - Format name or
1738
+ * symbols object if defining default
1739
+ * @param {DecimalFormatSymbols} [symbols] - Format symbols
1740
+ * @returns {this}
1741
+ */
1742
+ decimalFormat (nameOrSymbols, symbols) {
1743
+ if (typeof nameOrSymbols === 'string') {
1744
+ // Named format
1745
+ this.decimalFormats[nameOrSymbols] = symbols || {};
1746
+ } else {
1747
+ // Default format (unnamed)
1748
+ this.decimalFormats[''] = nameOrSymbols;
1749
+ }
1750
+ return this;
1751
+ }
1752
+
822
1753
  /**
823
1754
  * Add an attribute to the most recently opened element. Mirrors the joining
824
1755
  * transformer API so templates can call `this.attribute()`.