@tsrx/core 0.1.24 → 0.1.26

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.24",
6
+ "version": "0.1.26",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  /** @import * as AST from 'estree' */
2
+ /** @import * as ESTreeJSX from 'estree-jsx' */
2
3
  /** @import { Visitors, TopScopedClasses, StyleClasses } from '../../types/index' */
3
4
  /** @typedef {0 | 1} Direction */
4
5
 
@@ -66,8 +67,14 @@ function get_attribute_value(attribute) {
66
67
  * @param {AST.Node} node
67
68
  * @returns {boolean}
68
69
  */
69
- function is_runtime_dynamic_element(node) {
70
- return node?.metadata?.runtime_dynamic_element === true;
70
+ function is_dynamic_element(node) {
71
+ // `metadata.dynamicElement` marks lowered dynamic tags; `isDynamic` is the
72
+ // parser flag on a not-yet-lowered `<{expr}>` element. Both resolve their
73
+ // tag at runtime, so they can match any type selector.
74
+ return (
75
+ node?.metadata?.dynamicElement === true ||
76
+ /** @type {ESTreeJSX.JSXExpressionContainer} */ (node)?.isDynamic === true
77
+ );
71
78
  }
72
79
 
73
80
  /**
@@ -390,7 +397,7 @@ function get_descendant_elements(node, adjacent_only) {
390
397
  * @returns {boolean}
391
398
  */
392
399
  function can_render_dynamic_content(element, check_classes = false) {
393
- if (is_runtime_dynamic_element(element)) {
400
+ if (is_dynamic_element(element)) {
394
401
  return true;
395
402
  }
396
403
 
@@ -1014,7 +1021,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
1014
1021
  }
1015
1022
 
1016
1023
  case 'TypeSelector': {
1017
- if (is_runtime_dynamic_element(element)) {
1024
+ if (is_dynamic_element(element)) {
1018
1025
  break;
1019
1026
  }
1020
1027
 
package/src/index.js CHANGED
@@ -173,6 +173,7 @@ export {
173
173
  get_style_element_stylesheet as getStyleElementStylesheet,
174
174
  } from './transform/style-ref.js';
175
175
  export {
176
+ add_extra_source_mappings_from_matching_expression,
176
177
  clone_expression_node,
177
178
  clone_identifier,
178
179
  clone_jsx_name,
package/src/plugin.js CHANGED
@@ -76,6 +76,25 @@ const REGEX_PRECEDING_KEYWORDS = new Set([
76
76
  'throw',
77
77
  ]);
78
78
 
79
+ // Transparent wrappers to look through when validating a dynamic tag
80
+ // expression (`<{expr}>`), and syntax that disqualifies one outright.
81
+ const DYNAMIC_TAG_WRAPPER_TYPES = new Set([
82
+ 'TSAsExpression',
83
+ 'TSTypeAssertion',
84
+ 'TSNonNullExpression',
85
+ 'ParenthesizedExpression',
86
+ 'ChainExpression',
87
+ ]);
88
+ const DYNAMIC_TAG_DISALLOWED_TYPES = new Set([
89
+ 'SpreadElement',
90
+ 'ExperimentalSpreadProperty',
91
+ 'ObjectExpression',
92
+ 'ArrayExpression',
93
+ 'CallExpression',
94
+ 'NewExpression',
95
+ 'TaggedTemplateExpression',
96
+ ]);
97
+
79
98
  /** @type {WeakMap<Record<string, boolean>, Map<string, number>>} */
80
99
  const argument_clash_first_positions = new WeakMap();
81
100
  /** @type {WeakMap<Record<string, boolean>, Set<string>>} */
@@ -1163,6 +1182,7 @@ export function TSRXPlugin(config) {
1163
1182
  if (next === CharCode.slash) return false;
1164
1183
  const tagLike =
1165
1184
  next === CharCode.greaterThan ||
1185
+ next === CharCode.openBrace ||
1166
1186
  next === CharCode.at ||
1167
1187
  next === CharCode.dollar ||
1168
1188
  next === CharCode.underscore ||
@@ -2581,10 +2601,70 @@ export function TSRXPlugin(config) {
2581
2601
  } else if (node.type === 'MemberExpression' || node.type === 'JSXMemberExpression') {
2582
2602
  // For components like <Foo.Bar>, return "Foo.Bar"
2583
2603
  return this.getElementName(node.object) + '.' + this.getElementName(node.property);
2604
+ } else if (this.#isDynamicJSXElementName(node)) {
2605
+ // Dynamic tags (`<{Tag}>`) name by expression source. The braces keep
2606
+ // them from colliding with static tag names ('style', 'head', ...) and
2607
+ // read as source syntax in error messages (`</{Tag}>`).
2608
+ const expression = /** @type {AST.Expression} */ (/** @type {any} */ (node).expression);
2609
+ return `{${this.input.slice(expression.start, expression.end).trim()}}`;
2584
2610
  }
2585
2611
  return null;
2586
2612
  }
2587
2613
 
2614
+ /**
2615
+ * @param {any} name
2616
+ * @returns {boolean}
2617
+ */
2618
+ #isDynamicJSXElementName(name) {
2619
+ return !!(name && name.type === 'JSXExpressionContainer' && name.isDynamic === true);
2620
+ }
2621
+
2622
+ /**
2623
+ * Dynamic tag expressions must be able to resolve to an element name:
2624
+ * an identifier, member access, static string, or a runtime expression
2625
+ * composed of those. Constructed values (calls, spreads, concatenation,
2626
+ * interpolation, object/array literals) and static non-string literals
2627
+ * can never be valid tag names.
2628
+ * @param {any} expression
2629
+ * @returns {boolean}
2630
+ */
2631
+ #isValidDynamicTagExpression(expression) {
2632
+ let node = expression;
2633
+ while (node && DYNAMIC_TAG_WRAPPER_TYPES.has(node.type)) {
2634
+ node = node.expression;
2635
+ }
2636
+ if (!node || node.type?.startsWith?.('JSX')) return false;
2637
+ if (node.type === 'Identifier') return node.name !== 'undefined';
2638
+ if (node.type === 'Literal') return typeof node.value === 'string';
2639
+ if (node.type === 'UnaryExpression' && node.operator === 'void') return false;
2640
+ return !this.#containsDisallowedDynamicTagSyntax(node);
2641
+ }
2642
+
2643
+ /**
2644
+ * @param {any} node
2645
+ * @param {Set<any>} [seen]
2646
+ * @returns {boolean}
2647
+ */
2648
+ #containsDisallowedDynamicTagSyntax(node, seen = new Set()) {
2649
+ if (!node || typeof node !== 'object' || seen.has(node)) return false;
2650
+ seen.add(node);
2651
+ if (Array.isArray(node)) {
2652
+ return node.some((child) => this.#containsDisallowedDynamicTagSyntax(child, seen));
2653
+ }
2654
+ if (
2655
+ DYNAMIC_TAG_DISALLOWED_TYPES.has(node.type) ||
2656
+ (node.type === 'TemplateLiteral' && node.expressions.length > 0) ||
2657
+ (node.type === 'BinaryExpression' && node.operator === '+')
2658
+ ) {
2659
+ return true;
2660
+ }
2661
+ for (const key of Object.keys(node)) {
2662
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') continue;
2663
+ if (this.#containsDisallowedDynamicTagSyntax(node[key], seen)) return true;
2664
+ }
2665
+ return false;
2666
+ }
2667
+
2588
2668
  /**
2589
2669
  * `<T,>(x: T) => x` and `<T>(x: T): T => x` should parse as generic
2590
2670
  * arrow functions, not JSX elements. acorn-typescript's `readToken`
@@ -2639,6 +2719,7 @@ export function TSRXPlugin(config) {
2639
2719
  const isTagLikeAfterLt =
2640
2720
  next === CharCode.slash ||
2641
2721
  next === CharCode.greaterThan ||
2722
+ next === CharCode.openBrace ||
2642
2723
  next === CharCode.at ||
2643
2724
  next === CharCode.dollar ||
2644
2725
  next === CharCode.underscore ||
@@ -2759,6 +2840,7 @@ export function TSRXPlugin(config) {
2759
2840
  !isWhitespaceAfterLt &&
2760
2841
  (nextChar === CharCode.slash ||
2761
2842
  nextChar === CharCode.greaterThan ||
2843
+ nextChar === CharCode.openBrace ||
2762
2844
  nextChar === CharCode.at ||
2763
2845
  nextChar === CharCode.dollar ||
2764
2846
  nextChar === CharCode.underscore ||
@@ -3444,6 +3526,18 @@ export function TSRXPlugin(config) {
3444
3526
  return this.finishNode(node, 'JSXIdentifier');
3445
3527
  }
3446
3528
 
3529
+ #parseJSXDynamicElementName() {
3530
+ const container = this.jsx_parseExpressionContainer();
3531
+ container.isDynamic = true;
3532
+ if (!this.#isValidDynamicTagExpression(container.expression)) {
3533
+ this.raise(
3534
+ /** @type {number} */ (container.expression?.start ?? container.start),
3535
+ 'Dynamic element names must be an identifier, member expression, static string, or runtime expression; calls, spreads, string concatenation, string interpolation, and static null, undefined, boolean, number, object, and array literals are not valid tag names.',
3536
+ );
3537
+ }
3538
+ return container;
3539
+ }
3540
+
3447
3541
  /**
3448
3542
  * @type {Parse.Parser['jsx_parseElementName']}
3449
3543
  */
@@ -3452,6 +3546,10 @@ export function TSRXPlugin(config) {
3452
3546
  return '';
3453
3547
  }
3454
3548
 
3549
+ if (this.type === tt.braceL) {
3550
+ return this.#parseJSXDynamicElementName();
3551
+ }
3552
+
3455
3553
  let node = this.jsx_parseNamespacedName();
3456
3554
 
3457
3555
  if (node.type === 'JSXNamespacedName') {
@@ -3983,7 +4081,10 @@ export function TSRXPlugin(config) {
3983
4081
  );
3984
4082
  node.attributes = [];
3985
4083
  const nodeName = this.jsx_parseElementName();
3986
- if (nodeName) node.name = nodeName;
4084
+ if (nodeName) node.name = /** @type {any} */ (nodeName);
4085
+ if (this.#isDynamicJSXElementName(nodeName)) {
4086
+ /** @type {any} */ (node).isDynamic = true;
4087
+ }
3987
4088
  if (this.match(tt.relational) || this.match(tt.bitShift)) {
3988
4089
  const typeArguments = /** @type {any} */ (this).tsTryParseAndCatch(() =>
3989
4090
  /** @type {any} */ (this).tsParseTypeArgumentsInExpression(),
@@ -4003,6 +4104,9 @@ export function TSRXPlugin(config) {
4003
4104
  this.getElementName(nodeName) === 'style' ? 'JSXStyleElement' : 'JSXElement';
4004
4105
  /** @type {any} */ (opening_template_node).openingElement = node;
4005
4106
  /** @type {any} */ (opening_template_node).closingElement = null;
4107
+ if (this.#isDynamicJSXElementName(nodeName)) {
4108
+ /** @type {any} */ (opening_template_node).isDynamic = true;
4109
+ }
4006
4110
  } else {
4007
4111
  /** @type {any} */ (opening_template_node).type = 'JSXFragment';
4008
4112
  /** @type {any} */ (opening_template_node).openingFragment =
@@ -4076,6 +4180,7 @@ export function TSRXPlugin(config) {
4076
4180
  this.#openingNativeTemplateNode = previous_opening_native_template_node;
4077
4181
  }
4078
4182
  const tag_name = open.name ? this.getElementName(open.name) : null;
4183
+ const is_dynamic = this.#isDynamicJSXElementName(open.name);
4079
4184
  const is_style = tag_name === 'style';
4080
4185
  const inside_head = this.#path.findLast((n) => this.#isNativeElementNamed(n, 'head'));
4081
4186
 
@@ -4109,6 +4214,9 @@ export function TSRXPlugin(config) {
4109
4214
  /** @type {ESTreeJSX.JSXElement} */ (node).type = 'JSXElement';
4110
4215
  /** @type {ESTreeJSX.JSXElement} */ (node).openingElement = open;
4111
4216
  /** @type {ESTreeJSX.JSXElement} */ (node).closingElement = null;
4217
+ if (is_dynamic) {
4218
+ /** @type {any} */ (node).isDynamic = true;
4219
+ }
4112
4220
  }
4113
4221
  }
4114
4222
 
@@ -4232,7 +4340,36 @@ export function TSRXPlugin(config) {
4232
4340
  body.push(text);
4233
4341
  }
4234
4342
  } else if (this.#isJSXControlFlowDirectiveStart()) {
4235
- body.push(this.#parseJSXControlFlowExpression());
4343
+ const directive = this.#parseJSXControlFlowExpression();
4344
+ body.push(directive);
4345
+ // `#parseTemplateControlFlowBlock` reads the token after the block's
4346
+ // closing `}` in a code (b_stat) context, which runs `skipSpace()` and
4347
+ // advances `start` past any whitespace. The following token is therefore a
4348
+ // JS token (e.g. the `else` keyword), and when it is actually sibling
4349
+ // template raw text it reaches `#parseTemplateRawText` having lost the
4350
+ // space(s) between `}` and the text (e.g. `@if (x) { … } else` -> the text
4351
+ // "else" instead of " else"). JSX text after a plain element keeps that
4352
+ // whitespace, so when raw text follows and only whitespace was skipped,
4353
+ // rewind `start` to the block's end to re-include the dropped whitespace.
4354
+ const blockEnd = directive.end;
4355
+ const nextCh = this.input.charCodeAt(this.start);
4356
+ const startsRawText =
4357
+ this.type !== tt.eof &&
4358
+ nextCh !== CharCode.lessThan &&
4359
+ nextCh !== CharCode.openBrace &&
4360
+ nextCh !== CharCode.closeBrace &&
4361
+ !this.#isJSXControlFlowDirectiveStart();
4362
+ if (
4363
+ startsRawText &&
4364
+ typeof blockEnd === 'number' &&
4365
+ this.start > blockEnd &&
4366
+ /^\s*$/.test(this.input.slice(blockEnd, this.start))
4367
+ ) {
4368
+ const loc = acorn.getLineInfo(this.input, blockEnd);
4369
+ this.pos = blockEnd;
4370
+ this.start = blockEnd;
4371
+ this.startLoc = new acorn.Position(loc.line, loc.column);
4372
+ }
4236
4373
  } else if (this.type === tt.braceR) {
4237
4374
  // Leaving a native template body. We may still be in TSX/JSX tokenization
4238
4375
  // context (e.g. after parsing markup), but the closing `}` is a JS token.
@@ -4259,6 +4396,18 @@ export function TSRXPlugin(config) {
4259
4396
  this.start = startPos;
4260
4397
  this.startLoc = startLoc;
4261
4398
  this.exprAllowed = false;
4399
+ // A genuine `jsxTagStart` pushes `tc_expr` + `tc_oTag` in its
4400
+ // `updateContext`; faking the token here skips those pushes. That is
4401
+ // harmless for an opening tag (the next token is the tag name), but a
4402
+ // closing tag (`</`) immediately runs `context.length -= 2` in the
4403
+ // slash `updateContext`, which would underflow the context stack and
4404
+ // throw "Invalid array length" (e.g. `<>@if (a) { … } done</>`). Push
4405
+ // the two contexts a real `jsxTagStart` would have added so the closing
4406
+ // tag pops its own contexts instead of the enclosing template's.
4407
+ if (this.input.charCodeAt(this.pos) === CharCode.slash) {
4408
+ this.context.push(tstc.tc_expr);
4409
+ this.context.push(tstc.tc_oTag);
4410
+ }
4262
4411
  this.next();
4263
4412
  }
4264
4413
  if (this.value === '/' || this.type === tt.slash) {
@@ -4274,6 +4423,9 @@ export function TSRXPlugin(config) {
4274
4423
  } finally {
4275
4424
  this.#closingNativeTemplateNode = false;
4276
4425
  }
4426
+ if (this.#isDynamicJSXElementName(closingElement.name)) {
4427
+ /** @type {any} */ (closingElement).isDynamic = true;
4428
+ }
4277
4429
  this.exprAllowed = false;
4278
4430
 
4279
4431
  // Validate that the closing tag matches the opening tag
@@ -89,6 +89,33 @@ export function clone_jsx_name(name, source_node = name) {
89
89
  return name;
90
90
  }
91
91
 
92
+ /**
93
+ * Record extra source positions on a generated expression so one generated
94
+ * range can map back to several source ranges. Used for dynamic tags, where
95
+ * the generated `is={expr}` value stands in for both `<{expr}` and `</{expr}>`;
96
+ * segments.js turns each recorded node into an additional mapping token.
97
+ * @param {any} generated
98
+ * @param {any} source
99
+ * @returns {void}
100
+ */
101
+ export function add_extra_source_mappings_from_matching_expression(generated, source) {
102
+ if (!generated || !source || generated.type !== source.type) return;
103
+
104
+ if (generated.type === 'Identifier' || generated.type === 'PrivateIdentifier') {
105
+ if (!source.loc) return;
106
+ generated.metadata ??= { path: [] };
107
+ generated.metadata.extra_source_mappings ??= [];
108
+ generated.metadata.extra_source_mappings.push(source);
109
+ return;
110
+ }
111
+
112
+ for (const key of ['expression', 'object', 'property']) {
113
+ if (generated[key] && source[key]) {
114
+ add_extra_source_mappings_from_matching_expression(generated[key], source[key]);
115
+ }
116
+ }
117
+ }
118
+
92
119
  /**
93
120
  * @returns {AST.Literal}
94
121
  */
@@ -52,7 +52,14 @@ export function tsx_node_to_jsx_expression(node, in_jsx_child = false) {
52
52
  (/** @type {any} */ child) => child.type !== 'JSXText' || child.value.trim() !== '',
53
53
  );
54
54
 
55
- if (children.length === 1 && children[0].type !== 'JSXText') {
55
+ if (
56
+ children.length === 1 &&
57
+ children[0].type !== 'JSXText' &&
58
+ // Reactive-block containers (dynamic tags) must stay expression
59
+ // children so the host JSX compiler wraps them in a render block;
60
+ // unwrapping to a bare call would evaluate them once.
61
+ children[0].metadata?.tsrx_reactive_block !== true
62
+ ) {
56
63
  const only = children[0];
57
64
  if (only.type === 'JSXExpressionContainer' && !in_jsx_child) {
58
65
  return only.expression;