@tsrx/core 0.1.58 → 0.1.60

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.
@@ -2,7 +2,13 @@
2
2
  /** @import * as ESRap from 'esrap' */
3
3
 
4
4
  import tsx from 'esrap/languages/tsx';
5
- import { should_preserve_comment, format_comment } from '../../comment-utils.js';
5
+ import {
6
+ should_preserve_comment,
7
+ should_preserve_jsx_tooling_comment,
8
+ is_file_level_pragma,
9
+ format_comment,
10
+ } from '../../comment-utils.js';
11
+ import { has_location } from '../../utils/ast.js';
6
12
  import { with_deferred_imports } from '../imports.js';
7
13
 
8
14
  /**
@@ -69,25 +75,64 @@ export function set_node_path_metadata(node, path) {
69
75
  * (structural tokens carry one-character source locations). typeOnly/volar
70
76
  * prints opt in — their maps are consumed positionally by the language
71
77
  * tooling and never shipped; build prints stay sparse.
72
- * @param {AST.CommentWithLocation[]} [comments] Source comments; the ones
73
- * `should_preserve_comment` classifies as semantic-to-TS (`@ts-nocheck`,
74
- * `@jsxImportSource`, triple-slash references, …) and that LEAD the program
75
- * are re-emitted at the top of the printed output. The generated TSX is real
76
- * TS input dropping a leading pragma changes how the whole file checks.
78
+ * @param {AST.CommentWithLocation[]} [comments] Source comments. In type-only
79
+ * output, file-wide pragmas lead the program while documentation and scoped
80
+ * annotations stay with their declarations, members, or statements. A sparse
81
+ * print with explicitly supplied comments retains its existing leading-pragma
82
+ * behavior. Ordinary build callers supply no comments.
77
83
  */
78
84
  export function tsx_with_ts_locations(boundary_tokens = false, comments = undefined) {
79
85
  const base = with_deferred_imports(tsx({ boundaryTokens: boundary_tokens }));
80
86
  const { _: base_visitor, ...base_visitors } = base;
87
+ const preserve_comments = comments !== undefined;
88
+ const preserve_owner_comments = boundary_tokens && preserve_comments;
89
+ /** @type {Set<string> | null} */
90
+ const emitted_comments = preserve_comments ? new Set() : null;
91
+
92
+ /**
93
+ * @param {AST.CommentWithLocation} comment
94
+ * @param {ESRap.Context} context
95
+ */
96
+ const write_preserved_comment = (comment, context) => {
97
+ if (
98
+ !emitted_comments ||
99
+ !(preserve_owner_comments
100
+ ? should_preserve_jsx_tooling_comment(comment)
101
+ : should_preserve_comment(comment))
102
+ ) {
103
+ return;
104
+ }
105
+ const key = `${comment.start}:${comment.end}:${comment.type}:${comment.value}`;
106
+ if (emitted_comments.has(key)) return;
107
+ emitted_comments.add(key);
108
+ if (comment.loc) context.location(comment.loc.start.line, comment.loc.start.column);
109
+ context.write(format_comment(comment));
110
+ if (comment.loc) context.location(comment.loc.end.line, comment.loc.end.column);
111
+ context.newline();
112
+ };
113
+
114
+ /**
115
+ * @param {AST.Node} node
116
+ * @param {ESRap.Context} context
117
+ */
118
+ const write_leading_comments = (node, context) => {
119
+ if (!node.leadingComments || !is_comment_owner(node)) return;
120
+ for (const comment of node.leadingComments) {
121
+ if (has_location(comment)) write_preserved_comment(comment, context);
122
+ }
123
+ };
81
124
 
82
125
  const leading_preserved = (/** @type {AST.Program} */ program) => {
83
- if (!comments?.length) return [];
126
+ if (!preserve_comments || !comments?.length) return [];
84
127
  // Injected statements (dynamic-import/try-import prepends) carry no
85
128
  // loc; anchor "leading" on the first statement that maps to source,
86
129
  // else every preserved comment in the file would hoist to the top.
87
130
  const first = program.body.find((node) => node.loc);
88
131
  return comments.filter(
89
132
  (comment) =>
90
- should_preserve_comment(comment) &&
133
+ (preserve_owner_comments
134
+ ? is_file_level_pragma(comment)
135
+ : should_preserve_comment(comment)) &&
91
136
  (first?.loc == null ||
92
137
  (comment.loc &&
93
138
  (comment.loc.end.line < first.loc.start.line ||
@@ -100,10 +145,7 @@ export function tsx_with_ts_locations(boundary_tokens = false, comments = undefi
100
145
  const wrappers = {
101
146
  Program: (node, context) => {
102
147
  for (const comment of leading_preserved(node)) {
103
- if (comment.loc) context.location(comment.loc.start.line, comment.loc.start.column);
104
- context.write(format_comment(comment));
105
- if (comment.loc) context.location(comment.loc.end.line, comment.loc.end.column);
106
- context.newline();
148
+ write_preserved_comment(comment, context);
107
149
  }
108
150
  /** @type {NonNullable<typeof base.Program>} */ (base.Program)(node, context);
109
151
  },
@@ -206,8 +248,13 @@ export function tsx_with_ts_locations(boundary_tokens = false, comments = undefi
206
248
  context.visit(node.body);
207
249
  },
208
250
  _(node, context, visit) {
251
+ if (preserve_owner_comments) write_leading_comments(node, context);
209
252
  const visit_with_locations = () => {
210
- if (!LOCATION_WRAPPED_NODE_TYPES.has(node.type) || !node.loc) {
253
+ if (
254
+ !node.loc ||
255
+ (!LOCATION_WRAPPED_NODE_TYPES.has(node.type) &&
256
+ !(boundary_tokens && TOOLING_LOCATION_WRAPPED_NODE_TYPES.has(node.type)))
257
+ ) {
211
258
  visit(node);
212
259
  return;
213
260
  }
@@ -226,6 +273,44 @@ export function tsx_with_ts_locations(boundary_tokens = false, comments = undefi
226
273
  return { ...base_visitors, ...wrappers };
227
274
  }
228
275
 
276
+ /**
277
+ * A newline is safe before a statement/declaration or a member, but not before
278
+ * an arbitrary expression: `return /** @type {number} *\/ 1` must not become a
279
+ * bare return, and `throw` forbids a line terminator before its argument.
280
+ * @param {AST.Node} node
281
+ * @returns {boolean}
282
+ */
283
+ function is_comment_owner(node) {
284
+ return (
285
+ node.type.endsWith('Declaration') ||
286
+ node.type.endsWith('Statement') ||
287
+ COMMENT_OWNER_NODE_TYPES.has(node.type)
288
+ );
289
+ }
290
+
291
+ const COMMENT_OWNER_NODE_TYPES = new Set([
292
+ 'VariableDeclarator',
293
+ 'Property',
294
+ 'PropertyDefinition',
295
+ 'AccessorProperty',
296
+ 'MethodDefinition',
297
+ 'TSAbstractPropertyDefinition',
298
+ 'TSAbstractAccessorProperty',
299
+ 'TSAbstractMethodDefinition',
300
+ 'TSPropertySignature',
301
+ 'TSMethodSignature',
302
+ 'TSIndexSignature',
303
+ 'TSEnumMember',
304
+ 'TSExportAssignment',
305
+ ]);
306
+
307
+ const TOOLING_LOCATION_WRAPPED_NODE_TYPES = new Set([
308
+ 'ExportNamedDeclaration',
309
+ 'ExportDefaultDeclaration',
310
+ 'ExportAllDeclaration',
311
+ 'TSPropertySignature',
312
+ ]);
313
+
229
314
  // Be careful when adding visitors that are already defined in `wrappers`.
230
315
  // JSXOpeningElement is intentionally in both places: its custom printer still
231
316
  // needs a location marker around the whole node.
@@ -561,7 +561,17 @@ export function createJsxTransform(platform) {
561
561
  * @returns {JsxTransformResult}
562
562
  */
563
563
  function transform(ast, source, filename, options) {
564
- const suspense_source = options?.suspenseSource ?? platform.imports.suspense;
564
+ const effective_platform =
565
+ options?.runtimeImports === 'direct' && platform.directRuntimeImports
566
+ ? {
567
+ ...platform,
568
+ imports: {
569
+ ...platform.imports,
570
+ ...platform.directRuntimeImports,
571
+ },
572
+ }
573
+ : platform;
574
+ const suspense_source = options?.suspenseSource ?? effective_platform.imports.suspense;
565
575
  const collect = !!(options?.collect || options?.loose);
566
576
  /** @type {AST.CSS.StyleSheet[]} */
567
577
  const stylesheets = [];
@@ -570,7 +580,7 @@ export function createJsxTransform(platform) {
570
580
 
571
581
  /** @type {TransformContext} */
572
582
  const transform_context = {
573
- platform,
583
+ platform: effective_platform,
574
584
  local_statement_component_index: 0,
575
585
  needs_error_boundary: false,
576
586
  needs_suspense: false,
@@ -718,9 +728,24 @@ export function createJsxTransform(platform) {
718
728
  // hooks can inspect the original JSX child shape.
719
729
  const raw_children = node_children(node).map((child) => ({ ...child }));
720
730
  const inner = /** @type {AST.TSRXJSXElement} */ (next() ?? node);
731
+ const in_jsx_child = in_jsx_child_context(path);
721
732
  const hook = platform.hooks?.transformElement;
722
- if (hook) return hook(inner, state, raw_children);
723
- return to_jsx_element(inner, state, raw_children, in_jsx_child_context(path));
733
+ const produced = hook
734
+ ? hook(inner, state, raw_children)
735
+ : to_jsx_element(inner, state, raw_children, in_jsx_child);
736
+ // A host element carrying `ref` plus a spread lowers to a generated
737
+ // `let X = __normalize_spread_props_for_ref_attr(…)` that rides on the
738
+ // element's metadata for a later pass to hoist. Only the render-block
739
+ // statement builder and the native-directive path hoist it, so an
740
+ // element in plain-JS expression position — a ternary arm, a concise
741
+ // arrow body, a declarator init, a callback body, an attribute value, an
742
+ // array element — reaches neither: the declaration is dropped while the
743
+ // rewritten attributes still reference the name, and the type-only print
744
+ // carries an undefined identifier (TS2304). Wrap it in the same IIFE the
745
+ // native-directive path already uses.
746
+ return state.typeOnly && produced.type !== 'JSXSpreadChild' && produced.type !== 'JSXText'
747
+ ? wrap_jsx_setup_declarations(produced, in_jsx_child)
748
+ : produced;
724
749
  },
725
750
 
726
751
  JSXExpressionContainer(node, { next, state }) {
@@ -781,7 +806,7 @@ export function createJsxTransform(platform) {
781
806
  return visited;
782
807
  }
783
808
  const is_component = is_component_like_jsx_name(visited.name);
784
- return b.jsx_opening_element(
809
+ const lowered = b.jsx_opening_element(
785
810
  visited.name,
786
811
  merge_duplicate_refs(
787
812
  normalize_host_ref_spreads(visited.attributes || [], !is_component, transform_context),
@@ -791,6 +816,17 @@ export function createJsxTransform(platform) {
791
816
  visited.typeArguments,
792
817
  has_location(visited) ? visited : undefined,
793
818
  );
819
+ // `normalize_host_ref_spreads` is NOT idempotent: run twice it reads the
820
+ // `ref={[authored, __spread_props1.ref]}` array it just produced as an
821
+ // AUTHORED ref and lowers again, emitting a second helper binding and a
822
+ // nested ref array. The attribute list cannot answer "already lowered"
823
+ // on its own — `merge_duplicate_refs` rebuilds the merged `ref` without
824
+ // the `synthetic_ref` marker — so record it on the element instead.
825
+ lowered.metadata = {
826
+ ...(lowered.metadata || {}),
827
+ host_ref_spread_lowered: true,
828
+ };
829
+ return lowered;
794
830
  },
795
831
  });
796
832
 
@@ -811,7 +847,7 @@ export function createJsxTransform(platform) {
811
847
  if (platform.hooks?.injectImports) {
812
848
  platform.hooks.injectImports(expanded, transform_context, suspense_source);
813
849
  } else {
814
- inject_try_imports(expanded, transform_context, platform, suspense_source);
850
+ inject_try_imports(expanded, transform_context, effective_platform, suspense_source);
815
851
  }
816
852
 
817
853
  // Lower any `@{ … }` code blocks left in generated helper bodies before the
@@ -1055,6 +1091,7 @@ function inject_dynamic_import(program, transform_context) {
1055
1091
  * @param {AST.CSS.StyleSheet} css
1056
1092
  * @param {TransformContext} transform_context
1057
1093
  * @param {boolean} [export_top_scoped_classes]
1094
+ * @param {string} [region_hash]
1058
1095
  * @returns {void}
1059
1096
  */
1060
1097
  function apply_css_definition_metadata(
@@ -1062,6 +1099,7 @@ function apply_css_definition_metadata(
1062
1099
  css,
1063
1100
  transform_context,
1064
1101
  export_top_scoped_classes = false,
1102
+ region_hash = css.hash,
1065
1103
  ) {
1066
1104
  analyze_css(css);
1067
1105
 
@@ -1072,7 +1110,7 @@ function apply_css_definition_metadata(
1072
1110
 
1073
1111
  const prune = () => {
1074
1112
  for (const element of elements) {
1075
- prune_css(css, element, style_classes, top_scoped_classes);
1113
+ prune_css(css, element, style_classes, top_scoped_classes, region_hash);
1076
1114
  }
1077
1115
  };
1078
1116
 
@@ -2281,19 +2319,20 @@ function node_contains_native_tsrx_template(node) {
2281
2319
 
2282
2320
  /**
2283
2321
  * @param {AST.NativeTSRXNode} node
2284
- * @returns {AST.CSS.StyleSheet | null}
2322
+ * @param {boolean} allow_multiple
2323
+ * @returns {AST.CSS.StyleSheet[] | null}
2285
2324
  */
2286
- function collect_tsrx_stylesheet(node) {
2325
+ function collect_tsrx_stylesheets(node, allow_multiple) {
2287
2326
  /** @type {AST.CSS.StyleSheet[]} */
2288
2327
  const styles = [];
2289
2328
  collect_style_elements(node_children(node), styles);
2290
2329
 
2291
2330
  if (styles.length === 0) return null;
2292
- if (styles.length > 1) {
2331
+ if (styles.length > 1 && !allow_multiple) {
2293
2332
  throw new Error('TSRX fragments can only have one style tag');
2294
2333
  }
2295
2334
 
2296
- return styles[0];
2335
+ return styles;
2297
2336
  }
2298
2337
 
2299
2338
  /**
@@ -2302,14 +2341,37 @@ function collect_tsrx_stylesheet(node) {
2302
2341
  * @returns {JsxStyleContext | null}
2303
2342
  */
2304
2343
  function prepare_tsrx_fragment_styles(node, transform_context) {
2305
- const css = collect_tsrx_stylesheet(node);
2306
- if (!css) return null;
2307
-
2344
+ // Type-only output must stay analyzable: a throw here makes language hosts
2345
+ // (tsrx-tsc, editors) fall back to presenting the RAW source as the virtual
2346
+ // TSX, so every CSS brace in the file becomes a TSX parse error. Platforms
2347
+ // whose runtime dialect allows one scope to split its CSS across several
2348
+ // `<style>` tags are valid input here, and platforms that forbid it still
2349
+ // report the rule through their own runtime compiler.
2350
+ const sheets = collect_tsrx_stylesheets(node, transform_context.typeOnly);
2351
+ if (!sheets) return null;
2352
+
2353
+ const css = sheets[0];
2308
2354
  const style_refs = collect_style_ref_attributes(node);
2309
- // `prune_css` inside marks the matching selectors as used/scoped; selectors
2310
- // that match no element render commented out, like the Ripple target.
2311
- apply_css_definition_metadata(node, css, transform_context, style_refs.length > 0);
2312
- transform_context.stylesheets.push(css);
2355
+ // A component scope keeps ONE hash even when its CSS is split across
2356
+ // several `<style>` tags: rebase every sheet onto the first sheet's hash
2357
+ // before scoping, so selector rewriting and the DOM hash annotation below
2358
+ // agree. `apply_css_definition_metadata` accumulates into the component's
2359
+ // `styleClasses`/`topScopedClasses` metadata, so per-sheet calls compose
2360
+ // into one class map for style refs.
2361
+ for (const sheet of sheets) {
2362
+ const region_hash = sheet.hash;
2363
+ sheet.hash = css.hash;
2364
+ // `prune_css` inside marks the matching selectors as used/scoped; selectors
2365
+ // that match no element render commented out, like the Ripple target.
2366
+ apply_css_definition_metadata(
2367
+ node,
2368
+ sheet,
2369
+ transform_context,
2370
+ style_refs.length > 0,
2371
+ region_hash,
2372
+ );
2373
+ transform_context.stylesheets.push(sheet);
2374
+ }
2313
2375
  const fragment = annotate_tsrx_with_hash(
2314
2376
  node,
2315
2377
  css.hash,
@@ -6110,8 +6172,16 @@ function transform_element_attributes_dispatch(attrs, transform_context, element
6110
6172
  }
6111
6173
  const hook = transform_context.platform.hooks?.transformElementAttributes;
6112
6174
  const result = hook ? hook(attrs, transform_context, element) : attrs;
6175
+ // An element in plain-JS expression position reaches BOTH lowering sites —
6176
+ // the JSXOpeningElement visitor above and this dispatch — so without the
6177
+ // marker its host ref/spread is lowered twice. Scoped to the type-only
6178
+ // print: runtime emit for the other platforms sharing this transform keeps
6179
+ // its existing output.
6180
+ const already_lowered =
6181
+ transform_context.typeOnly &&
6182
+ element?.openingElement?.metadata?.host_ref_spread_lowered === true;
6113
6183
  return merge_duplicate_refs(
6114
- normalize_host_ref_spreads(result, !is_component, transform_context),
6184
+ already_lowered ? result : normalize_host_ref_spreads(result, !is_component, transform_context),
6115
6185
  transform_context,
6116
6186
  );
6117
6187
  }
@@ -34,11 +34,19 @@ import {
34
34
  build_line_offsets,
35
35
  get_mapping_from_node,
36
36
  } from '../source-map-utils.js';
37
- import { should_preserve_comment } from '../comment-utils.js';
37
+ import { should_preserve_jsx_tooling_comment, format_comment } from '../comment-utils.js';
38
38
  import { has_location } from '../utils/ast.js';
39
39
 
40
40
  const LAZY_PARAM_IDENTIFIER_REGEX = /^__lazy\d+$/;
41
41
  const RETURN_KEYWORD = 'return';
42
+ const EXPORT_KEYWORD = 'export';
43
+ const BLOCK_DECLARATION_TYPES = new Set([
44
+ 'FunctionDeclaration',
45
+ 'ClassDeclaration',
46
+ 'TSInterfaceDeclaration',
47
+ 'TSEnumDeclaration',
48
+ 'TSModuleDeclaration',
49
+ ]);
42
50
 
43
51
  /**
44
52
  * @param {string} value
@@ -418,6 +426,88 @@ export function convert_source_map_to_mappings(
418
426
  return candidates[Math.min(index, candidates.length - 1)];
419
427
  }
420
428
 
429
+ /**
430
+ * A comment's end can share a source coordinate with the next declaration,
431
+ * and synthetic file pragmas can share offset zero with the first export.
432
+ * Select the position that actually prints this node's opening text instead
433
+ * of treating the first source-map entry as an unambiguous boundary.
434
+ * @param {AST.Position} position
435
+ * @param {string} text
436
+ * @returns {number | undefined}
437
+ */
438
+ function generated_offset_for_text(position, text) {
439
+ const positions = src_to_gen_map.get(`${position.line}:${position.column}`);
440
+ for (const generated of positions ?? []) {
441
+ const offset = loc_to_offset(generated.line, generated.column, gen_line_offsets);
442
+ if (generated_code.startsWith(text, offset)) return offset;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * @param {AST.NodeWithLocation} node
448
+ * @param {string} start_text
449
+ * @returns {CodeMapping | undefined}
450
+ */
451
+ function declaration_mapping(node, start_text) {
452
+ const start = generated_offset_for_text(node.loc.start, start_text);
453
+ if (start === undefined) return;
454
+ const positions = src_to_gen_map.get(`${node.loc.end.line}:${node.loc.end.column}`);
455
+ for (const generated of positions ?? []) {
456
+ const end = loc_to_offset(generated.line, generated.column, gen_line_offsets);
457
+ if (end < start) continue;
458
+ return {
459
+ sourceOffsets: [node.start],
460
+ lengths: [node.end - node.start],
461
+ generatedOffsets: [start],
462
+ generatedLengths: [end - start],
463
+ data: { ...mapping_data_verify_only, customData: {} },
464
+ };
465
+ }
466
+ }
467
+
468
+ /** @param {AST.ExportNamedDeclaration | AST.ExportDefaultDeclaration | AST.ExportAllDeclaration} node */
469
+ function add_export_mapping(node) {
470
+ if (!has_location(node)) return;
471
+ const mapping = declaration_mapping(node, EXPORT_KEYWORD);
472
+ if (mapping) {
473
+ const declaration = node.type === 'ExportAllDeclaration' ? null : node.declaration;
474
+ const end = mapping.generatedOffsets[0] + mapping.generatedLengths[0];
475
+ // A semicolon-free source declaration shares its end with its last
476
+ // expression. The first map entry then precedes the statement's emitted
477
+ // semicolon. Block declarations are different: a following `;` is an
478
+ // empty statement, not part of TypeScript's declaration range.
479
+ if (
480
+ generated_code[end] === ';' &&
481
+ (!declaration || !BLOCK_DECLARATION_TYPES.has(declaration.type))
482
+ ) {
483
+ mapping.generatedLengths[0]++;
484
+ }
485
+ // Full declaration queries need both endpoints in the same Volar
486
+ // mapping, not a linear claim over the generated body. A transformed
487
+ // component can contain synthetic imports, tags, and helper calls that
488
+ // must not acquire source locations merely because it is exported.
489
+ const generated_start = mapping.generatedOffsets[0];
490
+ const generated_end = generated_start + mapping.generatedLengths[0];
491
+ mapping.sourceOffsets = [node.start, node.end];
492
+ mapping.generatedOffsets = [generated_start, generated_end];
493
+ mapping.lengths = [0, 0];
494
+ mapping.generatedLengths = [0, 0];
495
+ mappings.push(mapping);
496
+ }
497
+ tokens.push({
498
+ source: EXPORT_KEYWORD,
499
+ generated: EXPORT_KEYWORD,
500
+ loc: {
501
+ start: node.loc.start,
502
+ end: {
503
+ line: node.loc.start.line,
504
+ column: node.loc.start.column + EXPORT_KEYWORD.length,
505
+ },
506
+ },
507
+ metadata: {},
508
+ });
509
+ }
510
+
421
511
  /**
422
512
  * Needed for a mapping that includes the computed brackets for diagnostics
423
513
  * @param {AST.MethodDefinition | AST.Property} node
@@ -464,25 +554,24 @@ export function convert_source_map_to_mappings(
464
554
  if (!Array.isArray(comments)) continue;
465
555
 
466
556
  for (const comment of comments) {
467
- if (!has_location(comment) || !should_preserve_comment(comment)) continue;
557
+ if (!has_location(comment) || !should_preserve_jsx_tooling_comment(comment)) continue;
468
558
 
469
559
  const comment_key = `${comment.start}:${comment.end}`;
470
560
  if (mapped_comments.has(comment_key)) continue;
471
561
  mapped_comments.add(comment_key);
472
562
 
473
- try {
474
- mappings.push(
475
- get_mapping_from_node(
476
- comment,
477
- src_to_gen_map,
478
- gen_line_offsets,
479
- mapping_data_verify_only,
480
- ),
481
- );
482
- } catch {
483
- // Comments that were not emitted in generated TSX have no source-map
484
- // segment. They should not produce Volar mappings.
485
- }
563
+ const text = format_comment(comment);
564
+ const start = generated_offset_for_text(comment.loc.start, text);
565
+ // A stripped or moved comment must not claim another node's shared
566
+ // source coordinate. The printer writes this exact formatted text.
567
+ if (start === undefined) continue;
568
+ mappings.push({
569
+ sourceOffsets: [comment.start],
570
+ lengths: [comment.end - comment.start],
571
+ generatedOffsets: [start],
572
+ generatedLengths: [text.length],
573
+ data: { ...mapping_data_verify_only, customData: {} },
574
+ });
486
575
  }
487
576
  }
488
577
  }
@@ -704,6 +793,7 @@ export function convert_source_map_to_mappings(
704
793
  }
705
794
  return;
706
795
  } else if (node.type === 'ExportNamedDeclaration') {
796
+ add_export_mapping(node);
707
797
  if (node.specifiers && node.specifiers.length > 0) {
708
798
  for (const specifier of node.specifiers) {
709
799
  visit(specifier);
@@ -715,12 +805,14 @@ export function convert_source_map_to_mappings(
715
805
  }
716
806
  return;
717
807
  } else if (node.type === 'ExportDefaultDeclaration') {
808
+ add_export_mapping(node);
718
809
  // Visit the declaration
719
810
  if (node.declaration) {
720
811
  visit(/** @type {AST.Node} */ (node.declaration));
721
812
  }
722
813
  return;
723
814
  } else if (node.type === 'ExportAllDeclaration') {
815
+ add_export_mapping(node);
724
816
  // Nothing to visit (just source string)
725
817
  return;
726
818
  } else if (node.type === 'JSXOpeningElement') {
@@ -793,7 +885,7 @@ export function convert_source_map_to_mappings(
793
885
  definition: {
794
886
  description: `CSS class selector for '.${name}'`,
795
887
  location: {
796
- embeddedId: get_style_region_id(css.hash),
888
+ embeddedId: get_style_region_id(cssLocation.regionHash ?? css.hash),
797
889
  start: cssLocation.start,
798
890
  end: cssLocation.end,
799
891
  },
@@ -1908,6 +2000,24 @@ export function convert_source_map_to_mappings(
1908
2000
  }
1909
2001
  return;
1910
2002
  } else if (node.type === 'TSPropertySignature') {
2003
+ if (has_location(node)) {
2004
+ const start_text = node.readonly
2005
+ ? 'readonly'
2006
+ : node.computed
2007
+ ? '['
2008
+ : node.key.type === 'Identifier'
2009
+ ? node.key.name
2010
+ : source.slice(node.start, node.key.end);
2011
+ const mapping = declaration_mapping(node, start_text);
2012
+ if (mapping) {
2013
+ const end = mapping.generatedOffsets[0] + mapping.generatedLengths[0];
2014
+ // esrap's containing type/interface prints member separators
2015
+ // after the property's own end marker. TS includes that `;`
2016
+ // in its PropertySignature range, even when it was not authored.
2017
+ if (generated_code[end] === ';') mapping.generatedLengths[0]++;
2018
+ mappings.push(mapping);
2019
+ }
2020
+ }
1911
2021
  // Property signature in type
1912
2022
  if (node.key) {
1913
2023
  visit(node.key);
@@ -2354,7 +2464,11 @@ export function convert_source_map_to_mappings(
2354
2464
 
2355
2465
  // Add a mapping for the very beginning of the file to handle import additions
2356
2466
  // This ensures that code actions adding imports at the top work correctly
2357
- if (!isImportDeclarationPresent && mappings.length > 0 && mappings[0].sourceOffsets[0] > 0) {
2467
+ if (
2468
+ !isImportDeclarationPresent &&
2469
+ mappings.length > 0 &&
2470
+ (mappings[0].sourceOffsets[0] > 0 || mappings[0].generatedOffsets[0] > 0)
2471
+ ) {
2358
2472
  mappings.unshift({
2359
2473
  sourceOffsets: [0],
2360
2474
  generatedOffsets: [0],
package/types/index.d.ts CHANGED
@@ -989,6 +989,8 @@ declare module 'estree-jsx' {
989
989
  interface JSXOpeningElement {
990
990
  metadata: BaseNodeMetaData & {
991
991
  native_tsrx_pretransformed?: boolean;
992
+ /** Type-only host ref/spread normalization has already run for this element. */
993
+ host_ref_spread_lowered?: boolean;
992
994
  };
993
995
  }
994
996
 
@@ -2090,6 +2092,8 @@ export type TopScopedClasses = Map<
2090
2092
  start: number;
2091
2093
  end: number;
2092
2094
  selector: AST.CSS.ClassSelector;
2095
+ /** Source `<style>` region for editor definition navigation. */
2096
+ regionHash?: string;
2093
2097
  }
2094
2098
  >;
2095
2099
 
@@ -2218,6 +2222,14 @@ export interface VolarCompileOptions extends Omit<ParseOptions, 'errors' | 'comm
2218
2222
  dev?: boolean;
2219
2223
  }
2220
2224
 
2225
+ /**
2226
+ * Selects where generated runtime helper imports resolve from. Direct mode
2227
+ * emits bare imports from the target's standalone runtime package; the package
2228
+ * that owns the generated modules must declare that runtime as a direct
2229
+ * production dependency.
2230
+ */
2231
+ export type RuntimeImportMode = 'compiler' | 'direct';
2232
+
2221
2233
  /**
2222
2234
  * Common base options accepted by every TSRX target's `compile` entry point.
2223
2235
  * Targets that need extra knobs (e.g. ripple's `mode`/`dev`/`hmr`, preact's
@@ -2227,6 +2239,13 @@ export interface VolarCompileOptions extends Omit<ParseOptions, 'errors' | 'comm
2227
2239
  export interface BaseCompileOptions {
2228
2240
  collect?: boolean;
2229
2241
  loose?: boolean;
2242
+ /**
2243
+ * Selects where generated runtime helper imports resolve from. The default
2244
+ * `'compiler'` mode preserves compiler-package compatibility subpaths;
2245
+ * `'direct'` targets the renderer's small runtime package, which the package
2246
+ * owning the generated modules must declare as a direct production dependency.
2247
+ */
2248
+ runtimeImports?: RuntimeImportMode;
2230
2249
  }
2231
2250
 
2232
2251
  /**
@@ -1,7 +1,7 @@
1
1
  import type * as AST from 'estree';
2
2
  import type * as ESTreeJSX from 'estree-jsx';
3
3
  import type { RawSourceMap } from 'source-map';
4
- import type { CompileError, JsxHelperComponent, JsxHelperState } from './index';
4
+ import type { CompileError, JsxHelperComponent, JsxHelperState, RuntimeImportMode } from './index';
5
5
 
6
6
  /**
7
7
  * Result returned by a JSX platform transform (React, Preact, Solid).
@@ -82,6 +82,13 @@ export interface JsxTransformContext {
82
82
  * Optional per-call compile options passed to a created JSX transform.
83
83
  */
84
84
  export interface JsxTransformOptions {
85
+ /**
86
+ * Selects compiler-package compatibility imports or direct renderer runtime
87
+ * package imports. Defaults to `'compiler'`. Direct mode requires the package
88
+ * owning generated modules to declare the target runtime as a direct
89
+ * production dependency.
90
+ */
91
+ runtimeImports?: RuntimeImportMode;
85
92
  /**
86
93
  * Override the import source used for `Suspense` in try-block transforms.
87
94
  * Falls back to `platform.imports.suspense`. Preact uses this to let the
@@ -412,6 +419,17 @@ export interface JsxPlatform {
412
419
  forOfIterableHelper?: string;
413
420
  };
414
421
 
422
+ /**
423
+ * Runtime-helper import sources used when a compile call opts into direct
424
+ * runtime imports. Fields omitted here continue using `imports`.
425
+ */
426
+ directRuntimeImports?: {
427
+ errorBoundary?: string;
428
+ mergeRefs?: string;
429
+ refProp?: string;
430
+ forOfIterableHelper?: string;
431
+ };
432
+
415
433
  jsx: {
416
434
  /**
417
435
  * Rewrite Ripple's `class` attribute to `className` for legacy targets
@@ -1,13 +1 @@
1
- // Helper type for item in an array or iterable
2
- // example: IterationValue<typeof something>
3
- export type IterationValue<T> = T extends readonly unknown[]
4
- ? T[number]
5
- : T extends Iterable<infer U>
6
- ? U
7
- : never;
8
-
9
- export function map_iterable<T, U>(
10
- value: Iterable<T>,
11
- fn: (item: T, index: number, is_last: boolean) => U,
12
- tail?: () => U | U[],
13
- ): U[];
1
+ export * from '@tsrx/runtime/iterable';