@tsrx/core 0.1.8 → 0.1.9

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.8",
6
+ "version": "0.1.9",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -82,10 +82,10 @@
82
82
  "vscode-languageserver-types": "^3.17.5",
83
83
  "vue": "3.6.0-beta.10",
84
84
  "vue-jsx-vapor": "^3.2.12",
85
- "@tsrx/preact": "0.1.8",
86
- "@tsrx/react": "0.2.8",
87
- "@tsrx/solid": "0.1.8",
88
- "@tsrx/vue": "0.1.8"
85
+ "@tsrx/preact": "0.1.9",
86
+ "@tsrx/react": "0.2.9",
87
+ "@tsrx/solid": "0.1.9",
88
+ "@tsrx/vue": "0.1.9"
89
89
  },
90
90
  "files": [
91
91
  "src",
@@ -5,4 +5,5 @@ export const DIAGNOSTIC_CODES = {
5
5
  UNCLOSED_TAG: 'tsrx-unclosed-tag',
6
6
  MISMATCHED_CLOSING_TAG: 'tsrx-mismatched-closing-tag',
7
7
  TEMPLATE_EXPRESSION_TRAILING_SEMICOLON: 'tsrx-template-expression-trailing-semicolon',
8
+ HTML_DIRECTIVE_AS_ATTRIBUTE_VALUE: 'tsrx-html-directive-as-attribute-value',
8
9
  };
package/src/index.js CHANGED
@@ -146,14 +146,21 @@ export {
146
146
  clone_switch_helper_invocation as cloneSwitchHelperInvocation,
147
147
  collect_param_bindings as collectParamBindings,
148
148
  collect_statement_bindings as collectStatementBindings,
149
+ create_host_html_attribute as createHostHtmlAttribute,
150
+ create_host_html_conflict_error as createHostHtmlConflictError,
149
151
  createJsxTransform,
150
152
  CREATE_REF_PROP_INTERNAL_NAME,
151
153
  extract_jsx_setup_declarations as extractJsxSetupDeclarations,
154
+ get_host_html_conflicting_attribute as getHostHtmlConflictingAttribute,
155
+ get_invalid_html_child_error_message as getInvalidHtmlChildErrorMessage,
156
+ is_component_like_element,
152
157
  is_ref_prop_expression as isRefPropExpression,
153
158
  MERGE_REFS_INTERNAL_NAME,
154
159
  merge_duplicate_refs as mergeDuplicateRefs,
155
160
  NORMALIZE_SPREAD_PROPS_INTERNAL_NAME,
156
161
  plan_switch_lift as planSwitchLift,
162
+ recover_invalid_html_child as recoverInvalidHtmlChild,
163
+ rewrite_host_html_children as rewriteHostHtmlChildren,
157
164
  return_value_body_to_expression as returnValueBodyToExpression,
158
165
  rewrite_loop_continues_to_bare_returns as rewriteLoopContinuesToBareReturns,
159
166
  to_jsx_attribute as toJsxAttribute,
package/src/plugin.js CHANGED
@@ -19,6 +19,8 @@ import { DIAGNOSTIC_CODES } from './diagnostics.js';
19
19
 
20
20
  const JSX_EXPRESSION_VALUE_ERROR =
21
21
  'JSX elements cannot be used as expressions. Wrap JSX with `<>...</>` or `<tsx>...</tsx>`, wrap TSRX templates with `<tsrx>...</tsrx>`, or use elements as statements within a component.';
22
+ const HTML_ATTRIBUTE_VALUE_ERROR =
23
+ '`{html ...}` is not supported as an attribute value. Use a string literal or expression without `html`.';
22
24
 
23
25
  const CharCode = Object.freeze({
24
26
  tab: 9,
@@ -1969,6 +1971,27 @@ export function TSRXPlugin(config) {
1969
1971
  /** @type {AST.RefAttribute} */ (node).argument = this.parseMaybeAssign();
1970
1972
  this.expect(tt.braceR);
1971
1973
  return /** @type {AST.RefAttribute} */ (this.finishNode(node, 'RefAttribute'));
1974
+ } else if (this.type === tt.name && this.value === 'html') {
1975
+ // {html ...}
1976
+ // The support is purely for better error messages to avoid
1977
+ // the parser throw an unexpected token error
1978
+ const id = /** @type {AST.Identifier} */ (this.parseIdentNode());
1979
+ id.tracked = false;
1980
+ this.finishNode(id, 'Identifier');
1981
+ this.next();
1982
+ const value = this.type === tt.braceR ? id : this.parseMaybeAssign();
1983
+ const report_end = this.type === tt.braceR ? this.end : (value.end ?? this.end);
1984
+ this.#report_recoverable_error_range(
1985
+ node.start ?? id.start ?? this.start,
1986
+ report_end,
1987
+ HTML_ATTRIBUTE_VALUE_ERROR,
1988
+ DIAGNOSTIC_CODES.HTML_DIRECTIVE_AS_ATTRIBUTE_VALUE,
1989
+ );
1990
+ /** @type {AST.Attribute} */ (node).name = id;
1991
+ /** @type {AST.Attribute} */ (node).value = value;
1992
+ /** @type {AST.Attribute} */ (node).shorthand = false;
1993
+ this.expect(tt.braceR);
1994
+ return this.finishNode(node, 'Attribute');
1972
1995
  } else if (this.type === tt.ellipsis) {
1973
1996
  this.expect(tt.ellipsis);
1974
1997
  /** @type {AST.SpreadAttribute} */ (node).argument = this.parseMaybeAssign();
@@ -1992,10 +2015,18 @@ export function TSRXPlugin(config) {
1992
2015
  }
1993
2016
  }
1994
2017
  /** @type {ESTreeJSX.JSXAttribute} */ (node).name = this.jsx_parseNamespacedName();
1995
- /** @type {ESTreeJSX.JSXAttribute} */ (node).value =
1996
- /** @type {ESTreeJSX.JSXAttribute['value'] | null} */ (
1997
- this.eat(tt.eq) ? this.jsx_parseAttributeValue() : null
2018
+ const value = /** @type {ESTreeJSX.JSXAttribute['value'] | null} */ (
2019
+ this.eat(tt.eq) ? this.jsx_parseAttributeValue() : null
2020
+ );
2021
+ if (value?.type === 'JSXExpressionContainer' && value.html) {
2022
+ this.#report_recoverable_error_range(
2023
+ value.start ?? node.start ?? this.start,
2024
+ value.end ?? node.end ?? this.end,
2025
+ HTML_ATTRIBUTE_VALUE_ERROR,
2026
+ DIAGNOSTIC_CODES.HTML_DIRECTIVE_AS_ATTRIBUTE_VALUE,
1998
2027
  );
2028
+ }
2029
+ /** @type {ESTreeJSX.JSXAttribute} */ (node).value = value;
1999
2030
  return this.finishNode(node, 'JSXAttribute');
2000
2031
  }
2001
2032
 
@@ -2205,6 +2236,8 @@ export function TSRXPlugin(config) {
2205
2236
  // In JSX text mode, '<' and '{' always start a tag/expression container.
2206
2237
  // `exprAllowed` can be false here due to surrounding parser state, but
2207
2238
  // throwing breaks valid templates (e.g. sibling tags after a close).
2239
+ this.start = this.pos;
2240
+ this.startLoc = this.curPosition();
2208
2241
  if (ch === CharCode.lessThan) {
2209
2242
  ++this.pos;
2210
2243
  return this.finishToken(tstt.jsxTagStart);
@@ -2434,6 +2467,8 @@ export function TSRXPlugin(config) {
2434
2467
  /** @type {AST.NodeWithLocation} */ (element).loc.start = position;
2435
2468
  element.metadata = { path: [] };
2436
2469
  element.children = [];
2470
+ element.type = 'Element';
2471
+ this.#path.push(element);
2437
2472
 
2438
2473
  const open = /** @type {ESTreeJSX.JSXOpeningElement & AST.NodeWithLocation} */ (
2439
2474
  this.jsx_parseOpeningElementAt(start, position)
@@ -2492,8 +2527,6 @@ export function TSRXPlugin(config) {
2492
2527
  element.type = 'Element';
2493
2528
  }
2494
2529
 
2495
- this.#path.push(element);
2496
-
2497
2530
  for (const attr of open.attributes) {
2498
2531
  if (attr.type === 'JSXAttribute') {
2499
2532
  /** @type {AST.Attribute} */ (/** @type {unknown} */ (attr)).type = 'Attribute';
@@ -2534,7 +2567,12 @@ export function TSRXPlugin(config) {
2534
2567
 
2535
2568
  element.attributes = open.attributes;
2536
2569
  element.metadata ??= { path: [] };
2537
- element.metadata.commentContainerId = ++this.#commentContextId;
2570
+ // Opening-tag parsing can tokenize comments that appear before the first
2571
+ // child. Preserve that early container id so the comment stays associated
2572
+ // with this element during comment attachment/printing.
2573
+ if (element.metadata.commentContainerId === undefined) {
2574
+ element.metadata.commentContainerId = ++this.#commentContextId;
2575
+ }
2538
2576
 
2539
2577
  if (element.selfClosing) {
2540
2578
  this.#path.pop();
@@ -2548,7 +2586,7 @@ export function TSRXPlugin(config) {
2548
2586
  enterScope: true,
2549
2587
  });
2550
2588
 
2551
- if (element.type === 'Tsx') {
2589
+ if (/** @type {AST.Tsx} */ (element).type === 'Tsx') {
2552
2590
  this.#path.pop();
2553
2591
 
2554
2592
  if (!element.unclosed) {
@@ -2725,7 +2763,7 @@ export function TSRXPlugin(config) {
2725
2763
  enterScope: true,
2726
2764
  });
2727
2765
 
2728
- if (element.type === 'Tsx') {
2766
+ if (/** @type {AST.Tsx} */ (element).type === 'Tsx') {
2729
2767
  this.#path.pop();
2730
2768
 
2731
2769
  if (!element.unclosed) {
@@ -2749,12 +2787,15 @@ export function TSRXPlugin(config) {
2749
2787
  this.#popTsxTokenContextBeforeTemplateExpressionChild();
2750
2788
  this.next();
2751
2789
  }
2752
- } else if (element.type === 'TsxCompat') {
2790
+ } else if (/** @type {AST.TsxCompat} */ (element).type === 'TsxCompat') {
2753
2791
  this.#path.pop();
2754
2792
 
2755
2793
  if (!element.unclosed) {
2756
2794
  const raise_error = () => {
2757
- this.raise(this.start, `Expected closing tag '</tsx:${element.kind}>'`);
2795
+ this.raise(
2796
+ this.start,
2797
+ `Expected closing tag '</tsx:${/** @type {AST.TsxCompat} */ (element).kind}>'`,
2798
+ );
2758
2799
  };
2759
2800
 
2760
2801
  this.next();
@@ -2771,7 +2812,7 @@ export function TSRXPlugin(config) {
2771
2812
  raise_error();
2772
2813
  }
2773
2814
  this.next();
2774
- if (this.value !== element.kind) {
2815
+ if (this.value !== /** @type {AST.TsxCompat} */ (element).kind) {
2775
2816
  raise_error();
2776
2817
  }
2777
2818
  this.next();
@@ -2781,7 +2822,10 @@ export function TSRXPlugin(config) {
2781
2822
  this.#popTsxTokenContextBeforeTemplateExpressionChild();
2782
2823
  this.next();
2783
2824
  }
2784
- } else if (element.type === 'Tsrx' && this.#path[this.#path.length - 1] === element) {
2825
+ } else if (
2826
+ /** @type {AST.Tsrx} */ (element).type === 'Tsrx' &&
2827
+ this.#path[this.#path.length - 1] === element
2828
+ ) {
2785
2829
  this.#report_broken_markup_error(
2786
2830
  this.start,
2787
2831
  "Unclosed tag '<tsrx>'. Expected '</tsrx>' before end of component.",
@@ -60,20 +60,43 @@ const HOOK_CALLBACK_OUTER_MUTATION_ERROR =
60
60
  const TEMPLATE_FRAGMENT_ERROR =
61
61
  'JSX fragment syntax is not needed in TSRX templates. TSRX renders in immediate mode, so everything is already a fragment. Use `<>...</>` only within <tsx>...</tsx>.';
62
62
 
63
+ /**
64
+ * @param {TransformContext} transform_context
65
+ * @returns {string}
66
+ */
67
+ export function get_invalid_html_child_error_message(transform_context) {
68
+ return `\`{html ...}\` is only supported as the sole child of an element in ${transform_context.platform.name}.`;
69
+ }
70
+
63
71
  /**
64
72
  * @param {AST.Node} node
65
73
  * @param {TransformContext} transform_context
66
74
  */
67
- function report_html_template_unsupported_error(node, transform_context) {
68
- // this should be a fatal error so we don't pass the errors collection,
69
- // since we don't have a transform for the Html node
75
+ function report_invalid_html_child_error(node, transform_context) {
70
76
  error(
71
- `\`{html ...}\` is not supported on the ${transform_context.platform.name} target. Use \`dangerouslySetInnerHTML={{ __html: ... }}\` as an element attribute instead.`,
77
+ get_invalid_html_child_error_message(transform_context),
72
78
  transform_context.filename,
73
79
  node,
80
+ transform_context.errors,
81
+ transform_context.comments,
74
82
  );
75
83
  }
76
84
 
85
+ /**
86
+ * In loose/editor mode `error(...)` records the diagnostic and continues, so an
87
+ * invalid standalone `{html ...}` child still needs a valid expression node for
88
+ * the virtual TSX output.
89
+ *
90
+ * @param {any} node
91
+ * @param {TransformContext} transform_context
92
+ * @returns {ESTreeJSX.JSXExpressionContainer}
93
+ */
94
+ export function recover_invalid_html_child(node, transform_context) {
95
+ report_invalid_html_child_error(node, transform_context);
96
+ const expression = set_loc(clone_expression_node(node.expression), node);
97
+ return to_jsx_expression_container(expression, node);
98
+ }
99
+
77
100
  /**
78
101
  * @param {AST.Node} node
79
102
  * @param {TransformContext} transform_context
@@ -2310,10 +2333,19 @@ function to_jsx_element(node, transform_context, raw_children = node.children ||
2310
2333
  selfClosing = child_transform.selfClosing;
2311
2334
  }
2312
2335
  } else {
2313
- if (walked_children.some((/** @type {any} */ c) => c && c.type === 'Html')) {
2314
- return report_html_template_unsupported_error(node, transform_context);
2336
+ const html_child_transform = rewrite_host_html_children(
2337
+ node,
2338
+ walked_children,
2339
+ raw_children,
2340
+ attributes,
2341
+ transform_context,
2342
+ );
2343
+ if (html_child_transform) {
2344
+ children = html_child_transform.children;
2345
+ selfClosing = html_child_transform.selfClosing;
2346
+ } else {
2347
+ children = create_element_children(walked_children, transform_context);
2315
2348
  }
2316
- children = create_element_children(walked_children, transform_context);
2317
2349
  }
2318
2350
  const has_unmappable_attribute = attributes.some(
2319
2351
  (/** @type {any} */ attribute) => attribute?.metadata?.has_unmappable_value,
@@ -2341,6 +2373,117 @@ function to_jsx_element(node, transform_context, raw_children = node.children ||
2341
2373
  return set_loc(b.jsx_element_fresh(openingElement, closingElement, children), node);
2342
2374
  }
2343
2375
 
2376
+ /**
2377
+ * @param {any} node
2378
+ * @param {any[]} walked_children
2379
+ * @param {any[]} raw_children
2380
+ * @param {any[]} attributes
2381
+ * @param {TransformContext} transform_context
2382
+ * @returns {{ children: any[]; selfClosing: boolean } | null}
2383
+ */
2384
+ export function rewrite_host_html_children(
2385
+ node,
2386
+ walked_children,
2387
+ raw_children,
2388
+ attributes,
2389
+ transform_context,
2390
+ ) {
2391
+ const source_children = raw_children || walked_children;
2392
+ const source_html_index = source_children.findIndex((child) => child?.type === 'Html');
2393
+ if (source_html_index === -1) {
2394
+ return null;
2395
+ }
2396
+ const source_html = source_children[source_html_index];
2397
+ const walked_html =
2398
+ walked_children[source_html_index]?.type === 'Html'
2399
+ ? walked_children[source_html_index]
2400
+ : source_html;
2401
+
2402
+ if (is_component_like_element(node) || source_children.length !== 1) {
2403
+ report_invalid_html_child_error(source_html, transform_context);
2404
+ }
2405
+
2406
+ const conflicting_attribute = get_host_html_conflicting_attribute(attributes, transform_context);
2407
+ if (conflicting_attribute !== null) {
2408
+ error(
2409
+ create_host_html_conflict_error(conflicting_attribute, transform_context),
2410
+ transform_context.filename,
2411
+ source_html,
2412
+ transform_context.errors,
2413
+ transform_context.comments,
2414
+ );
2415
+ }
2416
+
2417
+ attributes.push(create_host_html_attribute(walked_html, source_html, transform_context));
2418
+
2419
+ return { children: [], selfClosing: true };
2420
+ }
2421
+
2422
+ /**
2423
+ * @param {any[]} attributes
2424
+ * @param {TransformContext} transform_context
2425
+ * @returns {{ kind: 'attribute'; name: string } | null}
2426
+ */
2427
+ export function get_host_html_conflicting_attribute(attributes, transform_context) {
2428
+ const conflicting_attributes = get_host_html_conflicting_attribute_names(transform_context);
2429
+ for (const name of conflicting_attributes) {
2430
+ if (has_jsx_attribute(attributes, name)) {
2431
+ return { kind: 'attribute', name };
2432
+ }
2433
+ }
2434
+
2435
+ return null;
2436
+ }
2437
+
2438
+ /**
2439
+ * @param {{ kind: 'attribute'; name: string }} conflicting_attribute
2440
+ * @param {TransformContext} transform_context
2441
+ * @returns {string}
2442
+ */
2443
+ export function create_host_html_conflict_error(conflicting_attribute, transform_context) {
2444
+ const html_attribute = get_host_html_attribute_name(transform_context);
2445
+ return `\`{html ...}\` lowers to \`${html_attribute}\` on the ${transform_context.platform.name} target and cannot be combined with an existing \`${conflicting_attribute.name}\` attribute.`;
2446
+ }
2447
+
2448
+ /**
2449
+ * @param {TransformContext} transform_context
2450
+ * @returns {string[]}
2451
+ */
2452
+ function get_host_html_conflicting_attribute_names(transform_context) {
2453
+ switch (transform_context.platform.name) {
2454
+ case 'Solid':
2455
+ return ['innerHTML', 'textContent'];
2456
+ case 'Vue':
2457
+ return ['innerHTML'];
2458
+ default:
2459
+ return [get_host_html_attribute_name(transform_context)];
2460
+ }
2461
+ }
2462
+
2463
+ /**
2464
+ * @param {TransformContext} transform_context
2465
+ * @returns {'dangerouslySetInnerHTML' | 'innerHTML'}
2466
+ */
2467
+ function get_host_html_attribute_name(transform_context) {
2468
+ return transform_context.platform.jsx?.htmlProp === 'dangerouslySetInnerHTML'
2469
+ ? 'dangerouslySetInnerHTML'
2470
+ : 'innerHTML';
2471
+ }
2472
+
2473
+ /**
2474
+ * @param {any[]} attributes
2475
+ * @param {string} name
2476
+ * @returns {boolean}
2477
+ */
2478
+ function has_jsx_attribute(attributes, name) {
2479
+ return attributes.some(
2480
+ (attr) =>
2481
+ attr?.type === 'JSXAttribute' &&
2482
+ attr.name?.type === 'JSXIdentifier' &&
2483
+ attr.name.name === name,
2484
+ );
2485
+ }
2486
+
2344
2487
  /**
2345
2488
  * @param {any[]} children
2346
2489
  * @param {TransformContext} transform_context
@@ -3580,7 +3723,7 @@ function to_jsx_child(node, transform_context) {
3580
3723
  case 'TSRXExpression':
3581
3724
  return to_jsx_expression_container(node.expression, node);
3582
3725
  case 'Html':
3583
- return report_html_template_unsupported_error(node, transform_context);
3726
+ return recover_invalid_html_child(node, transform_context);
3584
3727
  case 'IfStatement':
3585
3728
  return (
3586
3729
  transform_context.platform.hooks?.controlFlow?.ifStatement ?? if_statement_to_jsx_child
@@ -4918,7 +5061,7 @@ function transform_element_attributes_dispatch(attrs, transform_context, element
4918
5061
  * @param {any} element
4919
5062
  * @returns {boolean}
4920
5063
  */
4921
- function is_component_like_element(element) {
5064
+ export function is_component_like_element(element) {
4922
5065
  const id = element?.id;
4923
5066
  if (!id) return false;
4924
5067
  if (id.type === 'Identifier') return /^[A-Z]/.test(id.name);
@@ -5127,6 +5270,31 @@ function is_named_ref_attribute(attr) {
5127
5270
  );
5128
5271
  }
5129
5272
 
5273
+ /**
5274
+ * @param {any} html_expression
5275
+ * @param {any} source_attr
5276
+ * @param {TransformContext} transform_context
5277
+ * @returns {any}
5278
+ */
5279
+ export function create_host_html_attribute(html_expression, source_attr, transform_context) {
5280
+ const expression =
5281
+ html_expression?.type === 'Html' ? html_expression.expression : html_expression;
5282
+ const name = get_host_html_attribute_name(transform_context);
5283
+ const value =
5284
+ name === 'dangerouslySetInnerHTML'
5285
+ ? set_loc(b.object([b.prop('init', b.id('__html'), expression)]), source_attr)
5286
+ : expression;
5287
+ const value_container = to_jsx_expression_container(value, source_attr);
5288
+ if (name !== 'dangerouslySetInnerHTML') {
5289
+ setLocation(value_container, source_attr, true);
5290
+ }
5291
+
5292
+ return set_loc(
5293
+ build_jsx_attribute(b.jsx_id(name), value_container, false, source_attr),
5294
+ source_attr,
5295
+ );
5296
+ }
5297
+
5130
5298
  /**
5131
5299
  * @param {any} expression
5132
5300
  * @returns {boolean}
@@ -345,6 +345,8 @@ export interface JsxPlatform {
345
345
  * explicit `ref={normalized.ref}` attribute.
346
346
  */
347
347
  hostSpreadRefStrategy?: 'explicit-ref-attr';
348
+ /** Native host prop used when lowering a sole child `{html ...}`. */
349
+ htmlProp?: 'dangerouslySetInnerHTML' | 'innerHTML';
348
350
  };
349
351
 
350
352
  validation: {