ripple 0.2.131 → 0.2.132

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": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.131",
6
+ "version": "0.2.132",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -81,6 +81,6 @@
81
81
  "typescript": "^5.9.2"
82
82
  },
83
83
  "peerDependencies": {
84
- "ripple": "0.2.131"
84
+ "ripple": "0.2.132"
85
85
  }
86
86
  }
@@ -107,7 +107,8 @@ function RipplePlugin(config) {
107
107
  // Inside nested functions (scopeStack.length >= 5), treat < as relational/generic operator
108
108
  // At component top-level (scopeStack.length <= 4), apply JSX detection logic
109
109
  // BUT: if the < is followed by /, it's a closing JSX tag, not a less-than operator
110
- const nextChar = this.pos + 1 < this.input.length ? this.input.charCodeAt(this.pos + 1) : -1;
110
+ const nextChar =
111
+ this.pos + 1 < this.input.length ? this.input.charCodeAt(this.pos + 1) : -1;
111
112
  const isClosingTag = nextChar === 47; // '/'
112
113
 
113
114
  if (this.scopeStack.length >= 5 && !isClosingTag) {
@@ -169,15 +170,19 @@ function RipplePlugin(config) {
169
170
 
170
171
  // Check if this is #Map or #Set
171
172
  if (this.input.slice(this.pos, this.pos + 4) === '#Map') {
172
- const charAfter = this.pos + 4 < this.input.length ? this.input.charCodeAt(this.pos + 4) : -1;
173
- if (charAfter === 40) { // ( character
173
+ const charAfter =
174
+ this.pos + 4 < this.input.length ? this.input.charCodeAt(this.pos + 4) : -1;
175
+ if (charAfter === 40) {
176
+ // ( character
174
177
  this.pos += 4; // consume '#Map'
175
178
  return this.finishToken(tt.name, '#Map');
176
179
  }
177
180
  }
178
181
  if (this.input.slice(this.pos, this.pos + 4) === '#Set') {
179
- const charAfter = this.pos + 4 < this.input.length ? this.input.charCodeAt(this.pos + 4) : -1;
180
- if (charAfter === 40) { // ( character
182
+ const charAfter =
183
+ this.pos + 4 < this.input.length ? this.input.charCodeAt(this.pos + 4) : -1;
184
+ if (charAfter === 40) {
185
+ // ( character
181
186
  this.pos += 4; // consume '#Set'
182
187
  return this.finishToken(tt.name, '#Set');
183
188
  }
@@ -206,12 +211,18 @@ function RipplePlugin(config) {
206
211
  // Check if this is an invalid #Identifier pattern
207
212
  // Valid patterns: #[, #{, #Map(, #Set(, #server
208
213
  // If we see # followed by an uppercase letter that isn't Map or Set, it's an error
209
- if (nextChar >= 65 && nextChar <= 90) { // A-Z
214
+ if (nextChar >= 65 && nextChar <= 90) {
215
+ // A-Z
210
216
  // Extract the identifier name
211
217
  let identEnd = this.pos + 1;
212
218
  while (identEnd < this.input.length) {
213
219
  const ch = this.input.charCodeAt(identEnd);
214
- if ((ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || (ch >= 48 && ch <= 57) || ch === 95) {
220
+ if (
221
+ (ch >= 65 && ch <= 90) ||
222
+ (ch >= 97 && ch <= 122) ||
223
+ (ch >= 48 && ch <= 57) ||
224
+ ch === 95
225
+ ) {
215
226
  // A-Z, a-z, 0-9, _
216
227
  identEnd++;
217
228
  } else {
@@ -220,7 +231,10 @@ function RipplePlugin(config) {
220
231
  }
221
232
  const identName = this.input.slice(this.pos + 1, identEnd);
222
233
  if (identName !== 'Map' && identName !== 'Set') {
223
- this.raise(this.pos, `Invalid tracked syntax '#${identName}'. Only #Map and #Set are currently supported using shorthand tracked syntax.`);
234
+ this.raise(
235
+ this.pos,
236
+ `Invalid tracked syntax '#${identName}'. Only #Map and #Set are currently supported using shorthand tracked syntax.`,
237
+ );
224
238
  }
225
239
  }
226
240
  }
@@ -940,9 +954,13 @@ function RipplePlugin(config) {
940
954
  return this.finishNode(node, 'JSXIdentifier');
941
955
  }
942
956
 
943
- // Override jsx_parseElementName to support @ syntax in member expressions
944
957
  jsx_parseElementName() {
945
- let node = this.jsx_parseIdentifier();
958
+ let node = this.jsx_parseNamespacedName();
959
+
960
+ if (node.type === 'JSXNamespacedName') {
961
+ return node;
962
+ }
963
+
946
964
  if (this.eat(tt.dot)) {
947
965
  let memberExpr = this.startNodeAt(node.start, node.loc && node.loc.start);
948
966
  memberExpr.object = node;
@@ -1019,11 +1037,15 @@ function RipplePlugin(config) {
1019
1037
  }
1020
1038
 
1021
1039
  jsx_readToken() {
1040
+ const inside_tsx_compat = this.#path.findLast((n) => n.type === 'TsxCompat');
1041
+ if (inside_tsx_compat) {
1042
+ return super.jsx_readToken();
1043
+ }
1022
1044
  let out = '',
1023
1045
  chunkStart = this.pos;
1024
1046
  const tok = this.acornTypeScript.tokTypes;
1025
1047
 
1026
- for (;;) {
1048
+ while (true) {
1027
1049
  if (this.pos >= this.input.length) this.raise(this.start, 'Unterminated JSX contents');
1028
1050
  let ch = this.input.charCodeAt(this.pos);
1029
1051
 
@@ -1178,10 +1200,29 @@ function RipplePlugin(config) {
1178
1200
  element.start = position.index;
1179
1201
  element.loc.start = position;
1180
1202
  element.metadata = {};
1181
- element.type = 'Element';
1182
- this.#path.push(element);
1183
1203
  element.children = [];
1184
1204
  const open = this.jsx_parseOpeningElementAt();
1205
+
1206
+ // Check if this is a namespaced element (tsx:react)
1207
+ const is_tsx_compat = open.name.type === 'JSXNamespacedName';
1208
+
1209
+ if (is_tsx_compat) {
1210
+ element.type = 'TsxCompat';
1211
+ element.kind = open.name.name.name; // e.g., "react" from "tsx:react"
1212
+
1213
+ if (open.selfClosing) {
1214
+ const tagName = open.name.namespace.name + ':' + open.name.name.name;
1215
+ this.raise(
1216
+ open.start,
1217
+ `TSX compatibility elements cannot be self-closing. '<${tagName} />' must have a closing tag '</${tagName}>'.`,
1218
+ );
1219
+ }
1220
+ } else {
1221
+ element.type = 'Element';
1222
+ }
1223
+
1224
+ this.#path.push(element);
1225
+
1185
1226
  for (const attr of open.attributes) {
1186
1227
  if (attr.type === 'JSXAttribute') {
1187
1228
  attr.type = 'Attribute';
@@ -1195,13 +1236,16 @@ function RipplePlugin(config) {
1195
1236
  }
1196
1237
  }
1197
1238
  }
1198
- if (open.name.type === 'JSXIdentifier') {
1199
- open.name.type = 'Identifier';
1239
+
1240
+ if (!is_tsx_compat) {
1241
+ if (open.name.type === 'JSXIdentifier') {
1242
+ open.name.type = 'Identifier';
1243
+ }
1244
+ element.id = convert_from_jsx(open.name);
1245
+ element.selfClosing = open.selfClosing;
1200
1246
  }
1201
1247
 
1202
- element.id = convert_from_jsx(open.name);
1203
1248
  element.attributes = open.attributes;
1204
- element.selfClosing = open.selfClosing;
1205
1249
  element.metadata = {};
1206
1250
 
1207
1251
  if (element.selfClosing) {
@@ -1295,10 +1339,40 @@ function RipplePlugin(config) {
1295
1339
  this.parseTemplateBody(element.children);
1296
1340
  this.exitScope();
1297
1341
 
1298
- // Check if this element was properly closed
1299
- // If we reach here and this element is still in the path, it means it was never closed
1300
- if (this.#path[this.#path.length - 1] === element) {
1342
+ if (element.type === 'TsxCompat') {
1343
+ this.#path.pop();
1344
+
1345
+ const raise_error = () => {
1346
+ this.raise(this.start, `Expected closing tag '</tsx:${element.kind}>'`);
1347
+ };
1348
+
1349
+ this.next();
1350
+ // we should expect to see </tsx:kind>
1351
+ if (this.value !== '/') {
1352
+ raise_error();
1353
+ }
1354
+ this.next();
1355
+ if (this.value !== 'tsx') {
1356
+ raise_error();
1357
+ }
1358
+ this.next();
1359
+ if (this.type.label !== ':') {
1360
+ raise_error();
1361
+ }
1362
+ this.next();
1363
+ if (this.value !== element.kind) {
1364
+ raise_error();
1365
+ }
1366
+ this.next();
1367
+ if (this.type.label !== 'jsxTagEnd') {
1368
+ raise_error();
1369
+ }
1370
+ this.next();
1371
+ } else if (this.#path[this.#path.length - 1] === element) {
1372
+ // Check if this element was properly closed
1373
+ // If we reach here and this element is still in the path, it means it was never closed
1301
1374
  const tagName = this.getElementName(element.id);
1375
+
1302
1376
  this.raise(
1303
1377
  this.start,
1304
1378
  `Unclosed tag '<${tagName}>'. Expected '</${tagName}>' before end of component.`,
@@ -1314,13 +1388,14 @@ function RipplePlugin(config) {
1314
1388
  }
1315
1389
  }
1316
1390
 
1317
- this.finishNode(element, 'Element');
1391
+ this.finishNode(element, element.type);
1318
1392
  return element;
1319
1393
  }
1320
1394
 
1321
1395
  parseTemplateBody(body) {
1322
- var inside_func =
1396
+ const inside_func =
1323
1397
  this.context.some((n) => n.token === 'function') || this.scopeStack.length > 1;
1398
+ const inside_tsx_compat = this.#path.findLast((n) => n.type === 'TsxCompat');
1324
1399
 
1325
1400
  if (!inside_func) {
1326
1401
  if (this.type.label === 'return') {
@@ -1334,6 +1409,19 @@ function RipplePlugin(config) {
1334
1409
  }
1335
1410
  }
1336
1411
 
1412
+ if (inside_tsx_compat) {
1413
+ this.exprAllowed = true;
1414
+
1415
+ while (true) {
1416
+ const node = super.parseExpression();
1417
+ body.push(node);
1418
+
1419
+ if (this.input.slice(this.pos, this.pos + 5) === '/tsx:') {
1420
+ return;
1421
+ }
1422
+ }
1423
+ }
1424
+
1337
1425
  if (this.type.label === '{') {
1338
1426
  const node = this.jsx_parseExpressionContainer();
1339
1427
  node.type = node.html ? 'Html' : 'Text';
@@ -1350,12 +1438,32 @@ function RipplePlugin(config) {
1350
1438
 
1351
1439
  // Validate that the closing tag matches the opening tag
1352
1440
  const currentElement = this.#path[this.#path.length - 1];
1353
- if (!currentElement || currentElement.type !== 'Element') {
1441
+ if (
1442
+ !currentElement ||
1443
+ (currentElement.type !== 'Element' && currentElement.type !== 'TsxCompat')
1444
+ ) {
1354
1445
  this.raise(this.start, 'Unexpected closing tag');
1355
1446
  }
1356
1447
 
1357
- const openingTagName = this.getElementName(currentElement.id);
1358
- const closingTagName = this.getElementName(closingTag);
1448
+ let openingTagName;
1449
+ let closingTagName;
1450
+
1451
+ if (currentElement.type === 'TsxCompat') {
1452
+ if (closingTag.type === 'JSXNamespacedName') {
1453
+ openingTagName = 'tsx:' + currentElement.kind;
1454
+ closingTagName = closingTag.namespace.name + ':' + closingTag.name.name;
1455
+ } else {
1456
+ openingTagName = 'tsx:' + currentElement.kind;
1457
+ closingTagName = this.getElementName(closingTag);
1458
+ }
1459
+ } else {
1460
+ // Regular Element node
1461
+ openingTagName = this.getElementName(currentElement.id);
1462
+ closingTagName =
1463
+ closingTag.type === 'JSXNamespacedName'
1464
+ ? closingTag.namespace.name + ':' + closingTag.name.name
1465
+ : this.getElementName(closingTag);
1466
+ }
1359
1467
 
1360
1468
  if (openingTagName !== closingTagName) {
1361
1469
  this.raise(
@@ -575,13 +575,17 @@ const visitors = {
575
575
  },
576
576
 
577
577
  JSXElement(node, context) {
578
- {
579
- error(
580
- 'Elements cannot be used as generic expressions, only as statements within a component',
581
- context.state.analysis.module.filename,
582
- node,
583
- );
578
+ const inside_tsx_compat = context.path.some((n) => n.type === 'TsxCompat');
579
+
580
+ if (inside_tsx_compat) {
581
+ return context.next();
584
582
  }
583
+ error(
584
+ 'Elements cannot be used as generic expressions, only as statements within a component',
585
+ context.state.analysis.module.filename,
586
+ node,
587
+ );
588
+
585
589
  },
586
590
 
587
591
  Element(node, context) {
@@ -150,6 +150,21 @@ function visit_title_element(node, context) {
150
150
  }
151
151
  }
152
152
 
153
+ /**
154
+ * @param {string} name
155
+ * @param {any} context
156
+ * @returns {string}
157
+ */
158
+ function import_from_ripple_if_needed(name, context) {
159
+ const alias = context.state.ripple_user_imports?.get?.(name);
160
+
161
+ if (!alias && !context.state.imports.has(`import { ${name} } from 'ripple'`)) {
162
+ context.state.imports.add(`import { ${name} } from 'ripple'`);
163
+ }
164
+
165
+ return alias ?? name;
166
+ }
167
+
153
168
  const visitors = {
154
169
  _: function set_scope(node, { next, state }) {
155
170
  const scope = state.scopes.get(node);
@@ -369,12 +384,10 @@ const visitors = {
369
384
 
370
385
  TrackedArrayExpression(node, context) {
371
386
  if (context.state.to_ts) {
372
- if (!context.state.imports.has(`import { TrackedArray } from 'ripple'`)) {
373
- context.state.imports.add(`import { TrackedArray } from 'ripple'`);
374
- }
387
+ const arrayAlias = import_from_ripple_if_needed("TrackedArray", context);
375
388
 
376
389
  return b.call(
377
- b.member(b.id('TrackedArray'), b.id('from')),
390
+ b.member(b.id(arrayAlias), b.id('from')),
378
391
  b.array(node.elements.map((el) => context.visit(el))),
379
392
  );
380
393
  }
@@ -388,12 +401,10 @@ const visitors = {
388
401
 
389
402
  TrackedObjectExpression(node, context) {
390
403
  if (context.state.to_ts) {
391
- if (!context.state.imports.has(`import { TrackedObject } from 'ripple'`)) {
392
- context.state.imports.add(`import { TrackedObject } from 'ripple'`);
393
- }
404
+ const objectAlias = import_from_ripple_if_needed("TrackedObject", context);
394
405
 
395
406
  return b.new(
396
- b.id('TrackedObject'),
407
+ b.id(objectAlias),
397
408
  b.object(node.properties.map((prop) => context.visit(prop))),
398
409
  );
399
410
  }
@@ -407,11 +418,9 @@ const visitors = {
407
418
 
408
419
  TrackedMapExpression(node, context) {
409
420
  if (context.state.to_ts) {
410
- if (!context.state.imports.has(`import { TrackedMap } from 'ripple'`)) {
411
- context.state.imports.add(`import { TrackedMap } from 'ripple'`);
412
- }
421
+ const mapAlias = import_from_ripple_if_needed('TrackedMap', context);
413
422
 
414
- const calleeId = b.id('TrackedMap');
423
+ const calleeId = b.id(mapAlias);
415
424
  // Preserve location from original node for Volar mapping
416
425
  calleeId.loc = node.loc;
417
426
  // Add metadata for Volar mapping - map "TrackedMap" identifier to "#Map" in source
@@ -428,11 +437,9 @@ const visitors = {
428
437
 
429
438
  TrackedSetExpression(node, context) {
430
439
  if (context.state.to_ts) {
431
- if (!context.state.imports.has(`import { TrackedSet } from 'ripple'`)) {
432
- context.state.imports.add(`import { TrackedSet } from 'ripple'`);
433
- }
440
+ const setAlias = import_from_ripple_if_needed('TrackedSet', context);
434
441
 
435
- const calleeId = b.id('TrackedSet');
442
+ const calleeId = b.id(setAlias);
436
443
  // Preserve location from original node for Volar mapping
437
444
  calleeId.loc = node.loc;
438
445
  // Add metadata for Volar mapping - map "TrackedSet" identifier to "#Set" in source
@@ -541,6 +548,84 @@ const visitors = {
541
548
  return visit_function(node, context);
542
549
  },
543
550
 
551
+ JSXText(node, context) {
552
+ return b.literal(node.value + '');
553
+ },
554
+
555
+ JSXIdentifier(node, context) {
556
+ return b.id(node.name);
557
+ },
558
+
559
+ JSXElement(node, context) {
560
+ const name = node.openingElement.name;
561
+ const attributes = node.openingElement.attributes;
562
+ const normalized_children = node.children.filter((child) => {
563
+ return child.type !== 'JSXText' || child.value.trim() !== '';
564
+ });
565
+
566
+ const props = b.object(
567
+ attributes.map((attr) => {
568
+ if (attr.type === 'JSXAttribute') {
569
+ return b.prop('init', context.visit(attr.name), context.visit(attr.value));
570
+ } else if (attr.type === 'JSXSpreadAttribute') {
571
+ return b.spread(context.visit(attr.argument));
572
+ }
573
+ }),
574
+ );
575
+
576
+ if (normalize_children.length > 0) {
577
+ props.properties.push(
578
+ b.prop(
579
+ 'init',
580
+ b.id('children'),
581
+ normalized_children.length === 1
582
+ ? context.visit(normalized_children[0])
583
+ : b.array(normalized_children.map((child) => context.visit(child))),
584
+ ),
585
+ );
586
+ }
587
+
588
+ return b.call(
589
+ '_$_jsx',
590
+ name.type === 'JSXIdentifier' && name.name[0].toLowerCase() === name.name[0]
591
+ ? b.literal(name.name)
592
+ : context.visit(name),
593
+ props,
594
+ );
595
+ },
596
+
597
+ TsxCompat(node, context) {
598
+ const { state, visit } = context;
599
+
600
+ state.template.push('<!>');
601
+
602
+ const normalized_children = node.children.filter((child) => {
603
+ return child.type !== 'JSXText' || child.value.trim() !== '';
604
+ });
605
+ const needs_fragment = normalized_children.length !== 1;
606
+ const id = state.flush_node();
607
+ const children_fn = b.arrow(
608
+ [b.id('__compat')],
609
+ needs_fragment
610
+ ? b.call(
611
+ '__compat._jsxs',
612
+ b.id('__compat.Fragment'),
613
+ b.object([
614
+ b.prop(
615
+ 'init',
616
+ b.id('children'),
617
+ b.array(normalized_children.map((child) => visit(child, state))),
618
+ ),
619
+ ]),
620
+ )
621
+ : visit(normalized_children[0], state),
622
+ );
623
+
624
+ context.state.init.push(
625
+ b.stmt(b.call('_$_.tsx_compat', b.literal(node.kind), id, children_fn)),
626
+ );
627
+ },
628
+
544
629
  Element(node, context) {
545
630
  const { state, visit } = context;
546
631
 
@@ -568,9 +653,10 @@ const visitors = {
568
653
 
569
654
  const handle_static_attr = (name, value) => {
570
655
  const attr_value = b.literal(
571
- ` ${name}${is_boolean_attribute(name) && value === true
572
- ? ''
573
- : `="${value === true ? '' : escape_html(value, true)}"`
656
+ ` ${name}${
657
+ is_boolean_attribute(name) && value === true
658
+ ? ''
659
+ : `="${value === true ? '' : escape_html(value, true)}"`
574
660
  }`,
575
661
  );
576
662
 
@@ -903,8 +989,11 @@ const visitors = {
903
989
  }
904
990
 
905
991
  if (node.metadata.scoped && state.component.css) {
906
- const hasClassAttr = node.attributes.some(attr =>
907
- attr.type === 'Attribute' && attr.name.type === 'Identifier' && attr.name.name === 'class'
992
+ const hasClassAttr = node.attributes.some(
993
+ (attr) =>
994
+ attr.type === 'Attribute' &&
995
+ attr.name.type === 'Identifier' &&
996
+ attr.name.name === 'class',
908
997
  );
909
998
  if (!hasClassAttr) {
910
999
  const name = is_spreading ? '#class' : 'class';
@@ -1061,10 +1150,10 @@ const visitors = {
1061
1150
  operator === '='
1062
1151
  ? context.visit(right)
1063
1152
  : b.binary(
1064
- operator === '+=' ? '+' : operator === '-=' ? '-' : operator === '*=' ? '*' : '/',
1065
- /** @type {Expression} */(context.visit(left)),
1066
- /** @type {Expression} */(context.visit(right)),
1067
- ),
1153
+ operator === '+=' ? '+' : operator === '-=' ? '-' : operator === '*=' ? '*' : '/',
1154
+ /** @type {Expression} */ (context.visit(left)),
1155
+ /** @type {Expression} */ (context.visit(right)),
1156
+ ),
1068
1157
  b.id('__block'),
1069
1158
  );
1070
1159
  }
@@ -1080,12 +1169,12 @@ const visitors = {
1080
1169
  operator === '='
1081
1170
  ? context.visit(right)
1082
1171
  : b.binary(
1083
- operator === '+=' ? '+' : operator === '-=' ? '-' : operator === '*=' ? '*' : '/',
1084
- /** @type {Expression} */(
1085
- context.visit(left, { ...context.state, metadata: { tracking: false } })
1172
+ operator === '+=' ? '+' : operator === '-=' ? '-' : operator === '*=' ? '*' : '/',
1173
+ /** @type {Expression} */ (
1174
+ context.visit(left, { ...context.state, metadata: { tracking: false } })
1175
+ ),
1176
+ /** @type {Expression} */ (context.visit(right)),
1086
1177
  ),
1087
- /** @type {Expression} */(context.visit(right)),
1088
- ),
1089
1178
  b.id('__block'),
1090
1179
  );
1091
1180
  }
@@ -1292,12 +1381,12 @@ const visitors = {
1292
1381
  b.stmt(b.call(b.id('__render'), b.id(consequent_id))),
1293
1382
  alternate_id
1294
1383
  ? b.stmt(
1295
- b.call(
1296
- b.id('__render'),
1297
- b.id(alternate_id),
1298
- node.alternate ? b.literal(false) : undefined,
1299
- ),
1300
- )
1384
+ b.call(
1385
+ b.id('__render'),
1386
+ b.id(alternate_id),
1387
+ node.alternate ? b.literal(false) : undefined,
1388
+ ),
1389
+ )
1301
1390
  : undefined,
1302
1391
  ),
1303
1392
  ]),
@@ -1374,9 +1463,9 @@ const visitors = {
1374
1463
  node.handler === null
1375
1464
  ? b.literal(null)
1376
1465
  : b.arrow(
1377
- [b.id('__anchor'), ...(node.handler.param ? [node.handler.param] : [])],
1378
- b.block(transform_body(node.handler.body.body, context)),
1379
- ),
1466
+ [b.id('__anchor'), ...(node.handler.param ? [node.handler.param] : [])],
1467
+ b.block(transform_body(node.handler.body.body, context)),
1468
+ ),
1380
1469
  node.pending === null
1381
1470
  ? undefined
1382
1471
  : b.arrow([b.id('__anchor')], b.block(transform_body(node.pending.body, context))),
@@ -1502,7 +1591,7 @@ function join_template(items) {
1502
1591
  }
1503
1592
 
1504
1593
  for (const quasi of template.quasis) {
1505
- quasi.value.raw = sanitize_template_string(/** @type {string} */(quasi.value.cooked));
1594
+ quasi.value.raw = sanitize_template_string(/** @type {string} */ (quasi.value.cooked));
1506
1595
  }
1507
1596
 
1508
1597
  quasi.tail = true;
@@ -1555,12 +1644,12 @@ function transform_ts_child(node, context) {
1555
1644
  const argument = visit(attr.argument, { ...state, metadata });
1556
1645
  return b.jsx_spread_attribute(argument);
1557
1646
  } else if (attr.type === 'RefAttribute') {
1558
- if (!context.state.imports.has(`import { createRefKey } from 'ripple'`)) {
1559
- context.state.imports.add(`import { createRefKey } from 'ripple'`);
1560
- }
1647
+ const createRefKeyAlias = import_from_ripple_if_needed('createRefKey', context);
1561
1648
  const metadata = { await: false };
1562
1649
  const argument = visit(attr.argument, { ...state, metadata });
1563
- const wrapper = b.object([b.prop('init', b.call('createRefKey'), argument, true)]);
1650
+ const wrapper = b.object(
1651
+ [b.prop('init', b.call(createRefKeyAlias), argument, true)]
1652
+ );
1564
1653
  return b.jsx_spread_attribute(wrapper);
1565
1654
  }
1566
1655
  });
@@ -1841,6 +1930,8 @@ function transform_children(children, context) {
1841
1930
  visit(node, { ...state, flush_node, namespace: state.namespace });
1842
1931
  } else if (node.type === 'HeadElement') {
1843
1932
  visit(node, { ...state, flush_node, namespace: state.namespace });
1933
+ } else if (node.type === 'TsxCompat') {
1934
+ visit(node, { ...state, flush_node, namespace: state.namespace });
1844
1935
  } else if (node.type === 'Html') {
1845
1936
  const metadata = { tracking: false, await: false };
1846
1937
  const expression = visit(node.expression, { ...state, metadata });
@@ -1966,9 +2057,75 @@ function transform_body(body, { visit, state }) {
1966
2057
  return [...body_state.setup, ...body_state.init, ...body_state.final];
1967
2058
  }
1968
2059
 
2060
+ /**
2061
+ * Create a TSX language handler with enhanced TypeScript support
2062
+ * @returns {Object} TSX language handler with TypeScript return type support
2063
+ */
2064
+ function create_tsx_with_typescript_support() {
2065
+ const base_tsx = tsx();
2066
+
2067
+ // Override the ArrowFunctionExpression handler to support TypeScript return types
2068
+ return {
2069
+ ...base_tsx,
2070
+ ArrowFunctionExpression(node, context) {
2071
+ if (node.async) context.write('async ');
2072
+
2073
+ context.write('(');
2074
+ // Visit each parameter
2075
+ for (let i = 0; i < node.params.length; i++) {
2076
+ if (i > 0) context.write(', ');
2077
+ context.visit(node.params[i]);
2078
+ }
2079
+ context.write(')');
2080
+
2081
+ // Add TypeScript return type annotation if present
2082
+ if (node.returnType) {
2083
+ context.visit(node.returnType);
2084
+ }
2085
+
2086
+ context.write(' => ');
2087
+
2088
+ if (
2089
+ node.body.type === 'ObjectExpression' ||
2090
+ (node.body.type === 'AssignmentExpression' && node.body.left.type === 'ObjectPattern') ||
2091
+ (node.body.type === 'LogicalExpression' && node.body.left.type === 'ObjectExpression') ||
2092
+ (node.body.type === 'ConditionalExpression' && node.body.test.type === 'ObjectExpression')
2093
+ ) {
2094
+ context.write('(');
2095
+ context.visit(node.body);
2096
+ context.write(')');
2097
+ } else {
2098
+ context.visit(node.body);
2099
+ }
2100
+ }
2101
+ };
2102
+ }
2103
+
1969
2104
  export function transform_client(filename, source, analysis, to_ts) {
2105
+ /**
2106
+ * User's named imports from 'ripple' so we can reuse them in TS output
2107
+ * when transforming shorthand syntax. E.g., if the user has already imported
2108
+ * TrackedArray, we want to reuse that import instead of importing it again
2109
+ * if we encounter `#[]`. It's a Map of export name to local name in case the
2110
+ * user renamed something when importing.
2111
+ * @type {Map<string, string>}
2112
+ */
2113
+ const ripple_user_imports = new Map(); // exported -> local
2114
+ if (analysis && analysis.ast && Array.isArray(analysis.ast.body)) {
2115
+ for (const stmt of analysis.ast.body) {
2116
+ if (stmt && stmt.type === 'ImportDeclaration' && stmt.source && stmt.source.value === 'ripple') {
2117
+ for (const spec of stmt.specifiers || []) {
2118
+ if (spec.type === 'ImportSpecifier' && spec.imported && spec.local) {
2119
+ ripple_user_imports.set(spec.imported.name, spec.local.name);
2120
+ }
2121
+ }
2122
+ }
2123
+ }
2124
+ }
2125
+
1970
2126
  const state = {
1971
2127
  imports: new Set(),
2128
+ ripple_user_imports,
1972
2129
  events: new Set(),
1973
2130
  template: null,
1974
2131
  hoisted: [],
@@ -1985,7 +2142,7 @@ export function transform_client(filename, source, analysis, to_ts) {
1985
2142
  };
1986
2143
 
1987
2144
  const program = /** @type {Program} */ (
1988
- walk(/** @type {Node} */(analysis.ast), { ...state, namespace: 'html' }, visitors)
2145
+ walk(/** @type {Node} */ (analysis.ast), { ...state, namespace: 'html' }, visitors)
1989
2146
  );
1990
2147
 
1991
2148
  for (const hoisted of state.hoisted) {
@@ -2004,7 +2161,7 @@ export function transform_client(filename, source, analysis, to_ts) {
2004
2161
  );
2005
2162
  }
2006
2163
 
2007
- const js = print(program, tsx(), {
2164
+ const js = print(program, to_ts ? create_tsx_with_typescript_support() : tsx(), {
2008
2165
  sourceMapContent: source,
2009
2166
  sourceMapSource: path.basename(filename),
2010
2167
  });
@@ -254,6 +254,16 @@ export function convert_source_map_to_mappings(ast, source, generated_code, sour
254
254
  visit(node.local);
255
255
  }
256
256
  return;
257
+ } else if (node.type === 'ExportSpecifier') {
258
+ // If local and exported are the same, only visit local to avoid duplicates
259
+ // Otherwise visit both in order
260
+ if (node.local && node.exported && node.local.name !== node.exported.name) {
261
+ visit(node.local);
262
+ visit(node.exported);
263
+ } else if (node.local) {
264
+ visit(node.local);
265
+ }
266
+ return;
257
267
  } else if (node.type === 'ExportNamedDeclaration') {
258
268
  if (node.specifiers && node.specifiers.length > 0) {
259
269
  for (const specifier of node.specifiers) {
@@ -119,6 +119,18 @@ export interface Element extends Omit<Node, 'type'> {
119
119
  metadata: any;
120
120
  }
121
121
 
122
+ /**
123
+ * TSX compatibility node for elements with namespaces like <tsx:react>
124
+ * Note: TsxCompat elements cannot be self-closing and must have a closing tag
125
+ */
126
+ export interface TsxCompat extends Omit<Node, 'type'> {
127
+ type: 'TsxCompat';
128
+ kind: string;
129
+ attributes: Array<Attribute | SpreadAttribute>;
130
+ children: Node[];
131
+ metadata: any;
132
+ }
133
+
122
134
  /**
123
135
  * Ripple attribute node
124
136
  */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @param {string} kind
3
+ * @param {Node} node
4
+ * @param {() => JSX.Element[]} children_fn
5
+ */
6
+ export function tsx_compat(kind, node, children_fn) {
7
+ throw new Error("Not implemented yet");
8
+ }
@@ -76,3 +76,5 @@ export { script } from './script.js';
76
76
  export { html } from './html.js';
77
77
 
78
78
  export { rpc } from './rpc.js';
79
+
80
+ export { tsx_compat } from './compat.js';
@@ -1,4 +1,16 @@
1
- import { parse, compile } from 'ripple/compiler';
1
+ import { parse, compile, compile_to_volar_mappings } from 'ripple/compiler';
2
+
3
+ function count_occurrences(string, subString) {
4
+ let count = 0;
5
+ let pos = string.indexOf(subString);
6
+
7
+ while (pos !== -1) {
8
+ count++;
9
+ pos = string.indexOf(subString, pos + subString.length);
10
+ }
11
+
12
+ return count;
13
+ }
2
14
 
3
15
  describe('compiler > basics', () => {
4
16
  it('parses style content correctly', () => {
@@ -200,4 +212,108 @@ describe('compiler > basics', () => {
200
212
 
201
213
  const result = compile(source, 'test.ripple', { mode: 'client' });
202
214
  });
215
+
216
+ it("doesn't add duplicate imports when encountering shorthand syntax", () => {
217
+ const source = `
218
+ import {
219
+ TrackedArray,
220
+ TrackedObject,
221
+ TrackedSet,
222
+ TrackedMap,
223
+ createRefKey,
224
+ } from 'ripple';
225
+
226
+ component App() {
227
+ const items = #[1, 2, 3];
228
+ const obj = #{ a: 1, b: 2, c: 3 };
229
+ const set = #Set([1, 2, 3]);
230
+ const map = #Map([['a', 1], ['b', 2], ['c', 3]]);
231
+
232
+ <div {ref () => {}} />
233
+ }
234
+ `;
235
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
236
+
237
+ expect(count_occurrences(result, "TrackedArray")).toBe(2);
238
+ expect(count_occurrences(result, "TrackedObject")).toBe(2);
239
+ expect(count_occurrences(result, "TrackedSet")).toBe(2);
240
+ expect(count_occurrences(result, "TrackedMap")).toBe(2);
241
+ expect(count_occurrences(result, "createRefKey")).toBe(2);
242
+ });
243
+
244
+ it("doesn't add duplicate imports for renamed imports when encountering shorthand syntax", () => {
245
+ const source = `
246
+ import {
247
+ TrackedArray as TA,
248
+ TrackedObject as TO,
249
+ TrackedSet as TS,
250
+ TrackedMap as TM,
251
+ createRefKey as crk,
252
+ } from 'ripple';
253
+
254
+ component App() {
255
+ const items = #[1, 2, 3];
256
+ const obj = #{ a: 1, b: 2, c: 3 };
257
+ const set = #Set([1, 2, 3]);
258
+ const map = #Map([['a', 1], ['b', 2], ['c', 3]]);
259
+
260
+ <div {ref () => {}} />
261
+ }
262
+ `;
263
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
264
+
265
+ expect(count_occurrences(result, "TrackedArray")).toBe(1);
266
+ expect(count_occurrences(result, "TA")).toBe(2);
267
+ expect(count_occurrences(result, "TrackedObject")).toBe(1);
268
+ expect(count_occurrences(result, "TO")).toBe(2);
269
+ expect(count_occurrences(result, "TrackedSet")).toBe(1);
270
+ expect(count_occurrences(result, "TS")).toBe(2);
271
+ expect(count_occurrences(result, "TrackedMap")).toBe(1);
272
+ expect(count_occurrences(result, "TM")).toBe(2);
273
+ expect(count_occurrences(result, "createRefKey")).toBe(1);
274
+ expect(count_occurrences(result, "crk")).toBe(2);
275
+ });
276
+
277
+ it('adds missing imports for shorthand syntax', () => {
278
+ const source = `
279
+ component App() {
280
+ const items = #[1, 2, 3];
281
+ const obj = #{ a: 1, b: 2, c: 3 };
282
+ const set = #Set([1, 2, 3]);
283
+ const map = #Map([['a', 1], ['b', 2], ['c', 3]]);
284
+
285
+ <div {ref () => {}} />
286
+ }
287
+ `;
288
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
289
+
290
+ expect(count_occurrences(result, "TrackedArray")).toBe(2);
291
+ expect(count_occurrences(result, "TrackedObject")).toBe(2);
292
+ expect(count_occurrences(result, "TrackedSet")).toBe(2);
293
+ expect(count_occurrences(result, "TrackedMap")).toBe(2);
294
+ expect(count_occurrences(result, "createRefKey")).toBe(2);
295
+ });
296
+
297
+ it('only adds missing imports for shorthand syntax, reusing existing ones', () => {
298
+ const source = `
299
+ import { TrackedArray, TrackedMap, createRefKey as crk } from 'ripple';
300
+
301
+ component App() {
302
+ const items = #[1, 2, 3];
303
+ const obj = #{ a: 1, b: 2, c: 3 };
304
+ const set = #Set([1, 2, 3]);
305
+ const map = #Map([['a', 1], ['b', 2], ['c', 3]]);
306
+
307
+ <div {ref () => {}} />
308
+ }
309
+ `;
310
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
311
+
312
+ expect(count_occurrences(result, "TrackedArray")).toBe(2);
313
+ expect(count_occurrences(result, "TrackedObject")).toBe(2);
314
+ expect(count_occurrences(result, "TrackedSet")).toBe(2);
315
+ expect(count_occurrences(result, "TrackedMap")).toBe(2);
316
+ expect(count_occurrences(result, "createRefKey")).toBe(1);
317
+ expect(count_occurrences(result, "crk")).toBe(2);
318
+ });
203
319
  });