@tsrx/core 0.1.30 → 0.1.32

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.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Core compiler infrastructure for TSRX syntax",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.1.30",
6
+ "version": "0.1.32",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
package/src/index.js CHANGED
@@ -159,11 +159,16 @@ export {
159
159
  rewrite_loop_continues_to_bare_returns as rewriteLoopContinuesToBareReturns,
160
160
  to_jsx_attribute as toJsxAttribute,
161
161
  validate_at_most_one_ref_attribute as validateAtMostOneRefAttribute,
162
+ wrap_edge_whitespace as wrapEdgeWhitespace,
162
163
  } from './transform/jsx/index.js';
163
164
  export {
164
165
  in_jsx_child_context as inJsxChildContext,
165
166
  tsx_node_to_jsx_expression as tsxNodeToJsxExpression,
166
167
  tsx_with_ts_locations as tsxWithTsLocations,
168
+ is_template_if_node as isTemplateIfNode,
169
+ is_template_for_of_node as isTemplateForOfNode,
170
+ is_template_switch_node as isTemplateSwitchNode,
171
+ is_template_try_node as isTemplateTryNode,
167
172
  } from './transform/jsx/helpers.js';
168
173
  export {
169
174
  collect_style_ref_attributes as collectStyleRefAttributes,
package/src/plugin.js CHANGED
@@ -5,12 +5,7 @@
5
5
  */
6
6
 
7
7
  import * as acorn from 'acorn';
8
- import {
9
- skipWhitespace,
10
- isWhitespaceTextNode,
11
- BINDING_TYPES,
12
- DestructuringErrors,
13
- } from './parse/index.js';
8
+ import { isWhitespaceTextNode, BINDING_TYPES, DestructuringErrors } from './parse/index.js';
14
9
  import { parse_style } from './parse/style.js';
15
10
  import { regex_newline_characters } from './utils/patterns.js';
16
11
  import { error } from './errors.js';
@@ -549,7 +544,6 @@ export function TSRXPlugin(config) {
549
544
  while (index < this.input.length) {
550
545
  if (this.#isTemplateLineCommentStart(index, start)) {
551
546
  const comment_start = index;
552
- const comment_start_loc = acorn.getLineInfo(this.input, comment_start);
553
547
  index += 2;
554
548
  while (
555
549
  index < this.input.length &&
@@ -558,20 +552,9 @@ export function TSRXPlugin(config) {
558
552
  ) {
559
553
  index++;
560
554
  }
561
- if (this.options.onComment && comment_start >= token_end) {
562
- const comment_end_loc = acorn.getLineInfo(this.input, index);
563
- // Pass null metadata so position-based attachment places the comment
564
- // as a leading comment on the following child (which the JSX printers
565
- // emit), rather than on the container's `elementLeadingComments`.
566
- this.options.onComment(
567
- false,
568
- this.input.slice(comment_start + 2, index),
569
- comment_start,
570
- index,
571
- new acorn.Position(comment_start_loc.line, comment_start_loc.column),
572
- new acorn.Position(comment_end_loc.line, comment_end_loc.column),
573
- /** @type {any} */ (null),
574
- );
555
+
556
+ if (comment_start >= token_end) {
557
+ this.#emitTemplateLineComment(comment_start, index, null);
575
558
  }
576
559
  continue;
577
560
  }
@@ -590,7 +573,7 @@ export function TSRXPlugin(config) {
590
573
  index,
591
574
  new acorn.Position(comment_start_loc.line, comment_start_loc.column),
592
575
  new acorn.Position(comment_end_loc.line, comment_end_loc.column),
593
- /** @type {any} */ (null),
576
+ null,
594
577
  );
595
578
  }
596
579
  continue;
@@ -640,6 +623,62 @@ export function TSRXPlugin(config) {
640
623
  return node.value !== '' && !regex_newline_characters.test(node.value);
641
624
  }
642
625
 
626
+ #skipTrailingLayoutWhitespace() {
627
+ let index = this.start;
628
+ let has_newline = false;
629
+ while (index < this.input.length) {
630
+ const ch = this.input.charCodeAt(index);
631
+ if (ch === CharCode.lineFeed || ch === CharCode.carriageReturn) {
632
+ has_newline = true;
633
+ index++;
634
+ } else if (ch === CharCode.space || ch === CharCode.tab) {
635
+ index++;
636
+ } else if (ch === CharCode.slash && this.input.charCodeAt(index + 1) === CharCode.slash) {
637
+ const comment_start = index;
638
+ while (index < this.input.length && !this.#isNewlineCharCode(index)) {
639
+ index++;
640
+ }
641
+ this.#emitTemplateLineComment(comment_start, index, null);
642
+ } else {
643
+ break;
644
+ }
645
+ }
646
+ if (!has_newline) return;
647
+ const loc = acorn.getLineInfo(this.input, index);
648
+ this.start = index;
649
+ this.startLoc = new acorn.Position(loc.line, loc.column);
650
+ if (this.pos <= index) {
651
+ this.curLine = loc.line;
652
+ this.lineStart = index - loc.column;
653
+ }
654
+ }
655
+
656
+ /**
657
+ * @param {number} index
658
+ */
659
+ #isNewlineCharCode(index) {
660
+ const ch = this.input.charCodeAt(index);
661
+ return ch === CharCode.lineFeed || ch === CharCode.carriageReturn;
662
+ }
663
+
664
+ /**
665
+ * @param {number} start
666
+ * @param {number} end
667
+ * @param {Parse.CommentMetaData | null} metadata
668
+ */
669
+ #emitTemplateLineComment(start, end, metadata) {
670
+ if (!this.options.onComment) return;
671
+ this.options.onComment(
672
+ false,
673
+ this.input.slice(start + 2, end),
674
+ start,
675
+ end,
676
+ acorn.getLineInfo(this.input, start),
677
+ acorn.getLineInfo(this.input, end),
678
+ metadata,
679
+ );
680
+ }
681
+
643
682
  #isSwitchCaseScriptStatementStart() {
644
683
  let index = skip_whitespace_from(this.input, this.start);
645
684
 
@@ -3650,15 +3689,17 @@ export function TSRXPlugin(config) {
3650
3689
  }
3651
3690
 
3652
3691
  /**
3653
- * `@try`/`@pending`/`@catch` blocks lower their direct `return`
3654
- * values into reactive boundary fallbacks, so unlike `@if`/`@for`/`@switch`
3655
- * blocks they legitimately allow `return <markup>` statements. Set the flag
3656
- * immediately before parsing each such block so its body sees it.
3692
+ * `@try`/`@pending`/`@catch` blocks are template control-flow blocks like
3693
+ * `@if`/`@for`/`@switch`: `return` is not allowed inside them. A `return`
3694
+ * is only valid in the JS setup at the top of a `@{ … }` code block, never
3695
+ * inside a `@`-directive block. Report any direct `return` with the same
3696
+ * template-return diagnostic used elsewhere.
3657
3697
  * @returns {AST.BlockStatement}
3658
3698
  */
3659
3699
  #parseTemplateControlFlowReturnBlock(createNewLexicalScope = true) {
3660
- this.#controlFlowBlockAllowsNativeReturn = true;
3661
- return this.#parseTemplateControlFlowBlock(createNewLexicalScope);
3700
+ const block = this.#parseTemplateControlFlowBlock(createNewLexicalScope);
3701
+ this.#report_invalid_template_return_statements(block.body);
3702
+ return block;
3662
3703
  }
3663
3704
 
3664
3705
  /**
@@ -4428,50 +4469,58 @@ export function TSRXPlugin(config) {
4428
4469
  this.context.pop();
4429
4470
  }
4430
4471
  return;
4431
- } else if (
4432
- this.type === tstt.jsxTagStart ||
4433
- this.input.charCodeAt(this.start) === CharCode.lessThan
4434
- ) {
4472
+ } else if (this.type === tstt.jsxTagStart) {
4435
4473
  const startPos = this.start;
4436
4474
  const startLoc = this.startLoc;
4437
- if (this.type === tstt.jsxTagStart) {
4438
- this.next();
4439
- } else {
4440
- // A control-flow block inside a native template can leave the tokenizer
4441
- // in normal JS mode, so a closing tag may arrive as a relational
4442
- // `<` token. Re-enter JSX closing-tag parsing manually.
4443
- this.pos = startPos + 1;
4444
- this.type = tstt.jsxTagStart;
4445
- this.start = startPos;
4446
- this.startLoc = startLoc;
4447
- this.exprAllowed = false;
4448
- // A genuine `jsxTagStart` pushes `tc_expr` + `tc_oTag` in its
4449
- // `updateContext`; faking the token here skips those pushes. That is
4450
- // harmless for an opening tag (the next token is the tag name), but a
4451
- // closing tag (`</`) immediately runs `context.length -= 2` in the
4452
- // slash `updateContext`, which would underflow the context stack and
4453
- // throw "Invalid array length" (e.g. `<>@if (a) { … } done</>`). Push
4454
- // the two contexts a real `jsxTagStart` would have added so the closing
4455
- // tag pops its own contexts instead of the enclosing template's.
4456
- if (this.input.charCodeAt(this.pos) === CharCode.slash) {
4457
- this.context.push(tstc.tc_expr);
4458
- this.context.push(tstc.tc_oTag);
4459
- }
4460
- this.next();
4461
- }
4475
+ this.next();
4462
4476
  if (this.value === '/' || this.type === tt.slash) {
4463
4477
  // Consume '/'
4464
4478
  this.next();
4465
4479
 
4466
- let closingElement;
4480
+ const closingNode = this.startNodeAt(startPos, startLoc);
4481
+ const inside_parent_template =
4482
+ this.#jsxExpressionContainerDepth === 0 &&
4483
+ this.#templateScriptParsingDepth === 0 &&
4484
+ this.#path.slice(0, -1).some((node) => this.#isNativeTemplateNode(node));
4467
4485
  this.#closingNativeTemplateNode = true;
4486
+ /** @type {ReturnType<Parse.Parser['jsx_parseElementName']>} */
4487
+ let closingName;
4468
4488
  try {
4469
- closingElement = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4470
- this.jsx_parseClosingElementAt(startPos, startLoc)
4471
- );
4489
+ closingName = this.jsx_parseElementName();
4472
4490
  } finally {
4473
4491
  this.#closingNativeTemplateNode = false;
4474
4492
  }
4493
+ if (closingName) {
4494
+ /** @type {ESTreeJSX.JSXClosingElement} */ (closingNode).name =
4495
+ /** @type {ESTreeJSX.JSXIdentifier} */ (closingName);
4496
+ }
4497
+ const current = /** @type {ESTreeJSX.JSXFragment | ESTreeJSX.JSXElement} */ (
4498
+ this.#path[this.#path.length - 1]
4499
+ );
4500
+ const current_name = this.#isNativeTemplateNode(current)
4501
+ ? current.type === 'JSXFragment'
4502
+ ? ''
4503
+ : current.openingElement?.name
4504
+ ? this.getElementName(current.openingElement.name)
4505
+ : null
4506
+ : null;
4507
+ const closing_name_str = !closingName
4508
+ ? ''
4509
+ : closingName.type === 'JSXNamespacedName'
4510
+ ? closingName.namespace.name + ':' + closingName.name.name
4511
+ : this.getElementName(closingName);
4512
+ if (!(inside_parent_template && current_name === closing_name_str)) {
4513
+ this.#closingNativeTemplateNode = true;
4514
+ }
4515
+ this.expect(tstt.jsxTagEnd);
4516
+ this.#closingNativeTemplateNode = false;
4517
+ const closingElement =
4518
+ /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4519
+ this.finishNode(
4520
+ closingNode,
4521
+ closingName ? 'JSXClosingElement' : 'JSXClosingFragment',
4522
+ )
4523
+ );
4475
4524
  if (this.#isDynamicJSXElementName(closingElement.name)) {
4476
4525
  /** @type {any} */ (closingElement).isDynamic = true;
4477
4526
  }
@@ -4584,7 +4633,7 @@ export function TSRXPlugin(config) {
4584
4633
  }
4585
4634
 
4586
4635
  this.#path.pop();
4587
- skipWhitespace(this);
4636
+ this.#skipTrailingLayoutWhitespace();
4588
4637
  return;
4589
4638
  }
4590
4639
  const node = this.parseElement();
@@ -260,3 +260,51 @@ export function tsx_with_ts_locations() {
260
260
 
261
261
  return { ...base, ...wrappers };
262
262
  }
263
+
264
+ /**
265
+ * @param {any} node
266
+ * @returns {boolean}
267
+ */
268
+ export function is_template_if_node(node) {
269
+ return (
270
+ node?.type === 'JSXIfExpression' ||
271
+ node?.metadata?.tsrxDirective === 'if' ||
272
+ (node?.type === 'IfStatement' && node?.statementType === 'IfStatement')
273
+ );
274
+ }
275
+
276
+ /**
277
+ * @param {any} node
278
+ * @returns {boolean}
279
+ */
280
+ export function is_template_for_of_node(node) {
281
+ return (
282
+ node?.type === 'JSXForExpression' ||
283
+ node?.metadata?.tsrxDirective === 'for' ||
284
+ (node?.type === 'ForOfStatement' && node?.statementType === 'ForOfStatement')
285
+ );
286
+ }
287
+
288
+ /**
289
+ * @param {any} node
290
+ * @returns {boolean}
291
+ */
292
+ export function is_template_switch_node(node) {
293
+ return (
294
+ node?.type === 'JSXSwitchExpression' ||
295
+ node?.metadata?.tsrxDirective === 'switch' ||
296
+ (node?.type === 'SwitchStatement' && node?.statementType === 'SwitchStatement')
297
+ );
298
+ }
299
+
300
+ /**
301
+ * @param {any} node
302
+ * @returns {boolean}
303
+ */
304
+ export function is_template_try_node(node) {
305
+ return (
306
+ node?.type === 'JSXTryExpression' ||
307
+ node?.metadata?.tsrxDirective === 'try' ||
308
+ (node?.type === 'TryStatement' && node?.statementType === 'TryStatement')
309
+ );
310
+ }
@@ -10,8 +10,11 @@ import { prune_css } from '../../analyze/prune.js';
10
10
  import {
11
11
  in_jsx_child_context,
12
12
  set_node_path_metadata,
13
- tsx_node_to_jsx_expression,
14
13
  tsx_with_ts_locations,
14
+ is_template_if_node,
15
+ is_template_for_of_node,
16
+ is_template_switch_node,
17
+ is_template_try_node,
15
18
  } from './helpers.js';
16
19
  import {
17
20
  add_extra_source_mappings_from_matching_expression,
@@ -71,6 +74,16 @@ const TSRX_IF_CONTINUE_ERROR =
71
74
  'Continue statements are not allowed inside TSRX template @if blocks. Filter before rendering or use conditional output instead.';
72
75
  const DYNAMIC_IMPORT_LOCAL = 'TsrxDynamic';
73
76
  const DYNAMIC_FACTORY_LOCAL = '_tsrx_dynamic';
77
+ const LEADING_INLINE_WHITESPACE = /^[ \t]+/;
78
+ const TRAILING_INLINE_WHITESPACE = /[ \t]+$/;
79
+
80
+ /**
81
+ * @param {string | undefined} ch
82
+ * @returns {boolean}
83
+ */
84
+ function is_newline_char(ch) {
85
+ return ch === '\n' || ch === '\r';
86
+ }
74
87
 
75
88
  /**
76
89
  * @param {AST.Node} node
@@ -2410,9 +2423,7 @@ function mark_native_pretransformed_jsx(node, seen = new Set()) {
2410
2423
  function get_tsrx_render_children(node) {
2411
2424
  return (node.children || []).filter(
2412
2425
  (/** @type {any} */ child) =>
2413
- child &&
2414
- child.type !== 'EmptyStatement' &&
2415
- (child.type !== 'JSXText' || child.value.trim() !== ''),
2426
+ child && child.type !== 'EmptyStatement' && (child.type !== 'JSXText' || child.value !== ''),
2416
2427
  );
2417
2428
  }
2418
2429
 
@@ -3396,7 +3407,9 @@ function create_element_children(children, transform_context) {
3396
3407
  const saved_inside_element_child = transform_context.inside_element_child;
3397
3408
  transform_context.inside_element_child = true;
3398
3409
  try {
3399
- return children.map((/** @type {any} */ child) => to_jsx_child(child, transform_context));
3410
+ return wrap_edge_whitespace(
3411
+ children.map((/** @type {any} */ child) => to_jsx_child(child, transform_context)),
3412
+ );
3400
3413
  } finally {
3401
3414
  transform_context.inside_element_child = saved_inside_element_child;
3402
3415
  }
@@ -3946,6 +3959,67 @@ function is_try_control_node(node) {
3946
3959
  return node?.type === 'TryStatement' || node?.type === 'JSXTryExpression';
3947
3960
  }
3948
3961
 
3962
+ /**
3963
+ * Wrap the inline whitespace at a fragment/element's content edges in `{' '}`
3964
+ * containers. A bare leading/trailing space is fragile: once the output is
3965
+ * line-wrapped (by prettier or the host JSX compiler) it becomes newline-adjacent
3966
+ * and is trimmed away, dropping a significant space. Whitespace BETWEEN siblings
3967
+ * stays bare text — it is not at an edge and is preserved as-is. Only spaces/tabs
3968
+ * are pulled out; whitespace runs containing a newline are layout indentation and
3969
+ * are left for the host compiler to collapse.
3970
+ *
3971
+ * @param {any[]} nodes
3972
+ * @returns {any[]}
3973
+ */
3974
+ export function wrap_edge_whitespace(nodes) {
3975
+ const length = nodes.length;
3976
+ if (length === 0) {
3977
+ return nodes;
3978
+ }
3979
+
3980
+ const first = nodes[0];
3981
+ const last = nodes[length - 1];
3982
+ if (first?.type !== 'JSXText' && last?.type !== 'JSXText') {
3983
+ return nodes;
3984
+ }
3985
+
3986
+ /** @type {(ESTreeJSX.JSXExpressionContainer | ESTreeJSX.JSXText)[]} */
3987
+ const out = [];
3988
+ for (let i = 0; i < length; i++) {
3989
+ const node = nodes[i];
3990
+ const at_start = i === 0;
3991
+ const at_end = i === length - 1;
3992
+ if (!node || node.type !== 'JSXText' || (!at_start && !at_end)) {
3993
+ out.push(node);
3994
+ continue;
3995
+ }
3996
+ let value = /** @type {string} */ (node.value);
3997
+ if (at_start) {
3998
+ const lead = LEADING_INLINE_WHITESPACE.exec(value);
3999
+ if (lead && !is_newline_char(value[lead[0].length])) {
4000
+ out.push(to_jsx_expression_container(b.literal(lead[0]), node));
4001
+ value = value.slice(lead[0].length);
4002
+ }
4003
+ }
4004
+ /** @type {ESTreeJSX.JSXExpressionContainer | null} */
4005
+ let trailing = null;
4006
+ if (at_end) {
4007
+ const trail = TRAILING_INLINE_WHITESPACE.exec(value);
4008
+ if (trail && !is_newline_char(value[value.length - trail[0].length - 1])) {
4009
+ trailing = to_jsx_expression_container(b.literal(trail[0]), node);
4010
+ value = value.slice(0, value.length - trail[0].length);
4011
+ }
4012
+ }
4013
+ if (value !== '') {
4014
+ out.push(b.jsx_text(value, value));
4015
+ }
4016
+ if (trailing) {
4017
+ out.push(trailing);
4018
+ }
4019
+ }
4020
+ return out;
4021
+ }
4022
+
3949
4023
  /**
3950
4024
  * @param {any} node
3951
4025
  * @param {TransformContext} transform_context
@@ -4031,15 +4105,15 @@ function to_jsx_child(node, transform_context) {
4031
4105
  function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = false) {
4032
4106
  const children = (node.children || []).filter(
4033
4107
  (/** @type {any} */ child) =>
4034
- child &&
4035
- child.type !== 'EmptyStatement' &&
4036
- (child.type !== 'JSXText' || child.value.trim() !== ''),
4108
+ child && child.type !== 'EmptyStatement' && (child.type !== 'JSXText' || child.value !== ''),
4037
4109
  );
4038
4110
 
4039
4111
  /** @type {any} */
4040
4112
  let expression;
4041
4113
  if (children.length === 0) {
4042
- expression = create_null_literal();
4114
+ expression = in_jsx_child
4115
+ ? set_loc(b.jsx_fragment([]), node.loc ? node : undefined)
4116
+ : create_null_literal();
4043
4117
  } else {
4044
4118
  expression = return_value_body_to_expression(children, node, transform_context);
4045
4119
  }
@@ -4049,10 +4123,10 @@ function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = fal
4049
4123
  const saved_inside_element_child = transform_context.inside_element_child;
4050
4124
  transform_context.inside_element_child = true;
4051
4125
  try {
4052
- const render_nodes = children.map((/** @type {any} */ child) =>
4053
- to_jsx_child(child, transform_context),
4126
+ const render_nodes = wrap_edge_whitespace(
4127
+ children.map((/** @type {any} */ child) => to_jsx_child(child, transform_context)),
4054
4128
  );
4055
- expression = build_return_expression(render_nodes) || create_null_literal();
4129
+ expression = build_return_expression(render_nodes, in_jsx_child) || create_null_literal();
4056
4130
  } finally {
4057
4131
  transform_context.inside_element_child = saved_inside_element_child;
4058
4132
  }
@@ -4485,54 +4559,6 @@ function validate_if_body_control_flow(node, transform_context) {
4485
4559
  }
4486
4560
  }
4487
4561
 
4488
- /**
4489
- * @param {any} node
4490
- * @returns {boolean}
4491
- */
4492
- function is_template_if_node(node) {
4493
- return (
4494
- node?.type === 'JSXIfExpression' ||
4495
- node?.metadata?.tsrxDirective === 'if' ||
4496
- (node?.type === 'IfStatement' && node?.statementType === 'IfStatement')
4497
- );
4498
- }
4499
-
4500
- /**
4501
- * @param {any} node
4502
- * @returns {boolean}
4503
- */
4504
- function is_template_for_of_node(node) {
4505
- return (
4506
- node?.type === 'JSXForExpression' ||
4507
- node?.metadata?.tsrxDirective === 'for' ||
4508
- (node?.type === 'ForOfStatement' && node?.statementType === 'ForOfStatement')
4509
- );
4510
- }
4511
-
4512
- /**
4513
- * @param {any} node
4514
- * @returns {boolean}
4515
- */
4516
- function is_template_switch_node(node) {
4517
- return (
4518
- node?.type === 'JSXSwitchExpression' ||
4519
- node?.metadata?.tsrxDirective === 'switch' ||
4520
- (node?.type === 'SwitchStatement' && node?.statementType === 'SwitchStatement')
4521
- );
4522
- }
4523
-
4524
- /**
4525
- * @param {any} node
4526
- * @returns {boolean}
4527
- */
4528
- function is_template_try_node(node) {
4529
- return (
4530
- node?.type === 'JSXTryExpression' ||
4531
- node?.metadata?.tsrxDirective === 'try' ||
4532
- (node?.type === 'TryStatement' && node?.statementType === 'TryStatement')
4533
- );
4534
- }
4535
-
4536
4562
  /**
4537
4563
  * @param {any} node
4538
4564
  * @returns {boolean}
@@ -5884,9 +5910,10 @@ function value_has_unmappable_jsx_loc(value) {
5884
5910
 
5885
5911
  /**
5886
5912
  * @param {any[]} render_nodes
5913
+ * @param {boolean} [in_jsx_child]
5887
5914
  * @returns {any}
5888
5915
  */
5889
- function build_return_expression(render_nodes) {
5916
+ function build_return_expression(render_nodes, in_jsx_child = false) {
5890
5917
  if (render_nodes.length === 0) return null;
5891
5918
  if (render_nodes.length === 1) {
5892
5919
  const only = render_nodes[0];
@@ -5900,6 +5927,9 @@ function build_return_expression(render_nodes) {
5900
5927
  return only.expression;
5901
5928
  }
5902
5929
  if (only.type === 'JSXText') {
5930
+ if (in_jsx_child) {
5931
+ return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
5932
+ }
5903
5933
  const value = (only.value ?? '').trim();
5904
5934
  return b.literal(value, JSON.stringify(value), only);
5905
5935
  }
@@ -368,6 +368,26 @@ export function literal(value, raw, loc_info) {
368
368
  return set_location(node, loc_info);
369
369
  }
370
370
 
371
+ /**
372
+ * Ripple template `Text` node wrapping a string literal of `value`. Used by the
373
+ * Ripple normalizer when lowering a JSX text child to a template Text node.
374
+ *
375
+ * Remove once Ripple transformers move to the parser's JSX AST
376
+ *
377
+ * @param {string} value
378
+ * @param {AST.NodeWithLocation} [loc_info]
379
+ * @returns {AST.Text}
380
+ */
381
+ export function text(value, loc_info) {
382
+ const node = /** @type {AST.Text} */ ({
383
+ type: 'Text',
384
+ expression: literal(value, JSON.stringify(value), loc_info),
385
+ metadata: { path: [] },
386
+ });
387
+
388
+ return set_location(node, loc_info);
389
+ }
390
+
371
391
  /**
372
392
  * @param {AST.Expression | AST.Super} object
373
393
  * @param {string | AST.Expression | AST.PrivateIdentifier} property