@tsrx/core 0.1.7 → 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.7",
6
+ "version": "0.1.9",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -64,7 +64,7 @@
64
64
  "@types/estree-jsx": "^1.0.5",
65
65
  "@types/estree": "^1.0.8",
66
66
  "acorn": "^8.15.0",
67
- "esrap": "^2.2.7",
67
+ "esrap": "^2.2.8",
68
68
  "is-reference": "^3.0.3",
69
69
  "magic-string": "^0.30.18",
70
70
  "zimmerframe": "^1.1.2"
@@ -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.7",
86
- "@tsrx/react": "0.2.7",
87
- "@tsrx/solid": "0.1.7",
88
- "@tsrx/vue": "0.1.7"
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,41 @@ 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`.';
24
+
25
+ const CharCode = Object.freeze({
26
+ tab: 9,
27
+ lineFeed: 10,
28
+ carriageReturn: 13,
29
+ space: 32,
30
+ doubleQuote: 34,
31
+ dollar: 36,
32
+ ampersand: 38,
33
+ singleQuote: 39,
34
+ openParen: 40,
35
+ closeParen: 41,
36
+ asterisk: 42,
37
+ slash: 47,
38
+ colon: 58,
39
+ semicolon: 59,
40
+ lessThan: 60,
41
+ equals: 61,
42
+ greaterThan: 62,
43
+ at: 64,
44
+ digit0: 48,
45
+ digit9: 57,
46
+ uppercaseA: 65,
47
+ uppercaseZ: 90,
48
+ openBracket: 91,
49
+ backslash: 92,
50
+ underscore: 95,
51
+ backtick: 96,
52
+ lowercaseA: 97,
53
+ lowercaseZ: 122,
54
+ openBrace: 123,
55
+ closeBrace: 125,
56
+ });
22
57
 
23
58
  /** @type {WeakMap<Record<string, boolean>, Map<string, number>>} */
24
59
  const argument_clash_first_positions = new WeakMap();
@@ -58,7 +93,13 @@ function get_argument_clash_reported_names(check_clashes) {
58
93
  function skip_whitespace_from(input, i) {
59
94
  while (i < input.length) {
60
95
  const ch = input.charCodeAt(i);
61
- if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) break;
96
+ if (
97
+ ch !== CharCode.space &&
98
+ ch !== CharCode.tab &&
99
+ ch !== CharCode.lineFeed &&
100
+ ch !== CharCode.carriageReturn
101
+ )
102
+ break;
62
103
  i++;
63
104
  }
64
105
  return i;
@@ -75,7 +116,7 @@ function skip_string_from(input, i, quote) {
75
116
  while (i < input.length) {
76
117
  const ch = input.charCodeAt(i);
77
118
  i++;
78
- if (ch === 92)
119
+ if (ch === CharCode.backslash)
79
120
  i++; // backslash escape
80
121
  else if (ch === quote) return i;
81
122
  }
@@ -95,7 +136,7 @@ function scan_balanced_from(input, i, open, close) {
95
136
  i++;
96
137
  while (i < input.length) {
97
138
  const ch = input.charCodeAt(i);
98
- if (ch === 34 || ch === 39 || ch === 96) {
139
+ if (ch === CharCode.doubleQuote || ch === CharCode.singleQuote || ch === CharCode.backtick) {
99
140
  i = skip_string_from(input, i, ch);
100
141
  continue;
101
142
  }
@@ -114,47 +155,50 @@ function scan_balanced_from(input, i, open, close) {
114
155
  * @param {number} pos
115
156
  */
116
157
  function looks_like_generic_arrow(input, pos) {
117
- if (input.charCodeAt(pos) !== 60) return false;
158
+ if (input.charCodeAt(pos) !== CharCode.lessThan) return false;
118
159
 
119
160
  // Match the angle brackets, skipping over string literals.
120
161
  let i = pos + 1;
121
162
  let depth = 1;
122
163
  while (i < input.length) {
123
164
  const ch = input.charCodeAt(i);
124
- if (ch === 34 || ch === 39 || ch === 96) {
165
+ if (ch === CharCode.doubleQuote || ch === CharCode.singleQuote || ch === CharCode.backtick) {
125
166
  i = skip_string_from(input, i, ch);
126
167
  continue;
127
168
  }
128
- if (ch === 60) depth++;
129
- else if (ch === 62 && --depth === 0) break;
169
+ if (ch === CharCode.lessThan) depth++;
170
+ else if (ch === CharCode.greaterThan && --depth === 0) break;
130
171
  i++;
131
172
  }
132
173
  if (depth !== 0) return false;
133
174
 
134
175
  // `>` must be followed by `(...)`.
135
176
  i = skip_whitespace_from(input, i + 1);
136
- if (input.charCodeAt(i) !== 40) return false;
137
- i = scan_balanced_from(input, i, 40, 41);
177
+ if (input.charCodeAt(i) !== CharCode.openParen) return false;
178
+ i = scan_balanced_from(input, i, CharCode.openParen, CharCode.closeParen);
138
179
  if (i === -1) return false;
139
180
 
140
181
  // Optional `: ReturnType` before `=>`.
141
182
  i = skip_whitespace_from(input, i);
142
- if (input.charCodeAt(i) === 58) {
183
+ if (input.charCodeAt(i) === CharCode.colon) {
143
184
  i++;
144
185
  while (i < input.length) {
145
186
  const ch = input.charCodeAt(i);
146
- if (ch === 34 || ch === 39 || ch === 96) {
187
+ if (ch === CharCode.doubleQuote || ch === CharCode.singleQuote || ch === CharCode.backtick) {
147
188
  i = skip_string_from(input, i, ch);
148
189
  continue;
149
190
  }
150
- if (ch === 61 && input.charCodeAt(i + 1) === 62) return true;
151
- if (ch === 59 || ch === 123 || ch === 125) return false;
191
+ if (ch === CharCode.equals && input.charCodeAt(i + 1) === CharCode.greaterThan) return true;
192
+ if (ch === CharCode.semicolon || ch === CharCode.openBrace || ch === CharCode.closeBrace)
193
+ return false;
152
194
  i++;
153
195
  }
154
196
  return false;
155
197
  }
156
198
 
157
- return input.charCodeAt(i) === 61 && input.charCodeAt(i + 1) === 62;
199
+ return (
200
+ input.charCodeAt(i) === CharCode.equals && input.charCodeAt(i + 1) === CharCode.greaterThan
201
+ );
158
202
  }
159
203
 
160
204
  /**
@@ -176,7 +220,13 @@ function previous_word_before(input, pos) {
176
220
  let i = pos - 1;
177
221
  while (i >= 0) {
178
222
  const ch = input.charCodeAt(i);
179
- if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) break;
223
+ if (
224
+ ch !== CharCode.space &&
225
+ ch !== CharCode.tab &&
226
+ ch !== CharCode.lineFeed &&
227
+ ch !== CharCode.carriageReturn
228
+ )
229
+ break;
180
230
  i--;
181
231
  }
182
232
  const end = i + 1;
@@ -266,7 +316,12 @@ export function TSRXPlugin(config) {
266
316
  let index = this.pos - 1;
267
317
  while (index >= 0) {
268
318
  const ch = this.input.charCodeAt(index);
269
- if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) {
319
+ if (
320
+ ch !== CharCode.space &&
321
+ ch !== CharCode.tab &&
322
+ ch !== CharCode.lineFeed &&
323
+ ch !== CharCode.carriageReturn
324
+ ) {
270
325
  return ch;
271
326
  }
272
327
  index--;
@@ -454,7 +509,7 @@ export function TSRXPlugin(config) {
454
509
  }
455
510
 
456
511
  const after = this.input.charCodeAt(this.pos + 4);
457
- return after === 62 /* > */;
512
+ return after === CharCode.greaterThan;
458
513
  }
459
514
 
460
515
  #parseTsxIslandText() {
@@ -466,7 +521,7 @@ export function TSRXPlugin(config) {
466
521
  const ch = this.input.charCodeAt(this.pos);
467
522
 
468
523
  // Stop at opening tag, expression, or the component-closing brace
469
- if (ch === 60 || ch === 123 || ch === 125) {
524
+ if (ch === CharCode.lessThan || ch === CharCode.openBrace || ch === CharCode.closeBrace) {
470
525
  break;
471
526
  }
472
527
 
@@ -496,24 +551,27 @@ export function TSRXPlugin(config) {
496
551
  // fragment props like `content={<></>}` still need the JSX context.
497
552
  while (index < this.input.length) {
498
553
  const ch = this.input.charCodeAt(index);
499
- if (ch === 32 || ch === 9) {
554
+ if (ch === CharCode.space || ch === CharCode.tab) {
500
555
  index++;
501
- } else if (ch === 10 || ch === 13) {
556
+ } else if (ch === CharCode.lineFeed || ch === CharCode.carriageReturn) {
502
557
  has_newline = true;
503
558
  index++;
504
- } else if (ch === 47 && this.input.charCodeAt(index + 1) === 42) {
559
+ } else if (
560
+ ch === CharCode.slash &&
561
+ this.input.charCodeAt(index + 1) === CharCode.asterisk
562
+ ) {
505
563
  const end = this.input.indexOf('*/', index + 2);
506
564
  const comment_end = end === -1 ? this.input.length : end + 2;
507
565
  if (this.input.slice(index, comment_end).match(regex_newline_characters)) {
508
566
  has_newline = true;
509
567
  }
510
568
  index = comment_end;
511
- } else if (ch === 47 && this.input.charCodeAt(index + 1) === 47) {
569
+ } else if (ch === CharCode.slash && this.input.charCodeAt(index + 1) === CharCode.slash) {
512
570
  has_newline = true;
513
571
  index += 2;
514
572
  while (index < this.input.length) {
515
573
  const comment_ch = this.input.charCodeAt(index);
516
- if (comment_ch === 10 || comment_ch === 13) break;
574
+ if (comment_ch === CharCode.lineFeed || comment_ch === CharCode.carriageReturn) break;
517
575
  index++;
518
576
  }
519
577
  } else {
@@ -521,7 +579,7 @@ export function TSRXPlugin(config) {
521
579
  }
522
580
  }
523
581
 
524
- if (!has_newline || this.input.charCodeAt(index) !== 123) {
582
+ if (!has_newline || this.input.charCodeAt(index) !== CharCode.openBrace) {
525
583
  return;
526
584
  }
527
585
 
@@ -544,16 +602,24 @@ export function TSRXPlugin(config) {
544
602
  #skipWhitespaceAndComments(index) {
545
603
  while (index < this.input.length) {
546
604
  const ch = this.input.charCodeAt(index);
547
- if (ch === 32 || ch === 9 || ch === 10 || ch === 13) {
605
+ if (
606
+ ch === CharCode.space ||
607
+ ch === CharCode.tab ||
608
+ ch === CharCode.lineFeed ||
609
+ ch === CharCode.carriageReturn
610
+ ) {
548
611
  index++;
549
- } else if (ch === 47 && this.input.charCodeAt(index + 1) === 42) {
612
+ } else if (
613
+ ch === CharCode.slash &&
614
+ this.input.charCodeAt(index + 1) === CharCode.asterisk
615
+ ) {
550
616
  const end = this.input.indexOf('*/', index + 2);
551
617
  index = end === -1 ? this.input.length : end + 2;
552
- } else if (ch === 47 && this.input.charCodeAt(index + 1) === 47) {
618
+ } else if (ch === CharCode.slash && this.input.charCodeAt(index + 1) === CharCode.slash) {
553
619
  index += 2;
554
620
  while (index < this.input.length) {
555
621
  const comment_ch = this.input.charCodeAt(index);
556
- if (comment_ch === 10 || comment_ch === 13) break;
622
+ if (comment_ch === CharCode.lineFeed || comment_ch === CharCode.carriageReturn) break;
557
623
  index++;
558
624
  }
559
625
  } else {
@@ -569,7 +635,7 @@ export function TSRXPlugin(config) {
569
635
  let count = 0;
570
636
  while (index < this.input.length) {
571
637
  index = this.#skipWhitespaceAndComments(index);
572
- if (this.input.charCodeAt(index) !== 125) break;
638
+ if (this.input.charCodeAt(index) !== CharCode.closeBrace) break;
573
639
  count++;
574
640
  index++;
575
641
  }
@@ -701,11 +767,11 @@ export function TSRXPlugin(config) {
701
767
  const prev = this.#previousNonWhitespaceChar();
702
768
  return (
703
769
  prev === null ||
704
- prev === 34 || // "
705
- prev === 59 || // ;
706
- prev === 62 || // >
707
- (prev === 123 && this.#allowDoubleQuotedTextChildAfterBrace) || // {
708
- prev === 125 // }
770
+ prev === CharCode.doubleQuote ||
771
+ prev === CharCode.semicolon ||
772
+ prev === CharCode.greaterThan ||
773
+ (prev === CharCode.openBrace && this.#allowDoubleQuotedTextChildAfterBrace) ||
774
+ prev === CharCode.closeBrace
709
775
  );
710
776
  }
711
777
 
@@ -718,13 +784,13 @@ export function TSRXPlugin(config) {
718
784
  while (this.pos < this.input.length) {
719
785
  const ch = this.input.charCodeAt(this.pos);
720
786
 
721
- if (ch === 34 /* " */) {
787
+ if (ch === CharCode.doubleQuote) {
722
788
  out += this.input.slice(chunkStart, this.pos);
723
789
  this.pos++;
724
790
  return this.finishToken(tt.string, out);
725
791
  }
726
792
 
727
- if (ch === 38 /* & */) {
793
+ if (ch === CharCode.ampersand) {
728
794
  out += this.input.slice(chunkStart, this.pos);
729
795
  out += this.jsx_readEntity();
730
796
  chunkStart = this.pos;
@@ -1050,7 +1116,7 @@ export function TSRXPlugin(config) {
1050
1116
  * @type {Parse.Parser['readToken']}
1051
1117
  */
1052
1118
  readToken(code) {
1053
- if (code === 60 && looks_like_generic_arrow(this.input, this.pos)) {
1119
+ if (code === CharCode.lessThan && looks_like_generic_arrow(this.input, this.pos)) {
1054
1120
  ++this.pos;
1055
1121
  return this.finishToken(tt.relational, '<');
1056
1122
  }
@@ -1062,7 +1128,19 @@ export function TSRXPlugin(config) {
1062
1128
  * @type {Parse.Parser['getTokenFromCode']}
1063
1129
  */
1064
1130
  getTokenFromCode(code) {
1065
- if (code === 34) {
1131
+ // Callback props that return `<tsrx>...</tsrx>` without a semicolon can
1132
+ // leave the attribute expression context above the still-open tag. Drop
1133
+ // it before tokenizing `/>`, otherwise Acorn treats `/` as a regexp.
1134
+ if (
1135
+ code === CharCode.slash &&
1136
+ this.input.charCodeAt(this.pos + 1) === CharCode.greaterThan &&
1137
+ this.curContext() === b_expr &&
1138
+ this.context[this.context.length - 2] === tstc.tc_oTag
1139
+ ) {
1140
+ this.context.pop();
1141
+ this.exprAllowed = false;
1142
+ }
1143
+ if (code === CharCode.doubleQuote) {
1066
1144
  const is_double_quoted_text_child = this.#isDoubleQuotedTextChildStart();
1067
1145
  this.#allowDoubleQuotedTextChildAfterBrace = false;
1068
1146
  if (is_double_quoted_text_child) {
@@ -1072,11 +1150,11 @@ export function TSRXPlugin(config) {
1072
1150
  this.#allowDoubleQuotedTextChildAfterBrace = false;
1073
1151
  }
1074
1152
 
1075
- if (code !== 60) {
1153
+ if (code !== CharCode.lessThan) {
1076
1154
  this.#allowTagStartAfterDoubleQuotedText = false;
1077
1155
  }
1078
1156
 
1079
- if (code === 60) {
1157
+ if (code === CharCode.lessThan) {
1080
1158
  // < character
1081
1159
  const inComponent = this.#isInsideComponentTemplate();
1082
1160
  /** @type {number | null} */
@@ -1093,7 +1171,7 @@ export function TSRXPlugin(config) {
1093
1171
  // Skip whitespace backwards
1094
1172
  while (lookback >= 0) {
1095
1173
  const ch = this.input.charCodeAt(lookback);
1096
- if (ch !== 32 && ch !== 9) break; // not space or tab
1174
+ if (ch !== CharCode.space && ch !== CharCode.tab) break; // not space or tab
1097
1175
  lookback--;
1098
1176
  }
1099
1177
 
@@ -1105,12 +1183,12 @@ export function TSRXPlugin(config) {
1105
1183
  // If preceded by identifier character (letter, digit, _, $) or closing paren,
1106
1184
  // this is likely TypeScript generics, not JSX
1107
1185
  const isIdentifierChar =
1108
- (prevChar >= 65 && prevChar <= 90) || // A-Z
1109
- (prevChar >= 97 && prevChar <= 122) || // a-z
1110
- (prevChar >= 48 && prevChar <= 57) || // 0-9
1111
- prevChar === 95 || // _
1112
- prevChar === 36 || // $
1113
- prevChar === 41; // )
1186
+ (prevChar >= CharCode.uppercaseA && prevChar <= CharCode.uppercaseZ) ||
1187
+ (prevChar >= CharCode.lowercaseA && prevChar <= CharCode.lowercaseZ) ||
1188
+ (prevChar >= CharCode.digit0 && prevChar <= CharCode.digit9) ||
1189
+ prevChar === CharCode.underscore ||
1190
+ prevChar === CharCode.dollar ||
1191
+ prevChar === CharCode.closeParen;
1114
1192
 
1115
1193
  if (isIdentifierChar) {
1116
1194
  return super.getTokenFromCode(code);
@@ -1125,23 +1203,26 @@ export function TSRXPlugin(config) {
1125
1203
  const nextChar =
1126
1204
  this.pos + 1 < this.input.length ? this.input.charCodeAt(this.pos + 1) : -1;
1127
1205
  const isWhitespaceAfterLt =
1128
- nextChar === 32 || nextChar === 9 || nextChar === 10 || nextChar === 13;
1206
+ nextChar === CharCode.space ||
1207
+ nextChar === CharCode.tab ||
1208
+ nextChar === CharCode.lineFeed ||
1209
+ nextChar === CharCode.carriageReturn;
1129
1210
  const isTagLikeAfterLt =
1130
1211
  !isWhitespaceAfterLt &&
1131
- (nextChar === 47 || // '/'
1132
- nextChar === 62 || // '>' (fragments: <>)
1133
- nextChar === 64 || // '@'
1134
- nextChar === 36 || // '$'
1135
- nextChar === 95 || // '_'
1136
- (nextChar >= 65 && nextChar <= 90) || // A-Z
1137
- (nextChar >= 97 && nextChar <= 122)); // a-z
1212
+ (nextChar === CharCode.slash ||
1213
+ nextChar === CharCode.greaterThan ||
1214
+ nextChar === CharCode.at ||
1215
+ nextChar === CharCode.dollar ||
1216
+ nextChar === CharCode.underscore ||
1217
+ (nextChar >= CharCode.uppercaseA && nextChar <= CharCode.uppercaseZ) ||
1218
+ (nextChar >= CharCode.lowercaseA && nextChar <= CharCode.lowercaseZ));
1138
1219
  const prevAllowsTagStart =
1139
1220
  prevNonWhitespaceChar === null ||
1140
- prevNonWhitespaceChar === 10 || // '\n'
1141
- prevNonWhitespaceChar === 13 || // '\r'
1142
- prevNonWhitespaceChar === 123 || // '{'
1143
- prevNonWhitespaceChar === 125 || // '}'
1144
- prevNonWhitespaceChar === 62; // '>'
1221
+ prevNonWhitespaceChar === CharCode.lineFeed || // '\n'
1222
+ prevNonWhitespaceChar === CharCode.carriageReturn || // '\r'
1223
+ prevNonWhitespaceChar === CharCode.openBrace ||
1224
+ prevNonWhitespaceChar === CharCode.closeBrace ||
1225
+ prevNonWhitespaceChar === CharCode.greaterThan;
1145
1226
 
1146
1227
  if (!inComponent && prevAllowsTagStart && isTagLikeAfterLt) {
1147
1228
  ++this.pos;
@@ -1153,10 +1234,10 @@ export function TSRXPlugin(config) {
1153
1234
  // a newline/indentation before the next '<'. This is important for inputs
1154
1235
  // like `<div />` and `</div><style>...</style>` which Prettier formats.
1155
1236
  if (
1156
- (prevNonWhitespaceChar === 34 /* '"' */ &&
1237
+ (prevNonWhitespaceChar === CharCode.doubleQuote &&
1157
1238
  this.#allowTagStartAfterDoubleQuotedText) ||
1158
- prevNonWhitespaceChar === 123 /* '{' */ ||
1159
- prevNonWhitespaceChar === 62 /* '>' */
1239
+ prevNonWhitespaceChar === CharCode.openBrace ||
1240
+ prevNonWhitespaceChar === CharCode.greaterThan
1160
1241
  ) {
1161
1242
  if (!isWhitespaceAfterLt) {
1162
1243
  this.#allowTagStartAfterDoubleQuotedText = false;
@@ -1172,8 +1253,8 @@ export function TSRXPlugin(config) {
1172
1253
  let lineStart = this.pos - 1;
1173
1254
  while (
1174
1255
  lineStart >= 0 &&
1175
- this.input.charCodeAt(lineStart) !== 10 &&
1176
- this.input.charCodeAt(lineStart) !== 13
1256
+ this.input.charCodeAt(lineStart) !== CharCode.lineFeed &&
1257
+ this.input.charCodeAt(lineStart) !== CharCode.carriageReturn
1177
1258
  ) {
1178
1259
  lineStart--;
1179
1260
  }
@@ -1183,7 +1264,7 @@ export function TSRXPlugin(config) {
1183
1264
  let allWhitespace = true;
1184
1265
  for (let i = lineStart; i < this.pos; i++) {
1185
1266
  const ch = this.input.charCodeAt(i);
1186
- if (ch !== 32 && ch !== 9) {
1267
+ if (ch !== CharCode.space && ch !== CharCode.tab) {
1187
1268
  allWhitespace = false;
1188
1269
  break;
1189
1270
  }
@@ -1205,7 +1286,7 @@ export function TSRXPlugin(config) {
1205
1286
  /**
1206
1287
  * Override isLet to recognize `let &{` and `let &[` as variable declarations.
1207
1288
  * Acorn's isLet checks the char after `let` and only recognizes `{`, `[`, or identifiers.
1208
- * The `&` char (38) is not in that set, so `let &{...}` would not be parsed as a declaration.
1289
+ * The `&` character is not in that set, so `let &{...}` would not be parsed as a declaration.
1209
1290
  * @type {Parse.Parser['isLet']}
1210
1291
  */
1211
1292
  isLet(context) {
@@ -1217,9 +1298,9 @@ export function TSRXPlugin(config) {
1217
1298
  const next = this.pos + match[0].length;
1218
1299
  const nextCh = this.input.charCodeAt(next);
1219
1300
  // If next char is &, check if char after & is { or [
1220
- if (nextCh === 38) {
1301
+ if (nextCh === CharCode.ampersand) {
1221
1302
  const afterAmp = this.input.charCodeAt(next + 1);
1222
- if (afterAmp === 123 || afterAmp === 91) return true;
1303
+ if (afterAmp === CharCode.openBrace || afterAmp === CharCode.openBracket) return true;
1223
1304
  }
1224
1305
  return super.isLet(context);
1225
1306
  }
@@ -1233,7 +1314,7 @@ export function TSRXPlugin(config) {
1233
1314
  if (this.type === tt.bitwiseAND) {
1234
1315
  // Check that the char immediately after & is { or [ (no whitespace)
1235
1316
  const charAfterAmp = this.input.charCodeAt(this.end);
1236
- if (charAfterAmp === 123 || charAfterAmp === 91) {
1317
+ if (charAfterAmp === CharCode.openBrace || charAfterAmp === CharCode.openBracket) {
1237
1318
  // & directly followed by { or [ — lazy destructuring
1238
1319
  this.next(); // consume &, now current token is { or [
1239
1320
  const pattern = super.parseBindingAtom();
@@ -1890,6 +1971,27 @@ export function TSRXPlugin(config) {
1890
1971
  /** @type {AST.RefAttribute} */ (node).argument = this.parseMaybeAssign();
1891
1972
  this.expect(tt.braceR);
1892
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');
1893
1995
  } else if (this.type === tt.ellipsis) {
1894
1996
  this.expect(tt.ellipsis);
1895
1997
  /** @type {AST.SpreadAttribute} */ (node).argument = this.parseMaybeAssign();
@@ -1913,10 +2015,18 @@ export function TSRXPlugin(config) {
1913
2015
  }
1914
2016
  }
1915
2017
  /** @type {ESTreeJSX.JSXAttribute} */ (node).name = this.jsx_parseNamespacedName();
1916
- /** @type {ESTreeJSX.JSXAttribute} */ (node).value =
1917
- /** @type {ESTreeJSX.JSXAttribute['value'] | null} */ (
1918
- 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,
1919
2027
  );
2028
+ }
2029
+ /** @type {ESTreeJSX.JSXAttribute} */ (node).value = value;
1920
2030
  return this.finishNode(node, 'JSXAttribute');
1921
2031
  }
1922
2032
 
@@ -2121,20 +2231,22 @@ export function TSRXPlugin(config) {
2121
2231
  let ch = this.input.charCodeAt(this.pos);
2122
2232
 
2123
2233
  switch (ch) {
2124
- case 60: // '<'
2125
- case 123: // '{'
2234
+ case CharCode.lessThan:
2235
+ case CharCode.openBrace:
2126
2236
  // In JSX text mode, '<' and '{' always start a tag/expression container.
2127
2237
  // `exprAllowed` can be false here due to surrounding parser state, but
2128
2238
  // throwing breaks valid templates (e.g. sibling tags after a close).
2129
- if (ch === 60) {
2239
+ this.start = this.pos;
2240
+ this.startLoc = this.curPosition();
2241
+ if (ch === CharCode.lessThan) {
2130
2242
  ++this.pos;
2131
2243
  return this.finishToken(tstt.jsxTagStart);
2132
2244
  }
2133
2245
  return this.getTokenFromCode(ch);
2134
2246
 
2135
- case 47: // '/'
2247
+ case CharCode.slash:
2136
2248
  // Check if this is a comment (// or /*)
2137
- if (this.input.charCodeAt(this.pos + 1) === 47) {
2249
+ if (this.input.charCodeAt(this.pos + 1) === CharCode.slash) {
2138
2250
  // '//'
2139
2251
  // Line comment - handle it properly
2140
2252
  const commentStart = this.pos;
@@ -2168,7 +2280,7 @@ export function TSRXPlugin(config) {
2168
2280
 
2169
2281
  // Continue processing from current position
2170
2282
  break;
2171
- } else if (this.input.charCodeAt(this.pos + 1) === 42) {
2283
+ } else if (this.input.charCodeAt(this.pos + 1) === CharCode.asterisk) {
2172
2284
  // '/*'
2173
2285
  // Block comment - handle it properly
2174
2286
  const commentStart = this.pos;
@@ -2178,8 +2290,8 @@ export function TSRXPlugin(config) {
2178
2290
  let commentText = '';
2179
2291
  while (this.pos < this.input.length - 1) {
2180
2292
  if (
2181
- this.input.charCodeAt(this.pos) === 42 &&
2182
- this.input.charCodeAt(this.pos + 1) === 47
2293
+ this.input.charCodeAt(this.pos) === CharCode.asterisk &&
2294
+ this.input.charCodeAt(this.pos + 1) === CharCode.slash
2183
2295
  ) {
2184
2296
  this.pos += 2;
2185
2297
  break;
@@ -2214,17 +2326,16 @@ export function TSRXPlugin(config) {
2214
2326
  this.exprAllowed = true;
2215
2327
  return original.readToken.call(this, ch);
2216
2328
 
2217
- case 38: // '&'
2329
+ case CharCode.ampersand:
2218
2330
  out += this.input.slice(chunkStart, this.pos);
2219
2331
  out += this.jsx_readEntity();
2220
2332
  chunkStart = this.pos;
2221
2333
  break;
2222
2334
 
2223
- case 62: // '>'
2224
- case 125: {
2225
- // '}'
2335
+ case CharCode.greaterThan:
2336
+ case CharCode.closeBrace: {
2226
2337
  if (
2227
- ch === 125 &&
2338
+ ch === CharCode.closeBrace &&
2228
2339
  (this.#path.length === 0 ||
2229
2340
  this.#path.at(-1)?.type === 'Component' ||
2230
2341
  this.#path.at(-1)?.type === 'Element' ||
@@ -2238,7 +2349,7 @@ export function TSRXPlugin(config) {
2238
2349
  'Unexpected token `' +
2239
2350
  this.input[this.pos] +
2240
2351
  '`. Did you mean `' +
2241
- (ch === 62 ? '&gt;' : '&rbrace;') +
2352
+ (ch === CharCode.greaterThan ? '&gt;' : '&rbrace;') +
2242
2353
  '` or ' +
2243
2354
  '`{"' +
2244
2355
  this.input[this.pos] +
@@ -2252,7 +2363,7 @@ export function TSRXPlugin(config) {
2252
2363
  out += this.input.slice(chunkStart, this.pos);
2253
2364
  out += this.jsx_readNewLine(true);
2254
2365
  chunkStart = this.pos;
2255
- } else if (ch === 32 || ch === 9) {
2366
+ } else if (ch === CharCode.space || ch === CharCode.tab) {
2256
2367
  ++this.pos;
2257
2368
  } else {
2258
2369
  this.#resetTokenStartToCurrentPosition();
@@ -2283,28 +2394,28 @@ export function TSRXPlugin(config) {
2283
2394
  // Check if the element being parsed IS a <tsx>, <tsrx>, or <tsx:*> tag
2284
2395
  // Current token is jsxTagStart, this.end is position after '<'
2285
2396
  const tag_name_start = this.end;
2286
- const is_fragment_tag = this.input.charCodeAt(tag_name_start) === 62;
2397
+ const is_fragment_tag = this.input.charCodeAt(tag_name_start) === CharCode.greaterThan;
2287
2398
  const char_after_tsx = this.input.charCodeAt(tag_name_start + 3);
2288
2399
  const char_after_tsrx = this.input.charCodeAt(tag_name_start + 4);
2289
2400
  const is_tsx_tag =
2290
2401
  this.input.startsWith('tsx', tag_name_start) &&
2291
2402
  (tag_name_start + 3 >= this.input.length ||
2292
- char_after_tsx === 62 || // >
2293
- char_after_tsx === 47 || // / (self-closing)
2294
- char_after_tsx === 32 || // space
2295
- char_after_tsx === 9 || // tab
2296
- char_after_tsx === 10 || // newline
2297
- char_after_tsx === 13 || // carriage return
2298
- char_after_tsx === 58); // : (tsx:react)
2403
+ char_after_tsx === CharCode.greaterThan ||
2404
+ char_after_tsx === CharCode.slash ||
2405
+ char_after_tsx === CharCode.space ||
2406
+ char_after_tsx === CharCode.tab ||
2407
+ char_after_tsx === CharCode.lineFeed ||
2408
+ char_after_tsx === CharCode.carriageReturn ||
2409
+ char_after_tsx === CharCode.colon);
2299
2410
  const is_tsrx_tag =
2300
2411
  this.input.startsWith('tsrx', tag_name_start) &&
2301
2412
  (tag_name_start + 4 >= this.input.length ||
2302
- char_after_tsrx === 62 || // >
2303
- char_after_tsrx === 47 || // / (self-closing)
2304
- char_after_tsrx === 32 || // space
2305
- char_after_tsrx === 9 || // tab
2306
- char_after_tsrx === 10 || // newline
2307
- char_after_tsrx === 13); // carriage return
2413
+ char_after_tsrx === CharCode.greaterThan ||
2414
+ char_after_tsrx === CharCode.slash ||
2415
+ char_after_tsrx === CharCode.space ||
2416
+ char_after_tsrx === CharCode.tab ||
2417
+ char_after_tsrx === CharCode.lineFeed ||
2418
+ char_after_tsrx === CharCode.carriageReturn);
2308
2419
 
2309
2420
  if (is_fragment_tag || is_tsx_tag || is_tsrx_tag) {
2310
2421
  // Use Ripple's parseElement to create a Tsx/Tsrx/TsxCompat node.
@@ -2356,6 +2467,8 @@ export function TSRXPlugin(config) {
2356
2467
  /** @type {AST.NodeWithLocation} */ (element).loc.start = position;
2357
2468
  element.metadata = { path: [] };
2358
2469
  element.children = [];
2470
+ element.type = 'Element';
2471
+ this.#path.push(element);
2359
2472
 
2360
2473
  const open = /** @type {ESTreeJSX.JSXOpeningElement & AST.NodeWithLocation} */ (
2361
2474
  this.jsx_parseOpeningElementAt(start, position)
@@ -2414,8 +2527,6 @@ export function TSRXPlugin(config) {
2414
2527
  element.type = 'Element';
2415
2528
  }
2416
2529
 
2417
- this.#path.push(element);
2418
-
2419
2530
  for (const attr of open.attributes) {
2420
2531
  if (attr.type === 'JSXAttribute') {
2421
2532
  /** @type {AST.Attribute} */ (/** @type {unknown} */ (attr)).type = 'Attribute';
@@ -2456,7 +2567,12 @@ export function TSRXPlugin(config) {
2456
2567
 
2457
2568
  element.attributes = open.attributes;
2458
2569
  element.metadata ??= { path: [] };
2459
- 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
+ }
2460
2576
 
2461
2577
  if (element.selfClosing) {
2462
2578
  this.#path.pop();
@@ -2470,7 +2586,7 @@ export function TSRXPlugin(config) {
2470
2586
  enterScope: true,
2471
2587
  });
2472
2588
 
2473
- if (element.type === 'Tsx') {
2589
+ if (/** @type {AST.Tsx} */ (element).type === 'Tsx') {
2474
2590
  this.#path.pop();
2475
2591
 
2476
2592
  if (!element.unclosed) {
@@ -2647,7 +2763,7 @@ export function TSRXPlugin(config) {
2647
2763
  enterScope: true,
2648
2764
  });
2649
2765
 
2650
- if (element.type === 'Tsx') {
2766
+ if (/** @type {AST.Tsx} */ (element).type === 'Tsx') {
2651
2767
  this.#path.pop();
2652
2768
 
2653
2769
  if (!element.unclosed) {
@@ -2671,12 +2787,15 @@ export function TSRXPlugin(config) {
2671
2787
  this.#popTsxTokenContextBeforeTemplateExpressionChild();
2672
2788
  this.next();
2673
2789
  }
2674
- } else if (element.type === 'TsxCompat') {
2790
+ } else if (/** @type {AST.TsxCompat} */ (element).type === 'TsxCompat') {
2675
2791
  this.#path.pop();
2676
2792
 
2677
2793
  if (!element.unclosed) {
2678
2794
  const raise_error = () => {
2679
- 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
+ );
2680
2799
  };
2681
2800
 
2682
2801
  this.next();
@@ -2693,7 +2812,7 @@ export function TSRXPlugin(config) {
2693
2812
  raise_error();
2694
2813
  }
2695
2814
  this.next();
2696
- if (this.value !== element.kind) {
2815
+ if (this.value !== /** @type {AST.TsxCompat} */ (element).kind) {
2697
2816
  raise_error();
2698
2817
  }
2699
2818
  this.next();
@@ -2703,7 +2822,10 @@ export function TSRXPlugin(config) {
2703
2822
  this.#popTsxTokenContextBeforeTemplateExpressionChild();
2704
2823
  this.next();
2705
2824
  }
2706
- } 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
+ ) {
2707
2829
  this.#report_broken_markup_error(
2708
2830
  this.start,
2709
2831
  "Unclosed tag '<tsrx>'. Expected '</tsrx>' before end of component.",
@@ -2787,7 +2909,10 @@ export function TSRXPlugin(config) {
2787
2909
  }
2788
2910
  if (this.type === tt.braceL) {
2789
2911
  body.push(this.#parseNativeTemplateExpressionContainer());
2790
- } else if (this.type === tt.string && this.input.charCodeAt(this.start) === 34) {
2912
+ } else if (
2913
+ this.type === tt.string &&
2914
+ this.input.charCodeAt(this.start) === CharCode.doubleQuote
2915
+ ) {
2791
2916
  body.push(this.parseDoubleQuotedTextChild());
2792
2917
  } else if (this.type === tt.braceR) {
2793
2918
  // Leaving a component/template body. We may still be in TSX/JSX tokenization
@@ -2800,8 +2925,8 @@ export function TSRXPlugin(config) {
2800
2925
  return;
2801
2926
  } else if (
2802
2927
  this.type === tstt.jsxTagStart ||
2803
- (this.input.charCodeAt(this.start) === 60 /* < */ &&
2804
- this.input.charCodeAt(this.start + 1) === 47) /* / */
2928
+ (this.input.charCodeAt(this.start) === CharCode.lessThan &&
2929
+ this.input.charCodeAt(this.start + 1) === CharCode.slash)
2805
2930
  ) {
2806
2931
  const startPos = this.start;
2807
2932
  const startLoc = this.startLoc;
@@ -3082,7 +3207,7 @@ export function TSRXPlugin(config) {
3082
3207
  if (
3083
3208
  this.#functionBodyDepth === 0 &&
3084
3209
  this.type === tt.string &&
3085
- this.input.charCodeAt(this.start) === 34 &&
3210
+ this.input.charCodeAt(this.start) === CharCode.doubleQuote &&
3086
3211
  (this.#path.at(-1)?.type === 'Component' || this.#path.at(-1)?.type === 'Element')
3087
3212
  ) {
3088
3213
  this.pos = this.start;
@@ -3096,7 +3221,7 @@ export function TSRXPlugin(config) {
3096
3221
  // e.g., &[data] = track(0); or &{x, y} = obj;
3097
3222
  if (this.type === tt.bitwiseAND) {
3098
3223
  const charAfterAmp = this.input.charCodeAt(this.end);
3099
- if (charAfterAmp === 123 || charAfterAmp === 91) {
3224
+ if (charAfterAmp === CharCode.openBrace || charAfterAmp === CharCode.openBracket) {
3100
3225
  const node = /** @type {AST.ExpressionStatement} */ (this.startNode());
3101
3226
  const assign_node = /** @type {AST.AssignmentExpression} */ (this.startNode());
3102
3227
  this.next(); // consume &
@@ -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: {