@tsrx/core 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Framework-agnostic CSS scoping utilities shared between the `@tsrx/react`
3
+ * and `@tsrx/solid` transforms. These walk the template AST and annotate
4
+ * `Element` nodes with a hash class so scope-qualified selectors
5
+ * (e.g. `.foo.hash`) match after rendering.
6
+ */
7
+
8
+ import { walk } from 'zimmerframe';
9
+
10
+ /**
11
+ * Mark every selector inside the stylesheet as "used" so `renderStylesheets`
12
+ * does not comment it out. We skip selector-pruning because component
13
+ * boundaries can be dynamic — any selector authored inside the component's
14
+ * `<style>` block is considered intentional.
15
+ *
16
+ * @param {any} stylesheet
17
+ * @returns {any}
18
+ */
19
+ export function prepare_stylesheet_for_render(stylesheet) {
20
+ walk(stylesheet, null, {
21
+ _(node, { next }) {
22
+ if (node && node.metadata && typeof node.metadata === 'object') {
23
+ node.metadata.used = true;
24
+ if (node.type === 'RelativeSelector' && !node.metadata.is_global) {
25
+ node.metadata.scoped = true;
26
+ }
27
+ }
28
+ return next();
29
+ },
30
+ });
31
+ return stylesheet;
32
+ }
33
+
34
+ /**
35
+ * @param {any} node
36
+ * @returns {boolean}
37
+ */
38
+ export function is_style_element(node) {
39
+ return (
40
+ node &&
41
+ node.type === 'Element' &&
42
+ node.id &&
43
+ node.id.type === 'Identifier' &&
44
+ node.id.name === 'style'
45
+ );
46
+ }
47
+
48
+ /**
49
+ * @param {any} node
50
+ * @returns {boolean}
51
+ */
52
+ export function is_composite_element(node) {
53
+ if (!node || node.type !== 'Element' || !node.id) {
54
+ return false;
55
+ }
56
+
57
+ if (node.id.type === 'Identifier') {
58
+ return /^[A-Z]/.test(node.id.name);
59
+ }
60
+
61
+ return node.id.type === 'MemberExpression';
62
+ }
63
+
64
+ /**
65
+ * Recursively walk `Element` nodes within a component body and add the hash
66
+ * class name so scope-qualified selectors (e.g. `.foo.hash`) match.
67
+ *
68
+ * @param {any} node
69
+ * @param {string} hash
70
+ * @returns {any}
71
+ */
72
+ export function annotate_with_hash(node, hash) {
73
+ if (!node || typeof node !== 'object') return node;
74
+ if (
75
+ node.type === 'Component' ||
76
+ node.type === 'FunctionDeclaration' ||
77
+ node.type === 'FunctionExpression' ||
78
+ node.type === 'ArrowFunctionExpression'
79
+ ) {
80
+ return node;
81
+ }
82
+
83
+ if (node.type === 'Element') {
84
+ if (!is_style_element(node) && !is_composite_element(node)) {
85
+ add_hash_class(node, hash);
86
+ }
87
+ if (Array.isArray(node.children)) {
88
+ node.children = node.children
89
+ .filter((/** @type {any} */ child) => !is_style_element(child))
90
+ .map((/** @type {any} */ child) => annotate_with_hash(child, hash));
91
+ }
92
+ return node;
93
+ }
94
+
95
+ for (const key of Object.keys(node)) {
96
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata' || key === 'css') {
97
+ continue;
98
+ }
99
+
100
+ const value = node[key];
101
+ if (Array.isArray(value)) {
102
+ node[key] = value.map((/** @type {any} */ child) => annotate_with_hash(child, hash));
103
+ } else if (value && typeof value === 'object') {
104
+ node[key] = annotate_with_hash(value, hash);
105
+ }
106
+ }
107
+
108
+ return node;
109
+ }
110
+
111
+ /**
112
+ * @param {any} component
113
+ * @param {string} hash
114
+ * @returns {void}
115
+ */
116
+ export function annotate_component_with_hash(component, hash) {
117
+ /** @type {any[]} */
118
+ const body = component.body;
119
+ component.body = body
120
+ .filter((/** @type {any} */ child) => !is_style_element(child))
121
+ .map((/** @type {any} */ child) => annotate_with_hash(child, hash));
122
+ }
123
+
124
+ /**
125
+ * Ensure the element carries a `class` attribute containing the scoping hash.
126
+ *
127
+ * @param {any} element
128
+ * @param {string} hash
129
+ * @returns {void}
130
+ */
131
+ export function add_hash_class(element, hash) {
132
+ const attrs = element.attributes || (element.attributes = []);
133
+ const existing = attrs.find(
134
+ (/** @type {any} */ a) =>
135
+ a.type === 'Attribute' &&
136
+ a.name &&
137
+ a.name.type === 'Identifier' &&
138
+ (a.name.name === 'class' || a.name.name === 'className'),
139
+ );
140
+
141
+ if (!existing) {
142
+ attrs.push({
143
+ type: 'Attribute',
144
+ name: { type: 'Identifier', name: 'class' },
145
+ value: { type: 'Literal', value: hash, raw: JSON.stringify(hash) },
146
+ });
147
+ return;
148
+ }
149
+
150
+ const value = existing.value;
151
+ if (!value) {
152
+ existing.value = { type: 'Literal', value: hash, raw: JSON.stringify(hash) };
153
+ return;
154
+ }
155
+
156
+ if (value.type === 'Literal' && typeof value.value === 'string') {
157
+ const merged = `${value.value} ${hash}`;
158
+ existing.value = { type: 'Literal', value: merged, raw: JSON.stringify(merged) };
159
+ return;
160
+ }
161
+
162
+ // Dynamic expression. Concatenate at runtime via template literal.
163
+ const expression = value.type === 'JSXExpressionContainer' ? value.expression : value;
164
+ existing.value = {
165
+ type: 'TemplateLiteral',
166
+ expressions: [expression],
167
+ quasis: [
168
+ {
169
+ type: 'TemplateElement',
170
+ value: { raw: '', cooked: '' },
171
+ tail: false,
172
+ },
173
+ {
174
+ type: 'TemplateElement',
175
+ value: { raw: ` ${hash}`, cooked: ` ${hash}` },
176
+ tail: true,
177
+ },
178
+ ],
179
+ };
180
+ }
@@ -9,6 +9,7 @@
9
9
  CodeMapping,
10
10
  VolarMappingsResult,
11
11
  PostProcessingChanges,
12
+ LineOffsets,
12
13
  } from '../../types/index';
13
14
  @import { CodeMapping as VolarCodeMapping } from '@volar/language-core';
14
15
  */
@@ -51,6 +52,7 @@ import {
51
52
  mapping_data_verify_complete,
52
53
  build_line_offsets,
53
54
  get_mapping_from_node,
55
+ maybe_get_mapping_from_node,
54
56
  } from '../source-map-utils.js';
55
57
 
56
58
  const LABEL_TO_COMPONENT_REPLACE_REGEX = /(function|\((property|method)\))/;
@@ -668,9 +670,21 @@ export function convert_source_map_to_mappings(
668
670
  return;
669
671
  } else if (node.type === 'JSXExpressionContainer') {
670
672
  if (node.loc) {
671
- mappings.push(
672
- get_mapping_from_node(node, src_to_gen_map, gen_line_offsets, mapping_data_verify_only),
673
+ // Use maybe_get_mapping_from_node because a transform may set the
674
+ // container's loc to the source range of the original `{...}`
675
+ // construct (e.g. a Ripple TSRXExpression or Text node), while
676
+ // esrap only emits a segment for the inner expression. In that
677
+ // case the container's start/end won't resolve — skip rather
678
+ // than hard-failing, and rely on the inner expression's mapping.
679
+ const mapping = maybe_get_mapping_from_node(
680
+ node,
681
+ src_to_gen_map,
682
+ gen_line_offsets,
683
+ mapping_data_verify_only,
673
684
  );
685
+ if (!(mapping instanceof Error)) {
686
+ mappings.push(mapping);
687
+ }
674
688
  }
675
689
  // Visit the expression inside {}
676
690
  if (node.expression) {
@@ -726,24 +740,38 @@ export function convert_source_map_to_mappings(
726
740
  }
727
741
  }
728
742
 
729
- if (closing || opening.selfClosing) {
730
- // Add the whole closing tag or the self-closing
731
- const mapping = get_mapping_from_node(
732
- closing ? closing : opening,
733
- src_to_gen_map,
734
- gen_line_offsets,
735
- mapping_data_verify_only,
736
- );
743
+ if ((closing?.loc || opening.loc) && (closing || opening.selfClosing)) {
744
+ // Add the whole closing tag or the self-closing.
745
+ // For self-closing elements, use maybe_get_mapping_from_node because
746
+ // attribute transforms (e.g. class→className, {ref fn}→ref={fn}) can shift
747
+ // the position of `/>` in the generated output, making the source map
748
+ // entry for the opening element's end position unresolvable.
749
+ const target_node = closing ? closing : opening;
750
+ const mapping = closing
751
+ ? get_mapping_from_node(
752
+ target_node,
753
+ src_to_gen_map,
754
+ gen_line_offsets,
755
+ mapping_data_verify_only,
756
+ )
757
+ : maybe_get_mapping_from_node(
758
+ target_node,
759
+ src_to_gen_map,
760
+ gen_line_offsets,
761
+ mapping_data_verify_only,
762
+ );
737
763
 
738
- // The generated code includes a semicolon after the closing or self-closed tag
739
- // We're extending the mapping to include the semicolon
740
- // because the diagnostics errors can include the whole element
741
- // and we need to account for the semicolon as it's a part of the diagnostic
742
- // At the same time, we could've instead applied this logic to the whole `node` element
743
- // but since we already map the opening - start, we just need the proper end
744
- // and it was causing some issues with mappings
745
- mapping.generatedLengths = [mapping.generatedLengths[0] + 1];
746
- mappings.push(mapping);
764
+ if (!(mapping instanceof Error)) {
765
+ // The generated code includes a semicolon after the closing or self-closed tag
766
+ // We're extending the mapping to include the semicolon
767
+ // because the diagnostics errors can include the whole element
768
+ // and we need to account for the semicolon as it's a part of the diagnostic
769
+ // At the same time, we could've instead applied this logic to the whole `node` element
770
+ // but since we already map the opening - start, we just need the proper end
771
+ // and it was causing some issues with mappings
772
+ mapping.generatedLengths = [mapping.generatedLengths[0] + 1];
773
+ mappings.push(mapping);
774
+ }
747
775
  }
748
776
 
749
777
  if (closing) {
@@ -769,37 +797,52 @@ export function convert_source_map_to_mappings(
769
797
  let start = node_fn.start;
770
798
  const async_keyword = 'async';
771
799
 
772
- if (node_fn.async) {
773
- // We explicitly mapped async and function in esrap
800
+ if (is_component && node_fn.id?.loc) {
801
+ const mapping = get_mapping_from_node(node_fn.id, src_to_gen_map, gen_line_offsets);
802
+ const generated_id_start = mapping.generatedOffsets[0];
803
+ const generated_keyword_start = find_component_keyword_offset(
804
+ generated_code,
805
+ generated_id_start,
806
+ );
807
+ mapping.sourceOffsets = [start];
808
+ mapping.lengths = [source_func_keyword.length];
809
+ mapping.generatedOffsets = [generated_keyword_start];
810
+ mapping.generatedLengths = ['function'.length];
811
+ mapping.data.customData.hover = replace_label_to_component;
812
+ mappings.push(mapping);
813
+ } else {
814
+ if (node_fn.async) {
815
+ // We explicitly mapped async and function in esrap
816
+ tokens.push({
817
+ source: async_keyword,
818
+ generated: async_keyword,
819
+ loc: {
820
+ start: { line: node_fn.loc.start.line, column: start_col },
821
+ end: {
822
+ line: node_fn.loc.start.line,
823
+ column: start_col + async_keyword.length,
824
+ },
825
+ },
826
+ metadata: {},
827
+ });
828
+
829
+ start_col += async_keyword.length + 1; // +1 for space
830
+ start += async_keyword.length + 1;
831
+ }
832
+
774
833
  tokens.push({
775
- source: async_keyword,
776
- generated: async_keyword,
834
+ source: source_func_keyword,
835
+ generated: 'function',
777
836
  loc: {
778
837
  start: { line: node_fn.loc.start.line, column: start_col },
779
838
  end: {
780
839
  line: node_fn.loc.start.line,
781
- column: start_col + async_keyword.length,
840
+ column: start_col + source_func_keyword.length,
782
841
  },
783
842
  },
784
- metadata: {},
843
+ metadata: is_component ? { hover: replace_label_to_component } : {},
785
844
  });
786
-
787
- start_col += async_keyword.length + 1; // +1 for space
788
- start += async_keyword.length + 1;
789
845
  }
790
-
791
- tokens.push({
792
- source: source_func_keyword,
793
- generated: 'function',
794
- loc: {
795
- start: { line: node_fn.loc.start.line, column: start_col },
796
- end: {
797
- line: node_fn.loc.start.line,
798
- column: start_col + source_func_keyword.length,
799
- },
800
- },
801
- metadata: is_component ? { hover: replace_label_to_component } : {},
802
- });
803
846
  }
804
847
 
805
848
  // Visit in source order: id, params, body
@@ -982,9 +1025,11 @@ export function convert_source_map_to_mappings(
982
1025
  visit(node.body);
983
1026
  }
984
1027
 
985
- mappings.push(
986
- get_mapping_from_node(node, src_to_gen_map, gen_line_offsets, mapping_data_verify_only),
987
- );
1028
+ if (node.loc) {
1029
+ mappings.push(
1030
+ get_mapping_from_node(node, src_to_gen_map, gen_line_offsets, mapping_data_verify_only),
1031
+ );
1032
+ }
988
1033
 
989
1034
  return;
990
1035
  } else if (node.type === 'WhileStatement' || node.type === 'DoWhileStatement') {
@@ -1321,9 +1366,11 @@ export function convert_source_map_to_mappings(
1321
1366
  }
1322
1367
  }
1323
1368
 
1324
- mappings.push(
1325
- get_mapping_from_node(node, src_to_gen_map, gen_line_offsets, mapping_data_verify_only),
1326
- );
1369
+ if (node.loc) {
1370
+ mappings.push(
1371
+ get_mapping_from_node(node, src_to_gen_map, gen_line_offsets, mapping_data_verify_only),
1372
+ );
1373
+ }
1327
1374
 
1328
1375
  return;
1329
1376
  } else if (node.type === 'SwitchCase') {
@@ -2030,11 +2077,16 @@ export function convert_source_map_to_mappings(
2030
2077
  );
2031
2078
  const source_length = source_text.length;
2032
2079
  const gen_length = gen_text.length;
2033
- const gen_line_col = get_generated_position(
2034
- token.loc.start.line,
2035
- token.loc.start.column,
2036
- src_to_gen_map,
2037
- );
2080
+ let gen_line_col;
2081
+ try {
2082
+ gen_line_col = get_generated_position(
2083
+ token.loc.start.line,
2084
+ token.loc.start.column,
2085
+ src_to_gen_map,
2086
+ );
2087
+ } catch {
2088
+ continue;
2089
+ }
2038
2090
  const gen_start = loc_to_offset(gen_line_col.line, gen_line_col.column, gen_line_offsets);
2039
2091
 
2040
2092
  /** @type {CustomMappingData} */
@@ -2138,3 +2190,117 @@ export function convert_source_map_to_mappings(
2138
2190
  cssMappings,
2139
2191
  };
2140
2192
  }
2193
+
2194
+ /**
2195
+ * @param {string} generated_code
2196
+ * @param {number} generated_id_start
2197
+ * @returns {number}
2198
+ */
2199
+ function find_component_keyword_offset(generated_code, generated_id_start) {
2200
+ const function_keyword_index = generated_code.lastIndexOf('function', generated_id_start);
2201
+
2202
+ if (function_keyword_index === -1) {
2203
+ return generated_id_start;
2204
+ }
2205
+
2206
+ return function_keyword_index;
2207
+ }
2208
+
2209
+ /**
2210
+ * Build a `VolarMappingsResult` from generated code plus source-map metadata.
2211
+ *
2212
+ * Framework packages are responsible for producing the generated AST/code/map.
2213
+ * Core owns the generic mapping conversion and result envelope so the editor
2214
+ * integration is not coupled to any specific framework package.
2215
+ *
2216
+ * @param {{
2217
+ * ast: AST.Program,
2218
+ * ast_from_source: AST.Program,
2219
+ * source: string,
2220
+ * generated_code: string,
2221
+ * source_map: RawSourceMap,
2222
+ * errors?: import('../../types/index').CompileError[],
2223
+ * post_processing_changes?: PostProcessingChanges,
2224
+ * line_offsets?: LineOffsets,
2225
+ * }} params
2226
+ * @returns {VolarMappingsResult}
2227
+ */
2228
+ export function create_volar_mappings_result({
2229
+ ast,
2230
+ ast_from_source,
2231
+ source,
2232
+ generated_code,
2233
+ source_map,
2234
+ errors = [],
2235
+ post_processing_changes,
2236
+ line_offsets,
2237
+ }) {
2238
+ return {
2239
+ ...convert_source_map_to_mappings(
2240
+ ast,
2241
+ ast_from_source,
2242
+ source,
2243
+ generated_code,
2244
+ source_map,
2245
+ /** @type {PostProcessingChanges} */ (post_processing_changes),
2246
+ line_offsets ?? build_line_offsets(generated_code),
2247
+ ),
2248
+ errors,
2249
+ };
2250
+ }
2251
+
2252
+ /**
2253
+ * Remove byte-for-byte duplicate mappings. Framework compilers that extract
2254
+ * shared helpers or replay JSX can emit identical mapping entries for the
2255
+ * same source and generated span; Volar merges duplicates into a single
2256
+ * hover/navigation result, so deduping upstream avoids a stutter.
2257
+ *
2258
+ * @param {CodeMapping[]} mappings
2259
+ * @returns {CodeMapping[]}
2260
+ */
2261
+ export function dedupe_mappings(mappings) {
2262
+ const deduped = [];
2263
+ const seen = new Set();
2264
+
2265
+ for (const mapping of mappings) {
2266
+ const key = JSON.stringify(serialize_mapping_value(mapping));
2267
+
2268
+ if (seen.has(key)) {
2269
+ continue;
2270
+ }
2271
+
2272
+ seen.add(key);
2273
+ deduped.push(mapping);
2274
+ }
2275
+
2276
+ return deduped;
2277
+ }
2278
+
2279
+ /**
2280
+ * Serialize a mapping (or any nested value) into a stable JSON-friendly
2281
+ * shape so {@link dedupe_mappings} can compare two entries by content.
2282
+ * Object keys are sorted and functions are reduced to their source so
2283
+ * structurally-identical entries produce the same string.
2284
+ *
2285
+ * @param {unknown} value
2286
+ * @returns {unknown}
2287
+ */
2288
+ export function serialize_mapping_value(value) {
2289
+ if (typeof value === 'function') {
2290
+ return value.toString();
2291
+ }
2292
+
2293
+ if (Array.isArray(value)) {
2294
+ return value.map(serialize_mapping_value);
2295
+ }
2296
+
2297
+ if (value && typeof value === 'object') {
2298
+ return Object.fromEntries(
2299
+ Object.entries(value)
2300
+ .sort(([left], [right]) => left.localeCompare(right))
2301
+ .map(([key, nested_value]) => [key, serialize_mapping_value(nested_value)]),
2302
+ );
2303
+ }
2304
+
2305
+ return value;
2306
+ }
package/types/index.d.ts CHANGED
@@ -1387,7 +1387,7 @@ export interface TransformClientState extends BaseState {
1387
1387
  applyParentCssScope?: AST.CSS.StyleSheet['hash'];
1388
1388
  skip_children_traversal: boolean;
1389
1389
  return_flags?: Map<AST.ReturnStatement, { name: string; tracked: boolean }>;
1390
- is_ripple_element?: boolean;
1390
+ is_tsrx_element?: boolean;
1391
1391
  }
1392
1392
 
1393
1393
  /** Override zimmerframe types and provide our own */
package/types/parse.d.ts CHANGED
@@ -182,7 +182,12 @@ export namespace Parse {
182
182
  }
183
183
 
184
184
  export interface Options extends Omit<acorn.Options, 'onComment' | 'ecmaVersion'> {
185
- rippleOptions: {
185
+ tsrxOptions?: {
186
+ loose: boolean;
187
+ errors: CoreCompiler.CompileError[];
188
+ filename: string | undefined;
189
+ };
190
+ rippleOptions?: {
186
191
  loose: boolean;
187
192
  errors: CoreCompiler.CompileError[];
188
193
  filename: string | undefined;