@tsrx/core 0.0.13 → 0.0.15

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/README.md CHANGED
@@ -61,15 +61,20 @@ component Button(props: Props) {
61
61
  ### 2. JSX-as-statements
62
62
 
63
63
  Inside a `component` body, JSX elements are valid _statement_ forms. They describe
64
- rendered output and are not expressions — they have no value.
64
+ rendered output and are not expressions — they have no value. Static text may be
65
+ written as a direct double-quoted child; dynamic values and other JavaScript
66
+ expressions stay inside `{}`.
65
67
 
66
68
  ```tsx
67
69
  component Greeting() {
68
- <h1>{'Hello'}</h1>
69
- <p>{'Welcome'}</p>
70
+ <h1>"Hello"</h1>
71
+ <p>"Welcome"</p>
70
72
  }
71
73
  ```
72
74
 
75
+ Only double quotes have direct-child text meaning. Single-quoted strings and
76
+ template literals remain JavaScript expressions and must be written inside `{}`.
77
+
73
78
  Elsewhere (outside a `component` body), JSX remains an expression, as in standard
74
79
  JSX.
75
80
 
@@ -84,9 +89,9 @@ introduced — but framework compilers treat them as _reactive_ boundaries.
84
89
  ```tsx
85
90
  component List(props: { items: Item[]; showHeader: boolean }) {
86
91
  if (props.showHeader) {
87
- <h1>{'Items'}</h1>
92
+ <h1>"Items"</h1>
88
93
  } else {
89
- <h2>{'(no header)'}</h2>
94
+ <h2>"(no header)"</h2>
90
95
  }
91
96
 
92
97
  for (const item of props.items) {
@@ -95,10 +100,10 @@ component List(props: { items: Item[]; showHeader: boolean }) {
95
100
 
96
101
  switch (props.items.length) {
97
102
  case 0:
98
- <p>{'empty'}</p>
103
+ <p>"empty"</p>
99
104
  break;
100
105
  default:
101
- <p>{'has items'}</p>
106
+ <p>"has items"</p>
102
107
  }
103
108
 
104
109
  try {
@@ -169,7 +174,7 @@ children).
169
174
 
170
175
  ```tsx
171
176
  component Page() {
172
- const header = <tsx><h1>{'Hello'}</h1></tsx>;
177
+ const header = <tsx><h1>Hello</h1></tsx>;
173
178
  renderSomewhereElse(header);
174
179
  }
175
180
  ```
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.0.13",
6
+ "version": "0.0.15",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -5,6 +5,9 @@
5
5
 
6
6
  import { error } from '../errors.js';
7
7
 
8
+ export const COMPONENT_RETURN_VALUE_ERROR =
9
+ 'Return statements inside components cannot have a return value.';
10
+
8
11
  const invalid_nestings = {
9
12
  // <p> cannot contain block-level elements
10
13
  p: new Set([
@@ -125,6 +128,50 @@ function get_element_tag(element) {
125
128
  return element.id.type === 'Identifier' ? element.id.name : null;
126
129
  }
127
130
 
131
+ /**
132
+ * @param {AST.ReturnStatement} node
133
+ * @returns {AST.ReturnStatement}
134
+ */
135
+ export function get_return_keyword_node(node) {
136
+ const return_keyword_length = 'return'.length;
137
+ const start = /** @type {AST.NodeWithLocation} */ (node).start ?? 0;
138
+ const loc = /** @type {AST.NodeWithLocation} */ (node).loc;
139
+
140
+ return /** @type {AST.ReturnStatement} */ ({
141
+ ...node,
142
+ end: start + return_keyword_length,
143
+ loc: loc
144
+ ? {
145
+ start: loc.start,
146
+ end: {
147
+ line: loc.start.line,
148
+ column: loc.start.column + return_keyword_length,
149
+ },
150
+ }
151
+ : undefined,
152
+ });
153
+ }
154
+
155
+ /**
156
+ * @param {AST.ReturnStatement} node
157
+ * @param {string | null | undefined} filename
158
+ * @param {CompileError[]} [errors]
159
+ * @param {AST.CommentWithLocation[]} [comments]
160
+ */
161
+ export function validate_component_return_statement(node, filename, errors, comments) {
162
+ if (node.argument === null) {
163
+ return;
164
+ }
165
+
166
+ error(
167
+ COMPONENT_RETURN_VALUE_ERROR,
168
+ filename ?? null,
169
+ get_return_keyword_node(node),
170
+ errors,
171
+ comments,
172
+ );
173
+ }
174
+
128
175
  /**
129
176
  * @param {AST.Element} element
130
177
  * @param {AnalysisContext} context
package/src/index.js CHANGED
@@ -78,12 +78,17 @@ export {
78
78
 
79
79
  // AST utils
80
80
  export {
81
+ get_component_from_path as getComponentFromPath,
81
82
  object,
82
83
  unwrap_pattern as unwrapPattern,
83
84
  extract_identifiers as extractIdentifiers,
84
85
  extract_paths as extractPaths,
85
86
  build_fallback as buildFallback,
86
87
  build_assignment_value as buildAssignmentValue,
88
+ is_class_node as isClassNode,
89
+ is_component_node as isComponentNode,
90
+ is_function_node as isFunctionNode,
91
+ is_inside_component as isInsideComponent,
87
92
  } from './utils/ast.js';
88
93
 
89
94
  // Builders (namespace re-export — members mirror AST node kinds)
@@ -197,4 +202,9 @@ export {
197
202
 
198
203
  // Analyze
199
204
  export { analyze_css as analyzeCss } from './analyze/css-analyze.js';
200
- export { validate_nesting as validateNesting } from './analyze/validation.js';
205
+ export {
206
+ COMPONENT_RETURN_VALUE_ERROR,
207
+ get_return_keyword_node as getReturnKeywordNode,
208
+ validate_component_return_statement as validateComponentReturnStatement,
209
+ validate_nesting as validateNesting,
210
+ } from './analyze/validation.js';
package/src/plugin.js CHANGED
@@ -16,6 +16,143 @@ import {
16
16
  import { regex_newline_characters } from './utils/patterns.js';
17
17
  import { error } from './errors.js';
18
18
 
19
+ /** @type {WeakMap<Record<string, boolean>, Map<string, number>>} */
20
+ const argument_clash_first_positions = new WeakMap();
21
+ /** @type {WeakMap<Record<string, boolean>, Set<string>>} */
22
+ const argument_clash_reported_names = new WeakMap();
23
+
24
+ /**
25
+ * @param {Record<string, boolean>} check_clashes
26
+ * @returns {Map<string, number>}
27
+ */
28
+ function get_argument_clash_first_positions(check_clashes) {
29
+ let first_positions = argument_clash_first_positions.get(check_clashes);
30
+ if (!first_positions) {
31
+ first_positions = new Map();
32
+ argument_clash_first_positions.set(check_clashes, first_positions);
33
+ }
34
+ return first_positions;
35
+ }
36
+
37
+ /**
38
+ * @param {Record<string, boolean>} check_clashes
39
+ * @returns {Set<string>}
40
+ */
41
+ function get_argument_clash_reported_names(check_clashes) {
42
+ let reported_names = argument_clash_reported_names.get(check_clashes);
43
+ if (!reported_names) {
44
+ reported_names = new Set();
45
+ argument_clash_reported_names.set(check_clashes, reported_names);
46
+ }
47
+ return reported_names;
48
+ }
49
+
50
+ /**
51
+ * @param {string} input
52
+ * @param {number} i
53
+ */
54
+ function skip_whitespace_from(input, i) {
55
+ while (i < input.length) {
56
+ const ch = input.charCodeAt(i);
57
+ if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) break;
58
+ i++;
59
+ }
60
+ return i;
61
+ }
62
+
63
+ /**
64
+ * Skip past a string literal opened at `i` with the given quote char code.
65
+ * @param {string} input
66
+ * @param {number} i
67
+ * @param {number} quote
68
+ */
69
+ function skip_string_from(input, i, quote) {
70
+ i++;
71
+ while (i < input.length) {
72
+ const ch = input.charCodeAt(i);
73
+ i++;
74
+ if (ch === 92)
75
+ i++; // backslash escape
76
+ else if (ch === quote) return i;
77
+ }
78
+ return i;
79
+ }
80
+
81
+ /**
82
+ * Scan past a balanced pair starting at `i` (which must point at `open`).
83
+ * Returns the position after the matching close, or -1 if unbalanced.
84
+ * @param {string} input
85
+ * @param {number} i
86
+ * @param {number} open
87
+ * @param {number} close
88
+ */
89
+ function scan_balanced_from(input, i, open, close) {
90
+ let depth = 1;
91
+ i++;
92
+ while (i < input.length) {
93
+ const ch = input.charCodeAt(i);
94
+ if (ch === 34 || ch === 39 || ch === 96) {
95
+ i = skip_string_from(input, i, ch);
96
+ continue;
97
+ }
98
+ if (ch === open) depth++;
99
+ else if (ch === close && --depth === 0) return i + 1;
100
+ i++;
101
+ }
102
+ return -1;
103
+ }
104
+
105
+ /**
106
+ * Best-effort lookahead at a `<` to decide whether it starts a generic arrow
107
+ * expression — `<...>(...)[: T] => ...`. Conservative: returns false on any
108
+ * unexpected shape so JSX continues to parse as JSX.
109
+ * @param {string} input
110
+ * @param {number} pos
111
+ */
112
+ function looks_like_generic_arrow(input, pos) {
113
+ if (input.charCodeAt(pos) !== 60) return false;
114
+
115
+ // Match the angle brackets, skipping over string literals.
116
+ let i = pos + 1;
117
+ let depth = 1;
118
+ while (i < input.length) {
119
+ const ch = input.charCodeAt(i);
120
+ if (ch === 34 || ch === 39 || ch === 96) {
121
+ i = skip_string_from(input, i, ch);
122
+ continue;
123
+ }
124
+ if (ch === 60) depth++;
125
+ else if (ch === 62 && --depth === 0) break;
126
+ i++;
127
+ }
128
+ if (depth !== 0) return false;
129
+
130
+ // `>` must be followed by `(...)`.
131
+ i = skip_whitespace_from(input, i + 1);
132
+ if (input.charCodeAt(i) !== 40) return false;
133
+ i = scan_balanced_from(input, i, 40, 41);
134
+ if (i === -1) return false;
135
+
136
+ // Optional `: ReturnType` before `=>`.
137
+ i = skip_whitespace_from(input, i);
138
+ if (input.charCodeAt(i) === 58) {
139
+ i++;
140
+ while (i < input.length) {
141
+ const ch = input.charCodeAt(i);
142
+ if (ch === 34 || ch === 39 || ch === 96) {
143
+ i = skip_string_from(input, i, ch);
144
+ continue;
145
+ }
146
+ if (ch === 61 && input.charCodeAt(i + 1) === 62) return true;
147
+ if (ch === 59 || ch === 123 || ch === 125) return false;
148
+ i++;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ return input.charCodeAt(i) === 61 && input.charCodeAt(i + 1) === 62;
154
+ }
155
+
19
156
  /**
20
157
  * Acorn parser plugin for Ripple syntax extensions.
21
158
  * Adds support for: component declarations, &[]/&{} lazy destructuring,
@@ -38,12 +175,14 @@ export function TSRXPlugin(config) {
38
175
  class TSRXParser extends Parser {
39
176
  /** @type {AST.Node[]} */
40
177
  #path = [];
178
+ #allowTagStartAfterDoubleQuotedText = false;
41
179
  #commentContextId = 0;
42
180
  #loose = false;
43
181
  /** @type {import('../types/index').CompileError[] | undefined} */
44
182
  #errors = undefined;
45
183
  /** @type {string | null} */
46
184
  #filename = null;
185
+ #functionBodyDepth = 0;
47
186
 
48
187
  /**
49
188
  * @param {Parse.Options} options
@@ -59,20 +198,21 @@ export function TSRXPlugin(config) {
59
198
 
60
199
  /**
61
200
  * @param {number} position
201
+ * @param {number} end
62
202
  * @param {string} message
63
203
  */
64
- #report_recoverable_error(position, message) {
204
+ #report_recoverable_error_range(position, end, message) {
65
205
  const start = Math.max(0, Math.min(position, this.input.length));
66
- const end = Math.min(this.input.length, start + 1);
206
+ const range_end = Math.max(start, Math.min(end, this.input.length));
67
207
  const start_loc = acorn.getLineInfo(this.input, start);
68
- const end_loc = acorn.getLineInfo(this.input, end);
208
+ const end_loc = acorn.getLineInfo(this.input, range_end);
69
209
 
70
210
  error(
71
211
  message,
72
212
  this.#filename,
73
213
  /** @type {AST.NodeWithLocation} */ ({
74
214
  start,
75
- end,
215
+ end: range_end,
76
216
  loc: {
77
217
  start: start_loc,
78
218
  end: end_loc,
@@ -82,6 +222,14 @@ export function TSRXPlugin(config) {
82
222
  );
83
223
  }
84
224
 
225
+ /**
226
+ * @param {number} position
227
+ * @param {string} message
228
+ */
229
+ #report_recoverable_error(position, message) {
230
+ this.#report_recoverable_error_range(position, position + 1, message);
231
+ }
232
+
85
233
  /**
86
234
  * In loose mode, keep parsing after duplicate declaration diagnostics so
87
235
  * editor tooling can continue producing AST and mappings.
@@ -96,7 +244,10 @@ export function TSRXPlugin(config) {
96
244
  ? message.message
97
245
  : String(message);
98
246
 
99
- if (error_message.includes('has already been declared')) {
247
+ if (
248
+ error_message.includes('has already been declared') ||
249
+ error_message === 'Argument name clash'
250
+ ) {
100
251
  this.#report_recoverable_error(position, error_message);
101
252
  return;
102
253
  }
@@ -381,11 +532,37 @@ export function TSRXPlugin(config) {
381
532
  return null;
382
533
  }
383
534
 
535
+ /**
536
+ * Inside a component, `<T,>(x: T) => x` should parse as a generic arrow
537
+ * function, not a JSX element. acorn-typescript's `readToken` would
538
+ * otherwise tokenize `<` as `jsxTagStart` (when `exprAllowed` or the
539
+ * context is `tc_expr`), bypassing our `getTokenFromCode` override. We
540
+ * intercept here, but only when the source from `<` actually looks like
541
+ * a generic arrow expression — so JSX like `<div>` keeps parsing normally.
542
+ *
543
+ * @type {Parse.Parser['readToken']}
544
+ */
545
+ readToken(code) {
546
+ if (
547
+ code === 60 &&
548
+ this.#path.findLast((n) => n.type === 'Component') &&
549
+ looks_like_generic_arrow(this.input, this.pos)
550
+ ) {
551
+ ++this.pos;
552
+ return this.finishToken(tt.relational, '<');
553
+ }
554
+ return super.readToken(code);
555
+ }
556
+
384
557
  /**
385
558
  * Get token from character code - handles Ripple-specific tokens
386
559
  * @type {Parse.Parser['getTokenFromCode']}
387
560
  */
388
561
  getTokenFromCode(code) {
562
+ if (code !== 60) {
563
+ this.#allowTagStartAfterDoubleQuotedText = false;
564
+ }
565
+
389
566
  if (code === 60) {
390
567
  // < character
391
568
  const inComponent = this.#path.findLast((n) => n.type === 'Component');
@@ -462,29 +639,21 @@ export function TSRXPlugin(config) {
462
639
  // Inside component template bodies, allow adjacent tags without requiring
463
640
  // a newline/indentation before the next '<'. This is important for inputs
464
641
  // like `<div />` and `</div><style>...</style>` which Prettier formats.
465
- if (prevNonWhitespaceChar === 123 /* '{' */ || prevNonWhitespaceChar === 62 /* '>' */) {
642
+ if (
643
+ (prevNonWhitespaceChar === 34 /* '"' */ &&
644
+ this.#allowTagStartAfterDoubleQuotedText) ||
645
+ prevNonWhitespaceChar === 123 /* '{' */ ||
646
+ prevNonWhitespaceChar === 62 /* '>' */
647
+ ) {
466
648
  if (!isWhitespaceAfterLt) {
649
+ this.#allowTagStartAfterDoubleQuotedText = false;
467
650
  ++this.pos;
468
651
  return this.finishToken(tstt.jsxTagStart);
469
652
  }
470
653
  }
471
654
 
472
- // Check if we're inside a nested function (arrow function, function expression, etc.)
473
- // We need to distinguish between being inside a function vs just being in nested scopes
474
- // (like for loops, if blocks, JSX elements, etc.)
475
- const nestedFunctionContext = this.context.some((ctx) => ctx.token === 'function');
476
-
477
- // Inside nested functions, treat < as relational/generic operator
478
- // BUT: if the < is followed by /, it's a closing JSX tag, not a less-than operator
479
- const nextChar =
480
- this.pos + 1 < this.input.length ? this.input.charCodeAt(this.pos + 1) : -1;
481
- const isClosingTag = nextChar === 47; // '/'
482
-
483
- if (nestedFunctionContext && !isClosingTag) {
484
- // Inside function - treat as TypeScript generic, not JSX
485
- ++this.pos;
486
- return this.finishToken(tt.relational, '<');
487
- }
655
+ // `<` inside a nested function body is intercepted earlier in
656
+ // `readToken` so it never reaches this path.
488
657
 
489
658
  // Check if everything before this position on the current line is whitespace
490
659
  let lineStart = this.pos - 1;
@@ -554,6 +723,7 @@ export function TSRXPlugin(config) {
554
723
  }
555
724
  }
556
725
  }
726
+ this.#allowTagStartAfterDoubleQuotedText = false;
557
727
  return super.getTokenFromCode(code);
558
728
  }
559
729
 
@@ -599,6 +769,67 @@ export function TSRXPlugin(config) {
599
769
  return super.parseBindingAtom();
600
770
  }
601
771
 
772
+ /**
773
+ * Acorn reports only the second duplicate function parameter. In loose
774
+ * mode, report the first one too so editor diagnostics can underline both
775
+ * binding sites. Keep strict mode on Acorn's normal fatal path.
776
+ *
777
+ * @type {Parse.Parser['checkLValSimple']}
778
+ */
779
+ checkLValSimple(expr, bindingType = BINDING_TYPES.BIND_NONE, checkClashes) {
780
+ if (
781
+ this.#loose &&
782
+ expr.type === 'Identifier' &&
783
+ bindingType !== BINDING_TYPES.BIND_NONE &&
784
+ checkClashes
785
+ ) {
786
+ const first_positions = get_argument_clash_first_positions(checkClashes);
787
+ const reported_names = get_argument_clash_reported_names(checkClashes);
788
+ const first_position = first_positions.get(expr.name);
789
+
790
+ if (Object.prototype.hasOwnProperty.call(checkClashes, expr.name)) {
791
+ if (first_position != null && !reported_names.has(expr.name)) {
792
+ this.#report_recoverable_error_range(
793
+ first_position,
794
+ first_position + expr.name.length,
795
+ 'Argument name clash',
796
+ );
797
+ reported_names.add(expr.name);
798
+ }
799
+ const start = /** @type {number} */ (expr.start);
800
+ this.#report_recoverable_error_range(
801
+ start,
802
+ /** @type {number} */ (expr.end ?? start + expr.name.length),
803
+ 'Argument name clash',
804
+ );
805
+ return;
806
+ }
807
+
808
+ const result = super.checkLValSimple(expr, bindingType, checkClashes);
809
+ first_positions.set(expr.name, /** @type {number} */ (expr.start));
810
+ return result;
811
+ }
812
+
813
+ return super.checkLValSimple(expr, bindingType, checkClashes);
814
+ }
815
+
816
+ /**
817
+ * Components do not use Acorn's normal function-body parser, but they
818
+ * should still report duplicate parameter names like functions do. Keep
819
+ * this validation on `BIND_OUTSIDE` so params are checked without being
820
+ * declared in the component template scope, preserving existing shadowing
821
+ * behavior.
822
+ *
823
+ * @param {AST.Pattern[]} params
824
+ */
825
+ checkComponentParams(params) {
826
+ /** @type {Record<string, boolean>} */
827
+ const name_hash = Object.create(null);
828
+ for (const param of params || []) {
829
+ this.checkLValInnerPattern(param, BINDING_TYPES.BIND_OUTSIDE, name_hash);
830
+ }
831
+ }
832
+
602
833
  /**
603
834
  * Parse expression atom - handles RippleArray and RippleObject literals
604
835
  * @type {Parse.Parser['parseExprAtom']}
@@ -741,11 +972,24 @@ export function TSRXPlugin(config) {
741
972
  }
742
973
 
743
974
  this.parseFunctionParams(node);
975
+ this.checkComponentParams(node.params);
976
+
977
+ // Reset before `eat(braceL)` so the lookahead `next()` it triggers reads
978
+ // the component body's first token as if we'd entered fresh — no
979
+ // surrounding function body should affect our parseStatement/parseBlock
980
+ // branching while inside the template.
981
+ const parent_function_body_depth = this.#functionBodyDepth;
982
+ this.#functionBodyDepth = 0;
983
+
744
984
  this.eat(tt.braceL);
745
985
  node.body = [];
746
986
  this.#path.push(node);
747
987
 
748
- this.parseTemplateBody(node.body);
988
+ try {
989
+ this.parseTemplateBody(node.body);
990
+ } finally {
991
+ this.#functionBodyDepth = parent_function_body_depth;
992
+ }
749
993
  this.#path.pop();
750
994
  this.exitScope();
751
995
 
@@ -962,6 +1206,19 @@ export function TSRXPlugin(config) {
962
1206
  return this.finishNode(node, isForIn ? 'ForInStatement' : 'ForOfStatement');
963
1207
  }
964
1208
 
1209
+ /**
1210
+ * @type {Parse.Parser['parseFunctionBody']}
1211
+ */
1212
+ parseFunctionBody(node, isArrowFunction, isMethod, forInit, ...args) {
1213
+ this.#functionBodyDepth++;
1214
+
1215
+ try {
1216
+ return super.parseFunctionBody(node, isArrowFunction, isMethod, forInit, ...args);
1217
+ } finally {
1218
+ this.#functionBodyDepth--;
1219
+ }
1220
+ }
1221
+
965
1222
  /**
966
1223
  * @type {Parse.Parser['checkUnreserved']}
967
1224
  */
@@ -1055,6 +1312,30 @@ export function TSRXPlugin(config) {
1055
1312
  );
1056
1313
  }
1057
1314
 
1315
+ /**
1316
+ * @returns {AST.TextNode}
1317
+ */
1318
+ parseDoubleQuotedTextChild() {
1319
+ const node = /** @type {AST.TextNode} */ (this.startNode());
1320
+ const expression = /** @type {AST.Literal} */ (this.startNode());
1321
+ const raw = this.input.slice(this.start, this.end);
1322
+ const end = this.end;
1323
+ const endLoc = this.endLoc;
1324
+
1325
+ expression.value = this.value;
1326
+ expression.raw = raw;
1327
+ node.expression = this.finishNodeAt(expression, 'Literal', end, endLoc);
1328
+
1329
+ this.#allowTagStartAfterDoubleQuotedText = true;
1330
+ try {
1331
+ this.next();
1332
+ } finally {
1333
+ this.#allowTagStartAfterDoubleQuotedText = false;
1334
+ }
1335
+
1336
+ return this.finishNodeAt(node, 'Text', end, endLoc);
1337
+ }
1338
+
1058
1339
  /**
1059
1340
  * @type {Parse.Parser['jsx_parseAttribute']}
1060
1341
  */
@@ -1916,12 +2197,16 @@ export function TSRXPlugin(config) {
1916
2197
 
1917
2198
  if (!inside_tsx.openingElement.name) {
1918
2199
  if (this.input.slice(this.pos, this.pos + 2) === '/>') {
2200
+ // Reset exprAllowed so the trailing `/` of `</>` is tokenized
2201
+ // as a slash rather than as the start of a regex literal.
2202
+ this.exprAllowed = false;
1919
2203
  return;
1920
2204
  }
1921
2205
  } else if (this.input.slice(this.pos, this.pos + 4) === '/tsx') {
1922
2206
  const after = this.input.charCodeAt(this.pos + 4);
1923
2207
  // Make sure it's </tsx> and not </tsx:...>
1924
2208
  if (after === 62 /* > */) {
2209
+ this.exprAllowed = false;
1925
2210
  return;
1926
2211
  }
1927
2212
  }
@@ -1988,6 +2273,7 @@ export function TSRXPlugin(config) {
1988
2273
  }
1989
2274
 
1990
2275
  if (this.input.slice(this.pos, this.pos + 5) === '/tsx:') {
2276
+ this.exprAllowed = false;
1991
2277
  return;
1992
2278
  }
1993
2279
 
@@ -2043,6 +2329,8 @@ export function TSRXPlugin(config) {
2043
2329
  delete node.text;
2044
2330
  }
2045
2331
  body.push(node);
2332
+ } else if (this.type === tt.string && this.input.charCodeAt(this.start) === 34) {
2333
+ body.push(this.parseDoubleQuotedTextChild());
2046
2334
  } else if (this.type === tt.braceR) {
2047
2335
  // Leaving a component/template body. We may still be in TSX/JSX tokenization
2048
2336
  // context (e.g. after parsing markup), but the closing `}` is a JS token.
@@ -2188,6 +2476,7 @@ export function TSRXPlugin(config) {
2188
2476
  if (
2189
2477
  context !== 'for' &&
2190
2478
  context !== 'if' &&
2479
+ this.#functionBodyDepth === 0 &&
2191
2480
  this.context.at(-1) === b_stat &&
2192
2481
  this.type === tt.braceL &&
2193
2482
  this.context.some((c) => c === tstc.tc_expr)
@@ -2277,7 +2566,13 @@ export function TSRXPlugin(config) {
2277
2566
  parseBlock(createNewLexicalScope, node, exitStrict) {
2278
2567
  const parent = this.#path.at(-1);
2279
2568
 
2280
- if (parent?.type === 'Component' || parent?.type === 'Element') {
2569
+ // Inside a JS function body, parse `{...}` as a regular block statement,
2570
+ // even if the nearest `#path` entry is a Component/Element — we're in a
2571
+ // nested function callable, not in a template.
2572
+ if (
2573
+ this.#functionBodyDepth === 0 &&
2574
+ (parent?.type === 'Component' || parent?.type === 'Element')
2575
+ ) {
2281
2576
  if (createNewLexicalScope === void 0) createNewLexicalScope = true;
2282
2577
  if (node === void 0) node = /** @type {AST.BlockStatement} */ (this.startNode());
2283
2578
 
@@ -106,6 +106,23 @@ export function tsx_with_ts_locations() {
106
106
  context.visit(node.typeAnnotation);
107
107
  }
108
108
  },
109
+ Identifier: (node, context) => {
110
+ context.write(node.name, node);
111
+ if (node.optional) {
112
+ context.write('?');
113
+ }
114
+ if (node.typeAnnotation) {
115
+ context.visit(node.typeAnnotation);
116
+ }
117
+ },
118
+ TSNamedTupleMember: (node, context) => {
119
+ context.visit(node.label);
120
+ if (node.optional) {
121
+ context.write('?');
122
+ }
123
+ context.write(': ');
124
+ context.visit(node.elementType);
125
+ },
109
126
  };
110
127
  for (const type of [
111
128
  // JS nodes whose esrap printer emits no location marker, causing