jtlt 0.3.0 → 0.4.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 (40) hide show
  1. package/CHANGES.md +9 -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 +7 -0
  6. package/demo/index.html +11 -14
  7. package/demo/index.js +206 -26
  8. package/demo/vendor/jamilih/dist/jml.mjs +2341 -0
  9. package/demo/vendor/jhtml/src/SAJJ/SAJJ.ObjectArrayDelegator.js +356 -0
  10. package/demo/vendor/jhtml/src/SAJJ/SAJJ.Stringifier.js +186 -0
  11. package/demo/vendor/jhtml/src/SAJJ/SAJJ.js +746 -0
  12. package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.html +33 -0
  13. package/demo/vendor/jhtml/src/SAJJ/testing/SAJJ.testing.js +25 -0
  14. package/demo/vendor/jhtml/src/jhtml-browser.js +5 -0
  15. package/demo/vendor/jhtml/src/jhtml-node.cts +3 -0
  16. package/demo/vendor/jhtml/src/jhtml-node.js +8 -0
  17. package/demo/vendor/jhtml/src/jhtml-node.mts +1 -0
  18. package/demo/vendor/jhtml/src/jhtml.cts +3 -0
  19. package/demo/vendor/jhtml/src/jhtml.js +602 -0
  20. package/demo/vendor/jhtml/src/jhtml.mts +1 -0
  21. package/demo/vendor/jsonpath-plus/dist/index-browser-esm.js +2158 -0
  22. package/demo/vendor/simple-get-json/dist/index-es.js +151 -0
  23. package/dist/JSONPathTransformerContext.d.ts +88 -4
  24. package/dist/JSONPathTransformerContext.d.ts.map +1 -1
  25. package/dist/XPathTransformer.d.ts +2 -2
  26. package/dist/XPathTransformerContext.d.ts +92 -8
  27. package/dist/XPathTransformerContext.d.ts.map +1 -1
  28. package/dist/index.d.ts +4 -4
  29. package/dist/index.d.ts.map +1 -1
  30. package/docs/API.expanded.md +5 -3
  31. package/docs/API.md +1 -1
  32. package/docs/TO-DO.md +55 -31
  33. package/eslint.config.js +3 -1
  34. package/package.json +24 -4
  35. package/rollup.config.js +13 -0
  36. package/src/JSONPathTransformerContext.js +422 -4
  37. package/src/XPathTransformer.js +1 -1
  38. package/src/XPathTransformerContext.js +462 -11
  39. package/src/index.js +9 -6
  40. package/tsconfig.json +5 -2
@@ -1,10 +1,12 @@
1
1
  import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
2
- // xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js
2
+ // eslint-disable-next-line @stylistic/max-len -- Long
3
+ // xpathVersion: 1 => browser/native XPathEvaluator API; 2 => xpath2.js, 3 => fontoxpath
4
+ import fontoxpath from 'fontoxpath';
3
5
 
4
6
  /**
5
7
  * @typedef {object} XPathTransformerContextConfig
6
8
  * @property {unknown} [data] - XML/DOM root to transform
7
- * @property {number} [xpathVersion] - 1 or 2 (default 1)
9
+ * @property {number} [xpathVersion] - 1, 2, 3.1 (default 1)
8
10
  * @property {import('./index.js').
9
11
  * JoiningTransformer} joiningTransformer Joiner
10
12
  * @property {boolean} [errorOnEqualPriority]
@@ -15,13 +17,13 @@ import xpath2 from 'xpath2.js'; // Runtime JS import; ambient types declared
15
17
  * Execution context for XPath-driven template application.
16
18
  *
17
19
  * Similar to JSONPathTransformerContext but uses XPath expressions on a
18
- * DOM/XML-like tree. Supports XPath 1.0 (default) or 2.0 when
19
- * `xpathVersion: 2`.
20
+ * DOM/XML-like tree. Supports XPath 1.0 (default), 2.0 when
21
+ * `xpathVersion: 2`, or 3.1 when `xpathVersion: 3.1`.
20
22
  *
21
23
  * Expected config:
22
24
  * - data: A Document, Element, or XML-like root node.
23
25
  * - joiningTransformer: joiner with append(), string(), object(), array(), etc.
24
- * - xpathVersion: 1|2 (default 1)
26
+ * - xpathVersion: 1|2|3.1 (default 1)
25
27
  * - errorOnEqualPriority, specificityPriorityResolver (same semantics).
26
28
  */
27
29
  class XPathTransformerContext {
@@ -69,7 +71,7 @@ class XPathTransformerContext {
69
71
  if (!expr) {
70
72
  return this._contextNode;
71
73
  }
72
- const version = this._config.xpathVersion === 2 ? 2 : 1;
74
+ const version = this._config.xpathVersion ?? 1;
73
75
  if (version === 1) {
74
76
  // Use native XPath (browser-like); rely on DOM doc if available.
75
77
  const doc = this._contextNode && this._contextNode.ownerDocument
@@ -141,8 +143,25 @@ class XPathTransformerContext {
141
143
  /* c8 ignore stop */
142
144
  }
143
145
  }
144
- // Version 2: xpath2.js
145
- const result = xpath2.evaluate(expr, this._contextNode);
146
+ if (version === 2) {
147
+ // Version 2: xpath2.js
148
+ const result = xpath2.evaluate(expr, this._contextNode);
149
+ if (asNodes) {
150
+ // eslint-disable-next-line @stylistic/max-len -- Long
151
+ /* c8 ignore next -- array wrap/identity branch counted in other tests */
152
+ return Array.isArray(result) ? result : [result];
153
+ }
154
+ /* c8 ignore next -- scalar return trivial; wrap behavior tested */
155
+ return result;
156
+ }
157
+
158
+ // eslint-disable-next-line @stylistic/max-len -- Long
159
+ // eslint-disable-next-line import/no-named-as-default-member -- Only as default
160
+ const result = fontoxpath.evaluateXPath(
161
+ expr, this._contextNode, undefined, undefined,
162
+ // Non-deprecated, predictable all results
163
+ 14 // ReturnType.ALL_RESULTS
164
+ );
146
165
  if (asNodes) {
147
166
  /* c8 ignore next -- array wrap/identity branch counted in other tests */
148
167
  return Array.isArray(result) ? result : [result];
@@ -523,14 +542,309 @@ class XPathTransformerContext {
523
542
  return this;
524
543
  }
525
544
  /**
526
- * Append number.
527
- * @param {number} num Number
545
+ * Append number with xsl:number-like formatting.
546
+ * @param {number|string|{
547
+ * value?: number|string,
548
+ * count?: string,
549
+ * level?: 'single'|'multiple'|'any',
550
+ * from?: string,
551
+ * format?: string,
552
+ * groupingSeparator?: string,
553
+ * groupingSize?: number
554
+ * }} num - Number value, "position()" string, or options object
528
555
  * @returns {XPathTransformerContext}
529
556
  */
530
557
  number (num) {
531
- this._getJoiningTransformer().number(num);
558
+ // Handle xsl:number-like functionality
559
+ if (typeof num === 'object' && num !== null) {
560
+ const opts = num;
561
+ let {value} = opts;
562
+
563
+ // Handle position() calculation
564
+ if (value === 'position()' || value === undefined) {
565
+ const {count} = opts;
566
+ const level = opts.level || 'single';
567
+ const {from} = opts;
568
+
569
+ switch (level) {
570
+ case 'single': {
571
+ value = this._calculatePosition(count, from);
572
+
573
+ break;
574
+ }
575
+ case 'multiple': {
576
+ // Hierarchical numbering: get position for each ancestor up to root
577
+ const positions = [];
578
+ let node = /** @type {any} */ (this._config).currentNode;
579
+ while (node) {
580
+ positions.unshift(this._calculatePosition(count, undefined));
581
+ node = node.parentNode;
582
+ if (from) {
583
+ const fromResult = /** @type {any} */ (
584
+ this._evalXPath(from, node)
585
+ );
586
+ if (fromResult && fromResult.length > 0) {
587
+ break;
588
+ }
589
+ }
590
+ }
591
+ value = positions.join('.');
592
+
593
+ break;
594
+ }
595
+ case 'any': {
596
+ value = this._calculatePositionAny(count, from);
597
+
598
+ break;
599
+ }
600
+ // No default
601
+ }
602
+ }
603
+
604
+ // Determine format string and locale
605
+ let format = opts.format || '1';
606
+ // @ts-expect-error: dynamic property access
607
+ const locale = opts.lang || 'en';
608
+ // @ts-expect-error: dynamic property access
609
+ const {letterValue} = opts;
610
+
611
+ // If letterValue is 'alphabetic', force alphabetic format
612
+ if (letterValue === 'alphabetic') {
613
+ format = (opts.format && (/^[aA]$/v).test(opts.format)) ? opts.format : 'a';
614
+ }
615
+
616
+ const numValue = typeof value === 'string' ? Number(value) : (value || 1);
617
+ const formatted = this._formatNumber(
618
+ numValue,
619
+ format,
620
+ opts.groupingSeparator,
621
+ opts.groupingSize,
622
+ locale
623
+ );
624
+ this._getJoiningTransformer().plainText(formatted);
625
+ } else if (num === 'position()') {
626
+ // Simple position() call
627
+ const pos = this._calculatePosition();
628
+ this._getJoiningTransformer().number(pos);
629
+ } else {
630
+ // Simple number
631
+ this._getJoiningTransformer().number(
632
+ typeof num === 'string' ? Number(num) : num
633
+ );
634
+ }
532
635
  return this;
533
636
  }
637
+
638
+ /**
639
+ * Calculate position of current node.
640
+ * @param {string} [count] - XPath pattern to match
641
+ * @param {string} [from] - XPath pattern for ancestor
642
+ * @returns {number}
643
+ * @private
644
+ */
645
+ _calculatePosition (count, from) {
646
+ // eslint-disable-next-line prefer-destructuring -- TS
647
+ const currentNode = /** @type {any} */ (this._config).currentNode;
648
+ if (!currentNode) {
649
+ return 1;
650
+ }
651
+
652
+ // Get parent node
653
+ const parent = currentNode.parentNode;
654
+ if (!parent) {
655
+ return 1;
656
+ }
657
+
658
+ // If from pattern specified, find that ancestor
659
+ let startNode = parent;
660
+ if (from) {
661
+ const fromResult = /** @type {any} */ (
662
+ this._evalXPath(from, currentNode)
663
+ );
664
+ if (fromResult && fromResult.length > 0) {
665
+ startNode = fromResult[0];
666
+ }
667
+ }
668
+
669
+ // Count preceding siblings
670
+ let position = 1;
671
+ let sibling = currentNode.previousSibling;
672
+
673
+ while (sibling) {
674
+ if (count) {
675
+ // Check if sibling matches count pattern
676
+ const matches = /** @type {any} */ (this._evalXPath(count, sibling));
677
+ if (matches && matches.length > 0) {
678
+ position++;
679
+ }
680
+ } else if (sibling.nodeType === currentNode.nodeType &&
681
+ (!currentNode.nodeName || sibling.nodeName === currentNode.nodeName)) {
682
+ position++;
683
+ }
684
+ sibling = sibling.previousSibling;
685
+ }
686
+
687
+ return position;
688
+ }
689
+
690
+ /**
691
+ * Calculate position counting all ancestors (level=any).
692
+ * @param {string} [count] - XPath pattern to match
693
+ * @param {string} [from] - XPath pattern for ancestor
694
+ * @returns {number}
695
+ * @private
696
+ */
697
+ _calculatePositionAny (count, from) {
698
+ // eslint-disable-next-line prefer-destructuring -- TS
699
+ const currentNode = /** @type {any} */ (this._config).currentNode;
700
+ if (!currentNode) {
701
+ return 1;
702
+ }
703
+
704
+ // Find root or 'from' node
705
+ let root = currentNode.ownerDocument || currentNode;
706
+ if (from) {
707
+ const fromResult = /** @type {any} */ (
708
+ this._evalXPath(from, currentNode)
709
+ );
710
+ if (fromResult && fromResult.length > 0) {
711
+ root = fromResult[0];
712
+ }
713
+ }
714
+
715
+ // Count all matching nodes in document order up to current
716
+ const pattern = count || 'node()';
717
+ const allNodes = /** @type {any[]} */ (
718
+ this._evalXPath('//' + pattern, root)
719
+ );
720
+
721
+ for (const [i, allNode] of allNodes.entries()) {
722
+ if (allNode === currentNode) {
723
+ return i + 1;
724
+ }
725
+ }
726
+
727
+ return 1;
728
+ }
729
+
730
+ /**
731
+ * Format a number according to format string.
732
+ * @param {number} num - Number to format
733
+ * @param {string} format - Format string (1, a, A, i, I, 01, etc.)
734
+ * @param {string} [groupingSeparator] - Separator for grouping (e.g., ',')
735
+ * @param {number} [groupingSize] - Size of groups (e.g., 3 for 1,000)
736
+ * @param {string} [locale]
737
+ * @returns {string}
738
+ * @private
739
+ */
740
+ _formatNumber (num, format, groupingSeparator, groupingSize, locale = 'en') {
741
+ if (Number.isNaN(num)) {
742
+ return String(num);
743
+ }
744
+
745
+ let result;
746
+ const formatChar = format.charAt(0);
747
+
748
+ switch (formatChar) {
749
+ case 'i': {
750
+ result = this._toRoman(num).toLowerCase();
751
+
752
+ break;
753
+ }
754
+ case 'I': {
755
+ result = this._toRoman(num);
756
+
757
+ break;
758
+ }
759
+ case 'a': {
760
+ result = this._toAlphabetic(num, false);
761
+
762
+ break;
763
+ }
764
+ case 'A': {
765
+ result = this._toAlphabetic(num, true);
766
+
767
+ break;
768
+ }
769
+ case '0': {
770
+ const width = format.length;
771
+ result = String(num).padStart(width, '0');
772
+
773
+ break;
774
+ }
775
+ default: {
776
+ // Use Intl.NumberFormat for decimal formatting if grouping/locale
777
+ // options are provided
778
+ let options = {};
779
+ if (groupingSeparator || groupingSize) {
780
+ options = {
781
+ useGrouping: true
782
+ };
783
+ }
784
+ try {
785
+ result = new Intl.NumberFormat(locale, options).format(num);
786
+ if (groupingSeparator) {
787
+ result = result.replaceAll(',', groupingSeparator);
788
+ }
789
+ } catch (e) {
790
+ result = String(num);
791
+ }
792
+ }
793
+ }
794
+ return result;
795
+ }
796
+
797
+ /**
798
+ * Convert number to Roman numerals.
799
+ * @param {number} num - Number to convert (1-3999)
800
+ * @returns {string}
801
+ * @private
802
+ */
803
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
804
+ _toRoman (num) {
805
+ if (num < 1 || num > 3999) {
806
+ return String(num);
807
+ }
808
+
809
+ const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
810
+ const syms = [
811
+ 'M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'
812
+ ];
813
+
814
+ let result = '';
815
+ for (const [i, val] of vals.entries()) {
816
+ while (num >= val) {
817
+ result += syms[i];
818
+ num -= val;
819
+ }
820
+ }
821
+ return result;
822
+ }
823
+
824
+ /**
825
+ * Convert number to alphabetic sequence.
826
+ * @param {number} num - Number to convert
827
+ * @param {boolean} uppercase - Use uppercase letters
828
+ * @returns {string}
829
+ * @private
830
+ */
831
+ // eslint-disable-next-line class-methods-use-this -- Avoid for now
832
+ _toAlphabetic (num, uppercase) {
833
+ if (num < 1) {
834
+ return String(num);
835
+ }
836
+
837
+ let result = '';
838
+ const base = uppercase ? 65 : 97; // 'A' or 'a'
839
+
840
+ while (num > 0) {
841
+ num--; // Make 0-indexed
842
+ result = String.fromCodePoint(base + (num % 26)) + result;
843
+ num = Math.floor(num / 26);
844
+ }
845
+
846
+ return result;
847
+ }
534
848
  /**
535
849
  * Append plain text (no escaping changes).
536
850
  * @param {string} str Text
@@ -824,6 +1138,143 @@ class XPathTransformerContext {
824
1138
  return this;
825
1139
  }
826
1140
 
1141
+ /**
1142
+ * Analyze a string with a regular expression, equivalent to
1143
+ * xsl:analyze-string. Processes matching and non-matching substrings
1144
+ * with separate callbacks.
1145
+ * @param {string} str - The string to analyze
1146
+ * @param {string|RegExp} regex - Regular expression to match against
1147
+ * @param {{
1148
+ * matchingSubstring?: (
1149
+ * this: XPathTransformerContext,
1150
+ * substring: string,
1151
+ * groups: string[],
1152
+ * regexGroup: (n: number) => string
1153
+ * ) => void,
1154
+ * nonMatchingSubstring?: (
1155
+ * this: XPathTransformerContext,
1156
+ * substring: string
1157
+ * ) => void,
1158
+ * flags?: string
1159
+ * }} options - Options object
1160
+ * @returns {XPathTransformerContext}
1161
+ */
1162
+ analyzeString (str, regex, options = {}) {
1163
+ // Ensure we have a string
1164
+ const inputString = String(str || '');
1165
+
1166
+ // If empty string, do nothing
1167
+ if (inputString.length === 0) {
1168
+ return this;
1169
+ }
1170
+
1171
+ const {
1172
+ matchingSubstring,
1173
+ nonMatchingSubstring,
1174
+ flags = ''
1175
+ } = options;
1176
+
1177
+ // Convert regex to RegExp if it's a string
1178
+ let regexObj;
1179
+ if (typeof regex === 'string') {
1180
+ // Ensure 'g' flag is present for global matching
1181
+ const actualFlags = flags.includes('g') ? flags : flags + 'g';
1182
+ regexObj = new RegExp(regex, actualFlags);
1183
+ } else {
1184
+ regexObj = regex;
1185
+ // Ensure global flag is set
1186
+ if (!regexObj.global) {
1187
+ regexObj = new RegExp(
1188
+ regexObj.source,
1189
+ regexObj.flags + 'g'
1190
+ );
1191
+ }
1192
+ }
1193
+
1194
+ // Check for zero-length matches (error condition in XSLT)
1195
+ if (regexObj.test('')) {
1196
+ throw new Error(
1197
+ 'Regular expression matches zero-length string'
1198
+ );
1199
+ }
1200
+
1201
+ // Store captured groups for access during callback
1202
+ /** @type {string[] | undefined} */
1203
+ let currentCapturedGroups;
1204
+
1205
+ /**
1206
+ * Get captured group by index.
1207
+ * @param {number} groupNumber - Group index
1208
+ * @returns {string} - Captured group or empty string
1209
+ */
1210
+ const getRegexGroup = (groupNumber) => {
1211
+ if (!currentCapturedGroups ||
1212
+ groupNumber < 0 ||
1213
+ groupNumber >= currentCapturedGroups.length) {
1214
+ return '';
1215
+ }
1216
+ return currentCapturedGroups[groupNumber] || '';
1217
+ };
1218
+
1219
+ // Save previous context to restore later
1220
+ const prevContext = this._contextNode;
1221
+
1222
+ let lastIndex = 0;
1223
+ let match;
1224
+
1225
+ // Bind callbacks to this context
1226
+ const boundMatchingSubstring = matchingSubstring
1227
+ ? matchingSubstring.bind(this)
1228
+ : undefined;
1229
+ const boundNonMatchingSubstring = nonMatchingSubstring
1230
+ ? nonMatchingSubstring.bind(this)
1231
+ : undefined;
1232
+
1233
+ // Find all matches
1234
+ while ((match = regexObj.exec(inputString)) !== null) {
1235
+ // Process non-matching substring before this match
1236
+ if (match.index > lastIndex) {
1237
+ const nonMatchingStr = inputString.slice(lastIndex, match.index);
1238
+ if (boundNonMatchingSubstring) {
1239
+ boundNonMatchingSubstring(nonMatchingStr);
1240
+ }
1241
+ }
1242
+
1243
+ // Process matching substring
1244
+ if (boundMatchingSubstring) {
1245
+ const matchingStr = match[0];
1246
+ // Store captured groups: [full match, group1, group2, ...]
1247
+ currentCapturedGroups = [...match];
1248
+ boundMatchingSubstring(
1249
+ matchingStr, currentCapturedGroups, getRegexGroup
1250
+ );
1251
+ currentCapturedGroups = undefined;
1252
+ }
1253
+
1254
+ const {lastIndex: newLastIndex} = regexObj;
1255
+ lastIndex = newLastIndex;
1256
+
1257
+ // Prevent infinite loop on zero-length matches (shouldn't happen
1258
+ // due to earlier check, but defensive)
1259
+ if (match.index === regexObj.lastIndex) {
1260
+ regexObj.lastIndex++;
1261
+ }
1262
+ }
1263
+
1264
+ // Process final non-matching substring
1265
+ if (lastIndex < inputString.length) {
1266
+ const nonMatchingStr = inputString.slice(lastIndex);
1267
+ if (boundNonMatchingSubstring) {
1268
+ boundNonMatchingSubstring(nonMatchingStr);
1269
+ }
1270
+ }
1271
+
1272
+ // Restore previous context
1273
+ this._contextNode = prevContext;
1274
+
1275
+ return this;
1276
+ }
1277
+
827
1278
  /* c8 ignore start -- static default rules object has spotty function
828
1279
  * attribution under coverage; behavior is exercised via applyTemplates */
829
1280
  static DefaultTemplateRules = {
package/src/index.js CHANGED
@@ -175,8 +175,8 @@ export const setWindow = (win) => {
175
175
  * import('./JSONPathTransformerContext.js').default
176
176
  * >],
177
177
  * engineType: 'xpath',
178
- * xpathVersion?: 1|2,
179
- * outputType?: 'string'|'dom'|'json'
178
+ * xpathVersion?: 1|2|3.1,
179
+ * outputType?: T
180
180
  * }} XPathJTLTOptions
181
181
  */
182
182
 
@@ -390,10 +390,13 @@ class JTLT {
390
390
  : null
391
391
  );
392
392
  this.config.templates = query
393
- // eslint-disable-next-line @stylistic/max-len -- Long
394
- ? /** @type {JSONPathTemplateObject<joiningTypes>[]|XPathTemplateObject<joiningTypes>[]} */ ([
395
- {name: 'root', path: '$', template: query}
396
- ])
393
+ ? this.config.engineType === 'xpath'
394
+ ? /** @type {XPathTemplateObject<joiningTypes>[]} */ ([
395
+ {name: 'root', path: '//*', template: query}
396
+ ])
397
+ : /** @type {JSONPathTemplateObject<joiningTypes>[]} */ ([
398
+ {name: 'root', path: '$', template: query}
399
+ ])
397
400
  // eslint-disable-next-line @stylistic/max-len -- Long
398
401
  : /** @type {JSONPathTemplateObject<joiningTypes>[]|XPathTemplateObject<joiningTypes>[]} */ (
399
402
  cfg.templates || [cfg.template]
package/tsconfig.json CHANGED
@@ -9,6 +9,9 @@
9
9
  "noEmit": true,
10
10
  "strict": true
11
11
  },
12
- "include": ["*.js", "*.d.ts", "src/**/*.js", "test/*.js", "typings/xpath2-js.d.ts"],
13
- "exclude": ["node_modules", "./dist/**/*.js"]
12
+ "include": [
13
+ "*.js", "*.d.ts", "src/**/*.js", "demo/**/*.js", "test/**/*.js",
14
+ "typings/xpath2-js.d.ts"
15
+ ],
16
+ "exclude": ["node_modules", "./dist/**/*.js", "demo/codemirror.esm.js", "demo/vendor"]
14
17
  }