@tsrx/core 0.1.31 → 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.31",
6
+ "version": "0.1.32",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
package/src/index.js CHANGED
@@ -159,6 +159,7 @@ 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,
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
 
@@ -4430,50 +4469,58 @@ export function TSRXPlugin(config) {
4430
4469
  this.context.pop();
4431
4470
  }
4432
4471
  return;
4433
- } else if (
4434
- this.type === tstt.jsxTagStart ||
4435
- this.input.charCodeAt(this.start) === CharCode.lessThan
4436
- ) {
4472
+ } else if (this.type === tstt.jsxTagStart) {
4437
4473
  const startPos = this.start;
4438
4474
  const startLoc = this.startLoc;
4439
- if (this.type === tstt.jsxTagStart) {
4440
- this.next();
4441
- } else {
4442
- // A control-flow block inside a native template can leave the tokenizer
4443
- // in normal JS mode, so a closing tag may arrive as a relational
4444
- // `<` token. Re-enter JSX closing-tag parsing manually.
4445
- this.pos = startPos + 1;
4446
- this.type = tstt.jsxTagStart;
4447
- this.start = startPos;
4448
- this.startLoc = startLoc;
4449
- this.exprAllowed = false;
4450
- // A genuine `jsxTagStart` pushes `tc_expr` + `tc_oTag` in its
4451
- // `updateContext`; faking the token here skips those pushes. That is
4452
- // harmless for an opening tag (the next token is the tag name), but a
4453
- // closing tag (`</`) immediately runs `context.length -= 2` in the
4454
- // slash `updateContext`, which would underflow the context stack and
4455
- // throw "Invalid array length" (e.g. `<>@if (a) { … } done</>`). Push
4456
- // the two contexts a real `jsxTagStart` would have added so the closing
4457
- // tag pops its own contexts instead of the enclosing template's.
4458
- if (this.input.charCodeAt(this.pos) === CharCode.slash) {
4459
- this.context.push(tstc.tc_expr);
4460
- this.context.push(tstc.tc_oTag);
4461
- }
4462
- this.next();
4463
- }
4475
+ this.next();
4464
4476
  if (this.value === '/' || this.type === tt.slash) {
4465
4477
  // Consume '/'
4466
4478
  this.next();
4467
4479
 
4468
- 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));
4469
4485
  this.#closingNativeTemplateNode = true;
4486
+ /** @type {ReturnType<Parse.Parser['jsx_parseElementName']>} */
4487
+ let closingName;
4470
4488
  try {
4471
- closingElement = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4472
- this.jsx_parseClosingElementAt(startPos, startLoc)
4473
- );
4489
+ closingName = this.jsx_parseElementName();
4474
4490
  } finally {
4475
4491
  this.#closingNativeTemplateNode = false;
4476
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
+ );
4477
4524
  if (this.#isDynamicJSXElementName(closingElement.name)) {
4478
4525
  /** @type {any} */ (closingElement).isDynamic = true;
4479
4526
  }
@@ -4586,7 +4633,7 @@ export function TSRXPlugin(config) {
4586
4633
  }
4587
4634
 
4588
4635
  this.#path.pop();
4589
- skipWhitespace(this);
4636
+ this.#skipTrailingLayoutWhitespace();
4590
4637
  return;
4591
4638
  }
4592
4639
  const node = this.parseElement();
@@ -74,6 +74,16 @@ const TSRX_IF_CONTINUE_ERROR =
74
74
  'Continue statements are not allowed inside TSRX template @if blocks. Filter before rendering or use conditional output instead.';
75
75
  const DYNAMIC_IMPORT_LOCAL = 'TsrxDynamic';
76
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
+ }
77
87
 
78
88
  /**
79
89
  * @param {AST.Node} node
@@ -2413,9 +2423,7 @@ function mark_native_pretransformed_jsx(node, seen = new Set()) {
2413
2423
  function get_tsrx_render_children(node) {
2414
2424
  return (node.children || []).filter(
2415
2425
  (/** @type {any} */ child) =>
2416
- child &&
2417
- child.type !== 'EmptyStatement' &&
2418
- (child.type !== 'JSXText' || child.value.trim() !== ''),
2426
+ child && child.type !== 'EmptyStatement' && (child.type !== 'JSXText' || child.value !== ''),
2419
2427
  );
2420
2428
  }
2421
2429
 
@@ -3399,7 +3407,9 @@ function create_element_children(children, transform_context) {
3399
3407
  const saved_inside_element_child = transform_context.inside_element_child;
3400
3408
  transform_context.inside_element_child = true;
3401
3409
  try {
3402
- 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
+ );
3403
3413
  } finally {
3404
3414
  transform_context.inside_element_child = saved_inside_element_child;
3405
3415
  }
@@ -3949,6 +3959,67 @@ function is_try_control_node(node) {
3949
3959
  return node?.type === 'TryStatement' || node?.type === 'JSXTryExpression';
3950
3960
  }
3951
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
+
3952
4023
  /**
3953
4024
  * @param {any} node
3954
4025
  * @param {TransformContext} transform_context
@@ -4034,15 +4105,15 @@ function to_jsx_child(node, transform_context) {
4034
4105
  function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = false) {
4035
4106
  const children = (node.children || []).filter(
4036
4107
  (/** @type {any} */ child) =>
4037
- child &&
4038
- child.type !== 'EmptyStatement' &&
4039
- (child.type !== 'JSXText' || child.value.trim() !== ''),
4108
+ child && child.type !== 'EmptyStatement' && (child.type !== 'JSXText' || child.value !== ''),
4040
4109
  );
4041
4110
 
4042
4111
  /** @type {any} */
4043
4112
  let expression;
4044
4113
  if (children.length === 0) {
4045
- expression = create_null_literal();
4114
+ expression = in_jsx_child
4115
+ ? set_loc(b.jsx_fragment([]), node.loc ? node : undefined)
4116
+ : create_null_literal();
4046
4117
  } else {
4047
4118
  expression = return_value_body_to_expression(children, node, transform_context);
4048
4119
  }
@@ -4052,10 +4123,10 @@ function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = fal
4052
4123
  const saved_inside_element_child = transform_context.inside_element_child;
4053
4124
  transform_context.inside_element_child = true;
4054
4125
  try {
4055
- const render_nodes = children.map((/** @type {any} */ child) =>
4056
- 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)),
4057
4128
  );
4058
- expression = build_return_expression(render_nodes) || create_null_literal();
4129
+ expression = build_return_expression(render_nodes, in_jsx_child) || create_null_literal();
4059
4130
  } finally {
4060
4131
  transform_context.inside_element_child = saved_inside_element_child;
4061
4132
  }
@@ -5839,9 +5910,10 @@ function value_has_unmappable_jsx_loc(value) {
5839
5910
 
5840
5911
  /**
5841
5912
  * @param {any[]} render_nodes
5913
+ * @param {boolean} [in_jsx_child]
5842
5914
  * @returns {any}
5843
5915
  */
5844
- function build_return_expression(render_nodes) {
5916
+ function build_return_expression(render_nodes, in_jsx_child = false) {
5845
5917
  if (render_nodes.length === 0) return null;
5846
5918
  if (render_nodes.length === 1) {
5847
5919
  const only = render_nodes[0];
@@ -5855,6 +5927,9 @@ function build_return_expression(render_nodes) {
5855
5927
  return only.expression;
5856
5928
  }
5857
5929
  if (only.type === 'JSXText') {
5930
+ if (in_jsx_child) {
5931
+ return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
5932
+ }
5858
5933
  const value = (only.value ?? '').trim();
5859
5934
  return b.literal(value, JSON.stringify(value), only);
5860
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