jtlt 0.8.0 → 0.10.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.
@@ -77,6 +77,39 @@ class AbstractJoiningTransformer {
77
77
 
78
78
  /** @type {Map<string, string>} */
79
79
  this._namespaceAliases = new Map();
80
+
81
+ /**
82
+ * @type {{
83
+ * onMultipleMatch?: "use-last"|"fail",
84
+ * warningOnMultipleMatch?: boolean,
85
+ * onNoMatch?: "shallow-copy"|"deep-copy"|"fail"|"apply-templates"|
86
+ * "shallow-skip"|"deep-skip"|"text-only-copy",
87
+ * warningOnNoMatch?: boolean
88
+ * }|undefined}
89
+ */
90
+ this._modeConfig = undefined;
91
+
92
+ /** @type {Set<string>} */
93
+ this._excludeResultPrefixes = new Set();
94
+
95
+ /** @type {Set<string>} */
96
+ this._usedNamespacePrefixes = new Set();
97
+
98
+ /**
99
+ * Pending namespace declarations that may be excluded.
100
+ * @type {Array<{
101
+ * prefix: string,
102
+ * namespaceURI: string,
103
+ * callback: () => void
104
+ * }>}
105
+ */
106
+ this._pendingNamespaces = [];
107
+
108
+ /**
109
+ * Track which prefixes have pending namespace declarations.
110
+ * @type {Map<string, {prefix: string, namespaceURI: string}>}
111
+ */
112
+ this._pendingNamespaceMap = new Map();
80
113
  }
81
114
 
82
115
  /**
@@ -101,6 +134,52 @@ class AbstractJoiningTransformer {
101
134
  return this;
102
135
  }
103
136
 
137
+ /**
138
+ * Configure mode behavior (similar to xsl:mode).
139
+ * @param {{
140
+ * onMultipleMatch?: "use-last"|"fail",
141
+ * warningOnMultipleMatch?: boolean,
142
+ * onNoMatch?: "shallow-copy"|"deep-copy"|"fail"|"apply-templates"|
143
+ * "shallow-skip"|"deep-skip"|"text-only-copy",
144
+ * warningOnNoMatch?: boolean
145
+ * }} cfg - Mode configuration
146
+ * @returns {this}
147
+ */
148
+ mode (cfg) {
149
+ this._modeConfig = cfg;
150
+ return this;
151
+ }
152
+
153
+ /**
154
+ * Configure stylesheet behavior (similar to xsl:stylesheet).
155
+ * Unlike xsl:stylesheet, this is a directive method and does not contain
156
+ * nested content.
157
+ * @param {{
158
+ * excludeResultPrefixes?: string[]
159
+ * }} cfg - Stylesheet configuration
160
+ * @returns {this}
161
+ */
162
+ stylesheet (cfg) {
163
+ if (cfg.excludeResultPrefixes) {
164
+ for (const prefix of cfg.excludeResultPrefixes) {
165
+ // Normalize empty string to internal #default representation
166
+ this._excludeResultPrefixes.add(prefix === '' ? '#default' : prefix);
167
+ }
168
+ }
169
+ return this;
170
+ }
171
+
172
+ /**
173
+ * Alias for stylesheet() method (XSLT compatibility).
174
+ * @param {{
175
+ * excludeResultPrefixes?: string[]
176
+ * }} cfg - Stylesheet configuration
177
+ * @returns {this}
178
+ */
179
+ transform (cfg) {
180
+ return this.stylesheet(cfg);
181
+ }
182
+
104
183
  /**
105
184
  * @param {string} name
106
185
  * @param {OutputCharacters} outputCharacters
@@ -171,6 +250,12 @@ class AbstractJoiningTransformer {
171
250
  const prefix = colonIdx === -1 ? '#default' : elemName.slice(0, colonIdx);
172
251
  const alias = this._getNamespaceAlias(prefix);
173
252
 
253
+ // Track that this prefix (after aliasing) is actually used
254
+ this._usedNamespacePrefixes.add(alias);
255
+
256
+ // If this prefix was buffered, output it now
257
+ this._flushPendingNamespace(alias);
258
+
174
259
  if (colonIdx === -1) {
175
260
  if (alias === '#default') {
176
261
  return elemName;
@@ -184,6 +269,34 @@ class AbstractJoiningTransformer {
184
269
  ) + elemName.slice(colonIdx + 1);
185
270
  }
186
271
 
272
+ /**
273
+ * Output a pending namespace declaration if it exists.
274
+ * This method should be overridden by subclasses.
275
+ * @param {string} _prefix
276
+ * @returns {void}
277
+ */
278
+ // eslint-disable-next-line class-methods-use-this -- Abstract
279
+ _flushPendingNamespace (_prefix) {
280
+ // Default implementation does nothing - subclasses override
281
+ // Using _prefix to indicate unused parameter
282
+ }
283
+
284
+ /**
285
+ * Track attribute name prefix usage.
286
+ * @param {string} attrName
287
+ * @returns {void}
288
+ */
289
+ _trackAttributePrefix (attrName) {
290
+ const colonIdx = attrName.indexOf(':');
291
+ if (colonIdx !== -1 && !attrName.startsWith('xmlns')) {
292
+ const prefix = attrName.slice(0, colonIdx);
293
+ this._usedNamespacePrefixes.add(prefix);
294
+
295
+ // If this prefix was buffered, output it now
296
+ this._flushPendingNamespace(prefix);
297
+ }
298
+ }
299
+
187
300
  /**
188
301
  * @param {string} str
189
302
  * @returns {string}
@@ -479,7 +479,24 @@ class DOMJoiningTransformer extends AbstractJoiningTransformer {
479
479
  * @returns {DOMJoiningTransformer}
480
480
  */
481
481
  namespace (prefix, namespaceURI) {
482
- const alias = this._getNamespaceAlias(prefix);
482
+ let alias = this._getNamespaceAlias(prefix);
483
+
484
+ // Normalize empty string or undefined/null to #default
485
+ if (!alias) {
486
+ alias = '#default';
487
+ }
488
+ const normalizedPrefix = alias;
489
+
490
+ // If this prefix is excluded, buffer it instead of outputting immediately
491
+ if (this._excludeResultPrefixes.has(normalizedPrefix)) {
492
+ this._pendingNamespaceMap.set(normalizedPrefix, {
493
+ prefix: alias,
494
+ namespaceURI
495
+ });
496
+ return this;
497
+ }
498
+
499
+ // Not excluded, output immediately
483
500
  /** @type {Element} */
484
501
  (this._dom).setAttributeNS(
485
502
  'http://www.w3.org/2000/xmlns/',
@@ -489,6 +506,23 @@ class DOMJoiningTransformer extends AbstractJoiningTransformer {
489
506
  return this;
490
507
  }
491
508
 
509
+ /**
510
+ * @param {string} prefix
511
+ * @returns {void}
512
+ * @override
513
+ */
514
+ _flushPendingNamespace (prefix) {
515
+ const pending = this._pendingNamespaceMap.get(prefix);
516
+ if (pending) {
517
+ /** @type {Element} */
518
+ (this._dom).setAttributeNS(
519
+ 'http://www.w3.org/2000/xmlns/',
520
+ pending.prefix === '#default' ? 'xmlns' : 'xmlns:' + pending.prefix,
521
+ this._replaceCharacterMaps(pending.namespaceURI)
522
+ );
523
+ this._pendingNamespaceMap.delete(prefix);
524
+ }
525
+ }
492
526
  /**
493
527
  * @param {string} name
494
528
  * @param {string} val
@@ -499,6 +533,10 @@ class DOMJoiningTransformer extends AbstractJoiningTransformer {
499
533
  this._dom.nodeType !== 1) {
500
534
  throw new Error('You may only set an attribute on an element');
501
535
  }
536
+
537
+ // Track attribute prefix usage for namespace exclusion
538
+ this._trackAttributePrefix(name);
539
+
502
540
  (/** @type {Element} */ (this._dom)).setAttribute(
503
541
  this._replaceNamespaceAliasInNamespaceDeclaration(name),
504
542
  this._replaceCharacterMaps(val)
@@ -551,6 +551,25 @@ class JSONJoiningTransformer extends AbstractJoiningTransformer {
551
551
  // No-op outside an element() callback (JSON joiner semantics)
552
552
  return this;
553
553
  }
554
+
555
+ let alias = this._getNamespaceAlias(prefix);
556
+
557
+ // Normalize empty string or undefined/null to #default
558
+ if (!alias) {
559
+ alias = '#default';
560
+ }
561
+ const normalizedPrefix = alias;
562
+
563
+ // If this prefix is excluded, buffer it instead of outputting immediately
564
+ if (this._excludeResultPrefixes.has(normalizedPrefix)) {
565
+ this._pendingNamespaceMap.set(normalizedPrefix, {
566
+ prefix: alias,
567
+ namespaceURI
568
+ });
569
+ return this;
570
+ }
571
+
572
+ // Not excluded, output immediately
554
573
  const top = /** @type {ElementInfo} */ (this._elementStack.at(-1));
555
574
  const {attsObj} = top;
556
575
 
@@ -558,7 +577,6 @@ class JSONJoiningTransformer extends AbstractJoiningTransformer {
558
577
  attsObj.xmlns = {};
559
578
  }
560
579
 
561
- const alias = this._getNamespaceAlias(prefix);
562
580
  /** @type {Record<string, string>} */
563
581
  (attsObj.xmlns)[alias === '#default' ? '' : alias] =
564
582
  this._replaceCharacterMaps(namespaceURI);
@@ -566,6 +584,32 @@ class JSONJoiningTransformer extends AbstractJoiningTransformer {
566
584
  return this;
567
585
  }
568
586
 
587
+ /**
588
+ * @param {string} prefix
589
+ * @returns {void}
590
+ * @override
591
+ */
592
+ _flushPendingNamespace (prefix) {
593
+ if (!this._elementStack.length) {
594
+ return;
595
+ }
596
+
597
+ const pending = this._pendingNamespaceMap.get(prefix);
598
+ if (pending) {
599
+ const top = /** @type {ElementInfo} */ (this._elementStack.at(-1));
600
+ const {attsObj} = top;
601
+
602
+ if (!attsObj.xmlns) {
603
+ attsObj.xmlns = {};
604
+ }
605
+
606
+ /** @type {Record<string, string>} */
607
+ (attsObj.xmlns)[pending.prefix === '#default' ? '' : pending.prefix] =
608
+ this._replaceCharacterMaps(pending.namespaceURI);
609
+
610
+ this._pendingNamespaceMap.delete(prefix);
611
+ }
612
+ }
569
613
  /**
570
614
  * Adds/updates an attribute for the most recently open element built via
571
615
  * a callback-driven element(). When not in an element callback context,
@@ -599,11 +643,18 @@ class JSONJoiningTransformer extends AbstractJoiningTransformer {
599
643
  if (name === '$a' && Array.isArray(val)) {
600
644
  val.forEach((pair) => {
601
645
  if (Array.isArray(pair) && pair.length > 1) {
602
- attsObj[String(pair[0])] = this._replaceCharacterMaps(pair[1]);
646
+ const attrName = String(pair[0]);
647
+ // Track each ordered attribute
648
+ this._trackAttributePrefix(attrName);
649
+ attsObj[attrName] = this._replaceCharacterMaps(pair[1]);
603
650
  }
604
651
  });
605
652
  return this;
606
653
  }
654
+
655
+ // Track attribute prefix usage for namespace exclusion
656
+ this._trackAttributePrefix(name);
657
+
607
658
  attsObj[this._replaceNamespaceAliasInNamespaceDeclaration(name)] =
608
659
  this._replaceCharacterMaps(/** @type {string} */ (val));
609
660
  return this;
@@ -30,6 +30,10 @@ class JSONPathTransformer {
30
30
  {path: template[0], template: template[1]}
31
31
  );
32
32
  }
33
+ // Normalize 'match' to 'path' for XSLT compatibility
34
+ if (template.match && !template.path) {
35
+ return {...template, path: template.match};
36
+ }
33
37
  return template;
34
38
  });
35
39
  this.templates.forEach((template, i, templates) => {
@@ -408,6 +408,48 @@ class JSONPathTransformerContext {
408
408
 
409
409
  let templateObj;
410
410
  if (!pathMatchedTemplates.length) {
411
+ // Check mode config for no-match behavior
412
+ const joiner = that._getJoiningTransformer();
413
+ const modeConfig = joiner._modeConfig;
414
+ if (modeConfig) {
415
+ const onNoMatch = modeConfig.onNoMatch ?? 'text-only-copy';
416
+ if (modeConfig.warningOnNoMatch) {
417
+ // eslint-disable-next-line no-console -- Warning as specified
418
+ console.warn(
419
+ 'Warning: No template matches. ' +
420
+ 'Mode is configured with warningOnNoMatch=true.'
421
+ );
422
+ }
423
+ if (onNoMatch === 'fail') {
424
+ throw new Error(
425
+ 'No template matches. Mode is configured with onNoMatch="fail".'
426
+ );
427
+ }
428
+ if (onNoMatch === 'deep-skip') {
429
+ // Skip this node and its descendants entirely
430
+ continue;
431
+ }
432
+ if (onNoMatch === 'shallow-copy') {
433
+ // Output the value as-is without processing children
434
+ joiner.append(value);
435
+ continue;
436
+ }
437
+ if (onNoMatch === 'deep-copy') {
438
+ // Output the value and all descendants as-is
439
+ joiner.append(JSON.stringify(value));
440
+ continue;
441
+ }
442
+ if (onNoMatch === 'text-only-copy') {
443
+ // Output only text content (primitives)
444
+ if (typeof value === 'string' || typeof value === 'number' ||
445
+ typeof value === 'boolean') {
446
+ joiner.append(String(value));
447
+ }
448
+ continue;
449
+ }
450
+ // 'apply-templates', 'shallow-skip', or other:
451
+ // use default template rules
452
+ }
411
453
  const dtr = JSONPathTransformer.DefaultTemplateRules;
412
454
  if (propertyNamesMode) {
413
455
  templateObj = dtr.transformPropertyNames;
@@ -446,6 +488,49 @@ class JSONPathTransformerContext {
446
488
  return (aPriority > bPriority) ? -1 : 1;
447
489
  });
448
490
 
491
+ // Check for multiple matches with same priority when mode is configured
492
+ const joiner = that._getJoiningTransformer();
493
+ const modeConfig = joiner._modeConfig;
494
+ if (modeConfig && pathMatchedTemplates.length > 1) {
495
+ // Check if top two templates have equal priority
496
+ const topPriority =
497
+ typeof pathMatchedTemplates[0].priority === 'number'
498
+ ? pathMatchedTemplates[0].priority
499
+ : (that._config.specificityPriorityResolver &&
500
+ pathMatchedTemplates[0].path
501
+ ? that._config.specificityPriorityResolver(
502
+ pathMatchedTemplates[0].path
503
+ )
504
+ /* c8 ignore next 2 -- defensive, templates without paths
505
+ are already filtered at line 388 */
506
+ : 0);
507
+ const secondPriority =
508
+ typeof pathMatchedTemplates[1].priority === 'number'
509
+ ? pathMatchedTemplates[1].priority
510
+ : (that._config.specificityPriorityResolver &&
511
+ pathMatchedTemplates[1].path
512
+ ? that._config.specificityPriorityResolver(
513
+ pathMatchedTemplates[1].path
514
+ )
515
+ /* c8 ignore next 2 -- defensive, templates without paths
516
+ are already filtered at line 388 */
517
+ : 0);
518
+ if (topPriority === secondPriority) {
519
+ if (modeConfig.onMultipleMatch === 'fail') {
520
+ throw new Error(
521
+ 'Multiple templates match with equal priority. ' +
522
+ 'Mode is configured with onMultipleMatch="fail".'
523
+ );
524
+ } else if (modeConfig.warningOnMultipleMatch !== false) {
525
+ // eslint-disable-next-line no-console -- Warning as specified
526
+ console.warn(
527
+ 'Warning: Multiple templates match with equal priority. ' +
528
+ 'Mode is configured with warningOnMultipleMatch=true.'
529
+ );
530
+ }
531
+ }
532
+ }
533
+
449
534
  templateObj =
450
535
  /** @type {import('./index.js').JSONPathTemplateObject<T>} */ (
451
536
  pathMatchedTemplates.shift()
@@ -471,7 +556,18 @@ class JSONPathTransformerContext {
471
556
  that._params = prevTemplateParams;
472
557
  if (typeof ret !== 'undefined') {
473
558
  // After the undefined check, ret is ResultType<T>
474
- that._getJoiningTransformer().append(
559
+ const joiner = that._getJoiningTransformer();
560
+ // Close any open tag before appending template return value
561
+ /* c8 ignore start -- _openTagState only on StringJoiningTransformer,
562
+ defensive check for string output mode */
563
+ // @ts-expect-error -- _openTagState only on StringJoiningTransformer
564
+ if (joiner._openTagState) {
565
+ joiner.append('>');
566
+ // @ts-expect-error -- _openTagState only on StringJoiningTransformer
567
+ joiner._openTagState = false;
568
+ }
569
+ /* c8 ignore stop */
570
+ joiner.append(
475
571
  /** @type {string|Node|*} */ (ret)
476
572
  );
477
573
  }
@@ -1757,6 +1853,22 @@ class JSONPathTransformerContext {
1757
1853
  return this;
1758
1854
  }
1759
1855
 
1856
+ /**
1857
+ * Configure mode behavior (similar to xsl:mode).
1858
+ * @param {{
1859
+ * onMultipleMatch?: "use-last"|"fail",
1860
+ * warningOnMultipleMatch?: boolean,
1861
+ * onNoMatch?: "shallow-copy"|"deep-copy"|"fail"|"apply-templates"|
1862
+ * "shallow-skip"|"deep-skip"|"text-only-copy",
1863
+ * warningOnNoMatch?: boolean
1864
+ * }} cfg - Mode configuration
1865
+ * @returns {this}
1866
+ */
1867
+ mode (cfg) {
1868
+ this._getJoiningTransformer().mode(cfg);
1869
+ return this;
1870
+ }
1871
+
1760
1872
  /**
1761
1873
  * @param {string} name
1762
1874
  * @param {import('./AbstractJoiningTransformer.js').
@@ -1790,6 +1902,31 @@ class JSONPathTransformerContext {
1790
1902
  return this;
1791
1903
  }
1792
1904
 
1905
+ /**
1906
+ * Configure stylesheet behavior (similar to xsl:stylesheet).
1907
+ * Unlike xsl:stylesheet, this is a directive method and does not contain
1908
+ * nested content.
1909
+ * @param {{
1910
+ * excludeResultPrefixes?: string[]
1911
+ * }} cfg - Stylesheet configuration
1912
+ * @returns {this}
1913
+ */
1914
+ stylesheet (cfg) {
1915
+ this._getJoiningTransformer().stylesheet(cfg);
1916
+ return this;
1917
+ }
1918
+
1919
+ /**
1920
+ * Alias for stylesheet() method (XSLT compatibility).
1921
+ * @param {{
1922
+ * excludeResultPrefixes?: string[]
1923
+ * }} cfg - Stylesheet configuration
1924
+ * @returns {this}
1925
+ */
1926
+ transform (cfg) {
1927
+ return this.stylesheet(cfg);
1928
+ }
1929
+
1793
1930
  /**
1794
1931
  * Create an element. Mirrors the joining transformer API so templates can
1795
1932
  * call `this.element()`.
@@ -633,7 +633,24 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
633
633
  * @returns {StringJoiningTransformer}
634
634
  */
635
635
  namespace (prefix, namespaceURI) {
636
- const alias = this._getNamespaceAlias(prefix);
636
+ let alias = this._getNamespaceAlias(prefix);
637
+
638
+ // Normalize empty string or undefined/null to #default
639
+ if (!alias) {
640
+ alias = '#default';
641
+ }
642
+ const normalizedPrefix = alias;
643
+
644
+ // If this prefix is excluded, buffer it instead of outputting immediately
645
+ if (this._excludeResultPrefixes.has(normalizedPrefix)) {
646
+ this._pendingNamespaceMap.set(normalizedPrefix, {
647
+ prefix: alias,
648
+ namespaceURI
649
+ });
650
+ return this;
651
+ }
652
+
653
+ // Not excluded, output immediately
637
654
  this.append(' ' + (
638
655
  alias === '#default' ? 'xmlns' : 'xmlns:' + alias
639
656
  ) + '="' +
@@ -641,6 +658,22 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
641
658
  return this;
642
659
  }
643
660
 
661
+ /**
662
+ * @param {string} prefix
663
+ * @returns {void}
664
+ * @override
665
+ */
666
+ _flushPendingNamespace (prefix) {
667
+ const pending = this._pendingNamespaceMap.get(prefix);
668
+ if (pending) {
669
+ this.append(' ' + (
670
+ pending.prefix === '#default' ? 'xmlns' : 'xmlns:' + pending.prefix
671
+ ) + '="' +
672
+ this._replaceCharacterMaps(pending.namespaceURI) + '"');
673
+ this._pendingNamespaceMap.delete(prefix);
674
+ }
675
+ }
676
+
644
677
  /**
645
678
  * @param {string} name - Attribute name
646
679
  * @param {string|Record<string, unknown>|
@@ -703,6 +736,10 @@ class StringJoiningTransformer extends AbstractJoiningTransformer {
703
736
  val = (this._cfg.preEscapedAttributes || avoidAttEscape)
704
737
  ? valStr
705
738
  : valStr.replaceAll('&', '&amp;').replaceAll('"', '&quot;');
739
+
740
+ // Track attribute prefix usage for namespace exclusion
741
+ this._trackAttributePrefix(name);
742
+
706
743
  this.append(
707
744
  ' ' + this._replaceNamespaceAliasInNamespaceDeclaration(name) + '="' +
708
745
  this._replaceCharacterMaps(val) + '"'
@@ -34,6 +34,10 @@ class XPathTransformer {
34
34
  if (Array.isArray(template)) {
35
35
  return {path: template[0], template: template[1]};
36
36
  }
37
+ // Normalize 'match' to 'path' for XSLT compatibility
38
+ if (template.match && !template.path) {
39
+ return {...template, path: template.match};
40
+ }
37
41
  return template;
38
42
  });
39
43
  this.templates.forEach((template) => {