@tsrx/core 0.0.19 → 0.0.20

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
@@ -35,194 +35,19 @@ const ast = parseModule(source, 'App.tsrx');
35
35
  The parser produces an ESTree-compatible AST, augmented with the TSRX node types
36
36
  listed below. Framework compilers walk this AST to emit their own output.
37
37
 
38
- ## TSRX Specification (draft)
38
+ ## Language docs
39
39
 
40
- TSRX is a superset of TypeScript. All valid TypeScript is valid TSRX. TSRX adds
41
- the following productions.
40
+ The TSRX website is the canonical source for language documentation:
42
41
 
43
- ### 1. `component` declarations
42
+ - [Getting Started](https://tsrx.dev/getting-started) install TSRX for React,
43
+ Preact, Solid, Vue, or Ripple and configure editor/AI tooling.
44
+ - [Features](https://tsrx.dev/features) — examples of components, statement
45
+ templates, control flow, scoped styles, server blocks, and lazy destructuring.
46
+ - [Specification](https://tsrx.dev/specification) — the current grammar and
47
+ parser-level semantics.
44
48
 
45
- A `component` is a new top-level and expression-level declaration form. It has the
46
- same shape as a function declaration, but is a distinct AST node (`Component`) so
47
- that framework compilers can treat it specially.
48
-
49
- ```tsx
50
- component Button(props: Props) {
51
- <button>{props.label}</button>
52
- }
53
- ```
54
-
55
- - `component` may be used wherever `function` may be used (declaration,
56
- expression, default export).
57
- - The body of a `component` may contain JSX-like elements as statements — see §3.
58
- - `component` is a contextual keyword. Use as an identifier is preserved in
59
- non-declaration positions.
60
-
61
- ### 2. JSX-as-statements
62
-
63
- Inside a `component` body, JSX elements are valid _statement_ forms. They describe
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 `{}`.
67
-
68
- ```tsx
69
- component Greeting() {
70
- <h1>"Hello"</h1>
71
- <p>"Welcome"</p>
72
- }
73
- ```
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
-
78
- Elsewhere (outside a `component` body), JSX remains an expression, as in standard
79
- JSX.
80
-
81
- ### 4. Control-flow statements in `component` bodies
82
-
83
- Inside a `component` body, the standard JavaScript control-flow keywords `if`,
84
- `else`, `for`, `switch`, and `try` gain an additional role: their branches may
85
- contain JSX-as-statements (§2) describing conditionally- or repeatedly-rendered
86
- output. The keywords retain their usual JavaScript syntax — no new grammar is
87
- introduced — but framework compilers treat them as _reactive_ boundaries.
88
-
89
- ```tsx
90
- component List(props: { items: Item[]; showHeader: boolean }) {
91
- if (props.showHeader) {
92
- <h1>"Items"</h1>
93
- } else {
94
- <h2>"(no header)"</h2>
95
- }
96
-
97
- for (const item of props.items) {
98
- <li>{item.name}</li>
99
- }
100
-
101
- switch (props.items.length) {
102
- case 0:
103
- <p>"empty"</p>
104
- break;
105
- default:
106
- <p>"has items"</p>
107
- }
108
-
109
- try {
110
- <AsyncThing />
111
- } catch (e) {
112
- <pre>{String(e)}</pre>
113
- }
114
- }
115
- ```
116
-
117
- **Early returns.** A bare `return;` (or `return` at the end of a branch) is a
118
- valid statement inside a `component` body and short-circuits any remaining
119
- rendering in the current branch. This composes naturally with the control-flow
120
- forms above:
121
-
122
- ```tsx
123
- component Page(props: { user: User | null }) {
124
- if (props.user == null) {
125
- <LoginPrompt />
126
- return;
127
- }
128
-
129
- <Dashboard user={props.user} />
130
- }
131
- ```
132
-
133
- Because a `component` body does not produce a value, `return` never carries an
134
- expression — it only marks a rendering short-circuit.
135
-
136
- **Nesting inside elements.** Control-flow statements may appear directly as
137
- children of a JSX element, not only at the top level of the component body. Their
138
- branches contribute children to the enclosing element in source order:
139
-
140
- ```tsx
141
- component Menu(props: { items: Item[]; loading: boolean }) {
142
- <ul>
143
- if (props.loading) {
144
- <li>{'loading…'}</li>
145
- } else {
146
- for (const item of props.items) {
147
- <li>
148
- <a href={item.href}>{item.label}</a>
149
- if (item.badge) {
150
- <span class="badge">{item.badge}</span>
151
- }
152
- </li>
153
- }
154
- }
155
- </ul>
156
- }
157
- ```
158
-
159
- Any control-flow form that is legal at the component-body level is also legal as a
160
- child of a JSX element, and may be nested to arbitrary depth.
161
-
162
- TSRX only describes what is syntactically permitted. The reactive semantics
163
- (dependency tracking, list reconciliation, error boundaries, suspense) are the
164
- responsibility of the framework compiler.
165
-
166
- ### 5. JSX escape hatch: `<tsx>...</tsx>`
167
-
168
- Because JSX inside a `component` body is a _statement_ (§2), the element itself
169
- has no value. To embed regular _expression_-form JSX — e.g. when a third-party
170
- library accepts a JSX tree as a value — wrap it in the reserved `<tsx>` element.
171
- Its children are parsed as standard JSX expressions and the whole form evaluates
172
- to the JSX expression value (or an array of values if there are multiple
173
- children).
174
-
175
- ```tsx
176
- component Page() {
177
- const header = <tsx><h1>Hello</h1></tsx>;
178
- renderSomewhereElse(header);
179
- }
180
- ```
181
-
182
- `<tsx>` is a reserved tag name in TSRX. It has no runtime representation of its
183
- own — the framework compiler unwraps it into the underlying JSX expression.
184
-
185
- ### 6. Lazy destructuring: `&[]` and `&{}`
186
-
187
- Two new destructuring forms prefixed with `&` bind by _reference_ rather than by
188
- value. Each bound name compiles to a lazy property lookup on the source, so reads
189
- and writes are deferred to the use-site.
190
-
191
- ```tsx
192
- let &[count] = source; // array-style lazy destructure
193
- let &{ name, age } = props; // object-style lazy destructure
194
- ```
195
-
196
- Semantics are provided by the framework compiler. TSRX only defines the syntax and
197
- the AST shape (`kind: 'lazy'` binding patterns).
198
-
199
- ### 7. `#server` blocks
200
-
201
- A `#server { ... }` block marks a lexical region whose contents are intended for
202
- the server compile target. TSRX parses the block and records its exports;
203
- framework compilers decide how to emit or strip it per target.
204
-
205
- ```ts
206
- #server {
207
- export async function load() { /* ... */ }
208
- }
209
- ```
210
-
211
- ### 8. `#style` identifier
212
-
213
- `#style` is a reserved identifier that refers, at compile time, to the set of
214
- scoped CSS classes declared in the current module. It is legal only in positions
215
- where the framework compiler expects a class-name value.
216
-
217
- ```tsx
218
- <div class={#style.card} />
219
- ```
220
-
221
- ### 9. Scoped CSS blocks
222
-
223
- A `component` may contain a trailing CSS block (delimited by the framework
224
- compiler's chosen grammar). The block is parsed into a `CSS.StyleSheet` AST node
225
- and hashed for scoping.
49
+ Keeping the language reference on the website avoids duplicating the specification
50
+ here and keeps package docs focused on the core parser API.
226
51
 
227
52
  ## What `@tsrx/core` provides
228
53
 
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.19",
6
+ "version": "0.0.20",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -0,0 +1,7 @@
1
+ export const DIAGNOSTIC_CODES = {
2
+ JSX_EXPRESSION_VALUE: 'tsrx-jsx-expression-value',
3
+ JSX_RETURN_IN_COMPONENT: 'tsrx-jsx-return-in-component',
4
+ FUNCTION_COMPONENT_SYNTAX: 'tsrx-function-component-syntax',
5
+ UNCLOSED_TAG: 'tsrx-unclosed-tag',
6
+ MISMATCHED_CLOSING_TAG: 'tsrx-mismatched-closing-tag',
7
+ };
package/src/errors.js CHANGED
@@ -10,9 +10,10 @@
10
10
  * @param {AST.Node | AST.NodeWithLocation} node
11
11
  * @param {CompileError[]} [errors]
12
12
  * @param {AST.CommentWithLocation[]} [comments]
13
+ * @param {string} [code]
13
14
  * @returns {void}
14
15
  */
15
- export function error(message, filename, node, errors, comments) {
16
+ export function error(message, filename, node, errors, comments, code) {
16
17
  if (errors && comments && is_error_suppressed(node, comments)) {
17
18
  return;
18
19
  }
@@ -25,6 +26,7 @@ export function error(message, filename, node, errors, comments) {
25
26
 
26
27
  // custom properties
27
28
  error.fileName = filename;
29
+ error.code = code;
28
30
  error.end = node.end ?? undefined;
29
31
  error.loc = !node.loc
30
32
  ? undefined
package/src/index.js CHANGED
@@ -24,6 +24,7 @@ export { create_scopes as createScopes, ScopeRoot, Scope } from './scope.js';
24
24
 
25
25
  // Errors
26
26
  export { error } from './errors.js';
27
+ export { DIAGNOSTIC_CODES } from './diagnostics.js';
27
28
 
28
29
  // Constants
29
30
  export {
@@ -201,7 +201,8 @@ export function createParser(...plugins) {
201
201
  return function parse(source, filename, options) {
202
202
  /** @type {AST.CommentWithLocation[]} */
203
203
  const comments = [];
204
- const output_comments = options?.comments;
204
+ const collect = !!(options?.collect || options?.loose);
205
+ const output_comments = collect ? options?.comments : undefined;
205
206
 
206
207
  const { onComment, add_comments } = get_comment_handlers(source, comments);
207
208
  /** @type {AST.Program} */
@@ -216,7 +217,8 @@ export function createParser(...plugins) {
216
217
  onComment,
217
218
  tsrxOptions: {
218
219
  filename,
219
- errors: options?.errors ?? [],
220
+ collect,
221
+ errors: collect ? (options?.errors ?? []) : undefined,
220
222
  loose: options?.loose || false,
221
223
  },
222
224
  });
package/src/plugin.js CHANGED
@@ -15,6 +15,10 @@ import {
15
15
  } from './parse/index.js';
16
16
  import { regex_newline_characters } from './utils/patterns.js';
17
17
  import { error } from './errors.js';
18
+ import { DIAGNOSTIC_CODES } from './diagnostics.js';
19
+
20
+ const JSX_EXPRESSION_VALUE_ERROR =
21
+ 'JSX elements cannot be used as expressions. Wrap with `<>...</>` or `<tsx>...</tsx>` or use elements as statements within a component.';
18
22
 
19
23
  /** @type {WeakMap<Record<string, boolean>, Map<string, number>>} */
20
24
  const argument_clash_first_positions = new WeakMap();
@@ -153,6 +157,35 @@ function looks_like_generic_arrow(input, pos) {
153
157
  return input.charCodeAt(i) === 61 && input.charCodeAt(i + 1) === 62;
154
158
  }
155
159
 
160
+ /**
161
+ * @param {AST.Node | null | undefined} node
162
+ * @returns {boolean}
163
+ */
164
+ function is_pascal_case_function(node) {
165
+ if (node && 'id' in node && node.id && node.id.type === 'Identifier') {
166
+ return /^[A-Z]/.test(node.id.name);
167
+ }
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * @param {string} input
173
+ * @param {number} pos
174
+ */
175
+ function previous_word_before(input, pos) {
176
+ let i = pos - 1;
177
+ while (i >= 0) {
178
+ const ch = input.charCodeAt(i);
179
+ if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) break;
180
+ i--;
181
+ }
182
+ const end = i + 1;
183
+ while (i >= 0 && /[$_\p{ID_Continue}]/u.test(input[i])) {
184
+ i--;
185
+ }
186
+ return input.slice(i + 1, end);
187
+ }
188
+
156
189
  /**
157
190
  * Acorn parser plugin for Ripple syntax extensions.
158
191
  * Adds support for: component declarations, &[]/&{} lazy destructuring,
@@ -178,7 +211,10 @@ export function TSRXPlugin(config) {
178
211
  #allowTagStartAfterDoubleQuotedText = false;
179
212
  #allowDoubleQuotedTextChildAfterBrace = false;
180
213
  #commentContextId = 0;
214
+ #collect = false;
181
215
  #loose = false;
216
+ /** @type {AST.Node[]} */
217
+ #functionStack = [];
182
218
  /** @type {import('../types/index').CompileError[] | undefined} */
183
219
  #errors = undefined;
184
220
  /** @type {string | null} */
@@ -192,6 +228,7 @@ export function TSRXPlugin(config) {
192
228
  constructor(options, input) {
193
229
  super(options, input);
194
230
  const tsrx_options = options?.tsrxOptions ?? options?.rippleOptions;
231
+ this.#collect = tsrx_options?.collect === true || tsrx_options?.loose === true;
195
232
  this.#loose = tsrx_options?.loose === true;
196
233
  this.#errors = tsrx_options?.errors;
197
234
  this.#filename = tsrx_options?.filename || null;
@@ -274,8 +311,9 @@ export function TSRXPlugin(config) {
274
311
  * @param {number} position
275
312
  * @param {number} end
276
313
  * @param {string} message
314
+ * @param {string} [code]
277
315
  */
278
- #report_recoverable_error_range(position, end, message) {
316
+ #report_recoverable_error_range(position, end, message, code) {
279
317
  const start = Math.max(0, Math.min(position, this.input.length));
280
318
  const range_end = Math.max(start, Math.min(end, this.input.length));
281
319
  const start_loc = acorn.getLineInfo(this.input, start);
@@ -292,20 +330,37 @@ export function TSRXPlugin(config) {
292
330
  end: end_loc,
293
331
  },
294
332
  }),
295
- this.#loose ? this.#errors : undefined,
333
+ this.#collect ? this.#errors : undefined,
334
+ undefined,
335
+ code,
296
336
  );
297
337
  }
298
338
 
299
339
  /**
300
340
  * @param {number} position
301
341
  * @param {string} message
342
+ * @param {string} [code]
343
+ */
344
+ #report_recoverable_error(position, message, code) {
345
+ this.#report_recoverable_error_range(position, position + 1, message, code);
346
+ }
347
+
348
+ /**
349
+ * @param {number} position
350
+ * @param {string} message
351
+ * @param {string} [code]
302
352
  */
303
- #report_recoverable_error(position, message) {
304
- this.#report_recoverable_error_range(position, position + 1, message);
353
+ #report_broken_markup_error(position, message, code = DIAGNOSTIC_CODES.UNCLOSED_TAG) {
354
+ if (this.#loose) return;
355
+ if (this.#collect) {
356
+ this.#report_recoverable_error(position, message, code);
357
+ return;
358
+ }
359
+ this.raise(position, message);
305
360
  }
306
361
 
307
362
  /**
308
- * In loose mode, keep parsing after duplicate declaration diagnostics so
363
+ * When collecting, keep parsing after duplicate declaration diagnostics so
309
364
  * editor tooling can continue producing AST and mappings.
310
365
  * @param {number} position
311
366
  * @param {string | { message?: string }} message
@@ -339,7 +394,7 @@ export function TSRXPlugin(config) {
339
394
  */
340
395
  reportReservedArrowTypeParam(node) {
341
396
  // Allow <T>() => {} syntax without requiring trailing comma
342
- if (this.#loose && node.params.length === 1 && node.extra?.trailingComma === undefined) {
397
+ if (this.#collect && node.params.length === 1 && node.extra?.trailingComma === undefined) {
343
398
  error(
344
399
  'This syntax is reserved in files with the .mts or .cts extension. Add a trailing comma, as in `<T,>() => ...`.',
345
400
  this.#filename,
@@ -350,7 +405,7 @@ export function TSRXPlugin(config) {
350
405
  }
351
406
 
352
407
  /**
353
- * Override to allow `readonly` type modifier on any type in loose mode.
408
+ * Override to allow `readonly` type modifier on any type when collecting.
354
409
  * By default, @sveltejs/acorn-typescript throws an error for `readonly { ... }`
355
410
  * because TypeScript only permits `readonly` on array and tuple types.
356
411
  * Suppress the error in the strict mode as ts is compiled away.
@@ -363,7 +418,7 @@ export function TSRXPlugin(config) {
363
418
  return;
364
419
  }
365
420
 
366
- if (this.#loose) {
421
+ if (this.#collect) {
367
422
  error(
368
423
  "'readonly' type modifier is only permitted on array and tuple literal types.",
369
424
  this.#filename,
@@ -607,21 +662,17 @@ export function TSRXPlugin(config) {
607
662
  }
608
663
 
609
664
  /**
610
- * Inside a component, `<T,>(x: T) => x` should parse as a generic arrow
611
- * function, not a JSX element. acorn-typescript's `readToken` would
612
- * otherwise tokenize `<` as `jsxTagStart` (when `exprAllowed` or the
613
- * context is `tc_expr`), bypassing our `getTokenFromCode` override. We
614
- * intercept here, but only when the source from `<` actually looks like
615
- * a generic arrow expression so JSX like `<div>` keeps parsing normally.
665
+ * `<T,>(x: T) => x` and `<T>(x: T): T => x` should parse as generic
666
+ * arrow functions, not JSX elements. acorn-typescript's `readToken`
667
+ * can otherwise tokenize `<` as `jsxTagStart` when expression parsing
668
+ * allows JSX, bypassing our `getTokenFromCode` override. We intercept
669
+ * only when the source from `<` actually looks like a generic arrow
670
+ * expression, so JSX like `<div>` keeps parsing normally.
616
671
  *
617
672
  * @type {Parse.Parser['readToken']}
618
673
  */
619
674
  readToken(code) {
620
- if (
621
- code === 60 &&
622
- this.#path.findLast((n) => n.type === 'Component') &&
623
- looks_like_generic_arrow(this.input, this.pos)
624
- ) {
675
+ if (code === 60 && looks_like_generic_arrow(this.input, this.pos)) {
625
676
  ++this.pos;
626
677
  return this.finishToken(tt.relational, '<');
627
678
  }
@@ -854,15 +905,15 @@ export function TSRXPlugin(config) {
854
905
  }
855
906
 
856
907
  /**
857
- * Acorn reports only the second duplicate function parameter. In loose
858
- * mode, report the first one too so editor diagnostics can underline both
908
+ * Acorn reports only the second duplicate function parameter. When collecting,
909
+ * report the first one too so editor diagnostics can underline both
859
910
  * binding sites. Keep strict mode on Acorn's normal fatal path.
860
911
  *
861
912
  * @type {Parse.Parser['checkLValSimple']}
862
913
  */
863
914
  checkLValSimple(expr, bindingType = BINDING_TYPES.BIND_NONE, checkClashes) {
864
915
  if (
865
- this.#loose &&
916
+ this.#collect &&
866
917
  expr.type === 'Identifier' &&
867
918
  bindingType !== BINDING_TYPES.BIND_NONE &&
868
919
  checkClashes
@@ -1298,10 +1349,12 @@ export function TSRXPlugin(config) {
1298
1349
  */
1299
1350
  parseFunctionBody(node, isArrowFunction, isMethod, forInit, ...args) {
1300
1351
  this.#functionBodyDepth++;
1352
+ this.#functionStack.push(node);
1301
1353
 
1302
1354
  try {
1303
1355
  return super.parseFunctionBody(node, isArrowFunction, isMethod, forInit, ...args);
1304
1356
  } finally {
1357
+ this.#functionStack.pop();
1305
1358
  this.#functionBodyDepth--;
1306
1359
  }
1307
1360
  }
@@ -1849,10 +1902,16 @@ export function TSRXPlugin(config) {
1849
1902
  );
1850
1903
  }
1851
1904
 
1852
- this.raise(
1853
- this.start,
1854
- 'JSX elements cannot be used as expressions. Wrap with `<tsx>...</tsx>` or use elements as statements within a component.',
1855
- );
1905
+ const code = this.#functionStack.findLast(is_pascal_case_function)
1906
+ ? DIAGNOSTIC_CODES.FUNCTION_COMPONENT_SYNTAX
1907
+ : this.#path.findLast((node) => node.type === 'Component') &&
1908
+ this.#functionStack.length === 0 &&
1909
+ previous_word_before(this.input, this.start) === 'return'
1910
+ ? DIAGNOSTIC_CODES.JSX_RETURN_IN_COMPONENT
1911
+ : DIAGNOSTIC_CODES.JSX_EXPRESSION_VALUE;
1912
+
1913
+ this.#report_recoverable_error(this.start, JSX_EXPRESSION_VALUE_ERROR, code);
1914
+ return super.jsx_parseElement();
1856
1915
  }
1857
1916
 
1858
1917
  /**
@@ -2051,12 +2110,10 @@ export function TSRXPlugin(config) {
2051
2110
  this.#path.pop();
2052
2111
  } else {
2053
2112
  // No closing tag
2054
- if (!this.#loose) {
2055
- this.raise(
2056
- open.end,
2057
- "Unclosed tag '<script>'. Expected '</script>' before end of component.",
2058
- );
2059
- }
2113
+ this.#report_broken_markup_error(
2114
+ open.end,
2115
+ "Unclosed tag '<script>'. Expected '</script>' before end of component.",
2116
+ );
2060
2117
  /** @type {AST.Element} */ (element).unclosed = true;
2061
2118
  this.#path.pop();
2062
2119
  }
@@ -2113,12 +2170,10 @@ export function TSRXPlugin(config) {
2113
2170
  this.exprAllowed = false;
2114
2171
  this.#path.pop();
2115
2172
  } else {
2116
- if (!this.#loose) {
2117
- this.raise(
2118
- open.end,
2119
- "Unclosed tag '<style>'. Expected '</style>' before end of component.",
2120
- );
2121
- }
2173
+ this.#report_broken_markup_error(
2174
+ open.end,
2175
+ "Unclosed tag '<style>'. Expected '</style>' before end of component.",
2176
+ );
2122
2177
  /** @type {AST.Element} */ (element).unclosed = true;
2123
2178
  this.#path.pop();
2124
2179
  }
@@ -2203,20 +2258,17 @@ export function TSRXPlugin(config) {
2203
2258
  }
2204
2259
  } else if (this.#path[this.#path.length - 1] === element) {
2205
2260
  // Check if this element was properly closed
2206
- if (!this.#loose) {
2207
- const tagName = this.getElementName(element.id);
2208
- this.raise(
2209
- this.start,
2210
- `Unclosed tag '<${tagName}>'. Expected '</${tagName}>' before end of component.`,
2211
- );
2212
- } else {
2213
- element.unclosed = true;
2214
- element.loc.end = {
2215
- .../** @type {AST.SourceLocation} */ (element.openingElement.loc).end,
2216
- };
2217
- element.end = element.openingElement.end;
2218
- this.#path.pop();
2219
- }
2261
+ const tagName = this.getElementName(element.id);
2262
+ this.#report_broken_markup_error(
2263
+ this.start,
2264
+ `Unclosed tag '<${tagName}>'. Expected '</${tagName}>' before end of component.`,
2265
+ );
2266
+ element.unclosed = true;
2267
+ element.loc.end = {
2268
+ .../** @type {AST.SourceLocation} */ (element.openingElement.loc).end,
2269
+ };
2270
+ element.end = element.openingElement.end;
2271
+ this.#path.pop();
2220
2272
  }
2221
2273
  }
2222
2274
 
@@ -2267,18 +2319,15 @@ export function TSRXPlugin(config) {
2267
2319
 
2268
2320
  while (true) {
2269
2321
  if (this.type === tt.eof || this.pos >= this.input.length || this.type === tt.braceR) {
2270
- if (!this.#loose) {
2271
- this.raise(
2272
- this.start,
2273
- `Unclosed tag '<tsx>'. Expected '</tsx>' before end of component.`,
2274
- );
2275
- } else {
2276
- inside_tsx.unclosed = true;
2277
- /** @type {AST.NodeWithLocation} */ (inside_tsx).loc.end = {
2278
- .../** @type {AST.SourceLocation} */ (inside_tsx.openingElement.loc).end,
2279
- };
2280
- inside_tsx.end = inside_tsx.openingElement.end;
2281
- }
2322
+ this.#report_broken_markup_error(
2323
+ this.start,
2324
+ `Unclosed tag '<tsx>'. Expected '</tsx>' before end of component.`,
2325
+ );
2326
+ inside_tsx.unclosed = true;
2327
+ /** @type {AST.NodeWithLocation} */ (inside_tsx).loc.end = {
2328
+ .../** @type {AST.SourceLocation} */ (inside_tsx.openingElement.loc).end,
2329
+ };
2330
+ inside_tsx.end = inside_tsx.openingElement.end;
2282
2331
  return;
2283
2332
  }
2284
2333
 
@@ -2344,18 +2393,15 @@ export function TSRXPlugin(config) {
2344
2393
 
2345
2394
  while (true) {
2346
2395
  if (this.type === tt.eof || this.pos >= this.input.length || this.type === tt.braceR) {
2347
- if (!this.#loose) {
2348
- this.raise(
2349
- this.start,
2350
- `Unclosed tag '<tsx:${inside_tsx_compat.kind}>'. Expected '</tsx:${inside_tsx_compat.kind}>' before end of component.`,
2351
- );
2352
- } else {
2353
- inside_tsx_compat.unclosed = true;
2354
- /** @type {AST.NodeWithLocation} */ (inside_tsx_compat).loc.end = {
2355
- .../** @type {AST.SourceLocation} */ (inside_tsx_compat.openingElement.loc).end,
2356
- };
2357
- inside_tsx_compat.end = inside_tsx_compat.openingElement.end;
2358
- }
2396
+ this.#report_broken_markup_error(
2397
+ this.start,
2398
+ `Unclosed tag '<tsx:${inside_tsx_compat.kind}>'. Expected '</tsx:${inside_tsx_compat.kind}>' before end of component.`,
2399
+ );
2400
+ inside_tsx_compat.unclosed = true;
2401
+ /** @type {AST.NodeWithLocation} */ (inside_tsx_compat).loc.end = {
2402
+ .../** @type {AST.SourceLocation} */ (inside_tsx_compat.openingElement.loc).end,
2403
+ };
2404
+ inside_tsx_compat.end = inside_tsx_compat.openingElement.end;
2359
2405
  return;
2360
2406
  }
2361
2407
 
@@ -2480,46 +2526,45 @@ export function TSRXPlugin(config) {
2480
2526
  }
2481
2527
 
2482
2528
  if (openingTagName !== closingTagName) {
2483
- if (!this.#loose) {
2484
- this.raise(
2485
- closingElement.start,
2486
- `Expected closing tag to match opening tag. Expected '</${openingTagName}>' but found '</${closingTagName}>'`,
2487
- );
2488
- } else {
2489
- // Loop through all unclosed elements on the stack
2490
- while (this.#path.length > 0) {
2491
- const elem = this.#path[this.#path.length - 1];
2529
+ // this will throw if not collecting errors
2530
+ this.#report_broken_markup_error(
2531
+ closingElement.start,
2532
+ `Expected closing tag to match opening tag. Expected '</${openingTagName}>' but found '</${closingTagName}>'`,
2533
+ DIAGNOSTIC_CODES.MISMATCHED_CLOSING_TAG,
2534
+ );
2535
+ // Loop through all unclosed elements on the stack
2536
+ while (this.#path.length > 0) {
2537
+ const elem = this.#path[this.#path.length - 1];
2492
2538
 
2493
- // Stop at non-Element boundaries (Component, etc.)
2494
- if (elem.type !== 'Element' && elem.type !== 'Tsx' && elem.type !== 'TsxCompat') {
2495
- break;
2496
- }
2539
+ // Stop at non-Element boundaries (Component, etc.)
2540
+ if (elem.type !== 'Element' && elem.type !== 'Tsx' && elem.type !== 'TsxCompat') {
2541
+ break;
2542
+ }
2497
2543
 
2498
- const elemName =
2499
- elem.type === 'TsxCompat'
2500
- ? 'tsx:' + elem.kind
2501
- : elem.type === 'Tsx'
2502
- ? elem.openingElement.name
2503
- ? 'tsx'
2504
- : null
2505
- : elem.id
2506
- ? this.getElementName(elem.id)
2507
- : null;
2508
-
2509
- // Found matching opening tag
2510
- if (elemName === closingTagName) {
2511
- break;
2512
- }
2544
+ const elemName =
2545
+ elem.type === 'TsxCompat'
2546
+ ? 'tsx:' + elem.kind
2547
+ : elem.type === 'Tsx'
2548
+ ? elem.openingElement.name
2549
+ ? 'tsx'
2550
+ : null
2551
+ : elem.id
2552
+ ? this.getElementName(elem.id)
2553
+ : null;
2554
+
2555
+ // Found matching opening tag
2556
+ if (elemName === closingTagName) {
2557
+ break;
2558
+ }
2513
2559
 
2514
- // Mark as unclosed and adjust location
2515
- elem.unclosed = true;
2516
- /** @type {AST.NodeWithLocation} */ (elem).loc.end = {
2517
- .../** @type {AST.SourceLocation} */ (elem.openingElement.loc).end,
2518
- };
2519
- elem.end = elem.openingElement.end;
2560
+ // Mark as unclosed and adjust location
2561
+ elem.unclosed = true;
2562
+ /** @type {AST.NodeWithLocation} */ (elem).loc.end = {
2563
+ .../** @type {AST.SourceLocation} */ (elem.openingElement.loc).end,
2564
+ };
2565
+ elem.end = elem.openingElement.end;
2520
2566
 
2521
- this.#path.pop(); // Remove from stack
2522
- }
2567
+ this.#path.pop(); // Remove from stack
2523
2568
  }
2524
2569
  }
2525
2570
 
package/src/scope.js CHANGED
@@ -335,7 +335,7 @@ export class Scope {
335
335
  `Cannot declare a variable named "${node.name}" as identifiers starting with "${IDENTIFIER_OBFUSCATION_PREFIX}" are reserved`,
336
336
  this.#error_options.filename,
337
337
  node,
338
- this.#error_options.loose ? this.#error_options.errors : undefined,
338
+ this.#error_options.collect ? this.#error_options.errors : undefined,
339
339
  this.#error_options.comments,
340
340
  );
341
341
  }
@@ -345,7 +345,7 @@ export class Scope {
345
345
  `'${node.name}' has already been declared in the current scope`,
346
346
  this.#error_options.filename,
347
347
  node,
348
- this.#error_options.loose ? this.#error_options.errors : undefined,
348
+ this.#error_options.collect ? this.#error_options.errors : undefined,
349
349
  this.#error_options.comments,
350
350
  );
351
351
  }
@@ -92,6 +92,7 @@ export function createJsxTransform(platform) {
92
92
  const module_uses_server_directive = should_scan_use_server_directive
93
93
  ? has_use_server_directive(ast)
94
94
  : true;
95
+ const collect = !!(options?.collect || options?.loose);
95
96
  /** @type {any[]} */
96
97
  const stylesheets = [];
97
98
 
@@ -107,8 +108,8 @@ export function createJsxTransform(platform) {
107
108
  lazy_next_id: 0,
108
109
  current_css_hash: null,
109
110
  filename: filename ?? null,
110
- loose: !!options?.loose,
111
- errors: options?.loose ? options?.errors : undefined,
111
+ collect,
112
+ errors: collect ? options?.errors : undefined,
112
113
  comments: options?.comments,
113
114
  // Platforms can seed their own tracking state (e.g. solid's
114
115
  // needs_show / needs_for flags) via `hooks.initialState`.
@@ -120,7 +121,12 @@ export function createJsxTransform(platform) {
120
121
  walk(/** @type {any} */ (ast), transform_context, {
121
122
  ReturnStatement(node, { next, path }) {
122
123
  if (get_component_from_path(path)) {
123
- validate_component_return_statement(node, filename);
124
+ validate_component_return_statement(
125
+ node,
126
+ filename,
127
+ transform_context.errors,
128
+ transform_context.comments,
129
+ );
124
130
  }
125
131
 
126
132
  return next();
@@ -3049,8 +3055,8 @@ export function validate_at_most_one_ref_attribute(raw_attrs, transform_context)
3049
3055
  }
3050
3056
  for (let i = 0; i < refs.length; i++) {
3051
3057
  const node = refs[i];
3052
- if (!transform_context?.loose && i === 0) {
3053
- // in the non-loose mode, only throw on the second duplicate
3058
+ if (!transform_context?.collect && i === 0) {
3059
+ // when not collecting, only throw on the second duplicate
3054
3060
  continue;
3055
3061
  }
3056
3062
  error(
package/types/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export { createJsxTransform, componentToFunctionDeclaration };
29
29
  * Compile error interface
30
30
  */
31
31
  export interface CompileError extends Error {
32
+ code: string | undefined;
32
33
  pos: number | undefined;
33
34
  raisedAt: number | undefined;
34
35
  end: number | undefined;
@@ -50,6 +51,11 @@ export interface CompileOptions {
50
51
  * When true, non-fatal errors are collected on the result's `errors`
51
52
  * array instead of being thrown. Defaults to false (strict mode: throws).
52
53
  */
54
+ collect?: boolean;
55
+ /**
56
+ * Enables editor-oriented parser recovery such as incomplete markup.
57
+ * Also collects non-fatal errors as `collect`.
58
+ */
53
59
  loose?: boolean;
54
60
  }
55
61
 
@@ -1149,6 +1155,7 @@ export interface ParseError {
1149
1155
  * Parse options
1150
1156
  */
1151
1157
  export interface ParseOptions {
1158
+ collect?: boolean;
1152
1159
  loose?: boolean;
1153
1160
  errors?: CompileError[];
1154
1161
  comments?: AST.CommentWithLocation[];
@@ -1278,7 +1285,7 @@ export interface ScopeConstructorInterface {
1278
1285
  parent: ScopeInterface | null;
1279
1286
  porous: boolean;
1280
1287
  error_options: {
1281
- loose: boolean;
1288
+ collect: boolean;
1282
1289
  errors: CompileError[];
1283
1290
  filename: string;
1284
1291
  comments?: AST.CommentWithLocation[];
@@ -1371,7 +1378,7 @@ export interface AnalysisState extends BaseState {
1371
1378
  };
1372
1379
  elements?: AST.Element[];
1373
1380
  function_depth?: number;
1374
- loose?: boolean;
1381
+ collect?: boolean;
1375
1382
  configured_compat_kinds?: Set<string>;
1376
1383
  metadata: BaseStateMetaData & {
1377
1384
  styleClasses?: StyleClasses;
@@ -1582,7 +1589,7 @@ export interface CompileResult {
1582
1589
  css: string;
1583
1590
  /**
1584
1591
  * Non-fatal errors collected during compilation. Populated only when the
1585
- * caller passes `loose: true`; empty otherwise.
1592
+ * caller passes `collect: true` or `loose: true`; empty otherwise.
1586
1593
  */
1587
1594
  errors: CompileError[];
1588
1595
  }
@@ -42,8 +42,8 @@ export interface JsxTransformContext {
42
42
  /** Source filename for diagnostics; null when the caller did not supply one. */
43
43
  filename: string | null;
44
44
  /** True when recoverable errors should be collected onto `errors` instead of thrown. */
45
- loose: boolean;
46
- /** Collected non-fatal errors. Undefined when `loose` is false. */
45
+ collect: boolean;
46
+ /** Collected non-fatal errors. Undefined when `collect` is false. */
47
47
  errors: CompileError[] | undefined;
48
48
  /** Module-level comments used to honor `@tsrx-ignore` / `@tsrx-expect-error`. */
49
49
  comments: AST.CommentWithLocation[] | undefined;
@@ -64,10 +64,14 @@ export interface JsxTransformOptions {
64
64
  * of thrown so editor tooling can surface them as diagnostics. Errors that
65
65
  * leave the transform in an unrecoverable state are still thrown.
66
66
  */
67
+ collect?: boolean;
68
+ /**
69
+ * Don't collect allowable errors such as unclosed tags
70
+ */
67
71
  loose?: boolean;
68
72
  /**
69
73
  * Collected non-fatal errors. The transform appends to this array when
70
- * `loose` is true; callers read it after the transform returns.
74
+ * `collect` or `loose` is true; callers read it after the transform returns.
71
75
  */
72
76
  errors?: CompileError[];
73
77
  /**
package/types/parse.d.ts CHANGED
@@ -183,13 +183,15 @@ export namespace Parse {
183
183
 
184
184
  export interface Options extends Omit<acorn.Options, 'onComment' | 'ecmaVersion'> {
185
185
  tsrxOptions?: {
186
+ collect: boolean;
186
187
  loose: boolean;
187
- errors: CoreCompiler.CompileError[];
188
+ errors: CoreCompiler.CompileError[] | undefined;
188
189
  filename: string | undefined;
189
190
  };
190
191
  rippleOptions?: {
192
+ collect: boolean;
191
193
  loose: boolean;
192
- errors: CoreCompiler.CompileError[];
194
+ errors: CoreCompiler.CompileError[] | undefined;
193
195
  filename: string | undefined;
194
196
  };
195
197
  // The type has "latest" but it's converted to 1e8 at runtime