@tsrx/core 0.1.31 → 0.1.33

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.33",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
package/src/constants.js CHANGED
@@ -2,6 +2,10 @@ export const TEMPLATE_FRAGMENT = 1;
2
2
  export const TEMPLATE_USE_IMPORT_NODE = 1 << 1;
3
3
  export const IS_CONTROLLED = 1 << 2;
4
4
  export const IS_INDEXED = 1 << 3;
5
+ // A control-flow block that is its component's sole root: renders before the
6
+ // parent `__anchor` (sibling semantics, no `<!>` wrapper). Must match the value
7
+ // in ripple's runtime constants.
8
+ export const ROOT_CONTROLLED = 1 << 4;
5
9
  export const TEMPLATE_SVG_NAMESPACE = 1 << 5;
6
10
  export const TEMPLATE_MATHML_NAMESPACE = 1 << 6;
7
11
 
package/src/index.js CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  TEMPLATE_USE_IMPORT_NODE,
32
32
  IS_CONTROLLED,
33
33
  IS_INDEXED,
34
+ ROOT_CONTROLLED,
34
35
  TEMPLATE_SVG_NAMESPACE,
35
36
  TEMPLATE_MATHML_NAMESPACE,
36
37
  HYDRATION_START,
@@ -147,6 +148,7 @@ export {
147
148
  create_hook_safe_helper as createHookSafeHelper,
148
149
  create_element_ref_target_type as createElementRefTargetType,
149
150
  create_element_ref_target_type_for_name as createElementRefTargetTypeForName,
151
+ build_return_expression as buildReturnExpression,
150
152
  createJsxTransform,
151
153
  extract_jsx_setup_declarations as extractJsxSetupDeclarations,
152
154
  is_component_like_element,
@@ -159,10 +161,11 @@ export {
159
161
  rewrite_loop_continues_to_bare_returns as rewriteLoopContinuesToBareReturns,
160
162
  to_jsx_attribute as toJsxAttribute,
161
163
  validate_at_most_one_ref_attribute as validateAtMostOneRefAttribute,
164
+ wrap_edge_whitespace as wrapEdgeWhitespace,
162
165
  } from './transform/jsx/index.js';
163
166
  export {
164
167
  in_jsx_child_context as inJsxChildContext,
165
- tsx_node_to_jsx_expression as tsxNodeToJsxExpression,
168
+ is_empty_jsx_fragment as isEmptyJsxFragment,
166
169
  tsx_with_ts_locations as tsxWithTsLocations,
167
170
  is_template_if_node as isTemplateIfNode,
168
171
  is_template_for_of_node as isTemplateForOfNode,
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';
@@ -237,6 +232,7 @@ export function TSRXPlugin(config) {
237
232
  // If we push an undefined context, Acorn's tokenizer will later crash reading `.override`.
238
233
  const b_stat = tc.b_stat || acorn.tokContexts.b_stat;
239
234
  const b_expr = tc.b_expr || acorn.tokContexts.b_expr;
235
+ const q_tmpl = tc.q_tmpl || acorn.tokContexts.q_tmpl;
240
236
  const tstt = Parser.acornTypeScript.tokTypes;
241
237
  const tstc = Parser.acornTypeScript.tokContexts;
242
238
 
@@ -549,7 +545,6 @@ export function TSRXPlugin(config) {
549
545
  while (index < this.input.length) {
550
546
  if (this.#isTemplateLineCommentStart(index, start)) {
551
547
  const comment_start = index;
552
- const comment_start_loc = acorn.getLineInfo(this.input, comment_start);
553
548
  index += 2;
554
549
  while (
555
550
  index < this.input.length &&
@@ -558,20 +553,9 @@ export function TSRXPlugin(config) {
558
553
  ) {
559
554
  index++;
560
555
  }
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
- );
556
+
557
+ if (comment_start >= token_end) {
558
+ this.#emitTemplateLineComment(comment_start, index, null);
575
559
  }
576
560
  continue;
577
561
  }
@@ -590,7 +574,7 @@ export function TSRXPlugin(config) {
590
574
  index,
591
575
  new acorn.Position(comment_start_loc.line, comment_start_loc.column),
592
576
  new acorn.Position(comment_end_loc.line, comment_end_loc.column),
593
- /** @type {any} */ (null),
577
+ null,
594
578
  );
595
579
  }
596
580
  continue;
@@ -640,6 +624,62 @@ export function TSRXPlugin(config) {
640
624
  return node.value !== '' && !regex_newline_characters.test(node.value);
641
625
  }
642
626
 
627
+ #skipTrailingLayoutWhitespace() {
628
+ let index = this.start;
629
+ let has_newline = false;
630
+ while (index < this.input.length) {
631
+ const ch = this.input.charCodeAt(index);
632
+ if (ch === CharCode.lineFeed || ch === CharCode.carriageReturn) {
633
+ has_newline = true;
634
+ index++;
635
+ } else if (ch === CharCode.space || ch === CharCode.tab) {
636
+ index++;
637
+ } else if (ch === CharCode.slash && this.input.charCodeAt(index + 1) === CharCode.slash) {
638
+ const comment_start = index;
639
+ while (index < this.input.length && !this.#isNewlineCharCode(index)) {
640
+ index++;
641
+ }
642
+ this.#emitTemplateLineComment(comment_start, index, null);
643
+ } else {
644
+ break;
645
+ }
646
+ }
647
+ if (!has_newline) return;
648
+ const loc = acorn.getLineInfo(this.input, index);
649
+ this.start = index;
650
+ this.startLoc = new acorn.Position(loc.line, loc.column);
651
+ if (this.pos <= index) {
652
+ this.curLine = loc.line;
653
+ this.lineStart = index - loc.column;
654
+ }
655
+ }
656
+
657
+ /**
658
+ * @param {number} index
659
+ */
660
+ #isNewlineCharCode(index) {
661
+ const ch = this.input.charCodeAt(index);
662
+ return ch === CharCode.lineFeed || ch === CharCode.carriageReturn;
663
+ }
664
+
665
+ /**
666
+ * @param {number} start
667
+ * @param {number} end
668
+ * @param {Parse.CommentMetaData | null} metadata
669
+ */
670
+ #emitTemplateLineComment(start, end, metadata) {
671
+ if (!this.options.onComment) return;
672
+ this.options.onComment(
673
+ false,
674
+ this.input.slice(start + 2, end),
675
+ start,
676
+ end,
677
+ acorn.getLineInfo(this.input, start),
678
+ acorn.getLineInfo(this.input, end),
679
+ metadata,
680
+ );
681
+ }
682
+
643
683
  #isSwitchCaseScriptStatementStart() {
644
684
  let index = skip_whitespace_from(this.input, this.start);
645
685
 
@@ -1233,14 +1273,21 @@ export function TSRXPlugin(config) {
1233
1273
  */
1234
1274
  #parseCodeBlockSetupStatement() {
1235
1275
  const previous_context = this.context;
1236
- this.context = previous_context.filter(
1237
- (context) =>
1238
- context !== tstc.tc_expr && context !== tstc.tc_oTag && context !== tstc.tc_cTag,
1239
- );
1276
+ const at_template_literal = this.type === tt.backQuote;
1240
1277
  let pushed_statement_context = false;
1241
- if (this.curContext() !== b_stat) {
1242
- this.context.push(b_stat);
1243
- pushed_statement_context = true;
1278
+ if (at_template_literal) {
1279
+ if (this.curContext() !== q_tmpl) {
1280
+ this.context.push(q_tmpl);
1281
+ }
1282
+ } else {
1283
+ this.context = previous_context.filter(
1284
+ (context) =>
1285
+ context !== tstc.tc_expr && context !== tstc.tc_oTag && context !== tstc.tc_cTag,
1286
+ );
1287
+ if (this.curContext() !== b_stat) {
1288
+ this.context.push(b_stat);
1289
+ pushed_statement_context = true;
1290
+ }
1244
1291
  }
1245
1292
  this.exprAllowed = true;
1246
1293
  const previous_path = this.#path;
@@ -1248,22 +1295,7 @@ export function TSRXPlugin(config) {
1248
1295
  this.#templateScriptParsingDepth++;
1249
1296
  let node;
1250
1297
  try {
1251
- // A code-block/directive body is statements plus at most one render node —
1252
- // never bare text or markup tokens. If the tokenizer mis-read trailing
1253
- // code as JSX (raw text or a tag-name token — both can happen for a
1254
- // statement following the render node, depending on the leftover context),
1255
- // reposition to the token start and re-read it as code now that the
1256
- // template path is hidden. It then parses as a statement so the
1257
- // one-render-node rule reports a clear "statements cannot follow" error
1258
- // instead of a generic parse fault.
1259
1298
  if (this.type === tstt.jsxText || this.type === tstt.jsxName) {
1260
- // Rewinding `pos` to the mis-read token's start must also rewind the
1261
- // line counter: a `jsxText` token can span newlines (e.g. the blank
1262
- // line before a following render node), and reading it already
1263
- // advanced `curLine`/`lineStart` to its end. Resetting only `pos`
1264
- // would leave the line counter ahead of `pos`, inflating the `loc`
1265
- // of this statement and every node after it (which crashes source-map
1266
- // mapping when the inflated end line runs past the file).
1267
1299
  const loc = acorn.getLineInfo(this.input, this.start);
1268
1300
  this.pos = this.start;
1269
1301
  this.curLine = loc.line;
@@ -1277,7 +1309,9 @@ export function TSRXPlugin(config) {
1277
1309
  if (pushed_statement_context && this.curContext() === b_stat) {
1278
1310
  this.context.pop();
1279
1311
  }
1280
- this.context = previous_context;
1312
+ if (!at_template_literal) {
1313
+ this.context = previous_context;
1314
+ }
1281
1315
  }
1282
1316
  if (this.curContext() === tstc.tc_expr) {
1283
1317
  this.context.pop();
@@ -4344,22 +4378,29 @@ export function TSRXPlugin(config) {
4344
4378
  parseTemplateBody(body) {
4345
4379
  const current_template_node = this.#currentNativeTemplateNode();
4346
4380
  if (!current_template_node) return;
4347
- // Outside a `@{ … }` block every element/fragment body is plain JSX (§2,
4348
- // §5). There is no script section and no `---` fence to infer — text is
4349
- // text, and setup code lives only inside a code block.
4350
4381
  current_template_node.metadata ??= { path: [] };
4351
4382
  current_template_node.metadata.templateMode = 'template';
4352
4383
 
4353
- // `@{ … }` code block as element/fragment content (§2 rule 1). Sibling
4354
- // code blocks are allowed, so this is not gated on an empty body;
4355
- // reposition onto the `@` if leading whitespace was tokenized ahead of it.
4356
4384
  if (this.#atCodeBlockStart()) {
4357
4385
  const at_index = skip_whitespace_from(this.input, this.start);
4358
4386
  if (this.start !== at_index) {
4387
+ const ws_start = this.start;
4388
+ const ws_start_loc = this.startLoc;
4389
+ const ws_value = this.input.slice(ws_start, at_index);
4390
+ const text_node = /** @type {ESTreeJSX.JSXText} */ (
4391
+ this.startNodeAt(ws_start, ws_start_loc)
4392
+ );
4393
+ text_node.value = ws_value;
4394
+ text_node.raw = ws_value;
4359
4395
  const loc = acorn.getLineInfo(this.input, at_index);
4396
+ const at_position = new acorn.Position(loc.line, loc.column);
4397
+ this.finishNodeAt(text_node, 'JSXText', at_index, at_position);
4398
+ if (this.#shouldKeepTemplateTextNode(text_node)) {
4399
+ body.push(text_node);
4400
+ }
4360
4401
  this.pos = at_index;
4361
4402
  this.start = at_index;
4362
- this.startLoc = new acorn.Position(loc.line, loc.column);
4403
+ this.startLoc = at_position;
4363
4404
  this.curLine = loc.line;
4364
4405
  this.lineStart = at_index - loc.column;
4365
4406
  }
@@ -4377,8 +4418,23 @@ export function TSRXPlugin(config) {
4377
4418
  // text never starts at `<`, so drop the leaked context and re-read the
4378
4419
  // tag instead of emitting an empty node.
4379
4420
  if (this.input.charCodeAt(this.start) === CharCode.lessThan) {
4380
- while (this.curContext() === tstc.tc_expr) {
4381
- this.context.pop();
4421
+ if (this.input.charCodeAt(this.start + 1) === CharCode.slash) {
4422
+ while (this.curContext() === tstc.tc_expr) {
4423
+ this.context.pop();
4424
+ }
4425
+ } else {
4426
+ let native_depth = 0;
4427
+ for (const node of this.#path) {
4428
+ if (this.#isNativeTemplateNode(node)) native_depth++;
4429
+ }
4430
+ let tc_expr_depth = 0;
4431
+ for (const context of this.context) {
4432
+ if (context === tstc.tc_expr) tc_expr_depth++;
4433
+ }
4434
+ while (tc_expr_depth > native_depth && this.curContext() === tstc.tc_expr) {
4435
+ this.context.pop();
4436
+ tc_expr_depth--;
4437
+ }
4382
4438
  }
4383
4439
  this.pos = this.start;
4384
4440
  this.exprAllowed = true;
@@ -4430,50 +4486,58 @@ export function TSRXPlugin(config) {
4430
4486
  this.context.pop();
4431
4487
  }
4432
4488
  return;
4433
- } else if (
4434
- this.type === tstt.jsxTagStart ||
4435
- this.input.charCodeAt(this.start) === CharCode.lessThan
4436
- ) {
4489
+ } else if (this.type === tstt.jsxTagStart) {
4437
4490
  const startPos = this.start;
4438
4491
  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
- }
4492
+ this.next();
4464
4493
  if (this.value === '/' || this.type === tt.slash) {
4465
4494
  // Consume '/'
4466
4495
  this.next();
4467
4496
 
4468
- let closingElement;
4497
+ const closingNode = this.startNodeAt(startPos, startLoc);
4498
+ const inside_parent_template =
4499
+ this.#jsxExpressionContainerDepth === 0 &&
4500
+ this.#templateScriptParsingDepth === 0 &&
4501
+ this.#path.slice(0, -1).some((node) => this.#isNativeTemplateNode(node));
4469
4502
  this.#closingNativeTemplateNode = true;
4503
+ /** @type {ReturnType<Parse.Parser['jsx_parseElementName']>} */
4504
+ let closingName;
4470
4505
  try {
4471
- closingElement = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4472
- this.jsx_parseClosingElementAt(startPos, startLoc)
4473
- );
4506
+ closingName = this.jsx_parseElementName();
4474
4507
  } finally {
4475
4508
  this.#closingNativeTemplateNode = false;
4476
4509
  }
4510
+ if (closingName) {
4511
+ /** @type {ESTreeJSX.JSXClosingElement} */ (closingNode).name =
4512
+ /** @type {ESTreeJSX.JSXIdentifier} */ (closingName);
4513
+ }
4514
+ const current = /** @type {ESTreeJSX.JSXFragment | ESTreeJSX.JSXElement} */ (
4515
+ this.#path[this.#path.length - 1]
4516
+ );
4517
+ const current_name = this.#isNativeTemplateNode(current)
4518
+ ? current.type === 'JSXFragment'
4519
+ ? ''
4520
+ : current.openingElement?.name
4521
+ ? this.getElementName(current.openingElement.name)
4522
+ : null
4523
+ : null;
4524
+ const closing_name_str = !closingName
4525
+ ? ''
4526
+ : closingName.type === 'JSXNamespacedName'
4527
+ ? closingName.namespace.name + ':' + closingName.name.name
4528
+ : this.getElementName(closingName);
4529
+ if (!(inside_parent_template && current_name === closing_name_str)) {
4530
+ this.#closingNativeTemplateNode = true;
4531
+ }
4532
+ this.expect(tstt.jsxTagEnd);
4533
+ this.#closingNativeTemplateNode = false;
4534
+ const closingElement =
4535
+ /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4536
+ this.finishNode(
4537
+ closingNode,
4538
+ closingName ? 'JSXClosingElement' : 'JSXClosingFragment',
4539
+ )
4540
+ );
4477
4541
  if (this.#isDynamicJSXElementName(closingElement.name)) {
4478
4542
  /** @type {any} */ (closingElement).isDynamic = true;
4479
4543
  }
@@ -4586,7 +4650,7 @@ export function TSRXPlugin(config) {
4586
4650
  }
4587
4651
 
4588
4652
  this.#path.pop();
4589
- skipWhitespace(this);
4653
+ this.#skipTrailingLayoutWhitespace();
4590
4654
  return;
4591
4655
  }
4592
4656
  const node = this.parseElement();
@@ -19,6 +19,20 @@ export function in_jsx_child_context(path) {
19
19
  return !!parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment');
20
20
  }
21
21
 
22
+ /**
23
+ * @param {any} node
24
+ * @returns {boolean}
25
+ */
26
+ export function is_empty_jsx_fragment(node) {
27
+ return (
28
+ node?.type === 'JSXFragment' &&
29
+ !(node.children || []).some(
30
+ (/** @type {any} */ child) =>
31
+ child && (child.type !== 'JSXText' || child.value.trim() !== ''),
32
+ )
33
+ );
34
+ }
35
+
22
36
  /**
23
37
  * Match Ripple's transform path metadata shape: every node seen by the walker
24
38
  * carries its current ancestor path for downstream CSS pruning and mapping
@@ -36,45 +50,6 @@ export function set_node_path_metadata(node, path) {
36
50
  }
37
51
  }
38
52
 
39
- /**
40
- * Flatten a JSX-compatible island's children into a single expression. In a
41
- * JSX-child position, a JSXExpressionContainer `{expr}` is valid and must stay
42
- * wrapped. In an expression position (e.g. `return ...`), `{expr}` parses as
43
- * a block/object literal, so unwrap to `expr`.
44
- *
45
- * @param {any} node
46
- * @param {boolean} [in_jsx_child]
47
- * @returns {any}
48
- */
49
- export function tsx_node_to_jsx_expression(node, in_jsx_child = false) {
50
- const children = (node.children || []).filter(
51
- (/** @type {any} */ child) => child.type !== 'JSXText' || child.value.trim() !== '',
52
- );
53
-
54
- if (
55
- children.length === 1 &&
56
- children[0].type !== 'JSXText' &&
57
- // Reactive-block containers (dynamic tags) must stay expression
58
- // children so the host JSX compiler wraps them in a render block;
59
- // unwrapping to a bare call would evaluate them once.
60
- children[0].metadata?.tsrx_reactive_block !== true
61
- ) {
62
- const only = children[0];
63
- if (only.type === 'JSXExpressionContainer' && !in_jsx_child) {
64
- return only.expression;
65
- }
66
- return only;
67
- }
68
-
69
- return /** @type {any} */ ({
70
- type: 'JSXFragment',
71
- openingFragment: { type: 'JSXOpeningFragment', metadata: { path: [] } },
72
- closingFragment: { type: 'JSXClosingFragment', metadata: { path: [] } },
73
- children,
74
- metadata: { path: [] },
75
- });
76
- }
77
-
78
53
  /**
79
54
  * Wrap esrap's `tsx()` printer with location markers for nodes whose spans
80
55
  * (e.g. the leading `new ` of a NewExpression or the angle-bracket delimiters