@tsrx/core 0.0.4 → 0.0.6

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.0.4",
6
+ "version": "0.0.6",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
package/src/index.js CHANGED
@@ -132,7 +132,38 @@ export { escape } from './utils/escaping.js';
132
132
 
133
133
  // Transform
134
134
  export { render_stylesheets as renderStylesheets } from './transform/stylesheet.js';
135
- export { convert_source_map_to_mappings as convertSourceMapToMappings } from './transform/segments.js';
135
+ export {
136
+ prepare_stylesheet_for_render as prepareStylesheetForRender,
137
+ is_style_element as isStyleElement,
138
+ is_composite_element as isCompositeElement,
139
+ annotate_with_hash as annotateWithHash,
140
+ annotate_component_with_hash as annotateComponentWithHash,
141
+ add_hash_class as addHashClass,
142
+ } from './transform/scoping.js';
143
+ export {
144
+ convert_source_map_to_mappings as convertSourceMapToMappings,
145
+ create_volar_mappings_result as createVolarMappingsResult,
146
+ dedupe_mappings as dedupeMappings,
147
+ serialize_mapping_value as serializeMappingValue,
148
+ } from './transform/segments.js';
149
+ export {
150
+ create_lazy_context as createLazyContext,
151
+ collect_lazy_bindings as collectLazyBindings,
152
+ collect_lazy_bindings_from_component as collectLazyBindingsFromComponent,
153
+ collect_lazy_bindings_from_statements as collectLazyBindingsFromStatements,
154
+ preallocate_lazy_ids as preallocateLazyIds,
155
+ apply_lazy_transforms as applyLazyTransforms,
156
+ replace_lazy_params as replaceLazyParams,
157
+ } from './transform/lazy.js';
158
+ export {
159
+ find_first_top_level_await as findFirstTopLevelAwait,
160
+ find_first_top_level_await_in_component_body as findFirstTopLevelAwaitInComponentBody,
161
+ } from './transform/await.js';
162
+ export {
163
+ is_interleaved_body as isInterleavedBody,
164
+ is_capturable_jsx_child as isCapturableJsxChild,
165
+ capture_jsx_child as captureJsxChild,
166
+ } from './transform/jsx-interleave.js';
136
167
 
137
168
  // Analyze
138
169
  export { analyze_css as analyzeCss } from './analyze/css-analyze.js';
@@ -110,6 +110,68 @@ export function isWhitespaceTextNode(node) {
110
110
  return false;
111
111
  }
112
112
 
113
+ /**
114
+ * @type {AcornPlugin}
115
+ */
116
+ function elementTemplateClosingTagPlugin(Base) {
117
+ const jsxTagStart = /** @type {any} */ (Base).acornTypeScript?.tokTypes?.jsxTagStart;
118
+ if (!jsxTagStart) return Base;
119
+
120
+ /**
121
+ * @param {any} parser
122
+ */
123
+ function inElementTemplateBodyDirect(parser) {
124
+ const stack = parser.context;
125
+ const top = stack[stack.length - 1];
126
+ const below = stack[stack.length - 2];
127
+ return top && top.token === '{' && below && below.token === '<tag>...</tag>';
128
+ }
129
+
130
+ /**
131
+ * @param {any} parser
132
+ */
133
+ function inElementTemplateBodyAnywhere(parser) {
134
+ const stack = parser.context;
135
+ for (let i = 1; i < stack.length; i++) {
136
+ if (
137
+ stack[i] &&
138
+ stack[i].token === '{' &&
139
+ stack[i - 1] &&
140
+ stack[i - 1].token === '<tag>...</tag>'
141
+ ) {
142
+ return true;
143
+ }
144
+ }
145
+ return false;
146
+ }
147
+
148
+ return class extends Base {
149
+ /** @param {number} code */
150
+ // @ts-ignore — extending acorn's Parser with internal hooks
151
+ getTokenFromCode(code) {
152
+ if (code === 60 /* '<' */ && !(/** @type {any} */ (this).inType)) {
153
+ const self = /** @type {any} */ (this);
154
+ const nextChar =
155
+ self.pos + 1 < self.input.length ? self.input.charCodeAt(self.pos + 1) : -1;
156
+ if (nextChar === 47 /* '/' */ && inElementTemplateBodyDirect(self)) {
157
+ ++self.pos;
158
+ return self.finishToken(jsxTagStart);
159
+ }
160
+ }
161
+ // @ts-ignore — super dispatches to next layer in the plugin chain
162
+ return super.getTokenFromCode(code);
163
+ }
164
+
165
+ // @ts-ignore — extending acorn's Parser with internal hooks
166
+ canInsertSemicolon() {
167
+ const self = /** @type {any} */ (this);
168
+ if (self.type === jsxTagStart && inElementTemplateBodyAnywhere(self)) return true;
169
+ // @ts-ignore
170
+ return super.canInsertSemicolon();
171
+ }
172
+ };
173
+ }
174
+
113
175
  /**
114
176
  * Create a parser by composing Acorn with TypeScript/JSX support and optional framework plugins.
115
177
  *
@@ -125,6 +187,7 @@ export function createParser(...plugins) {
125
187
  acorn.Parser.extend(
126
188
  tsPlugin({ jsx: true }),
127
189
  ...plugins.map((p) => /** @type {AcornPlugin} */ (/** @type {unknown} */ (p))),
190
+ elementTemplateClosingTagPlugin,
128
191
  )
129
192
  )
130
193
  );
@@ -151,7 +214,7 @@ export function createParser(...plugins) {
151
214
  allowReturnOutsideFunction: true,
152
215
  locations: true,
153
216
  onComment,
154
- rippleOptions: {
217
+ tsrxOptions: {
155
218
  filename,
156
219
  errors: options?.errors ?? [],
157
220
  loose: options?.loose || false,
@@ -119,7 +119,7 @@ export function parse_style(content, options) {
119
119
 
120
120
  return {
121
121
  source: content,
122
- hash: `ripple-${hash(content)}`,
122
+ hash: `tsrx-${hash(content)}`,
123
123
  type: 'StyleSheet',
124
124
  children: read_body(parser),
125
125
  start: 0,
package/src/plugin.js CHANGED
@@ -51,9 +51,10 @@ export function TSRXPlugin(config) {
51
51
  */
52
52
  constructor(options, input) {
53
53
  super(options, input);
54
- this.#loose = options?.rippleOptions.loose === true;
55
- this.#errors = options?.rippleOptions.errors;
56
- this.#filename = options?.rippleOptions.filename || null;
54
+ const tsrx_options = options?.tsrxOptions ?? options?.rippleOptions;
55
+ this.#loose = tsrx_options?.loose === true;
56
+ this.#errors = tsrx_options?.errors;
57
+ this.#filename = tsrx_options?.filename || null;
57
58
  }
58
59
 
59
60
  /**
@@ -438,6 +439,10 @@ export function TSRXPlugin(config) {
438
439
  const isTagLikeAfterLt =
439
440
  !isWhitespaceAfterLt &&
440
441
  (nextChar === 47 || // '/'
442
+ nextChar === 62 || // '>' (fragments: <>)
443
+ nextChar === 64 || // '@'
444
+ nextChar === 36 || // '$'
445
+ nextChar === 95 || // '_'
441
446
  (nextChar >= 65 && nextChar <= 90) || // A-Z
442
447
  (nextChar >= 97 && nextChar <= 122)); // a-z
443
448
  const prevAllowsTagStart =
@@ -502,13 +507,11 @@ export function TSRXPlugin(config) {
502
507
  }
503
508
  }
504
509
 
505
- // Check if the character after < is not whitespace
506
- if (allWhitespace && this.pos + 1 < this.input.length) {
507
- const nextChar = this.input.charCodeAt(this.pos + 1);
508
- if (nextChar !== 32 && nextChar !== 9 && nextChar !== 10 && nextChar !== 13) {
509
- ++this.pos;
510
- return this.finishToken(tstt.jsxTagStart);
511
- }
510
+ // At the start of a line inside template bodies, only treat `<` as
511
+ // a tag start when the following character can actually begin a tag.
512
+ if (allWhitespace && isTagLikeAfterLt) {
513
+ ++this.pos;
514
+ return this.finishToken(tstt.jsxTagStart);
512
515
  }
513
516
  }
514
517
  }
@@ -1472,7 +1475,7 @@ export function TSRXPlugin(config) {
1472
1475
  */
1473
1476
  parseElement() {
1474
1477
  const inside_head = this.#path.findLast(
1475
- (n) => n.type === 'Element' && n.id.type === 'Identifier' && n.id.name === 'head',
1478
+ (n) => n.type === 'Element' && n.id && n.id.type === 'Identifier' && n.id.name === 'head',
1476
1479
  );
1477
1480
  // Adjust the start so we capture the `<` as part of the element
1478
1481
  const start = this.start - 1;
@@ -1491,10 +1494,14 @@ export function TSRXPlugin(config) {
1491
1494
  // Always attach the concrete opening element node for accurate source mapping
1492
1495
  element.openingElement = open;
1493
1496
 
1494
- // Check if this is a namespaced element (tsx:react)
1495
- const is_tsx_compat = open.name.type === 'JSXNamespacedName';
1497
+ // Fragments (<>) produce JSXOpeningFragment with no `name` property
1498
+ const is_fragment = !open.name;
1499
+ const is_tsx_compat = !is_fragment && open.name.type === 'JSXNamespacedName';
1496
1500
  const is_tsx =
1497
- !is_tsx_compat && open.name.type === 'JSXIdentifier' && open.name.name === 'tsx';
1501
+ !is_fragment &&
1502
+ !is_tsx_compat &&
1503
+ open.name.type === 'JSXIdentifier' &&
1504
+ open.name.name === 'tsx';
1498
1505
 
1499
1506
  if (is_tsx_compat) {
1500
1507
  const namespace_node = /** @type {ESTreeJSX.JSXNamespacedName} */ (open.name);
@@ -1544,11 +1551,13 @@ export function TSRXPlugin(config) {
1544
1551
  }
1545
1552
  }
1546
1553
 
1547
- if (!is_tsx_compat && !is_tsx) {
1554
+ if (!is_tsx_compat && !is_tsx && !is_fragment) {
1548
1555
  /** @type {AST.Element} */ (element).id = /** @type {AST.Identifier} */ (
1549
1556
  convert_from_jsx(/** @type {ESTreeJSX.JSXIdentifier} */ (open.name))
1550
1557
  );
1551
1558
  element.selfClosing = open.selfClosing;
1559
+ } else if (is_fragment) {
1560
+ element.selfClosing = false;
1552
1561
  }
1553
1562
 
1554
1563
  element.attributes = open.attributes;
@@ -1562,6 +1571,10 @@ export function TSRXPlugin(config) {
1562
1571
  this.pos--;
1563
1572
  this.next();
1564
1573
  }
1574
+ } else if (is_fragment) {
1575
+ this.enterScope(0);
1576
+ this.parseTemplateBody(/** @type {AST.Element} */ (element).children);
1577
+ this.exitScope();
1565
1578
  } else {
1566
1579
  if (/** @type {ESTreeJSX.JSXIdentifier} */ (open.name).name === 'script') {
1567
1580
  let content = '';
@@ -1814,7 +1827,7 @@ export function TSRXPlugin(config) {
1814
1827
  }
1815
1828
  }
1816
1829
 
1817
- if (element.closingElement && !is_tsx_compat && !is_tsx) {
1830
+ if (element.closingElement && !is_tsx_compat && !is_tsx && element.closingElement.name) {
1818
1831
  /** @type {unknown} */ (element.closingElement.name) = convert_from_jsx(
1819
1832
  element.closingElement.name,
1820
1833
  );
@@ -2039,12 +2052,13 @@ export function TSRXPlugin(config) {
2039
2052
  ? closingElement.name.namespace.name + ':' + closingElement.name.name.name
2040
2053
  : this.getElementName(closingElement.name);
2041
2054
  } else {
2042
- // Regular Element node
2043
- openingTagName = this.getElementName(currentElement.id);
2044
- closingTagName =
2045
- closingElement.name?.type === 'JSXNamespacedName'
2055
+ // Regular Element node (or fragment)
2056
+ openingTagName = currentElement.id ? this.getElementName(currentElement.id) : null;
2057
+ closingTagName = closingElement.name
2058
+ ? closingElement.name?.type === 'JSXNamespacedName'
2046
2059
  ? closingElement.name.namespace.name + ':' + closingElement.name.name.name
2047
- : this.getElementName(closingElement.name);
2060
+ : this.getElementName(closingElement.name)
2061
+ : null;
2048
2062
  }
2049
2063
 
2050
2064
  if (openingTagName !== closingTagName) {
@@ -2068,7 +2082,9 @@ export function TSRXPlugin(config) {
2068
2082
  ? 'tsx:' + elem.kind
2069
2083
  : elem.type === 'Tsx'
2070
2084
  ? 'tsx'
2071
- : this.getElementName(elem.id);
2085
+ : elem.id
2086
+ ? this.getElementName(elem.id)
2087
+ : null;
2072
2088
 
2073
2089
  // Found matching opening tag
2074
2090
  if (elemName === closingTagName) {
@@ -2089,9 +2105,9 @@ export function TSRXPlugin(config) {
2089
2105
 
2090
2106
  const elementToClose = this.#path[this.#path.length - 1];
2091
2107
  if (elementToClose && elementToClose.type === 'Element') {
2092
- const elementToCloseName = this.getElementName(
2093
- /** @type {AST.Element} */ (elementToClose).id,
2094
- );
2108
+ const elementToCloseName = /** @type {AST.Element} */ (elementToClose).id
2109
+ ? this.getElementName(/** @type {AST.Element} */ (elementToClose).id)
2110
+ : null;
2095
2111
  if (elementToCloseName === closingTagName) {
2096
2112
  /** @type {AST.Element} */ (elementToClose).closingElement = closingElement;
2097
2113
  }
@@ -286,7 +286,7 @@ export function build_line_offsets(text) {
286
286
  * @param {number} [gen_max_len]
287
287
  * @returns {CodeMapping | Error}
288
288
  */
289
- function maybe_get_mapping_from_node(
289
+ export function maybe_get_mapping_from_node(
290
290
  node,
291
291
  src_to_gen_map,
292
292
  gen_line_offsets,
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @param {any[]} body_nodes
3
+ * @returns {any | null}
4
+ */
5
+ export function find_first_top_level_await_in_component_body(body_nodes) {
6
+ for (const node of body_nodes) {
7
+ const found = find_first_top_level_await(node, false);
8
+ if (found) return found;
9
+ }
10
+
11
+ return null;
12
+ }
13
+
14
+ /**
15
+ * @param {any} node
16
+ * @param {boolean} inside_nested_function
17
+ * @returns {any | null}
18
+ */
19
+ export function find_first_top_level_await(node, inside_nested_function) {
20
+ if (!node || typeof node !== 'object') {
21
+ return null;
22
+ }
23
+
24
+ if (Array.isArray(node)) {
25
+ for (const child of node) {
26
+ const found = find_first_top_level_await(child, inside_nested_function);
27
+ if (found) return found;
28
+ }
29
+
30
+ return null;
31
+ }
32
+
33
+ if (
34
+ node.type === 'FunctionDeclaration' ||
35
+ node.type === 'FunctionExpression' ||
36
+ node.type === 'ArrowFunctionExpression'
37
+ ) {
38
+ return inside_nested_function ? null : find_first_top_level_await(node.body, true);
39
+ }
40
+
41
+ if (inside_nested_function) {
42
+ return null;
43
+ }
44
+
45
+ if (node.type === 'AwaitExpression' || (node.type === 'ForOfStatement' && node.await === true)) {
46
+ return node;
47
+ }
48
+
49
+ for (const key of Object.keys(node)) {
50
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') {
51
+ continue;
52
+ }
53
+
54
+ const found = find_first_top_level_await(node[key], false);
55
+ if (found) return found;
56
+ }
57
+
58
+ return null;
59
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Helpers for preserving source-order semantics when non-JSX statements are
3
+ * interleaved with JSX children inside a component or element body.
4
+ *
5
+ * Without these, targets like React and Solid would hoist all statements
6
+ * before any JSX is constructed, so mutations between sibling JSX children
7
+ * would be observed by every sibling instead of only the ones that appear
8
+ * textually after the mutation.
9
+ */
10
+
11
+ /**
12
+ * Returns true when the body contains a non-JSX statement that appears
13
+ * after a JSX child. In that case JSX children must be captured at their
14
+ * source position so mutations in following statements do not retroactively
15
+ * change what earlier children rendered.
16
+ *
17
+ * The `is_jsx_child` predicate is target-specific — each target recognizes
18
+ * a different set of JSX-bearing node types (Ripple `Element`, `Text`,
19
+ * `TSRXExpression`, etc. plus plain JSX nodes).
20
+ *
21
+ * @param {any[]} body_nodes
22
+ * @param {(node: any) => boolean} is_jsx_child
23
+ * @returns {boolean}
24
+ */
25
+ export function is_interleaved_body(body_nodes, is_jsx_child) {
26
+ let seen_jsx = false;
27
+ for (const child of body_nodes) {
28
+ if (is_jsx_child(child)) {
29
+ seen_jsx = true;
30
+ } else if (seen_jsx) {
31
+ return true;
32
+ }
33
+ }
34
+ return false;
35
+ }
36
+
37
+ /**
38
+ * Only JSX nodes that evaluate to a single expression can be hoisted into a
39
+ * `const`. Static text children (`JSXText`) are inert and don't need
40
+ * capturing — their position relative to mutations doesn't change output.
41
+ *
42
+ * @param {any} jsx
43
+ * @returns {boolean}
44
+ */
45
+ export function is_capturable_jsx_child(jsx) {
46
+ if (!jsx) return false;
47
+ const t = jsx.type;
48
+ return t === 'JSXElement' || t === 'JSXFragment' || t === 'JSXExpressionContainer';
49
+ }
50
+
51
+ /**
52
+ * Build a `VariableDeclaration` that captures a JSX child into a const at
53
+ * its source position, along with a JSXExpressionContainer referencing the
54
+ * capture. The caller inserts the declaration into the enclosing block's
55
+ * statements in source order and uses the reference in place of the JSX
56
+ * child inside the returned fragment.
57
+ *
58
+ * @param {any} jsx
59
+ * @param {number} capture_index
60
+ * @returns {{ declaration: any, reference: any }}
61
+ */
62
+ export function capture_jsx_child(jsx, capture_index) {
63
+ const name = `_tsrx_child_${capture_index}`;
64
+ const init = jsx.type === 'JSXExpressionContainer' ? jsx.expression : jsx;
65
+
66
+ const declaration = /** @type {any} */ ({
67
+ type: 'VariableDeclaration',
68
+ kind: 'const',
69
+ declarations: [
70
+ /** @type {any} */ ({
71
+ type: 'VariableDeclarator',
72
+ id: /** @type {any} */ ({
73
+ type: 'Identifier',
74
+ name,
75
+ metadata: { path: [] },
76
+ }),
77
+ init,
78
+ metadata: { path: [] },
79
+ }),
80
+ ],
81
+ metadata: { path: [] },
82
+ });
83
+
84
+ // NOTE: JSXExpressionContainer nodes are intentionally created without
85
+ // loc — they're synthetic wrappers whose source positions don't
86
+ // correspond to source-map entries and adding loc causes Volar mapping
87
+ // failures.
88
+ const reference = /** @type {any} */ ({
89
+ type: 'JSXExpressionContainer',
90
+ expression: /** @type {any} */ ({
91
+ type: 'Identifier',
92
+ name,
93
+ metadata: { path: [] },
94
+ }),
95
+ metadata: { path: [] },
96
+ });
97
+
98
+ return { declaration, reference };
99
+ }