@tsrx/prettier-plugin 0.3.71 → 0.3.74

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.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/index.js +800 -409
  3. package/src/index.test.js +5786 -11
package/src/index.js CHANGED
@@ -129,26 +129,6 @@ export const printers = {
129
129
  };
130
130
  }
131
131
 
132
- if (node.type === 'ScriptContent' && node.content) {
133
- return async (textToDoc) => {
134
- try {
135
- // Format JS/TS using Prettier's textToDoc
136
- const body = await textToDoc(node.content, {
137
- parser: 'babel-ts',
138
- });
139
-
140
- // Return complete element with tags
141
- // return ['<script>', indent([hardline, formattedContent]), hardline, '</script>'];
142
- return body;
143
- } catch (error) {
144
- // If JS/TS has syntax errors, return original unformatted content
145
- console.error('Error formatting JS/TS inside <script>:', error);
146
- return node.content;
147
- // return ['<script>', indent([hardline, node.content]), hardline, '</script>'];
148
- }
149
- };
150
- }
151
-
152
132
  return null;
153
133
  },
154
134
  /**
@@ -761,16 +741,9 @@ function printRippleNode(node, path, options, print, args) {
761
741
 
762
742
  const isInlineContext = args && args.isInlineContext;
763
743
  const suppressLeadingComments = args && args.suppressLeadingComments;
764
- const suppressExpressionLeadingComments = args && args.suppressExpressionLeadingComments;
765
- const parentNode = /** @type {AST.Node | null} */ (path.getParentNode());
766
-
767
- // For TSRXExpression and Text nodes, don't add leading comments here - they should be handled
768
- // as separate children within elements, not as part of the expression.
769
- const shouldSkipLeadingComments =
770
- parentNode?.type === 'Element' && (node.type === 'TSRXExpression' || node.type === 'Text');
771
744
 
772
745
  // Handle leading comments
773
- if (node.leadingComments && !shouldSkipLeadingComments && !suppressLeadingComments) {
746
+ if (node.leadingComments && !suppressLeadingComments) {
774
747
  for (let i = 0; i < node.leadingComments.length; i++) {
775
748
  const comment = node.leadingComments[i];
776
749
  const nextComment = node.leadingComments[i + 1];
@@ -786,7 +759,7 @@ function printRippleNode(node, path, options, print, args) {
786
759
  if (blankLinesBetween > 0) {
787
760
  parts.push(hardline);
788
761
  }
789
- } else if (isLastComment) {
762
+ } else if (isLastComment && node.type !== 'JSXText') {
790
763
  // Preserve a blank line between the last comment and the node if it existed
791
764
  const blankLinesBetween = getBlankLinesBetweenNodes(comment, node);
792
765
  if (blankLinesBetween > 0) {
@@ -902,10 +875,56 @@ function printRippleNode(node, path, options, print, args) {
902
875
  case 'IfStatement':
903
876
  nodeContent = printIfStatement(node, path, options, print);
904
877
  break;
878
+ case 'JSXIfExpression':
879
+ nodeContent = [
880
+ '@',
881
+ printIfStatement(
882
+ /** @type {AST.IfStatement} */ (/** @type {unknown} */ (node)),
883
+ path,
884
+ options,
885
+ print,
886
+ true,
887
+ ),
888
+ ];
889
+ break;
905
890
 
906
891
  case 'ForOfStatement':
907
892
  nodeContent = printForOfStatement(node, path, options, print);
908
893
  break;
894
+ case 'JSXForExpression':
895
+ if (node.statementType === 'ForInStatement') {
896
+ nodeContent = [
897
+ '@',
898
+ printForInStatement(
899
+ /** @type {AST.ForInStatement} */ (/** @type {unknown} */ (node)),
900
+ path,
901
+ options,
902
+ print,
903
+ ),
904
+ ];
905
+ } else if (node.statementType === 'ForStatement') {
906
+ nodeContent = [
907
+ '@',
908
+ printForStatement(
909
+ /** @type {AST.ForStatement} */ (/** @type {unknown} */ (node)),
910
+ path,
911
+ options,
912
+ print,
913
+ ),
914
+ ];
915
+ } else {
916
+ nodeContent = [
917
+ '@',
918
+ printForOfStatement(
919
+ /** @type {AST.ForOfStatement} */ (/** @type {unknown} */ (node)),
920
+ path,
921
+ options,
922
+ print,
923
+ true,
924
+ ),
925
+ ];
926
+ }
927
+ break;
909
928
 
910
929
  case 'ForStatement':
911
930
  nodeContent = printForStatement(node, path, options, print);
@@ -931,6 +950,18 @@ function printRippleNode(node, path, options, print, args) {
931
950
  case 'TryStatement':
932
951
  nodeContent = printTryStatement(node, path, options, print);
933
952
  break;
953
+ case 'JSXTryExpression':
954
+ nodeContent = [
955
+ '@',
956
+ printTryStatement(
957
+ /** @type {AST.TryStatement} */ (/** @type {unknown} */ (node)),
958
+ path,
959
+ options,
960
+ print,
961
+ true,
962
+ ),
963
+ ];
964
+ break;
934
965
 
935
966
  case 'ArrayExpression': {
936
967
  if (!node.elements || node.elements.length === 0) {
@@ -1497,9 +1528,10 @@ function printRippleNode(node, path, options, print, args) {
1497
1528
  (typePath) => print(typePath, { preferInlineSimpleUnionType: true }),
1498
1529
  'typeAnnotation',
1499
1530
  );
1500
- nodeContent = willBreak(typeAnnotation)
1501
- ? [path.call(print, 'expression'), ' as', indent([line, typeAnnotation])]
1502
- : [path.call(print, 'expression'), ' as ', typeAnnotation];
1531
+ nodeContent =
1532
+ node.typeAnnotation.type !== 'TSTypeLiteral' && willBreak(typeAnnotation)
1533
+ ? [path.call(print, 'expression'), ' as', indent([line, typeAnnotation])]
1534
+ : [path.call(print, 'expression'), ' as ', typeAnnotation];
1503
1535
  break;
1504
1536
  }
1505
1537
 
@@ -1508,14 +1540,19 @@ function printRippleNode(node, path, options, print, args) {
1508
1540
  (typePath) => print(typePath, { preferInlineSimpleUnionType: true }),
1509
1541
  'typeAnnotation',
1510
1542
  );
1511
- nodeContent = willBreak(typeAnnotation)
1512
- ? [path.call(print, 'expression'), ' satisfies', indent([line, typeAnnotation])]
1513
- : [path.call(print, 'expression'), ' satisfies ', typeAnnotation];
1543
+ nodeContent =
1544
+ node.typeAnnotation.type !== 'TSTypeLiteral' && willBreak(typeAnnotation)
1545
+ ? [path.call(print, 'expression'), ' satisfies', indent([line, typeAnnotation])]
1546
+ : [path.call(print, 'expression'), ' satisfies ', typeAnnotation];
1514
1547
  break;
1515
1548
  }
1516
1549
 
1517
1550
  case 'TSNonNullExpression': {
1518
- nodeContent = [path.call(print, 'expression'), '!'];
1551
+ const expression = path.call(print, 'expression');
1552
+ const needsParens =
1553
+ node.expression.type === 'TSAsExpression' ||
1554
+ node.expression.type === 'TSSatisfiesExpression';
1555
+ nodeContent = needsParens ? ['(', expression, ')!'] : [expression, '!'];
1519
1556
  break;
1520
1557
  }
1521
1558
 
@@ -1628,6 +1665,14 @@ function printRippleNode(node, path, options, print, args) {
1628
1665
  case 'SwitchStatement':
1629
1666
  nodeContent = printSwitchStatement(node, path, options, print);
1630
1667
  break;
1668
+ case 'JSXSwitchExpression':
1669
+ nodeContent = printJSXSwitchExpression(
1670
+ /** @type {AST.SwitchStatement} */ (/** @type {unknown} */ (node)),
1671
+ path,
1672
+ options,
1673
+ print,
1674
+ );
1675
+ break;
1631
1676
 
1632
1677
  case 'SwitchCase':
1633
1678
  nodeContent = printSwitchCase(node, path, options, print);
@@ -1685,13 +1730,6 @@ function printRippleNode(node, path, options, print, args) {
1685
1730
  }
1686
1731
  break;
1687
1732
  }
1688
- case 'SpreadAttribute': {
1689
- /** @type {Doc[]} */
1690
- const parts = ['{...', path.call(print, 'argument'), '}'];
1691
- nodeContent = parts;
1692
- break;
1693
- }
1694
-
1695
1733
  case 'Identifier': {
1696
1734
  // Simple case - just return the name directly like Prettier core
1697
1735
  const trackedPrefix = node.tracked ? '@' : '';
@@ -2120,6 +2158,10 @@ function printRippleNode(node, path, options, print, args) {
2120
2158
  nodeContent = printTSCallSignatureDeclaration(node, path, options, print);
2121
2159
  break;
2122
2160
 
2161
+ case 'TSConstructSignatureDeclaration':
2162
+ nodeContent = printTSConstructSignatureDeclaration(node, path, options, print);
2163
+ break;
2164
+
2123
2165
  case 'TSEnumMember':
2124
2166
  nodeContent = printTSEnumMember(node, path, options, print);
2125
2167
  break;
@@ -2245,20 +2287,17 @@ function printRippleNode(node, path, options, print, args) {
2245
2287
  break;
2246
2288
  }
2247
2289
 
2248
- case 'Element':
2249
- nodeContent = printElement(node, path, options, print);
2250
- break;
2251
-
2252
- case 'TsxCompat':
2253
- nodeContent = printTsxCompat(node, path, options, print);
2290
+ case 'JSXCodeBlock':
2291
+ nodeContent = printJSXCodeBlock(node, path, options, print);
2254
2292
  break;
2255
2293
 
2256
- case 'Tsrx':
2257
- nodeContent = printTsrx(node, path, options, print);
2258
- break;
2259
-
2260
- case 'Tsx':
2261
- nodeContent = printTsx(node, path, options, print);
2294
+ case 'JSXStyleElement':
2295
+ nodeContent = printJSXElement(
2296
+ /** @type {ESTreeJSX.JSXElement} */ (/** @type {unknown} */ (node)),
2297
+ path,
2298
+ options,
2299
+ print,
2300
+ );
2262
2301
  break;
2263
2302
 
2264
2303
  case 'JSXElement':
@@ -2270,7 +2309,7 @@ function printRippleNode(node, path, options, print, args) {
2270
2309
  break;
2271
2310
 
2272
2311
  case 'JSXText':
2273
- nodeContent = node.value;
2312
+ nodeContent = printRawText(node.value);
2274
2313
  break;
2275
2314
 
2276
2315
  case 'JSXEmptyExpression':
@@ -2283,28 +2322,12 @@ function printRippleNode(node, path, options, print, args) {
2283
2322
  }
2284
2323
  break;
2285
2324
 
2286
- case 'Attribute':
2287
- nodeContent = printAttribute(node, path, options, print);
2325
+ case 'JSXAttribute':
2326
+ nodeContent = printJSXAttribute(node, path, options, print);
2288
2327
  break;
2289
2328
 
2290
- case 'TSRXExpression': {
2291
- const expressionDoc = suppressExpressionLeadingComments
2292
- ? path.call((exprPath) => print(exprPath, { suppressLeadingComments: true }), 'expression')
2293
- : path.call(print, 'expression');
2294
- nodeContent = ['{', expressionDoc, '}'];
2295
- break;
2296
- }
2297
-
2298
- case 'Text': {
2299
- if (typeof node.raw === 'string') {
2300
- nodeContent = printRawText(node.raw);
2301
- break;
2302
- }
2303
-
2304
- const expressionDoc = suppressExpressionLeadingComments
2305
- ? path.call((exprPath) => print(exprPath, { suppressLeadingComments: true }), 'expression')
2306
- : path.call(print, 'expression');
2307
- nodeContent = ['{', expressionDoc, '}'];
2329
+ case 'JSXSpreadAttribute': {
2330
+ nodeContent = ['{...', path.call(print, 'argument'), '}'];
2308
2331
  break;
2309
2332
  }
2310
2333
 
@@ -2531,7 +2554,10 @@ function printVariableDeclaration(node, path, options, print) {
2531
2554
  const isForLoopInit =
2532
2555
  (parentNode && parentNode.type === 'ForStatement' && parentNode.init === node) ||
2533
2556
  (parentNode && parentNode.type === 'ForOfStatement' && parentNode.left === node) ||
2534
- (parentNode && parentNode.type === 'ForInStatement' && parentNode.left === node);
2557
+ (parentNode && parentNode.type === 'ForInStatement' && parentNode.left === node) ||
2558
+ (parentNode &&
2559
+ parentNode.type === 'JSXForExpression' &&
2560
+ (parentNode.left === node || parentNode.init === node));
2535
2561
 
2536
2562
  const declarations = path.map(print, 'declarations');
2537
2563
  const declarationParts = join(', ', declarations);
@@ -2676,6 +2702,12 @@ function printArrowFunction(node, path, options, print, args) {
2676
2702
  if (shouldBreakBody) {
2677
2703
  parts.push(' =>', indent([hardline, bodyContent]));
2678
2704
  } else {
2705
+ if (isTemplateExpression(node.body)) {
2706
+ return conditionalGroup([
2707
+ group([...parts, ' => ', bodyContent]),
2708
+ group([...parts, ' =>', indent([hardline, bodyContent])]),
2709
+ ]);
2710
+ }
2679
2711
  parts.push(
2680
2712
  ' =>',
2681
2713
  group(indent(line), { id: groupId }),
@@ -2693,22 +2725,26 @@ function printArrowFunction(node, path, options, print, args) {
2693
2725
  * @returns {boolean}
2694
2726
  */
2695
2727
  function isTemplateExpression(node) {
2696
- return (
2697
- node.type === 'Tsx' ||
2698
- node.type === 'TsxCompat' ||
2699
- node.type === 'Tsrx' ||
2700
- node.type === 'JSXElement' ||
2701
- node.type === 'JSXFragment'
2702
- );
2728
+ return node.type === 'JSXElement' || node.type === 'JSXFragment';
2703
2729
  }
2704
2730
 
2705
2731
  /**
2706
2732
  * Check whether a braced attribute expression should close on its own line.
2707
2733
  * @param {AST.Node} node - The expression inside the attribute braces
2734
+ * @param {RippleFormatOptions} options
2735
+ * @param {AST.Node} [attributeNode]
2708
2736
  * @returns {boolean}
2709
2737
  */
2710
- function shouldBreakAttributeExpressionClosingBrace(node) {
2711
- return node.type === 'ArrowFunctionExpression' && node.body && isTemplateExpression(node.body);
2738
+ function shouldBreakAttributeExpressionClosingBrace(node, options, attributeNode = node) {
2739
+ return (
2740
+ node.type === 'ArrowFunctionExpression' &&
2741
+ node.body &&
2742
+ isTemplateExpression(node.body) &&
2743
+ sourceSpanExceedsPrintWidth(
2744
+ /** @type {AST.NodeWithLocation} */ (/** @type {unknown} */ (attributeNode ?? node)),
2745
+ options,
2746
+ )
2747
+ );
2712
2748
  }
2713
2749
 
2714
2750
  /**
@@ -2925,9 +2961,6 @@ function sourceSpanExceedsPrintWidth(node, options) {
2925
2961
  * @returns {boolean}
2926
2962
  */
2927
2963
  function shouldBreakArrowExpressionBody(node, options, args) {
2928
- if (args?.isInAttribute && isTemplateExpression(node)) {
2929
- return true;
2930
- }
2931
2964
  return (
2932
2965
  (node.type === 'BinaryExpression' || node.type === 'LogicalExpression') &&
2933
2966
  sourceSpanExceedsPrintWidth(/** @type {AST.NodeWithLocation} */ (node), options)
@@ -3300,9 +3333,10 @@ function extractAndPrintLeadingComments(node) {
3300
3333
  * @param {AstPath<AST.IfStatement>} path - The AST path
3301
3334
  * @param {RippleFormatOptions} options - Prettier options
3302
3335
  * @param {PrintFn} print - Print callback
3336
+ * @param {boolean} [directive]
3303
3337
  * @returns {Doc[]}
3304
3338
  */
3305
- function printIfStatement(node, path, options, print) {
3339
+ function printIfStatement(node, path, options, print, directive = false) {
3306
3340
  // Extract leading comments from test node to print them before 'if' keyword
3307
3341
  const testNode = node.test;
3308
3342
 
@@ -3346,8 +3380,24 @@ function printIfStatement(node, path, options, print) {
3346
3380
  parts.push(' ');
3347
3381
  }
3348
3382
 
3349
- parts.push('else ');
3350
- parts.push(path.call(print, 'alternate'));
3383
+ parts.push(directive ? '@else ' : 'else ');
3384
+ if (directive && node.alternate.type === 'IfStatement') {
3385
+ parts.push(
3386
+ path.call(
3387
+ (alternatePath) =>
3388
+ printIfStatement(
3389
+ /** @type {AST.IfStatement} */ (alternatePath.node),
3390
+ /** @type {AstPath<AST.IfStatement>} */ (alternatePath),
3391
+ options,
3392
+ print,
3393
+ true,
3394
+ ),
3395
+ 'alternate',
3396
+ ),
3397
+ );
3398
+ } else {
3399
+ parts.push(path.call(print, 'alternate'));
3400
+ }
3351
3401
  }
3352
3402
 
3353
3403
  return parts;
@@ -3381,9 +3431,10 @@ function printForInStatement(node, path, options, print) {
3381
3431
  * @param {AstPath<AST.ForOfStatement>} path - The AST path
3382
3432
  * @param {RippleFormatOptions} options - Prettier options
3383
3433
  * @param {PrintFn} print - Print callback
3434
+ * @param {boolean} [directive]
3384
3435
  * @returns {Doc[]}
3385
3436
  */
3386
- function printForOfStatement(node, path, options, print) {
3437
+ function printForOfStatement(node, path, options, print, directive = false) {
3387
3438
  /** @type {Doc[]} */
3388
3439
  const parts = [];
3389
3440
  parts.push('for (');
@@ -3404,6 +3455,10 @@ function printForOfStatement(node, path, options, print) {
3404
3455
 
3405
3456
  parts.push(') ');
3406
3457
  parts.push(path.call(print, 'body'));
3458
+ if (node.empty) {
3459
+ parts.push(directive ? ' @empty ' : ' empty ');
3460
+ parts.push(path.call(print, 'empty'));
3461
+ }
3407
3462
 
3408
3463
  return parts;
3409
3464
  }
@@ -3720,9 +3775,10 @@ function printClassDeclaration(node, path, options, print) {
3720
3775
  * @param {AstPath<AST.TryStatement>} path - The AST path
3721
3776
  * @param {RippleFormatOptions} options - Prettier options
3722
3777
  * @param {PrintFn} print - Print callback
3778
+ * @param {boolean} [directive=false] - Whether this is a JSX @try expression.
3723
3779
  * @returns {Doc[]}
3724
3780
  */
3725
- function printTryStatement(node, path, options, print) {
3781
+ function printTryStatement(node, path, options, print, directive = false) {
3726
3782
  // Extract leading comments from block node to print them before 'try' keyword
3727
3783
  const blockNode = node.block;
3728
3784
 
@@ -3742,12 +3798,12 @@ function printTryStatement(node, path, options, print) {
3742
3798
  parts.push(block);
3743
3799
 
3744
3800
  if (node.pending) {
3745
- parts.push(' pending ');
3801
+ parts.push(directive ? ' @pending ' : ' pending ');
3746
3802
  parts.push(path.call(print, 'pending'));
3747
3803
  }
3748
3804
 
3749
3805
  if (node.handler) {
3750
- parts.push(' catch');
3806
+ parts.push(directive ? ' @catch' : ' catch');
3751
3807
  if (node.handler.param) {
3752
3808
  parts.push(' (');
3753
3809
  parts.push(path.call(print, 'handler', 'param'));
@@ -4488,6 +4544,76 @@ function printSwitchStatement(node, path, options, print) {
4488
4544
  return parts;
4489
4545
  }
4490
4546
 
4547
+ /**
4548
+ * Print a JSX switch expression. JSX switch cases use explicit template blocks:
4549
+ * `case value: { ... }`, unlike ordinary JavaScript switch cases.
4550
+ * @param {AST.SwitchStatement} node - The switch expression node
4551
+ * @param {AstPath<AST.SwitchStatement>} path - The AST path
4552
+ * @param {RippleFormatOptions} options - Prettier options
4553
+ * @param {PrintFn} print - Print callback
4554
+ * @returns {Doc[]}
4555
+ */
4556
+ function printJSXSwitchExpression(node, path, options, print) {
4557
+ const discriminant = path.call(
4558
+ (discriminantPath) => print(discriminantPath, { suppressLeadingComments: true }),
4559
+ 'discriminant',
4560
+ );
4561
+
4562
+ /** @type {Doc[]} */
4563
+ const cases = [];
4564
+ for (let i = 0; i < node.cases.length; i++) {
4565
+ const caseDoc = [printJSXSwitchCase(node.cases[i], path, options, print, i)];
4566
+ if (i < node.cases.length - 1 && isNextLineEmpty(node.cases[i], options)) {
4567
+ caseDoc.push(hardline);
4568
+ }
4569
+ cases.push(caseDoc);
4570
+ }
4571
+
4572
+ const bodyDoc =
4573
+ cases.length > 0 ? [indent([hardline, join(hardline, cases)]), hardline] : hardline;
4574
+
4575
+ const discriminantDoc = group(['@switch (', indent([softline, discriminant]), softline, ')']);
4576
+
4577
+ return [
4578
+ ...extractAndPrintLeadingComments(node.discriminant),
4579
+ discriminantDoc,
4580
+ ' {',
4581
+ bodyDoc,
4582
+ '}',
4583
+ ];
4584
+ }
4585
+
4586
+ /**
4587
+ * @param {AST.SwitchCase} node
4588
+ * @param {AstPath<AST.SwitchStatement>} path
4589
+ * @param {RippleFormatOptions} options
4590
+ * @param {PrintFn} print
4591
+ * @param {number} index
4592
+ * @returns {Doc[]}
4593
+ */
4594
+ function printJSXSwitchCase(node, path, options, print, index) {
4595
+ const header = node.test
4596
+ ? ['@case ', path.call(print, 'cases', index, 'test'), ':']
4597
+ : '@default:';
4598
+ const consequents = node.consequent || [];
4599
+ const printedConsequents = [];
4600
+
4601
+ for (let i = 0; i < consequents.length; i++) {
4602
+ const child = consequents[i];
4603
+ if (!child || child.type === 'EmptyStatement') {
4604
+ continue;
4605
+ }
4606
+ printedConsequents.push(path.call(print, 'cases', index, 'consequent', i));
4607
+ }
4608
+
4609
+ const bodyDoc =
4610
+ printedConsequents.length > 0
4611
+ ? [indent([hardline, join(hardline, printedConsequents)]), hardline]
4612
+ : hardline;
4613
+
4614
+ return [header, ' {', bodyDoc, '}'];
4615
+ }
4616
+
4491
4617
  /**
4492
4618
  * Print a switch case
4493
4619
  * @param {AST.SwitchCase} node - The switch case node
@@ -4655,6 +4781,26 @@ function getBlankLinesBetweenPositions(current_pos, next_pos) {
4655
4781
  * @param {AST.Node | AST.CSS.StyleSheet | AST.Comment} nextNode - Next node
4656
4782
  * @returns {number}
4657
4783
  */
4784
+ /**
4785
+ * The position to measure a leading blank line against: the first leading
4786
+ * comment if any (so the comment lines aren't miscounted as blank), else the
4787
+ * node itself.
4788
+ * @param {any} node
4789
+ * @returns {any}
4790
+ */
4791
+ function leadingAnchor(node) {
4792
+ const lead = node?.leadingComments;
4793
+ if (Array.isArray(lead) && lead.length > 0 && lead[0].loc) {
4794
+ return lead[0];
4795
+ }
4796
+ return node;
4797
+ }
4798
+
4799
+ /**
4800
+ * @param {any} currentNode
4801
+ * @param {any} nextNode
4802
+ * @returns {number}
4803
+ */
4658
4804
  function getBlankLinesBetweenNodes(currentNode, nextNode) {
4659
4805
  // Return the number of blank lines between two nodes based on their location
4660
4806
  if (
@@ -5027,6 +5173,16 @@ function printVariableDeclarator(node, path, options, print) {
5027
5173
  }
5028
5174
  }
5029
5175
 
5176
+ if (isTemplateExpression(node.init)) {
5177
+ const groupId = Symbol('declaration');
5178
+ return group([
5179
+ group(id),
5180
+ ' =',
5181
+ group(indent(line), { id: groupId }),
5182
+ indentIfBreak(init, { groupId }),
5183
+ ]);
5184
+ }
5185
+
5030
5186
  // Default: simple inline format with space
5031
5187
  // Use group to allow breaking if needed - but keep inline when it fits
5032
5188
  return group([id, ' = ', init]);
@@ -5196,6 +5352,45 @@ function printTSCallSignatureDeclaration(node, path, options, print) {
5196
5352
  return parts;
5197
5353
  }
5198
5354
 
5355
+ /**
5356
+ * Print a TypeScript construct signature in an interface or type literal
5357
+ * @param {AST.TSConstructSignatureDeclaration} node - The construct signature node
5358
+ * @param {AstPath<AST.TSConstructSignatureDeclaration>} path - The AST path
5359
+ * @param {RippleFormatOptions} options - Prettier options
5360
+ * @param {PrintFn} print - Print callback
5361
+ * @returns {Doc[]}
5362
+ */
5363
+ function printTSConstructSignatureDeclaration(node, path, options, print) {
5364
+ /** @type {Doc[]} */
5365
+ const parts = ['new '];
5366
+
5367
+ if (node.typeParameters) {
5368
+ const type_params = path.call(print, 'typeParameters');
5369
+ if (Array.isArray(type_params)) {
5370
+ parts.push(...type_params);
5371
+ } else {
5372
+ parts.push(type_params);
5373
+ }
5374
+ }
5375
+
5376
+ parts.push('(');
5377
+ if (node.parameters && node.parameters.length > 0) {
5378
+ const params = path.map(print, 'parameters');
5379
+ for (let i = 0; i < params.length; i++) {
5380
+ if (i > 0) parts.push(', ');
5381
+ parts.push(params[i]);
5382
+ }
5383
+ }
5384
+ parts.push(')');
5385
+
5386
+ if (node.typeAnnotation) {
5387
+ parts.push(': ');
5388
+ parts.push(path.call(print, 'typeAnnotation'));
5389
+ }
5390
+
5391
+ return parts;
5392
+ }
5393
+
5199
5394
  /**
5200
5395
  * Print a TypeScript type reference (e.g., Array<string>)
5201
5396
  * @param {AST.TSTypeReference} node - The type reference node
@@ -5470,6 +5665,36 @@ function printRawText(raw) {
5470
5665
  );
5471
5666
  }
5472
5667
 
5668
+ /**
5669
+ * @param {string} raw
5670
+ * @returns {Doc | Doc[] | string}
5671
+ */
5672
+ function printJSXTextChild(raw) {
5673
+ const text = raw.trim();
5674
+ if (!text) {
5675
+ return '';
5676
+ }
5677
+
5678
+ const lines = text
5679
+ .split(/\r\n|\r|\n/u)
5680
+ .map((line) => line.trim())
5681
+ .filter(Boolean);
5682
+ if (lines.length <= 1) {
5683
+ return lines[0] ?? '';
5684
+ }
5685
+
5686
+ return join(hardline, lines);
5687
+ }
5688
+
5689
+ /**
5690
+ * @param {string} raw
5691
+ * @returns {string}
5692
+ */
5693
+ function normalizeInlineJSXText(raw) {
5694
+ const text = raw.replace(/[^\S\r\n]+/gu, ' ');
5695
+ return text.trim() || !/[\r\n]/u.test(text) ? text : '';
5696
+ }
5697
+
5473
5698
  /**
5474
5699
  * @param {AST.Node} parentNode
5475
5700
  * @param {AST.Node} firstChild
@@ -5481,8 +5706,7 @@ function shouldInlineSingleChild(parentNode, firstChild, childDoc) {
5481
5706
  return false;
5482
5707
  }
5483
5708
 
5484
- // Always inline Text nodes — they are explicit text child forms.
5485
- if (firstChild.type === 'Text') {
5709
+ if (firstChild.type === 'JSXText') {
5486
5710
  return true;
5487
5711
  }
5488
5712
 
@@ -5492,7 +5716,7 @@ function shouldInlineSingleChild(parentNode, firstChild, childDoc) {
5492
5716
 
5493
5717
  // Inline JSX expressions if they fit, but respect original multi-line formatting
5494
5718
  // for non-literal expressions (e.g. {children} should stay multi-line if written that way)
5495
- if (firstChild.type === 'TSRXExpression' || firstChild.type === 'JSXExpressionContainer') {
5719
+ if (firstChild.type === 'JSXExpressionContainer') {
5496
5720
  if (wasOriginallySingleLine(parentNode)) {
5497
5721
  return true;
5498
5722
  }
@@ -5510,19 +5734,71 @@ function shouldInlineSingleChild(parentNode, firstChild, childDoc) {
5510
5734
  return false;
5511
5735
  }
5512
5736
 
5513
- if (firstChild.type === 'Element' && firstChild.selfClosing) {
5514
- return (
5515
- !(/** @type {AST.Element} */ (parentNode).attributes) ||
5516
- /** @type {AST.Element} */ (parentNode).attributes.length === 0
5517
- );
5737
+ if (firstChild.type === 'JSXElement' && firstChild.openingElement?.selfClosing) {
5738
+ const parent = /** @type {any} */ (parentNode);
5739
+ return !parent.openingElement?.attributes?.length;
5518
5740
  }
5519
5741
 
5520
5742
  return false;
5521
5743
  }
5522
5744
 
5745
+ /**
5746
+ * Check whether a child can participate in compact inline TSRX content.
5747
+ * @param {any} child
5748
+ * @returns {boolean}
5749
+ */
5750
+ function isInlineableTextOrExpressionChild(child) {
5751
+ if (!child || (child.type !== 'JSXText' && child.type !== 'JSXExpressionContainer')) {
5752
+ return false;
5753
+ }
5754
+
5755
+ if (hasComment(/** @type {AST.Node & AST.NodeWithMaybeComments} */ (child))) {
5756
+ return false;
5757
+ }
5758
+
5759
+ const expression = /** @type {{ expression?: AST.Node & AST.NodeWithMaybeComments }} */ (child)
5760
+ .expression;
5761
+ return !expression || !hasComment(expression);
5762
+ }
5763
+
5764
+ /**
5765
+ * @param {any} node
5766
+ * @returns {boolean}
5767
+ */
5768
+ function shouldTryInlineMultipleTextChildren(node) {
5769
+ return (
5770
+ wasOriginallySingleLine(node) &&
5771
+ Array.isArray(node.children) &&
5772
+ node.children.length > 1 &&
5773
+ node.children.some((/** @type {any} */ child) => child.type === 'JSXText') &&
5774
+ node.children.every(isInlineableTextOrExpressionChild)
5775
+ );
5776
+ }
5777
+
5778
+ /**
5779
+ * @param {AST.Node} child
5780
+ * @returns {boolean}
5781
+ */
5782
+ function isSimpleJSXExpressionChild(child) {
5783
+ if (child?.type !== 'JSXExpressionContainer') {
5784
+ return false;
5785
+ }
5786
+
5787
+ const expression = child.expression;
5788
+ return (
5789
+ expression?.type === 'Identifier' ||
5790
+ expression?.type === 'Literal' ||
5791
+ expression?.type === 'TemplateLiteral' ||
5792
+ // Stock Prettier keeps a single `{expr}` child inline regardless of the
5793
+ // expression kind (member access, calls, etc.); only multiple children break.
5794
+ expression?.type === 'MemberExpression' ||
5795
+ expression?.type === 'CallExpression'
5796
+ );
5797
+ }
5798
+
5523
5799
  /**
5524
5800
  * Get leading comments from element metadata
5525
- * @param {AST.Element} node - The element node
5801
+ * @param {ESTreeJSX.JSXElement} node - The element node
5526
5802
  * @returns {AST.Comment[]}
5527
5803
  */
5528
5804
  function getElementLeadingComments(node) {
@@ -5582,200 +5858,10 @@ function createElementLevelCommentPartsTrimmed(comments) {
5582
5858
  return parts;
5583
5859
  }
5584
5860
 
5585
- /**
5586
- * Print a Tsx node - renders Ripple template children inside <tsx>...</tsx>
5587
- * or fragment shorthand <>...</> when the original source used a fragment.
5588
- * @param {AST.Tsx} node - The Tsx node
5589
- * @param {AstPath<AST.Tsx>} path - The AST path
5590
- * @param {RippleFormatOptions} options - Prettier options
5591
- * @param {PrintFn} print - Print callback
5592
- * @returns {Doc}
5593
- */
5594
- function printTsx(node, path, options, print) {
5595
- const is_fragment = !node.openingElement?.name;
5596
- const tagName = is_fragment ? '<>' : '<tsx>';
5597
- const closingTagName = is_fragment ? '</>' : '</tsx>';
5598
-
5599
- const hasChildren = Array.isArray(node.children) && node.children.length > 0;
5600
-
5601
- if (!hasChildren) {
5602
- return [tagName, closingTagName];
5603
- }
5604
-
5605
- // Print children - these are Ripple template children (Element, Text, etc.)
5606
- const printedChildren = [];
5607
-
5608
- for (let i = 0; i < node.children.length; i++) {
5609
- const child = node.children[i];
5610
-
5611
- if (child.type === 'JSXText') {
5612
- const text = child.value.trim();
5613
- if (!text) continue;
5614
- printedChildren.push(text);
5615
- } else {
5616
- const printedChild = path.call(print, 'children', i);
5617
- printedChildren.push(printedChild);
5618
- }
5619
- }
5620
-
5621
- if (printedChildren.length === 0) {
5622
- return [tagName, closingTagName];
5623
- }
5624
-
5625
- if (!is_fragment || printedChildren.length > 1) {
5626
- return group([
5627
- tagName,
5628
- indent([hardline, join(hardline, printedChildren)]),
5629
- hardline,
5630
- closingTagName,
5631
- ]);
5632
- }
5633
-
5634
- // Use softline to allow single-line when content fits
5635
- return group([
5636
- tagName,
5637
- indent([softline, join(softline, printedChildren)]),
5638
- softline,
5639
- closingTagName,
5640
- ]);
5641
- }
5642
-
5643
- /**
5644
- * Print a Tsrx node - renders native TSRX template children inside a fragment.
5645
- * @param {AST.Tsrx} node - The Tsrx node
5646
- * @param {AstPath<AST.Tsrx>} path - The AST path
5647
- * @param {RippleFormatOptions} options - Prettier options
5648
- * @param {PrintFn} print - Print callback
5649
- * @returns {Doc}
5650
- */
5651
- function printTsrx(node, path, options, print) {
5652
- const tagName = '<>';
5653
- const closingTagName = '</>';
5654
- const hasChildren = Array.isArray(node.children) && node.children.length > 0;
5655
-
5656
- if (!hasChildren) {
5657
- return [tagName, closingTagName];
5658
- }
5659
-
5660
- const printedChildren = [];
5661
-
5662
- for (let i = 0; i < node.children.length; i++) {
5663
- const child = node.children[i];
5664
-
5665
- if (child.type === 'JSXText') {
5666
- const text = child.value.trim();
5667
- if (!text) continue;
5668
- printedChildren.push(text);
5669
- } else {
5670
- const printedChild = path.call(print, 'children', i);
5671
- printedChildren.push(printedChild);
5672
- }
5673
- }
5674
-
5675
- if (printedChildren.length === 0) {
5676
- return [tagName, closingTagName];
5677
- }
5678
-
5679
- if (
5680
- printedChildren.length === 1 &&
5681
- ['Element', 'Text', 'TSRXExpression'].includes(node.children[0]?.type)
5682
- ) {
5683
- return group([tagName, indent([softline, printedChildren[0]]), softline, closingTagName]);
5684
- }
5685
-
5686
- return group([
5687
- tagName,
5688
- indent([hardline, join(hardline, printedChildren)]),
5689
- hardline,
5690
- closingTagName,
5691
- ]);
5692
- }
5693
-
5694
- /**
5695
- * Print a TSX compatibility node
5696
- * @param {AST.TsxCompat} node - The TSX compat node
5697
- * @param {AstPath<AST.TsxCompat>} path - The AST path
5698
- * @param {RippleFormatOptions} options - Prettier options
5699
- * @param {PrintFn} print - Print callback
5700
- * @returns {Doc}
5701
- */
5702
- function printTsxCompat(node, path, options, print) {
5703
- const tagName = `<tsx:${node.kind}>`;
5704
- const closingTagName = `</tsx:${node.kind}>`;
5705
-
5706
- const hasChildren = Array.isArray(node.children) && node.children.length > 0;
5707
-
5708
- if (!hasChildren) {
5709
- return [tagName, closingTagName];
5710
- }
5711
-
5712
- // Print JSXElement children - they remain as JSX
5713
- // Filter out whitespace-only JSXText nodes and merge adjacent text-like nodes
5714
- const finalChildren = [];
5715
- let accumulatedText = '';
5716
-
5717
- for (let i = 0; i < node.children.length; i++) {
5718
- const child = node.children[i];
5719
-
5720
- // Check if this is a text-like node (JSXText or Identifier in JSX context)
5721
- const isTextLike = child.type === 'JSXText';
5722
-
5723
- if (isTextLike) {
5724
- // Get the text content
5725
- let text;
5726
- if (child.type === 'JSXText') {
5727
- text = child.value.trim();
5728
- }
5729
-
5730
- if (text) {
5731
- if (accumulatedText) {
5732
- accumulatedText += ' ' + text;
5733
- } else {
5734
- accumulatedText = text;
5735
- }
5736
- }
5737
- } else {
5738
- // Before adding non-text node, flush accumulated text
5739
- if (accumulatedText) {
5740
- if (finalChildren.length > 0) {
5741
- finalChildren.push(hardline);
5742
- }
5743
- finalChildren.push(accumulatedText);
5744
- accumulatedText = '';
5745
- }
5746
-
5747
- if (finalChildren.length > 0) {
5748
- finalChildren.push(hardline);
5749
- }
5750
-
5751
- const printedChild = path.call(print, 'children', i);
5752
- finalChildren.push(printedChild);
5753
- }
5754
- }
5755
-
5756
- // Don't forget any remaining accumulated text
5757
- if (accumulatedText) {
5758
- if (finalChildren.length > 0) {
5759
- finalChildren.push(hardline);
5760
- }
5761
- finalChildren.push(accumulatedText);
5762
- }
5763
-
5764
- // Format the TsxCompat element
5765
- const elementOutput = group([
5766
- tagName,
5767
- indent([hardline, ...finalChildren]),
5768
- hardline,
5769
- closingTagName,
5770
- ]);
5771
-
5772
- return elementOutput;
5773
- }
5774
-
5775
5861
  /**
5776
5862
  * Print a JSX element
5777
- * @param {ESTreeJSX.JSXElement} node - The JSX element node
5778
- * @param {AstPath<ESTreeJSX.JSXElement>} path - The AST path
5863
+ * @param {AST.TSRXJSXElement} node - The JSX element node
5864
+ * @param {AstPath<any>} path - The AST path
5779
5865
  * @param {RippleFormatOptions} options - Prettier options
5780
5866
  * @param {PrintFn} print - Print callback
5781
5867
  * @returns {Doc | Doc[]}
@@ -5785,20 +5871,7 @@ function printJSXElement(node, path, options, print) {
5785
5871
  const openingElement = node.openingElement;
5786
5872
  const closingElement = node.closingElement;
5787
5873
 
5788
- /** @type {string} */
5789
- let tagName;
5790
- if (openingElement.name.type === 'JSXIdentifier') {
5791
- tagName = openingElement.name.name;
5792
- } else if (openingElement.name.type === 'JSXMemberExpression') {
5793
- // Handle Member expressions like React.Fragment
5794
- tagName = printJSXMemberExpression(openingElement.name);
5795
- } else if (openingElement.name.type === 'JSXNamespacedName') {
5796
- const namespace_name = openingElement.name.namespace.name;
5797
- const local_name = openingElement.name.name.name;
5798
- tagName = namespace_name + ':' + local_name;
5799
- } else {
5800
- tagName = 'Unknown';
5801
- }
5874
+ const tagName = printJSXElementName(openingElement.name);
5802
5875
 
5803
5876
  const isSelfClosing = openingElement.selfClosing;
5804
5877
  const hasAttributes = openingElement.attributes && openingElement.attributes.length > 0;
@@ -5810,6 +5883,12 @@ function printJSXElement(node, path, options, print) {
5810
5883
  typeArgsDoc = path.call(print, 'openingElement', 'typeArguments');
5811
5884
  }
5812
5885
 
5886
+ // Comments that sit inside the opening tag (before an attribute) are attached
5887
+ // by the parser to a body child; pull them out and key them by the attribute
5888
+ // they precede so they print in the opening tag, not jammed into the body.
5889
+ const openingTagCommentsByAttr = collectOpeningTagComments(node);
5890
+ const hasOpeningTagComments = openingTagCommentsByAttr.size > 0;
5891
+
5813
5892
  // Format attributes
5814
5893
  /** @type {Doc} */
5815
5894
  let attributesDoc = '';
@@ -5833,19 +5912,31 @@ function printJSXElement(node, path, options, print) {
5833
5912
  'attributes',
5834
5913
  i,
5835
5914
  );
5836
- } else if (attr.type === 'JSXSpreadAttribute' || attr.type === 'SpreadAttribute') {
5915
+ } else if (attr.type === 'JSXSpreadAttribute') {
5837
5916
  attrDoc = ['{...', path.call(print, 'openingElement', 'attributes', i, 'argument'), '}'];
5838
5917
  }
5839
5918
  if (!hasBreakingAttribute && attrDoc && willBreak(attrDoc)) {
5840
5919
  hasBreakingAttribute = true;
5841
5920
  }
5921
+ const lead = openingTagCommentsByAttr.get(i);
5922
+ if (lead) {
5923
+ /** @type {Doc[]} */
5924
+ const parts = [];
5925
+ for (const comment of lead) {
5926
+ parts.push(
5927
+ comment.type === 'Line' ? '//' + comment.value : '/*' + comment.value + '*/',
5928
+ );
5929
+ parts.push(hardline);
5930
+ }
5931
+ return [...parts, attrDoc];
5932
+ }
5842
5933
  return attrDoc;
5843
5934
  },
5844
5935
  );
5845
5936
  const attrLineBreak = options.singleAttributePerLine ? hardline : line;
5846
5937
  attributesDoc = indent([attrLineBreak, join(attrLineBreak, attrs)]);
5847
5938
  }
5848
- const shouldForceBreak = hasBreakingAttribute;
5939
+ const shouldForceBreak = hasBreakingAttribute || hasOpeningTagComments;
5849
5940
 
5850
5941
  if (isSelfClosing) {
5851
5942
  return group(['<', tagName, typeArgsDoc, attributesDoc, hasAttributes ? line : ' ', '/>'], {
@@ -5865,58 +5956,119 @@ function printJSXElement(node, path, options, print) {
5865
5956
  { shouldBreak: shouldForceBreak },
5866
5957
  );
5867
5958
 
5959
+ // Trailing comments after the last child are attached by the parser either to
5960
+ // the closing tag (`closingElement.leadingComments`) or, when the last child is
5961
+ // an `{expr}` container, to `metadata.elementLeadingComments` positioned inside
5962
+ // the body (start >= opening tag end). Emit both before `</tag>`.
5963
+ const openingTagEnd = /** @type {AST.NodeWithLocation} */ (openingElement).end;
5964
+ const bodyMetaComments = (node.metadata?.elementLeadingComments ?? []).filter(
5965
+ (/** @type {AST.Comment} */ comment) =>
5966
+ typeof comment.start === 'number' && comment.start >= openingTagEnd,
5967
+ );
5968
+ const trailingComments = [
5969
+ ...(node.closingElement?.leadingComments ?? []),
5970
+ ...bodyMetaComments,
5971
+ ].sort((a, b) => /** @type {number} */ (a.start) - /** @type {number} */ (b.start));
5972
+ const lastMeaningfulChild = [...(node.children ?? [])]
5973
+ .reverse()
5974
+ .find((child) => child.type !== 'JSXText' || child.value.trim());
5975
+ const closingCommentDocs = printElementBodyLineComments(trailingComments, lastMeaningfulChild);
5976
+ const hasClosingComments = closingCommentDocs.length > 0;
5977
+ // A comment-only element has no children; its comments live in `innerComments`.
5978
+ const innerCommentDocs = printElementBodyLineComments(node.innerComments);
5979
+
5868
5980
  if (!hasChildren) {
5981
+ const bodyComments = [...innerCommentDocs, ...closingCommentDocs];
5982
+ if (bodyComments.length > 0) {
5983
+ return group([openingTag, indent(bodyComments), hardline, '</', tagName, '>']);
5984
+ }
5869
5985
  return [openingTag, '</', tagName, '>'];
5870
5986
  }
5871
5987
 
5872
- // Format children - filter out empty text nodes and merge adjacent text nodes
5988
+ // A `@{ }` code block is the whole body and hugs the tags: `<div>@{ … }</div>`.
5989
+ if (node.children.length === 1 && node.children[0].type === 'JSXCodeBlock') {
5990
+ return group([openingTag, path.call(print, 'children', 0), '</', tagName, '>']);
5991
+ }
5992
+
5993
+ // Format children - filter out empty text nodes and merge adjacent text nodes.
5994
+ // childNodes tracks the source node behind each doc (a text run is a single
5995
+ // JSXText) so the join can preserve authored blank lines.
5873
5996
  const childrenDocs = [];
5997
+ const childNodes = [];
5874
5998
  let currentText = '';
5999
+ let currentTextNode = null;
5875
6000
 
5876
6001
  for (let i = 0; i < node.children.length; i++) {
5877
6002
  const child = node.children[i];
5878
6003
 
5879
6004
  if (child.type === 'JSXText') {
5880
- // Accumulate text content, preserving spaces between words
5881
- const trimmed = child.value.trim();
5882
- if (trimmed) {
6005
+ if (hasComment(/** @type {AST.Node & AST.NodeWithMaybeComments} */ (child))) {
6006
+ if (currentText) {
6007
+ childrenDocs.push(currentText);
6008
+ childNodes.push(currentTextNode);
6009
+ currentText = '';
6010
+ currentTextNode = null;
6011
+ }
6012
+ const printedChild = path.call(print, 'children', i);
6013
+ if (printedChild !== '') {
6014
+ childrenDocs.push(printedChild);
6015
+ childNodes.push(child);
6016
+ }
6017
+ continue;
6018
+ }
6019
+ // Accumulate text content, preserving meaningful boundary spaces.
6020
+ const text = normalizeInlineJSXText(child.value);
6021
+ if (text) {
5883
6022
  const nextChild = node.children[i + 1];
5884
6023
  const afterNextChild = node.children[i + 2];
5885
6024
  const nextText = afterNextChild?.type === 'JSXText' ? afterNextChild.value.trim() : '';
5886
6025
  if (
5887
6026
  tagName === 'tsrx' &&
5888
- trimmed.endsWith('=') &&
6027
+ text.trimEnd().endsWith('=') &&
5889
6028
  nextChild?.type === 'JSXElement' &&
5890
6029
  nextText === ';'
5891
6030
  ) {
5892
6031
  if (currentText) {
5893
6032
  childrenDocs.push(currentText);
6033
+ childNodes.push(currentTextNode);
5894
6034
  currentText = '';
6035
+ currentTextNode = null;
5895
6036
  }
5896
- childrenDocs.push([trimmed, ' ', path.call(print, 'children', i + 1), ';']);
6037
+ childrenDocs.push([text.trim(), ' ', path.call(print, 'children', i + 1), ';']);
6038
+ childNodes.push(child);
5897
6039
  i += 2;
5898
6040
  continue;
5899
6041
  }
5900
6042
 
5901
6043
  if (currentText) {
5902
- currentText += ' ' + trimmed;
6044
+ currentText += currentText.endsWith(' ') || text.startsWith(' ') ? text : ' ' + text;
5903
6045
  } else {
5904
- currentText = trimmed;
6046
+ currentText = text;
6047
+ currentTextNode = child;
5905
6048
  }
5906
6049
  }
5907
6050
  } else {
5908
6051
  // If we have accumulated text, push it before the non-text node
5909
6052
  if (currentText) {
5910
6053
  childrenDocs.push(currentText);
6054
+ childNodes.push(currentTextNode);
5911
6055
  currentText = '';
6056
+ currentTextNode = null;
5912
6057
  }
5913
6058
 
5914
6059
  if (child.type === 'JSXExpressionContainer') {
5915
6060
  // Handle JSX expression containers
5916
- childrenDocs.push(['{', path.call(print, 'children', i, 'expression'), '}']);
6061
+ childrenDocs.push([
6062
+ ...printTemplateChildLeadingComments(child),
6063
+ '{',
6064
+ path.call(print, 'children', i, 'expression'),
6065
+ '}',
6066
+ ]);
6067
+ childNodes.push(child);
5917
6068
  } else {
5918
6069
  // Handle nested JSX elements
5919
6070
  childrenDocs.push(path.call(print, 'children', i));
6071
+ childNodes.push(child);
5920
6072
  }
5921
6073
  }
5922
6074
  }
@@ -5924,37 +6076,71 @@ function printJSXElement(node, path, options, print) {
5924
6076
  // Don't forget any remaining text
5925
6077
  if (currentText) {
5926
6078
  childrenDocs.push(currentText);
6079
+ childNodes.push(currentTextNode);
5927
6080
  }
5928
6081
 
5929
- // Check if content can be inlined (single text node or single expression)
5930
- if (childrenDocs.length === 1 && typeof childrenDocs[0] === 'string') {
5931
- return group([openingTag, childrenDocs[0], '</', tagName, '>']);
6082
+ // A child with leading comments must break onto its own line, so the comment
6083
+ // reads above the child rather than being jammed onto the opening tag.
6084
+ const hasChildLeadingComments = node.children.some((child) => {
6085
+ const leadingComments = /** @type {AST.NodeWithMaybeComments} */ (child).leadingComments;
6086
+ return Array.isArray(leadingComments) && leadingComments.length > 0;
6087
+ });
6088
+ const forceMultiline = hasClosingComments || hasChildLeadingComments;
6089
+
6090
+ // Check if content can be inlined (single text node or single expression).
6091
+ // Trailing or child-leading comments force the multi-line layout. A single
6092
+ // text child stays inline when it fits and otherwise fills/wraps to printWidth.
6093
+ if (!forceMultiline && childrenDocs.length === 1 && typeof childrenDocs[0] === 'string') {
6094
+ // The open tag breaks for attributes independently; the text+closing get
6095
+ // their own group so the text only drops to its own (filled) lines when it
6096
+ // itself overflows — otherwise it hugs `>text</tag>`.
6097
+ return [
6098
+ openingTag,
6099
+ group([indent([softline, printRawText(childrenDocs[0])]), softline, '</', tagName, '>']),
6100
+ ];
5932
6101
  }
5933
6102
  const meaningfulChildren = node.children.filter(
5934
- (child) => child.type !== 'JSXText' || child.value.trim(),
6103
+ (/** @type {any} */ child) => child.type !== 'JSXText' || child.value.trim(),
5935
6104
  );
5936
6105
  const singleMeaningfulChild = meaningfulChildren.length === 1 ? meaningfulChildren[0] : null;
5937
6106
  if (
6107
+ !forceMultiline &&
5938
6108
  childrenDocs.length === 1 &&
5939
6109
  singleMeaningfulChild?.type === 'JSXExpressionContainer' &&
5940
- singleMeaningfulChild.expression.type === 'Identifier'
6110
+ isSimpleJSXExpressionChild(/** @type {AST.Node} */ (singleMeaningfulChild))
5941
6111
  ) {
5942
6112
  return group([openingTag, childrenDocs[0], '</', tagName, '>']);
5943
6113
  }
6114
+ if (
6115
+ !forceMultiline &&
6116
+ childrenDocs.length > 1 &&
6117
+ wasOriginallySingleLine(node) &&
6118
+ node.children.some((/** @type {any} */ child) => child.type === 'JSXText') &&
6119
+ node.children.every(
6120
+ (/** @type {any} */ child) =>
6121
+ child.type === 'JSXText' || isSimpleJSXExpressionChild(/** @type {AST.Node} */ (child)),
6122
+ )
6123
+ ) {
6124
+ return group([openingTag, ...childrenDocs, '</', tagName, '>']);
6125
+ }
5944
6126
 
5945
- // Multiple children or complex children - format with line breaks
6127
+ // Multiple children or complex children - format with line breaks. Text runs
6128
+ // fill/wrap to printWidth.
5946
6129
  const formattedChildren = [];
5947
6130
  for (let i = 0; i < childrenDocs.length; i++) {
5948
- formattedChildren.push(childrenDocs[i]);
6131
+ const childDoc = childrenDocs[i];
6132
+ formattedChildren.push(typeof childDoc === 'string' ? printRawText(childDoc) : childDoc);
5949
6133
  if (i < childrenDocs.length - 1) {
5950
- formattedChildren.push(hardline);
6134
+ // Preserve a single authored blank line between children (2+ collapse to 1).
6135
+ const blank = getBlankLinesBetweenNodes(childNodes[i], leadingAnchor(childNodes[i + 1])) > 0;
6136
+ formattedChildren.push(blank ? [hardline, hardline] : hardline);
5951
6137
  }
5952
6138
  }
5953
6139
 
5954
6140
  // Build the final element
5955
6141
  return group([
5956
6142
  openingTag,
5957
- indent([hardline, ...formattedChildren]),
6143
+ indent([hardline, ...formattedChildren, ...closingCommentDocs]),
5958
6144
  hardline,
5959
6145
  '</',
5960
6146
  tagName,
@@ -5977,23 +6163,46 @@ function printJSXFragment(node, path, options, print) {
5977
6163
  return '<></>';
5978
6164
  }
5979
6165
 
5980
- // Format children - filter out empty text nodes
6166
+ // A `@{ }` code block is the whole body and hugs the tags: `<>@{ … }</>`.
6167
+ if (node.children.length === 1 && /** @type {any} */ (node.children[0]).type === 'JSXCodeBlock') {
6168
+ return group(['<>', path.call(print, 'children', 0), '</>']);
6169
+ }
6170
+
6171
+ // Format children - filter out empty text nodes. childNodes tracks the source
6172
+ // node behind each doc so the join can preserve authored blank lines.
5981
6173
  const childrenDocs = [];
6174
+ const childNodes = [];
5982
6175
  for (let i = 0; i < node.children.length; i++) {
5983
6176
  const child = node.children[i];
5984
6177
 
5985
6178
  if (child.type === 'JSXText') {
6179
+ if (hasComment(/** @type {AST.Node & AST.NodeWithMaybeComments} */ (child))) {
6180
+ const printedChild = path.call(print, 'children', i);
6181
+ if (printedChild !== '') {
6182
+ childrenDocs.push(printedChild);
6183
+ childNodes.push(child);
6184
+ }
6185
+ continue;
6186
+ }
5986
6187
  // Handle JSX text nodes - trim whitespace and only include if not empty
5987
- const text = child.value.trim();
6188
+ const text = printJSXTextChild(child.value);
5988
6189
  if (text) {
5989
6190
  childrenDocs.push(text);
6191
+ childNodes.push(child);
5990
6192
  }
5991
6193
  } else if (child.type === 'JSXExpressionContainer') {
5992
6194
  // Handle JSX expression containers
5993
- childrenDocs.push(['{', path.call(print, 'children', i, 'expression'), '}']);
6195
+ childrenDocs.push([
6196
+ ...printTemplateChildLeadingComments(child),
6197
+ '{',
6198
+ path.call(print, 'children', i, 'expression'),
6199
+ '}',
6200
+ ]);
6201
+ childNodes.push(child);
5994
6202
  } else {
5995
6203
  // Handle nested JSX elements and fragments
5996
6204
  childrenDocs.push(path.call(print, 'children', i));
6205
+ childNodes.push(child);
5997
6206
  }
5998
6207
  }
5999
6208
 
@@ -6001,13 +6210,33 @@ function printJSXFragment(node, path, options, print) {
6001
6210
  if (childrenDocs.length === 1 && typeof childrenDocs[0] === 'string') {
6002
6211
  return ['<>', childrenDocs[0], '</>'];
6003
6212
  }
6213
+ const meaningfulChildren = node.children.filter(
6214
+ (child) => child.type !== 'JSXText' || child.value.trim(),
6215
+ );
6216
+ if (
6217
+ childrenDocs.length === 1 &&
6218
+ meaningfulChildren.length === 1 &&
6219
+ meaningfulChildren[0].type === 'JSXElement' &&
6220
+ wasOriginallySingleLine(node) &&
6221
+ !willBreak(childrenDocs[0])
6222
+ ) {
6223
+ // Keep the fragment inline when it fits; otherwise expand `<>` onto its own
6224
+ // lines so a breaking single child reads as `<>\n <Child …/>\n</>` rather than
6225
+ // `<><Child` with only the child's attributes broken.
6226
+ return conditionalGroup([
6227
+ ['<>', childrenDocs[0], '</>'],
6228
+ group(['<>', indent([hardline, childrenDocs[0]]), hardline, '</>']),
6229
+ ]);
6230
+ }
6004
6231
 
6005
6232
  // Multiple children or complex children - format with line breaks
6006
6233
  const formattedChildren = [];
6007
6234
  for (let i = 0; i < childrenDocs.length; i++) {
6008
6235
  formattedChildren.push(childrenDocs[i]);
6009
6236
  if (i < childrenDocs.length - 1) {
6010
- formattedChildren.push(hardline);
6237
+ // Preserve a single authored blank line between children (2+ collapse to 1).
6238
+ const blank = getBlankLinesBetweenNodes(childNodes[i], leadingAnchor(childNodes[i + 1])) > 0;
6239
+ formattedChildren.push(blank ? [hardline, hardline] : hardline);
6011
6240
  }
6012
6241
  }
6013
6242
 
@@ -6015,6 +6244,169 @@ function printJSXFragment(node, path, options, print) {
6015
6244
  return group(['<>', indent([hardline, ...formattedChildren]), hardline, '</>']);
6016
6245
  }
6017
6246
 
6247
+ /**
6248
+ * Comments written inside an opening tag, before an attribute, are attached by
6249
+ * the parser to the next visited body child (positionally they sort before the
6250
+ * opening tag's end, but the child is visited first). Pull those out of the
6251
+ * children and return a map from attribute index to the comments that precede it,
6252
+ * so the element printer can render them in the opening tag instead of the body.
6253
+ * @param {AST.TSRXJSXElement} node
6254
+ * @returns {Map<number, AST.Comment[]>}
6255
+ */
6256
+ function collectOpeningTagComments(node) {
6257
+ /** @type {Map<number, AST.Comment[]>} */
6258
+ const byAttr = new Map();
6259
+ const openingElement = /** @type {AST.NodeWithLocation} */ (node.openingElement);
6260
+ const attributes = /** @type {any[]} */ (node.openingElement?.attributes) ?? [];
6261
+ if (!openingElement || attributes.length === 0 || !Array.isArray(node.children)) {
6262
+ return byAttr;
6263
+ }
6264
+ const openingEnd = openingElement.end;
6265
+ /** @type {AST.Comment[]} */
6266
+ const collected = [];
6267
+ for (const child of node.children) {
6268
+ const lead = /** @type {AST.NodeWithMaybeComments} */ (child).leadingComments;
6269
+ if (!Array.isArray(lead) || lead.length === 0) continue;
6270
+ const keep = [];
6271
+ for (const comment of lead) {
6272
+ if (typeof comment.start === 'number' && comment.start < openingEnd) {
6273
+ collected.push(comment);
6274
+ } else {
6275
+ keep.push(comment);
6276
+ }
6277
+ }
6278
+ if (keep.length !== lead.length) {
6279
+ /** @type {any} */ (child).leadingComments = keep;
6280
+ }
6281
+ }
6282
+ if (collected.length === 0) return byAttr;
6283
+ collected.sort((a, b) => /** @type {number} */ (a.start) - /** @type {number} */ (b.start));
6284
+ let ci = 0;
6285
+ for (let ai = 0; ai < attributes.length; ai++) {
6286
+ const attrStart = /** @type {AST.NodeWithLocation} */ (attributes[ai]).start;
6287
+ /** @type {AST.Comment[]} */
6288
+ const forAttr = [];
6289
+ while (ci < collected.length && /** @type {number} */ (collected[ci].start) < attrStart) {
6290
+ forAttr.push(collected[ci]);
6291
+ ci++;
6292
+ }
6293
+ if (forAttr.length > 0) byAttr.set(ai, forAttr);
6294
+ }
6295
+ return byAttr;
6296
+ }
6297
+
6298
+ /**
6299
+ * Build doc parts for a template child's leading comments (each on its own line).
6300
+ * Used for `{expr}` children, whose `{ … }` form is printed inline by the JSX
6301
+ * printers and so would otherwise skip the node's attached leading comments.
6302
+ * @param {AST.Node & AST.NodeWithMaybeComments} child
6303
+ * @returns {Doc[]}
6304
+ */
6305
+ function printTemplateChildLeadingComments(child) {
6306
+ const comments = child.leadingComments;
6307
+ if (!comments || comments.length === 0) {
6308
+ return [];
6309
+ }
6310
+ /** @type {Doc[]} */
6311
+ const parts = [];
6312
+ for (let i = 0; i < comments.length; i++) {
6313
+ const comment = comments[i];
6314
+ if (comment.type === 'Line') {
6315
+ parts.push('//' + comment.value);
6316
+ } else if (comment.type === 'Block') {
6317
+ parts.push('/*' + comment.value + '*/');
6318
+ }
6319
+ parts.push(hardline);
6320
+ const next = comments[i + 1];
6321
+ if (next && getBlankLinesBetweenNodes(comment, next) > 0) {
6322
+ parts.push(hardline);
6323
+ }
6324
+ }
6325
+ return parts;
6326
+ }
6327
+
6328
+ /**
6329
+ * Build doc parts for `//` line comments attached to an element body — trailing
6330
+ * comments before `</tag>` (`closingElement.leadingComments`) or the comments of a
6331
+ * comment-only element (`innerComments`). Block comments are intentionally skipped:
6332
+ * they survive in the adjacent JSXText value and are already rendered as text, so
6333
+ * emitting them here would duplicate them. Each comment is emitted on its own line
6334
+ * at the children indent.
6335
+ * @param {AST.Comment[] | null | undefined} commentList
6336
+ * @param {any} [previousNode]
6337
+ * @returns {Doc[]}
6338
+ */
6339
+ function printElementBodyLineComments(commentList, previousNode = null) {
6340
+ const comments = (commentList ?? []).filter((comment) => comment.type === 'Line');
6341
+ if (comments.length === 0) {
6342
+ return [];
6343
+ }
6344
+ /** @type {Doc[]} */
6345
+ const parts = [];
6346
+ /** @type {AST.Node | AST.Comment | null | undefined} */
6347
+ let prev = previousNode;
6348
+ for (let i = 0; i < comments.length; i++) {
6349
+ parts.push(hardline);
6350
+ // Preserve a blank line before this comment if one existed in source.
6351
+ if (prev && getBlankLinesBetweenNodes(prev, comments[i]) > 0) {
6352
+ parts.push(hardline);
6353
+ }
6354
+ parts.push('//' + comments[i].value);
6355
+ prev = comments[i];
6356
+ }
6357
+ return parts;
6358
+ }
6359
+
6360
+ /**
6361
+ * Print a TSRX code block: setup statements then the single render output.
6362
+ * Callers in element/fragment body position hug it to the surrounding tags;
6363
+ * on its own as an arrow body it stands alone.
6364
+ * @param {AST.JSXCodeBlock} node
6365
+ * @param {AstPath<AST.JSXCodeBlock>} path
6366
+ * @param {RippleFormatOptions} options
6367
+ * @param {PrintFn} print
6368
+ * @returns {Doc}
6369
+ */
6370
+ function printJSXCodeBlock(node, path, options, print) {
6371
+ /** @type {Doc[]} */
6372
+ const parts = [];
6373
+ for (let i = 0; i < node.body.length; i++) {
6374
+ parts.push(path.call(print, 'body', i));
6375
+ if (i < node.body.length - 1) {
6376
+ parts.push(
6377
+ shouldAddBlankLine(node.body[i], node.body[i + 1]) ? [hardline, hardline] : hardline,
6378
+ );
6379
+ }
6380
+ }
6381
+ if (node.render) {
6382
+ if (node.body.length > 0) {
6383
+ // Preserve a blank line between the last setup statement and the render
6384
+ // output (measured to the render's leading comment, if any).
6385
+ const last = node.body[node.body.length - 1];
6386
+ const renderStart =
6387
+ /** @type {AST.NodeWithMaybeComments} */ (node.render).leadingComments?.[0] ?? node.render;
6388
+ parts.push(
6389
+ getBlankLinesBetweenNodes(last, renderStart) > 0 ? [hardline, hardline] : hardline,
6390
+ );
6391
+ }
6392
+ parts.push(path.call(print, 'render'));
6393
+ }
6394
+ // Trailing comments after the last statement/render inside the block.
6395
+ const innerCommentDocs = printElementBodyLineComments(node.innerComments);
6396
+ if (innerCommentDocs.length > 0) {
6397
+ const lastNode = node.render ?? node.body[node.body.length - 1];
6398
+ const firstComment = (node.innerComments ?? []).find((c) => c.type === 'Line');
6399
+ if (lastNode && firstComment && getBlankLinesBetweenNodes(lastNode, firstComment) > 0) {
6400
+ parts.push(hardline);
6401
+ }
6402
+ parts.push(...innerCommentDocs);
6403
+ }
6404
+ if (parts.length === 0) {
6405
+ return '@{}';
6406
+ }
6407
+ return group(['@{', indent([hardline, ...parts]), hardline, '}']);
6408
+ }
6409
+
6018
6410
  /**
6019
6411
  * Print a JSX attribute
6020
6412
  * @param {ESTreeJSX.JSXAttribute} attr - The JSX attribute node
@@ -6026,6 +6418,10 @@ function printJSXFragment(node, path, options, print) {
6026
6418
  function printJSXAttribute(attr, path, options, print) {
6027
6419
  const name = /** @type {ESTreeJSX.JSXIdentifier} */ (attr.name).name;
6028
6420
 
6421
+ if (attr.shorthand) {
6422
+ return ['{', name, '}'];
6423
+ }
6424
+
6029
6425
  if (!attr.value) {
6030
6426
  return name;
6031
6427
  }
@@ -6043,12 +6439,16 @@ function printJSXAttribute(attr, path, options, print) {
6043
6439
 
6044
6440
  if (attr.value.type === 'JSXExpressionContainer') {
6045
6441
  const expression = attr.value.expression;
6442
+ if (expression.type === 'Literal' && typeof expression.value === 'string') {
6443
+ const quote = options.jsxSingleQuote ? "'" : '"';
6444
+ return [name, '=', quote, /** @type {string} */ (expression.value), quote];
6445
+ }
6046
6446
  const exprDoc = path.call(
6047
6447
  (valuePath) => print(valuePath, { isInAttribute: true }),
6048
6448
  'value',
6049
6449
  'expression',
6050
6450
  );
6051
- if (shouldBreakAttributeExpressionClosingBrace(expression)) {
6451
+ if (shouldBreakAttributeExpressionClosingBrace(expression, options, attr)) {
6052
6452
  return [name, '={', exprDoc, hardline, '}'];
6053
6453
  }
6054
6454
  return [name, '={', exprDoc, '}'];
@@ -6058,20 +6458,33 @@ function printJSXAttribute(attr, path, options, print) {
6058
6458
  }
6059
6459
 
6060
6460
  /**
6061
- * Print a JSX member expression (e.g., React.Fragment)
6062
- * @param {AST.Node} node - The JSX member expression or identifier
6461
+ * Print a JSX element name.
6462
+ * @param {AST.Node} node - The JSX element name node
6063
6463
  * @returns {string}
6064
6464
  */
6065
- function printJSXMemberExpression(node) {
6465
+ function printJSXElementName(node) {
6066
6466
  if (node.type === 'JSXIdentifier') {
6067
- return node.name;
6467
+ return (isDynamicJSXIdentifier(node) ? '@' : '') + node.name;
6068
6468
  }
6069
6469
  if (node.type === 'JSXMemberExpression') {
6070
- return printJSXMemberExpression(node.object) + '.' + printJSXMemberExpression(node.property);
6470
+ return printJSXElementName(node.object) + '.' + printJSXElementName(node.property);
6471
+ }
6472
+ if (node.type === 'JSXNamespacedName') {
6473
+ const namespace_name = node.namespace.name;
6474
+ const local_name = node.name.name;
6475
+ return namespace_name + ':' + local_name;
6071
6476
  }
6072
6477
  return 'Unknown';
6073
6478
  }
6074
6479
 
6480
+ /**
6481
+ * @param {ESTreeJSX.JSXIdentifier} node
6482
+ * @returns {boolean}
6483
+ */
6484
+ function isDynamicJSXIdentifier(node) {
6485
+ return /** @type {{ dynamic?: boolean }} */ (node).dynamic === true;
6486
+ }
6487
+
6075
6488
  /**
6076
6489
  * Print a member expression as simple string (for element tag names)
6077
6490
  * @param {AST.Node} node - The node to print
@@ -6080,6 +6493,22 @@ function printJSXMemberExpression(node) {
6080
6493
  * @returns {string}
6081
6494
  */
6082
6495
  function printMemberExpressionSimple(node, options, computed = false) {
6496
+ if (node.type === 'JSXIdentifier') {
6497
+ return (isDynamicJSXIdentifier(node) ? '@' : '') + node.name;
6498
+ }
6499
+
6500
+ if (node.type === 'JSXMemberExpression') {
6501
+ return (
6502
+ printMemberExpressionSimple(node.object, options) +
6503
+ '.' +
6504
+ printMemberExpressionSimple(node.property, options, true)
6505
+ );
6506
+ }
6507
+
6508
+ if (node.type === 'JSXNamespacedName') {
6509
+ return node.namespace.name + ':' + node.name.name;
6510
+ }
6511
+
6083
6512
  if (node.type === 'Identifier') {
6084
6513
  return (computed ? '' : node.tracked ? '@' : '') + node.name;
6085
6514
  }
@@ -6127,7 +6556,7 @@ function is_attribute_value_breakable(value, is_nested_in_object = false) {
6127
6556
  }
6128
6557
 
6129
6558
  /**
6130
- * Print a Ripple Element node
6559
+ * Print a JSX element node
6131
6560
  * @param {AST.Element} element - The element node
6132
6561
  * @param {AstPath<AST.Element>} path - The AST path
6133
6562
  * @param {RippleFormatOptions} options - Prettier options
@@ -6135,7 +6564,7 @@ function is_attribute_value_breakable(value, is_nested_in_object = false) {
6135
6564
  * @returns {Doc}
6136
6565
  */
6137
6566
  function printElement(element, path, options, print) {
6138
- const node = /** @type {AST.Element & AST.NodeWithLocation} */ (element);
6567
+ const node = /** @type {any} */ (element);
6139
6568
  const tagName = printMemberExpressionSimple(node.id, options);
6140
6569
  const openingElement = /** @type {any} */ (node.openingElement);
6141
6570
  /** @type {Doc} */
@@ -6143,7 +6572,7 @@ function printElement(element, path, options, print) {
6143
6572
  if (openingElement?.typeArguments) {
6144
6573
  typeArgsDoc = path.call(print, 'openingElement', 'typeArguments');
6145
6574
  }
6146
- const elementLeadingComments = getElementLeadingComments(node);
6575
+ const elementLeadingComments = getElementLeadingComments(/** @type {any} */ (node));
6147
6576
 
6148
6577
  // `metadata.elementLeadingComments` may include comments that actually appear *inside* the element
6149
6578
  // body (after the opening tag). Those must not be hoisted before the element.
@@ -6178,7 +6607,7 @@ function printElement(element, path, options, print) {
6178
6607
  const openingEnd = /** @type {AST.NodeWithLocation} */ (node.openingElement).end;
6179
6608
  for (const child of node.children) {
6180
6609
  if (
6181
- (child.type === 'TSRXExpression' || child.type === 'Text') &&
6610
+ (child.type === 'JSXExpressionContainer' || child.type === 'JSXText') &&
6182
6611
  Array.isArray(child.leadingComments)
6183
6612
  ) {
6184
6613
  for (const comment of child.leadingComments) {
@@ -6255,11 +6684,14 @@ function printElement(element, path, options, print) {
6255
6684
  parts.push(attrLineBreak);
6256
6685
  const attrDoc = print(attrPath);
6257
6686
  parts.push(attrDoc);
6258
- const attr_node = /** @type {AST.Attribute | AST.SpreadAttribute} */ (attrPath.node);
6687
+ const attr_node = /** @type {ESTreeJSX.JSXAttribute | ESTreeJSX.JSXSpreadAttribute} */ (
6688
+ /** @type {unknown} */ (attrPath.node)
6689
+ );
6259
6690
  if (
6260
6691
  !hasBreakingAttribute &&
6261
6692
  (willBreak(attrDoc) ||
6262
- (attr_node.type === 'Attribute' && is_attribute_value_breakable(attr_node.value)))
6693
+ (attr_node.type === 'JSXAttribute' &&
6694
+ is_attribute_value_breakable(/** @type {any} */ (attr_node.value))))
6263
6695
  ) {
6264
6696
  hasBreakingAttribute = true;
6265
6697
  }
@@ -6367,15 +6799,17 @@ function printElement(element, path, options, print) {
6367
6799
  }
6368
6800
  }
6369
6801
 
6370
- const isTextLikeChild = currentChild.type === 'TSRXExpression' || currentChild.type === 'Text';
6802
+ const isTextLikeChild =
6803
+ currentChild.type === 'JSXExpressionContainer' || currentChild.type === 'JSXText';
6371
6804
  const hasTextLeadingComments =
6372
6805
  shouldLiftTextLevelComments &&
6373
6806
  isTextLikeChild &&
6374
6807
  Array.isArray(currentChild.leadingComments) &&
6375
6808
  currentChild.leadingComments.length > 0;
6809
+ const currentChildAny = /** @type {any} */ (currentChild);
6376
6810
  const rawExpressionLeadingComments =
6377
- isTextLikeChild && Array.isArray(currentChild.expression?.leadingComments)
6378
- ? currentChild.expression.leadingComments
6811
+ isTextLikeChild && Array.isArray(currentChildAny.expression?.leadingComments)
6812
+ ? currentChildAny.expression.leadingComments
6379
6813
  : null;
6380
6814
  const elementBodyLeadingComments =
6381
6815
  hasTextLeadingComments && node.openingElement
@@ -6481,10 +6915,10 @@ function printElement(element, path, options, print) {
6481
6915
  : nextChild;
6482
6916
  const whitespaceLinesCount = getBlankLinesBetweenNodes(currentChild, whitespaceTarget);
6483
6917
  const isTextOrExpressionChild =
6484
- currentChild.type === 'TSRXExpression' ||
6485
- currentChild.type === 'Text' ||
6486
- nextChild.type === 'TSRXExpression' ||
6487
- nextChild.type === 'Text';
6918
+ currentChild.type === 'JSXExpressionContainer' ||
6919
+ currentChild.type === 'JSXText' ||
6920
+ nextChild.type === 'JSXExpressionContainer' ||
6921
+ nextChild.type === 'JSXText';
6488
6922
 
6489
6923
  if (whitespaceLinesCount > 0) {
6490
6924
  finalChildren.push(hardline);
@@ -6535,10 +6969,15 @@ function printElement(element, path, options, print) {
6535
6969
 
6536
6970
  const closingTag = ['</', tagName, '>'];
6537
6971
  let elementOutput;
6972
+ const shouldTryInlineMultipleChildren =
6973
+ !openingTagAlwaysBreaks &&
6974
+ fallbackCommentParts.length === 0 &&
6975
+ closingElementComments.length === 0 &&
6976
+ shouldTryInlineMultipleTextChildren(node);
6538
6977
 
6539
6978
  if (finalChildren.length === 1) {
6540
6979
  const child = finalChildren[0];
6541
- const firstChild = node.children[0];
6980
+ const firstChild = /** @type {any} */ (node.children[0]);
6542
6981
  const isNonSelfClosingElement =
6543
6982
  firstChild && firstChild.type === 'Element' && !firstChild.selfClosing;
6544
6983
  const isElementChild = firstChild && firstChild.type === 'Element';
@@ -6569,63 +7008,15 @@ function printElement(element, path, options, print) {
6569
7008
  } else {
6570
7009
  elementOutput = [openingTag, indent([hardline, ...finalChildren]), hardline, closingTag];
6571
7010
  }
7011
+ } else if (shouldTryInlineMultipleChildren) {
7012
+ const inlineChildren = path.map(print, 'children');
7013
+ elementOutput = conditionalGroup([
7014
+ group([openingTag, ...inlineChildren, closingTag]),
7015
+ [openingTag, indent([hardline, ...finalChildren]), hardline, closingTag],
7016
+ ]);
6572
7017
  } else {
6573
7018
  elementOutput = group([openingTag, indent([hardline, ...finalChildren]), hardline, closingTag]);
6574
7019
  }
6575
7020
 
6576
7021
  return leadingCommentParts.length > 0 ? [...leadingCommentParts, elementOutput] : elementOutput;
6577
7022
  }
6578
-
6579
- /**
6580
- * Print a Ripple attribute node
6581
- * @param {AST.Attribute} node - The attribute node
6582
- * @param {AstPath<AST.Attribute>} path - The AST path
6583
- * @param {RippleFormatOptions} options - Prettier options
6584
- * @param {PrintFn} print - Print callback
6585
- * @returns {Doc[]}
6586
- */
6587
- function printAttribute(node, path, options, print) {
6588
- /** @type {Doc[]} */
6589
- const parts = [];
6590
-
6591
- // Handle shorthand syntax: {id} instead of id={id}
6592
- // Check if either node.shorthand is true, OR if the value is an Identifier with the same name
6593
- const isShorthand =
6594
- node.shorthand ||
6595
- (node.value && node.value.type === 'Identifier' && node.value.name === node.name.name);
6596
-
6597
- if (isShorthand) {
6598
- parts.push('{');
6599
- parts.push(node.name.name);
6600
- parts.push('}');
6601
- return parts;
6602
- }
6603
-
6604
- parts.push(node.name.name);
6605
-
6606
- if (node.value) {
6607
- if (node.value.type === 'Literal' && typeof node.value.value === 'string') {
6608
- // String literals don't need curly braces
6609
- // Use jsxSingleQuote option if available, otherwise use double quotes
6610
- parts.push('=');
6611
- const useJsxSingleQuote = options.jsxSingleQuote === true;
6612
- parts.push(
6613
- formatStringLiteral(node.value.value, {
6614
- ...options,
6615
- singleQuote: useJsxSingleQuote,
6616
- }),
6617
- );
6618
- } else {
6619
- // All other values need curly braces: numbers, booleans, null, expressions, etc.
6620
- parts.push('={');
6621
- // Pass inline context for attribute values (keep objects compact)
6622
- parts.push(path.call((attrPath) => print(attrPath, { isInAttribute: true }), 'value'));
6623
- if (shouldBreakAttributeExpressionClosingBrace(node.value)) {
6624
- parts.push(hardline);
6625
- }
6626
- parts.push('}');
6627
- }
6628
- }
6629
-
6630
- return parts;
6631
- }