ripple 0.2.131 → 0.2.133

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.133",
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.133"
85
85
  }
86
86
  }
@@ -1,3 +1,4 @@
1
+ // @ts-nocheck
1
2
  /** @import { Program } from 'estree' */
2
3
  /** @import {
3
4
  * CommentWithLocation,
@@ -28,6 +29,14 @@ function convert_from_jsx(node) {
28
29
  return node;
29
30
  }
30
31
 
32
+ function isWhitespaceTextNode(node) {
33
+ if (!node || node.type !== 'Text') {
34
+ return false;
35
+ }
36
+ const value = typeof node.value === 'string' ? node.value : typeof node.raw === 'string' ? node.raw : '';
37
+ return /^\s*$/.test(value);
38
+ }
39
+
31
40
  /**
32
41
  * Acorn parser plugin for Ripple syntax extensions
33
42
  * @param {RipplePluginConfig} [config] - Plugin configuration
@@ -42,6 +51,37 @@ function RipplePlugin(config) {
42
51
  class RippleParser extends Parser {
43
52
  /** @type {any[]} */
44
53
  #path = [];
54
+ #commentContextId = 0;
55
+
56
+ #createCommentMetadata() {
57
+ if (this.#path.length === 0) {
58
+ return null;
59
+ }
60
+
61
+ const container = this.#path[this.#path.length - 1];
62
+ if (!container || container.type !== 'Element') {
63
+ return null;
64
+ }
65
+
66
+ const children = Array.isArray(container.children) ? container.children : [];
67
+ const hasMeaningfulChildren = children.some((child) => child && !isWhitespaceTextNode(child));
68
+
69
+ if (hasMeaningfulChildren) {
70
+ return null;
71
+ }
72
+
73
+ container.metadata ??= {};
74
+ if (container.metadata.commentContainerId === undefined) {
75
+ container.metadata.commentContainerId = ++this.#commentContextId;
76
+ }
77
+
78
+ return {
79
+ containerId: container.metadata.commentContainerId,
80
+ containerType: container.type,
81
+ childIndex: children.length,
82
+ beforeMeaningfulChild: !hasMeaningfulChildren,
83
+ };
84
+ }
45
85
 
46
86
  /**
47
87
  * Helper method to get the element name from a JSX identifier or member expression
@@ -107,7 +147,8 @@ function RipplePlugin(config) {
107
147
  // Inside nested functions (scopeStack.length >= 5), treat < as relational/generic operator
108
148
  // At component top-level (scopeStack.length <= 4), apply JSX detection logic
109
149
  // 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;
150
+ const nextChar =
151
+ this.pos + 1 < this.input.length ? this.input.charCodeAt(this.pos + 1) : -1;
111
152
  const isClosingTag = nextChar === 47; // '/'
112
153
 
113
154
  if (this.scopeStack.length >= 5 && !isClosingTag) {
@@ -169,15 +210,19 @@ function RipplePlugin(config) {
169
210
 
170
211
  // Check if this is #Map or #Set
171
212
  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
213
+ const charAfter =
214
+ this.pos + 4 < this.input.length ? this.input.charCodeAt(this.pos + 4) : -1;
215
+ if (charAfter === 40) {
216
+ // ( character
174
217
  this.pos += 4; // consume '#Map'
175
218
  return this.finishToken(tt.name, '#Map');
176
219
  }
177
220
  }
178
221
  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
222
+ const charAfter =
223
+ this.pos + 4 < this.input.length ? this.input.charCodeAt(this.pos + 4) : -1;
224
+ if (charAfter === 40) {
225
+ // ( character
181
226
  this.pos += 4; // consume '#Set'
182
227
  return this.finishToken(tt.name, '#Set');
183
228
  }
@@ -206,12 +251,18 @@ function RipplePlugin(config) {
206
251
  // Check if this is an invalid #Identifier pattern
207
252
  // Valid patterns: #[, #{, #Map(, #Set(, #server
208
253
  // 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
254
+ if (nextChar >= 65 && nextChar <= 90) {
255
+ // A-Z
210
256
  // Extract the identifier name
211
257
  let identEnd = this.pos + 1;
212
258
  while (identEnd < this.input.length) {
213
259
  const ch = this.input.charCodeAt(identEnd);
214
- if ((ch >= 65 && ch <= 90) || (ch >= 97 && ch <= 122) || (ch >= 48 && ch <= 57) || ch === 95) {
260
+ if (
261
+ (ch >= 65 && ch <= 90) ||
262
+ (ch >= 97 && ch <= 122) ||
263
+ (ch >= 48 && ch <= 57) ||
264
+ ch === 95
265
+ ) {
215
266
  // A-Z, a-z, 0-9, _
216
267
  identEnd++;
217
268
  } else {
@@ -220,7 +271,10 @@ function RipplePlugin(config) {
220
271
  }
221
272
  const identName = this.input.slice(this.pos + 1, identEnd);
222
273
  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.`);
274
+ this.raise(
275
+ this.pos,
276
+ `Invalid tracked syntax '#${identName}'. Only #Map and #Set are currently supported using shorthand tracked syntax.`,
277
+ );
224
278
  }
225
279
  }
226
280
  }
@@ -940,9 +994,13 @@ function RipplePlugin(config) {
940
994
  return this.finishNode(node, 'JSXIdentifier');
941
995
  }
942
996
 
943
- // Override jsx_parseElementName to support @ syntax in member expressions
944
997
  jsx_parseElementName() {
945
- let node = this.jsx_parseIdentifier();
998
+ let node = this.jsx_parseNamespacedName();
999
+
1000
+ if (node.type === 'JSXNamespacedName') {
1001
+ return node;
1002
+ }
1003
+
946
1004
  if (this.eat(tt.dot)) {
947
1005
  let memberExpr = this.startNodeAt(node.start, node.loc && node.loc.start);
948
1006
  memberExpr.object = node;
@@ -1019,11 +1077,15 @@ function RipplePlugin(config) {
1019
1077
  }
1020
1078
 
1021
1079
  jsx_readToken() {
1080
+ const inside_tsx_compat = this.#path.findLast((n) => n.type === 'TsxCompat');
1081
+ if (inside_tsx_compat) {
1082
+ return super.jsx_readToken();
1083
+ }
1022
1084
  let out = '',
1023
1085
  chunkStart = this.pos;
1024
1086
  const tok = this.acornTypeScript.tokTypes;
1025
1087
 
1026
- for (;;) {
1088
+ while (true) {
1027
1089
  if (this.pos >= this.input.length) this.raise(this.start, 'Unterminated JSX contents');
1028
1090
  let ch = this.input.charCodeAt(this.pos);
1029
1091
 
@@ -1061,6 +1123,7 @@ function RipplePlugin(config) {
1061
1123
 
1062
1124
  // Call onComment if it exists
1063
1125
  if (this.options.onComment) {
1126
+ const metadata = this.#createCommentMetadata();
1064
1127
  this.options.onComment(
1065
1128
  false,
1066
1129
  commentText,
@@ -1068,6 +1131,7 @@ function RipplePlugin(config) {
1068
1131
  commentEnd,
1069
1132
  startLoc,
1070
1133
  endLoc,
1134
+ metadata,
1071
1135
  );
1072
1136
  }
1073
1137
 
@@ -1098,6 +1162,7 @@ function RipplePlugin(config) {
1098
1162
 
1099
1163
  // Call onComment if it exists
1100
1164
  if (this.options.onComment) {
1165
+ const metadata = this.#createCommentMetadata();
1101
1166
  this.options.onComment(
1102
1167
  true,
1103
1168
  commentText,
@@ -1105,6 +1170,7 @@ function RipplePlugin(config) {
1105
1170
  commentEnd,
1106
1171
  startLoc,
1107
1172
  endLoc,
1173
+ metadata,
1108
1174
  );
1109
1175
  }
1110
1176
 
@@ -1178,10 +1244,29 @@ function RipplePlugin(config) {
1178
1244
  element.start = position.index;
1179
1245
  element.loc.start = position;
1180
1246
  element.metadata = {};
1181
- element.type = 'Element';
1182
- this.#path.push(element);
1183
1247
  element.children = [];
1184
1248
  const open = this.jsx_parseOpeningElementAt();
1249
+
1250
+ // Check if this is a namespaced element (tsx:react)
1251
+ const is_tsx_compat = open.name.type === 'JSXNamespacedName';
1252
+
1253
+ if (is_tsx_compat) {
1254
+ element.type = 'TsxCompat';
1255
+ element.kind = open.name.name.name; // e.g., "react" from "tsx:react"
1256
+
1257
+ if (open.selfClosing) {
1258
+ const tagName = open.name.namespace.name + ':' + open.name.name.name;
1259
+ this.raise(
1260
+ open.start,
1261
+ `TSX compatibility elements cannot be self-closing. '<${tagName} />' must have a closing tag '</${tagName}>'.`,
1262
+ );
1263
+ }
1264
+ } else {
1265
+ element.type = 'Element';
1266
+ }
1267
+
1268
+ this.#path.push(element);
1269
+
1185
1270
  for (const attr of open.attributes) {
1186
1271
  if (attr.type === 'JSXAttribute') {
1187
1272
  attr.type = 'Attribute';
@@ -1195,14 +1280,18 @@ function RipplePlugin(config) {
1195
1280
  }
1196
1281
  }
1197
1282
  }
1198
- if (open.name.type === 'JSXIdentifier') {
1199
- open.name.type = 'Identifier';
1283
+
1284
+ if (!is_tsx_compat) {
1285
+ if (open.name.type === 'JSXIdentifier') {
1286
+ open.name.type = 'Identifier';
1287
+ }
1288
+ element.id = convert_from_jsx(open.name);
1289
+ element.selfClosing = open.selfClosing;
1200
1290
  }
1201
1291
 
1202
- element.id = convert_from_jsx(open.name);
1203
1292
  element.attributes = open.attributes;
1204
- element.selfClosing = open.selfClosing;
1205
- element.metadata = {};
1293
+ element.metadata ??= {};
1294
+ element.metadata.commentContainerId = ++this.#commentContextId;
1206
1295
 
1207
1296
  if (element.selfClosing) {
1208
1297
  this.#path.pop();
@@ -1295,10 +1384,40 @@ function RipplePlugin(config) {
1295
1384
  this.parseTemplateBody(element.children);
1296
1385
  this.exitScope();
1297
1386
 
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) {
1387
+ if (element.type === 'TsxCompat') {
1388
+ this.#path.pop();
1389
+
1390
+ const raise_error = () => {
1391
+ this.raise(this.start, `Expected closing tag '</tsx:${element.kind}>'`);
1392
+ };
1393
+
1394
+ this.next();
1395
+ // we should expect to see </tsx:kind>
1396
+ if (this.value !== '/') {
1397
+ raise_error();
1398
+ }
1399
+ this.next();
1400
+ if (this.value !== 'tsx') {
1401
+ raise_error();
1402
+ }
1403
+ this.next();
1404
+ if (this.type.label !== ':') {
1405
+ raise_error();
1406
+ }
1407
+ this.next();
1408
+ if (this.value !== element.kind) {
1409
+ raise_error();
1410
+ }
1411
+ this.next();
1412
+ if (this.type.label !== 'jsxTagEnd') {
1413
+ raise_error();
1414
+ }
1415
+ this.next();
1416
+ } else if (this.#path[this.#path.length - 1] === element) {
1417
+ // Check if this element was properly closed
1418
+ // If we reach here and this element is still in the path, it means it was never closed
1301
1419
  const tagName = this.getElementName(element.id);
1420
+
1302
1421
  this.raise(
1303
1422
  this.start,
1304
1423
  `Unclosed tag '<${tagName}>'. Expected '</${tagName}>' before end of component.`,
@@ -1314,13 +1433,14 @@ function RipplePlugin(config) {
1314
1433
  }
1315
1434
  }
1316
1435
 
1317
- this.finishNode(element, 'Element');
1436
+ this.finishNode(element, element.type);
1318
1437
  return element;
1319
1438
  }
1320
1439
 
1321
1440
  parseTemplateBody(body) {
1322
- var inside_func =
1441
+ const inside_func =
1323
1442
  this.context.some((n) => n.token === 'function') || this.scopeStack.length > 1;
1443
+ const inside_tsx_compat = this.#path.findLast((n) => n.type === 'TsxCompat');
1324
1444
 
1325
1445
  if (!inside_func) {
1326
1446
  if (this.type.label === 'return') {
@@ -1334,6 +1454,19 @@ function RipplePlugin(config) {
1334
1454
  }
1335
1455
  }
1336
1456
 
1457
+ if (inside_tsx_compat) {
1458
+ this.exprAllowed = true;
1459
+
1460
+ while (true) {
1461
+ const node = super.parseExpression();
1462
+ body.push(node);
1463
+
1464
+ if (this.input.slice(this.pos, this.pos + 5) === '/tsx:') {
1465
+ return;
1466
+ }
1467
+ }
1468
+ }
1469
+
1337
1470
  if (this.type.label === '{') {
1338
1471
  const node = this.jsx_parseExpressionContainer();
1339
1472
  node.type = node.html ? 'Html' : 'Text';
@@ -1350,12 +1483,32 @@ function RipplePlugin(config) {
1350
1483
 
1351
1484
  // Validate that the closing tag matches the opening tag
1352
1485
  const currentElement = this.#path[this.#path.length - 1];
1353
- if (!currentElement || currentElement.type !== 'Element') {
1486
+ if (
1487
+ !currentElement ||
1488
+ (currentElement.type !== 'Element' && currentElement.type !== 'TsxCompat')
1489
+ ) {
1354
1490
  this.raise(this.start, 'Unexpected closing tag');
1355
1491
  }
1356
1492
 
1357
- const openingTagName = this.getElementName(currentElement.id);
1358
- const closingTagName = this.getElementName(closingTag);
1493
+ let openingTagName;
1494
+ let closingTagName;
1495
+
1496
+ if (currentElement.type === 'TsxCompat') {
1497
+ if (closingTag.type === 'JSXNamespacedName') {
1498
+ openingTagName = 'tsx:' + currentElement.kind;
1499
+ closingTagName = closingTag.namespace.name + ':' + closingTag.name.name;
1500
+ } else {
1501
+ openingTagName = 'tsx:' + currentElement.kind;
1502
+ closingTagName = this.getElementName(closingTag);
1503
+ }
1504
+ } else {
1505
+ // Regular Element node
1506
+ openingTagName = this.getElementName(currentElement.id);
1507
+ closingTagName =
1508
+ closingTag.type === 'JSXNamespacedName'
1509
+ ? closingTag.namespace.name + ':' + closingTag.name.name
1510
+ : this.getElementName(closingTag);
1511
+ }
1359
1512
 
1360
1513
  if (openingTagName !== closingTagName) {
1361
1514
  this.raise(
@@ -1535,7 +1688,7 @@ function RipplePlugin(config) {
1535
1688
  */
1536
1689
  function get_comment_handlers(source, comments, index = 0) {
1537
1690
  return {
1538
- onComment: (block, value, start, end, start_loc, end_loc) => {
1691
+ onComment: (block, value, start, end, start_loc, end_loc, metadata) => {
1539
1692
  if (block && /\n/.test(value)) {
1540
1693
  let a = start;
1541
1694
  while (a > 0 && source[a - 1] !== '\n') a -= 1;
@@ -1556,6 +1709,7 @@ function get_comment_handlers(source, comments, index = 0) {
1556
1709
  start: /** @type {import('acorn').Position} */ (start_loc),
1557
1710
  end: /** @type {import('acorn').Position} */ (end_loc),
1558
1711
  },
1712
+ context: metadata ?? null,
1559
1713
  });
1560
1714
  },
1561
1715
  add_comments: (ast) => {
@@ -1563,14 +1717,41 @@ function get_comment_handlers(source, comments, index = 0) {
1563
1717
 
1564
1718
  comments = comments
1565
1719
  .filter((comment) => comment.start >= index)
1566
- .map(({ type, value, start, end, loc }) => ({ type, value, start, end, loc }));
1720
+ .map(({ type, value, start, end, loc, context }) => ({ type, value, start, end, loc, context }));
1567
1721
 
1568
1722
  walk(ast, null, {
1569
1723
  _(node, { next, path }) {
1570
1724
  let comment;
1571
1725
 
1726
+ const metadata = /** @type {{ commentContainerId?: number, elementLeadingComments?: CommentWithLocation[] }} */ (node?.metadata);
1727
+
1728
+ if (metadata && metadata.commentContainerId !== undefined) {
1729
+ while (
1730
+ comments[0] &&
1731
+ comments[0].context &&
1732
+ comments[0].context.containerId === metadata.commentContainerId &&
1733
+ comments[0].context.beforeMeaningfulChild
1734
+ ) {
1735
+ const elementComment = /** @type {CommentWithLocation & { context?: any }} */ (comments.shift());
1736
+ (metadata.elementLeadingComments ||= []).push(elementComment);
1737
+ }
1738
+ }
1739
+
1572
1740
  while (comments[0] && comments[0].start < node.start) {
1573
1741
  comment = /** @type {CommentWithLocation} */ (comments.shift());
1742
+ if (comment.loc) {
1743
+ const ancestorElements = path
1744
+ .filter((ancestor) => ancestor && ancestor.type === 'Element' && ancestor.loc)
1745
+ .sort((a, b) => a.loc.start.line - b.loc.start.line);
1746
+
1747
+ const targetAncestor = ancestorElements.find((ancestor) => comment.loc.start.line < ancestor.loc.start.line);
1748
+
1749
+ if (targetAncestor) {
1750
+ targetAncestor.metadata ??= {};
1751
+ (targetAncestor.metadata.elementLeadingComments ||= []).push(comment);
1752
+ continue;
1753
+ }
1754
+ }
1574
1755
  (node.leadingComments ||= []).push(comment);
1575
1756
  }
1576
1757
 
@@ -1584,6 +1765,14 @@ function get_comment_handlers(source, comments, index = 0) {
1584
1765
  return;
1585
1766
  }
1586
1767
  }
1768
+ // Handle empty Element nodes the same way as empty BlockStatements
1769
+ if (node.type === 'Element' && (!node.children || node.children.length === 0)) {
1770
+ if (comments[0].start < node.end && comments[0].end < node.end) {
1771
+ comment = /** @type {CommentWithLocation} */ (comments.shift());
1772
+ (node.innerComments ||= []).push(comment);
1773
+ return;
1774
+ }
1775
+ }
1587
1776
  const parent = /** @type {any} */ (path.at(-1));
1588
1777
 
1589
1778
  if (parent === undefined || node.end !== parent.end) {
@@ -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
  });