@tsrx/prettier-plugin 0.3.78 → 0.3.79

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tsrx/prettier-plugin",
3
- "version": "0.3.78",
3
+ "version": "0.3.79",
4
4
  "description": "Ripple plugin for Prettier",
5
5
  "type": "module",
6
6
  "module": "src/index.js",
@@ -27,7 +27,7 @@
27
27
  "prettier": "^3.8.3"
28
28
  },
29
29
  "dependencies": {
30
- "@tsrx/core": "0.1.26"
30
+ "@tsrx/core": "0.1.27"
31
31
  },
32
32
  "files": [
33
33
  "src/"
package/src/index.js CHANGED
@@ -2292,20 +2292,20 @@ function printRippleNode(node, path, options, print, args) {
2292
2292
  break;
2293
2293
 
2294
2294
  case 'JSXStyleElement':
2295
- nodeContent = printJSXElement(
2296
- /** @type {ESTreeJSX.JSXElement} */ (/** @type {unknown} */ (node)),
2297
- path,
2298
- options,
2299
- print,
2300
- );
2295
+ nodeContent = printJSXElement(node, path, options, print);
2301
2296
  break;
2302
2297
 
2303
2298
  case 'JSXElement':
2304
- nodeContent = printJSXElement(node, path, options, print);
2299
+ nodeContent = printJSXElement(/** @type {AST.TSRXJSXElement} */ (node), path, options, print);
2305
2300
  break;
2306
2301
 
2307
2302
  case 'JSXFragment':
2308
- nodeContent = printJSXFragment(node, path, options, print);
2303
+ nodeContent = printJSXFragment(
2304
+ /** @type {AST.TSRXJSXFragment} */ (node),
2305
+ path,
2306
+ options,
2307
+ print,
2308
+ );
2309
2309
  break;
2310
2310
 
2311
2311
  case 'JSXText':
@@ -5694,86 +5694,6 @@ function normalizeInlineJSXText(raw) {
5694
5694
  return text.trim() || !/[\r\n]/u.test(text) ? text : '';
5695
5695
  }
5696
5696
 
5697
- /**
5698
- * @param {AST.Node} parentNode
5699
- * @param {AST.Node} firstChild
5700
- * @param {Doc} childDoc
5701
- * @returns {boolean}
5702
- */
5703
- function shouldInlineSingleChild(parentNode, firstChild, childDoc) {
5704
- if (!firstChild || childDoc == null) {
5705
- return false;
5706
- }
5707
-
5708
- if (firstChild.type === 'JSXText') {
5709
- return true;
5710
- }
5711
-
5712
- if (typeof childDoc === 'string') {
5713
- return childDoc.length <= 20 && !childDoc.includes('\n');
5714
- }
5715
-
5716
- // Inline JSX expressions if they fit, but respect original multi-line formatting
5717
- // for non-literal expressions (e.g. {children} should stay multi-line if written that way)
5718
- if (firstChild.type === 'JSXExpressionContainer') {
5719
- if (wasOriginallySingleLine(parentNode)) {
5720
- return true;
5721
- }
5722
- // For multi-line parents, only inline if the expression is a simple literal
5723
- const expr = firstChild.expression;
5724
- if (expr && (expr.type === 'Literal' || expr.type === 'TemplateLiteral')) {
5725
- return true;
5726
- }
5727
- return false;
5728
- }
5729
-
5730
- // Respect original formatting for elements: if parent was originally multi-line, keep it multi-line
5731
- // This follows Prettier's philosophy for decorators and objects
5732
- if (!wasOriginallySingleLine(parentNode)) {
5733
- return false;
5734
- }
5735
-
5736
- if (firstChild.type === 'JSXElement' && firstChild.openingElement?.selfClosing) {
5737
- const parent = /** @type {any} */ (parentNode);
5738
- return !parent.openingElement?.attributes?.length;
5739
- }
5740
-
5741
- return false;
5742
- }
5743
-
5744
- /**
5745
- * Check whether a child can participate in compact inline TSRX content.
5746
- * @param {any} child
5747
- * @returns {boolean}
5748
- */
5749
- function isInlineableTextOrExpressionChild(child) {
5750
- if (!child || (child.type !== 'JSXText' && child.type !== 'JSXExpressionContainer')) {
5751
- return false;
5752
- }
5753
-
5754
- if (hasComment(/** @type {AST.Node & AST.NodeWithMaybeComments} */ (child))) {
5755
- return false;
5756
- }
5757
-
5758
- const expression = /** @type {{ expression?: AST.Node & AST.NodeWithMaybeComments }} */ (child)
5759
- .expression;
5760
- return !expression || !hasComment(expression);
5761
- }
5762
-
5763
- /**
5764
- * @param {any} node
5765
- * @returns {boolean}
5766
- */
5767
- function shouldTryInlineMultipleTextChildren(node) {
5768
- return (
5769
- wasOriginallySingleLine(node) &&
5770
- Array.isArray(node.children) &&
5771
- node.children.length > 1 &&
5772
- node.children.some((/** @type {any} */ child) => child.type === 'JSXText') &&
5773
- node.children.every(isInlineableTextOrExpressionChild)
5774
- );
5775
- }
5776
-
5777
5697
  /**
5778
5698
  * @param {AST.Node} child
5779
5699
  * @returns {boolean}
@@ -5795,72 +5715,10 @@ function isSimpleJSXExpressionChild(child) {
5795
5715
  );
5796
5716
  }
5797
5717
 
5798
- /**
5799
- * Get leading comments from element metadata
5800
- * @param {ESTreeJSX.JSXElement} node - The element node
5801
- * @returns {AST.Comment[]}
5802
- */
5803
- function getElementLeadingComments(node) {
5804
- const fromMetadata = node?.metadata?.elementLeadingComments;
5805
- if (Array.isArray(fromMetadata)) {
5806
- return fromMetadata;
5807
- }
5808
- return [];
5809
- }
5810
-
5811
- /**
5812
- * Create doc parts for element-level comments
5813
- * @param {AST.Comment[]} comments - Array of comments
5814
- * @returns {Doc[]}
5815
- */
5816
- function createElementLevelCommentParts(comments) {
5817
- if (!comments || comments.length === 0) {
5818
- return [];
5819
- }
5820
-
5821
- /** @type {Doc[]} */
5822
- const parts = [];
5823
-
5824
- for (let i = 0; i < comments.length; i++) {
5825
- const comment = comments[i];
5826
- const nextComment = comments[i + 1];
5827
-
5828
- if (comment.type === 'Line') {
5829
- parts.push('//' + comment.value);
5830
- parts.push(hardline);
5831
- } else if (comment.type === 'Block') {
5832
- parts.push('/*' + comment.value + '*/');
5833
- parts.push(hardline);
5834
- }
5835
-
5836
- if (nextComment) {
5837
- const blankLinesBetween = getBlankLinesBetweenNodes(comment, nextComment);
5838
- if (blankLinesBetween > 0) {
5839
- parts.push(hardline);
5840
- }
5841
- }
5842
- }
5843
-
5844
- return parts;
5845
- }
5846
-
5847
- /**
5848
- * Create element-level comment parts with trailing hardline trimmed
5849
- * @param {AST.Comment[]} comments - Array of comments
5850
- * @returns {Doc[]}
5851
- */
5852
- function createElementLevelCommentPartsTrimmed(comments) {
5853
- const parts = createElementLevelCommentParts(comments);
5854
- if (parts.length > 0 && parts[parts.length - 1] === hardline) {
5855
- parts.pop();
5856
- }
5857
- return parts;
5858
- }
5859
-
5860
5718
  /**
5861
5719
  * Print a JSX element
5862
- * @param {AST.TSRXJSXElement} node - The JSX element node
5863
- * @param {AstPath<any>} path - The AST path
5720
+ * @param {AST.TSRXJSXElement | AST.JSXStyleElement} node - The JSX element node
5721
+ * @param {AstPath<AST.TSRXJSXElement>} path - The AST path
5864
5722
  * @param {RippleFormatOptions} options - Prettier options
5865
5723
  * @param {PrintFn} print - Print callback
5866
5724
  * @returns {Doc | Doc[]}
@@ -5873,7 +5731,7 @@ function printJSXElement(node, path, options, print) {
5873
5731
  // Dynamic tags (`<{expr}>`) print the opening expression for both tags so
5874
5732
  // they stay textually identical; static names print as plain strings.
5875
5733
  const tagName =
5876
- /** @type {any} */ (openingElement.name).type === 'JSXExpressionContainer'
5734
+ openingElement.name.type === 'JSXExpressionContainer'
5877
5735
  ? ['{', path.call(print, 'openingElement', 'name', 'expression'), '}']
5878
5736
  : printJSXElementName(openingElement.name);
5879
5737
 
@@ -5960,26 +5818,13 @@ function printJSXElement(node, path, options, print) {
5960
5818
  { shouldBreak: shouldForceBreak },
5961
5819
  );
5962
5820
 
5963
- // Trailing comments after the last child are attached by the parser either to
5964
- // the closing tag (`closingElement.leadingComments`) or, when the last child is
5965
- // an `{expr}` container, to `metadata.elementLeadingComments` positioned inside
5966
- // the body (start >= opening tag end). Emit both before `</tag>`.
5967
- const openingTagEnd = /** @type {AST.NodeWithLocation} */ (openingElement).end;
5968
- const bodyMetaComments = (node.metadata?.elementLeadingComments ?? []).filter(
5969
- (/** @type {AST.Comment} */ comment) =>
5970
- typeof comment.start === 'number' && comment.start >= openingTagEnd,
5821
+ // Comments before `</tag>` and the comments of a comment-only element.
5822
+ const { closingCommentDocs, innerCommentDocs } = collectElementBodyCommentDocs(
5823
+ /** @type {AST.TSRXJSXElement} */ (node),
5824
+ openingElement,
5825
+ node.closingElement,
5971
5826
  );
5972
- const trailingComments = [
5973
- ...(node.closingElement?.leadingComments ?? []),
5974
- ...bodyMetaComments,
5975
- ].sort((a, b) => /** @type {number} */ (a.start) - /** @type {number} */ (b.start));
5976
- const lastMeaningfulChild = [...(node.children ?? [])]
5977
- .reverse()
5978
- .find((child) => child.type !== 'JSXText' || child.value.trim());
5979
- const closingCommentDocs = printElementBodyLineComments(trailingComments, lastMeaningfulChild);
5980
5827
  const hasClosingComments = closingCommentDocs.length > 0;
5981
- // A comment-only element has no children; its comments live in `innerComments`.
5982
- const innerCommentDocs = printElementBodyLineComments(node.innerComments);
5983
5828
 
5984
5829
  if (!hasChildren) {
5985
5830
  const bodyComments = [...innerCommentDocs, ...closingCommentDocs];
@@ -6112,7 +5957,7 @@ function printJSXElement(node, path, options, print) {
6112
5957
  ];
6113
5958
  }
6114
5959
  const meaningfulChildren = node.children.filter(
6115
- (/** @type {any} */ child) => child.type !== 'JSXText' || child.value.trim(),
5960
+ (child) => child.type !== 'JSXText' || child.value.trim(),
6116
5961
  );
6117
5962
  const singleMeaningfulChild = meaningfulChildren.length === 1 ? meaningfulChildren[0] : null;
6118
5963
  if (
@@ -6127,9 +5972,9 @@ function printJSXElement(node, path, options, print) {
6127
5972
  !forceMultiline &&
6128
5973
  childrenDocs.length > 1 &&
6129
5974
  wasOriginallySingleLine(node) &&
6130
- node.children.some((/** @type {any} */ child) => child.type === 'JSXText') &&
5975
+ node.children.some((child) => child.type === 'JSXText') &&
6131
5976
  node.children.every(
6132
- (/** @type {any} */ child) =>
5977
+ (child) =>
6133
5978
  child.type === 'JSXText' || isSimpleJSXExpressionChild(/** @type {AST.Node} */ (child)),
6134
5979
  )
6135
5980
  ) {
@@ -6162,8 +6007,8 @@ function printJSXElement(node, path, options, print) {
6162
6007
 
6163
6008
  /**
6164
6009
  * Print a JSX fragment (<>...</>)
6165
- * @param {ESTreeJSX.JSXFragment} node - The JSX fragment node
6166
- * @param {AstPath<ESTreeJSX.JSXFragment>} path - The AST path
6010
+ * @param {AST.TSRXJSXFragment} node - The JSX fragment node
6011
+ * @param {AstPath<AST.TSRXJSXFragment>} path - The AST path
6167
6012
  * @param {RippleFormatOptions} options - Prettier options
6168
6013
  * @param {PrintFn} print - Print callback
6169
6014
  * @returns {Doc}
@@ -6171,12 +6016,23 @@ function printJSXElement(node, path, options, print) {
6171
6016
  function printJSXFragment(node, path, options, print) {
6172
6017
  const hasChildren = node.children && node.children.length > 0;
6173
6018
 
6019
+ // Comments before `</>` and the comments of a comment-only fragment.
6020
+ const { closingCommentDocs, innerCommentDocs } = collectElementBodyCommentDocs(
6021
+ node,
6022
+ node.openingFragment,
6023
+ node.closingFragment,
6024
+ );
6025
+
6174
6026
  if (!hasChildren) {
6027
+ const bodyComments = [...innerCommentDocs, ...closingCommentDocs];
6028
+ if (bodyComments.length > 0) {
6029
+ return group(['<>', indent(bodyComments), hardline, '</>']);
6030
+ }
6175
6031
  return '<></>';
6176
6032
  }
6177
6033
 
6178
6034
  // A `@{ … }` code block is the whole body and hugs the tags: `<>@{ … }</>`.
6179
- if (node.children.length === 1 && /** @type {any} */ (node.children[0]).type === 'JSXCodeBlock') {
6035
+ if (node.children.length === 1 && node.children[0].type === 'JSXCodeBlock') {
6180
6036
  return group(['<>', path.call(print, 'children', 0), '</>']);
6181
6037
  }
6182
6038
 
@@ -6219,7 +6075,11 @@ function printJSXFragment(node, path, options, print) {
6219
6075
  }
6220
6076
 
6221
6077
  // Check if content can be inlined (single text node or single expression)
6222
- if (childrenDocs.length === 1 && typeof childrenDocs[0] === 'string') {
6078
+ if (
6079
+ childrenDocs.length === 1 &&
6080
+ typeof childrenDocs[0] === 'string' &&
6081
+ closingCommentDocs.length === 0
6082
+ ) {
6223
6083
  return ['<>', childrenDocs[0], '</>'];
6224
6084
  }
6225
6085
  const meaningfulChildren = node.children.filter(
@@ -6230,6 +6090,7 @@ function printJSXFragment(node, path, options, print) {
6230
6090
  meaningfulChildren.length === 1 &&
6231
6091
  meaningfulChildren[0].type === 'JSXElement' &&
6232
6092
  wasOriginallySingleLine(node) &&
6093
+ closingCommentDocs.length === 0 &&
6233
6094
  !willBreak(childrenDocs[0])
6234
6095
  ) {
6235
6096
  // Keep the fragment inline when it fits; otherwise expand `<>` onto its own
@@ -6253,7 +6114,12 @@ function printJSXFragment(node, path, options, print) {
6253
6114
  }
6254
6115
 
6255
6116
  // Build the final fragment
6256
- return group(['<>', indent([hardline, ...formattedChildren]), hardline, '</>']);
6117
+ return group([
6118
+ '<>',
6119
+ indent([hardline, ...formattedChildren, ...closingCommentDocs]),
6120
+ hardline,
6121
+ '</>',
6122
+ ]);
6257
6123
  }
6258
6124
 
6259
6125
  /**
@@ -6262,14 +6128,14 @@ function printJSXFragment(node, path, options, print) {
6262
6128
  * opening tag's end, but the child is visited first). Pull those out of the
6263
6129
  * children and return a map from attribute index to the comments that precede it,
6264
6130
  * so the element printer can render them in the opening tag instead of the body.
6265
- * @param {AST.TSRXJSXElement} node
6131
+ * @param {AST.TSRXJSXElement | AST.JSXStyleElement} node
6266
6132
  * @returns {Map<number, AST.Comment[]>}
6267
6133
  */
6268
6134
  function collectOpeningTagComments(node) {
6269
6135
  /** @type {Map<number, AST.Comment[]>} */
6270
6136
  const byAttr = new Map();
6271
6137
  const openingElement = /** @type {AST.NodeWithLocation} */ (node.openingElement);
6272
- const attributes = /** @type {any[]} */ (node.openingElement?.attributes) ?? [];
6138
+ const attributes = node.openingElement?.attributes ?? [];
6273
6139
  if (!openingElement || attributes.length === 0 || !Array.isArray(node.children)) {
6274
6140
  return byAttr;
6275
6141
  }
@@ -6288,7 +6154,7 @@ function collectOpeningTagComments(node) {
6288
6154
  }
6289
6155
  }
6290
6156
  if (keep.length !== lead.length) {
6291
- /** @type {any} */ (child).leadingComments = keep;
6157
+ child.leadingComments = keep;
6292
6158
  }
6293
6159
  }
6294
6160
  if (collected.length === 0) return byAttr;
@@ -6338,18 +6204,48 @@ function printTemplateChildLeadingComments(child) {
6338
6204
  }
6339
6205
 
6340
6206
  /**
6341
- * Build doc parts for `//` line comments attached to an element body — trailing
6207
+ * Collect and print the comments that belong to an element/fragment body:
6208
+ * trailing comments after the last child (attached by the parser to the closing
6209
+ * tag's `leadingComments` or, when the last child is an `{expr}` container, to
6210
+ * `metadata.elementLeadingComments` positioned inside the body) and the comments
6211
+ * of a comment-only body (`innerComments`).
6212
+ * @param {AST.TSRXJSXElement | AST.TSRXJSXFragment} node
6213
+ * @param {AST.TSRXJSXElement['openingElement'] | AST.TSRXJSXFragment['openingFragment']} openingNode
6214
+ * @param {AST.TSRXJSXElement['closingElement'] | AST.TSRXJSXFragment['closingFragment']} closingNode
6215
+ * @returns {{ closingCommentDocs: Doc[], innerCommentDocs: Doc[] }}
6216
+ */
6217
+ function collectElementBodyCommentDocs(node, openingNode, closingNode) {
6218
+ const openingEnd = openingNode?.end;
6219
+ const bodyMetaComments = (node.metadata?.elementLeadingComments ?? []).filter(
6220
+ (/** @type {AST.Comment} */ comment) =>
6221
+ typeof comment.start === 'number' &&
6222
+ typeof openingEnd === 'number' &&
6223
+ comment.start >= openingEnd,
6224
+ );
6225
+ const trailingComments = [...(closingNode?.leadingComments ?? []), ...bodyMetaComments].sort(
6226
+ (/** @type {AST.Comment} */ a, /** @type {AST.Comment} */ b) =>
6227
+ /** @type {number} */ (a.start) - /** @type {number} */ (b.start),
6228
+ );
6229
+ const lastMeaningfulChild = [...(node.children ?? [])]
6230
+ .reverse()
6231
+ .find((child) => child.type !== 'JSXText' || child.value.trim());
6232
+ return {
6233
+ closingCommentDocs: printElementBodyComments(trailingComments, lastMeaningfulChild),
6234
+ innerCommentDocs: printElementBodyComments(node.innerComments),
6235
+ };
6236
+ }
6237
+
6238
+ /**
6239
+ * Build doc parts for comments attached to an element body — trailing
6342
6240
  * comments before `</tag>` (`closingElement.leadingComments`) or the comments of a
6343
- * comment-only element (`innerComments`). Block comments are intentionally skipped:
6344
- * they survive in the adjacent JSXText value and are already rendered as text, so
6345
- * emitting them here would duplicate them. Each comment is emitted on its own line
6241
+ * comment-only element (`innerComments`). Each comment is emitted on its own line
6346
6242
  * at the children indent.
6347
6243
  * @param {AST.Comment[] | null | undefined} commentList
6348
6244
  * @param {any} [previousNode]
6349
6245
  * @returns {Doc[]}
6350
6246
  */
6351
- function printElementBodyLineComments(commentList, previousNode = null) {
6352
- const comments = (commentList ?? []).filter((comment) => comment.type === 'Line');
6247
+ function printElementBodyComments(commentList, previousNode = null) {
6248
+ const comments = commentList ?? [];
6353
6249
  if (comments.length === 0) {
6354
6250
  return [];
6355
6251
  }
@@ -6363,7 +6259,9 @@ function printElementBodyLineComments(commentList, previousNode = null) {
6363
6259
  if (prev && getBlankLinesBetweenNodes(prev, comments[i]) > 0) {
6364
6260
  parts.push(hardline);
6365
6261
  }
6366
- parts.push('//' + comments[i].value);
6262
+ parts.push(
6263
+ comments[i].type === 'Line' ? '//' + comments[i].value : '/*' + comments[i].value + '*/',
6264
+ );
6367
6265
  prev = comments[i];
6368
6266
  }
6369
6267
  return parts;
@@ -6404,10 +6302,10 @@ function printJSXCodeBlock(node, path, options, print) {
6404
6302
  parts.push(path.call(print, 'render'));
6405
6303
  }
6406
6304
  // Trailing comments after the last statement/render inside the block.
6407
- const innerCommentDocs = printElementBodyLineComments(node.innerComments);
6305
+ const innerCommentDocs = printElementBodyComments(node.innerComments);
6408
6306
  if (innerCommentDocs.length > 0) {
6409
6307
  const lastNode = node.render ?? node.body[node.body.length - 1];
6410
- const firstComment = (node.innerComments ?? []).find((c) => c.type === 'Line');
6308
+ const firstComment = (node.innerComments ?? [])[0];
6411
6309
  if (lastNode && firstComment && getBlankLinesBetweenNodes(lastNode, firstComment) > 0) {
6412
6310
  parts.push(hardline);
6413
6311
  }
@@ -6488,539 +6386,3 @@ function printJSXElementName(node) {
6488
6386
  }
6489
6387
  return 'Unknown';
6490
6388
  }
6491
-
6492
- /**
6493
- * Print a member expression as simple string (for element tag names)
6494
- * @param {AST.Node} node - The node to print
6495
- * @param {RippleFormatOptions} options - Prettier options
6496
- * @param {boolean} [computed=false] - Whether the property is computed
6497
- * @returns {string}
6498
- */
6499
- function printMemberExpressionSimple(node, options, computed = false) {
6500
- if (node.type === 'JSXIdentifier') {
6501
- return node.name;
6502
- }
6503
-
6504
- if (node.type === 'JSXMemberExpression') {
6505
- return (
6506
- printMemberExpressionSimple(node.object, options) +
6507
- '.' +
6508
- printMemberExpressionSimple(node.property, options, true)
6509
- );
6510
- }
6511
-
6512
- if (node.type === 'JSXNamespacedName') {
6513
- return node.namespace.name + ':' + node.name.name;
6514
- }
6515
-
6516
- if (node.type === 'Identifier') {
6517
- return node.name;
6518
- }
6519
-
6520
- if (node.type === 'MemberExpression') {
6521
- const obj = printMemberExpressionSimple(node.object, options);
6522
- let prop;
6523
- if (node.computed) {
6524
- prop = '[' + printMemberExpressionSimple(node.property, options, true) + ']';
6525
- } else {
6526
- prop = '.' + printMemberExpressionSimple(node.property, options, true);
6527
- }
6528
- return obj + prop;
6529
- }
6530
-
6531
- if (node.type === 'Literal') {
6532
- return computed ? formatStringLiteral(node.value, options) : JSON.stringify(node.value);
6533
- }
6534
- return '';
6535
- }
6536
-
6537
- /**
6538
- * Check whether an attribute value can expand into multiline content.
6539
- * @param {AST.Expression | null | undefined} value - The attribute value node
6540
- * @param {boolean} [is_nested_in_object=false] - Whether this value is nested within an object literal
6541
- * @returns {boolean}
6542
- */
6543
- function is_attribute_value_breakable(value, is_nested_in_object = false) {
6544
- if (!value) return false;
6545
-
6546
- switch (value.type) {
6547
- case 'ConditionalExpression':
6548
- // Keep simple top-level ternary attributes inline when they fit.
6549
- // We only force-break when a conditional is nested in an object literal value.
6550
- return is_nested_in_object;
6551
- case 'ObjectExpression':
6552
- return value.properties.some(
6553
- (property) =>
6554
- property.type === 'Property' &&
6555
- is_attribute_value_breakable(/** @type {AST.Expression} */ (property.value), true),
6556
- );
6557
- default:
6558
- return false;
6559
- }
6560
- }
6561
-
6562
- /**
6563
- * Print a JSX element node
6564
- * @param {AST.Element} element - The element node
6565
- * @param {AstPath<AST.Element>} path - The AST path
6566
- * @param {RippleFormatOptions} options - Prettier options
6567
- * @param {PrintFn} print - Print callback
6568
- * @returns {Doc}
6569
- */
6570
- function printElement(element, path, options, print) {
6571
- const node = /** @type {any} */ (element);
6572
- const tagName = printMemberExpressionSimple(node.id, options);
6573
- const openingElement = /** @type {any} */ (node.openingElement);
6574
- /** @type {Doc} */
6575
- let typeArgsDoc = '';
6576
- if (openingElement?.typeArguments) {
6577
- typeArgsDoc = path.call(print, 'openingElement', 'typeArguments');
6578
- }
6579
- const elementLeadingComments = getElementLeadingComments(/** @type {any} */ (node));
6580
-
6581
- // `metadata.elementLeadingComments` may include comments that actually appear *inside* the element
6582
- // body (after the opening tag). Those must not be hoisted before the element.
6583
- const outerElementLeadingComments = elementLeadingComments.filter(
6584
- (/** @type {AST.Comment} */ comment) =>
6585
- typeof comment.start !== 'number' || comment.start < node.start,
6586
- );
6587
- const innerElementBodyComments = elementLeadingComments.filter(
6588
- (/** @type {AST.Comment} */ comment) =>
6589
- typeof comment.start === 'number' &&
6590
- comment.start >= /** @type {AST.NodeWithLocation} */ (node.openingElement).end &&
6591
- comment.start < node.end,
6592
- );
6593
- const metadataCommentParts =
6594
- outerElementLeadingComments.length > 0
6595
- ? createElementLevelCommentParts(outerElementLeadingComments)
6596
- : [];
6597
- const fallbackElementComments = [];
6598
- const shouldLiftTextLevelComments = outerElementLeadingComments.length === 0;
6599
-
6600
- const hasChildren = Array.isArray(node.children) && node.children.length > 0;
6601
- const hasInnerComments = Array.isArray(node.innerComments) && node.innerComments.length > 0;
6602
- const isSelfClosing = !!node.selfClosing;
6603
- const hasAttributes = Array.isArray(node.attributes) && node.attributes.length > 0;
6604
-
6605
- // Collect comments that the parser attached to children but actually belong inside
6606
- // the opening tag (positionally before openingElement.end). These should be printed
6607
- // as leading comments before the appropriate attribute, not lifted to element-level.
6608
- /** @type {Set<AST.Comment>} */
6609
- const openingTagCommentsSet = new Set();
6610
- if (hasChildren && node.openingElement) {
6611
- const openingEnd = /** @type {AST.NodeWithLocation} */ (node.openingElement).end;
6612
- for (const child of node.children) {
6613
- if (
6614
- (child.type === 'JSXExpressionContainer' || child.type === 'JSXText') &&
6615
- Array.isArray(child.leadingComments)
6616
- ) {
6617
- for (const comment of child.leadingComments) {
6618
- if (
6619
- typeof comment.start === 'number' &&
6620
- comment.start >= node.start &&
6621
- comment.start < openingEnd
6622
- ) {
6623
- openingTagCommentsSet.add(comment);
6624
- }
6625
- }
6626
- }
6627
- }
6628
- }
6629
-
6630
- // Build a map from attribute index to comments that should precede that attribute
6631
- const openingTagCommentsByAttrIndex = new Map();
6632
- if (openingTagCommentsSet.size > 0 && hasAttributes) {
6633
- const sortedOTC = [...openingTagCommentsSet].sort(
6634
- (a, b) => /** @type {number} */ (a.start) - /** @type {number} */ (b.start),
6635
- );
6636
- let commentIdx = 0;
6637
- for (let attrIdx = 0; attrIdx < node.attributes.length; attrIdx++) {
6638
- const attr = node.attributes[attrIdx];
6639
- const commentsForAttr = [];
6640
- while (
6641
- commentIdx < sortedOTC.length &&
6642
- /** @type {number} */ (sortedOTC[commentIdx].start) <
6643
- /** @type {AST.NodeWithLocation} */ (attr).start
6644
- ) {
6645
- commentsForAttr.push(sortedOTC[commentIdx]);
6646
- commentIdx++;
6647
- }
6648
- if (commentsForAttr.length > 0) {
6649
- openingTagCommentsByAttrIndex.set(attrIdx, commentsForAttr);
6650
- }
6651
- }
6652
- }
6653
-
6654
- if (isSelfClosing && !hasInnerComments && !hasAttributes) {
6655
- const elementDoc = group(['<', tagName, typeArgsDoc, ' />']);
6656
- return metadataCommentParts.length > 0 ? [...metadataCommentParts, elementDoc] : elementDoc;
6657
- }
6658
-
6659
- // Determine the line break type for attributes
6660
- // When singleAttributePerLine is true, force each attribute on its own line with hardline
6661
- // Otherwise, use line to allow collapsing when it fits
6662
- const attrLineBreak = options.singleAttributePerLine ? hardline : line;
6663
-
6664
- const shouldUseSelfClosingSyntax = isSelfClosing || (!hasChildren && !hasInnerComments);
6665
-
6666
- const hasOpeningTagComments = openingTagCommentsSet.size > 0;
6667
- let attrIndex = 0;
6668
- let hasBreakingAttribute = false;
6669
- const attrDocs = hasAttributes
6670
- ? path.map((attrPath) => {
6671
- const idx = attrIndex++;
6672
- const commentsForAttr = openingTagCommentsByAttrIndex.get(idx);
6673
- /** @type {Doc[]} */
6674
- const parts = [];
6675
- if (commentsForAttr) {
6676
- for (const comment of commentsForAttr) {
6677
- // Line comments (//) consume the rest of the line, so they must
6678
- // use hardline to force a break. Block comments can use normal breaks.
6679
- if (comment.type === 'Line') {
6680
- parts.push(hardline);
6681
- parts.push('//' + comment.value);
6682
- } else if (comment.type === 'Block') {
6683
- parts.push(attrLineBreak);
6684
- parts.push('/*' + comment.value + '*/');
6685
- }
6686
- }
6687
- }
6688
- parts.push(attrLineBreak);
6689
- const attrDoc = print(attrPath);
6690
- parts.push(attrDoc);
6691
- const attr_node = /** @type {ESTreeJSX.JSXAttribute | ESTreeJSX.JSXSpreadAttribute} */ (
6692
- /** @type {unknown} */ (attrPath.node)
6693
- );
6694
- if (
6695
- !hasBreakingAttribute &&
6696
- (willBreak(attrDoc) ||
6697
- (attr_node.type === 'JSXAttribute' &&
6698
- is_attribute_value_breakable(/** @type {any} */ (attr_node.value))))
6699
- ) {
6700
- hasBreakingAttribute = true;
6701
- }
6702
- return parts;
6703
- }, 'attributes')
6704
- : [];
6705
- const shouldForceBreak = hasOpeningTagComments || hasBreakingAttribute;
6706
- const openingTagAlwaysBreaks =
6707
- (hasAttributes && options.singleAttributePerLine) || shouldForceBreak;
6708
- const openingTag = group([
6709
- '<',
6710
- tagName,
6711
- typeArgsDoc,
6712
- hasAttributes
6713
- ? indent([
6714
- ...attrDocs,
6715
- // Force the group to break when there are line comments in the opening tag,
6716
- // or when any attribute value would break (e.g. multiline objects, ternaries).
6717
- // This ensures attributes are broken onto separate lines rather than breaking
6718
- // expression values inline on the same line as the tag name.
6719
- ...(shouldForceBreak ? [breakParent] : []),
6720
- ])
6721
- : '',
6722
- // Add line break opportunity before > or />
6723
- // Use line for self-closing (keeps space), softline for non-self-closing when attributes present
6724
- // When bracketSameLine is true, don't add line break for non-self-closing elements
6725
- shouldUseSelfClosingSyntax
6726
- ? hasAttributes
6727
- ? line
6728
- : ''
6729
- : hasAttributes && !options.bracketSameLine
6730
- ? softline
6731
- : '',
6732
- shouldUseSelfClosingSyntax ? (hasAttributes ? '/>' : ' />') : '>',
6733
- ]);
6734
-
6735
- if (!hasChildren) {
6736
- if (!hasInnerComments) {
6737
- return metadataCommentParts.length > 0 ? [...metadataCommentParts, openingTag] : openingTag;
6738
- }
6739
-
6740
- /** @type {Doc[]} */
6741
- const innerParts = [];
6742
- for (const comment of node.innerComments ?? []) {
6743
- if (comment.type === 'Line') {
6744
- innerParts.push('//' + comment.value);
6745
- innerParts.push(hardline);
6746
- } else if (comment.type === 'Block') {
6747
- innerParts.push('/*' + comment.value + '*/');
6748
- innerParts.push(hardline);
6749
- }
6750
- }
6751
-
6752
- if (innerParts.length > 0 && innerParts[innerParts.length - 1] === hardline) {
6753
- innerParts.pop();
6754
- }
6755
-
6756
- const closingTag = ['</', tagName, '>'];
6757
- const elementOutput = group([
6758
- openingTag,
6759
- indent([hardline, ...innerParts]),
6760
- hardline,
6761
- closingTag,
6762
- ]);
6763
- return metadataCommentParts.length > 0
6764
- ? [...metadataCommentParts, elementOutput]
6765
- : elementOutput;
6766
- }
6767
-
6768
- // Has children - use unified children processing
6769
- // Build children with whitespace preservation
6770
- /** @type {Doc[]} */
6771
- const finalChildren = [];
6772
- const sortedInnerElementBodyComments =
6773
- innerElementBodyComments.length > 0
6774
- ? innerElementBodyComments.slice().sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
6775
- : [];
6776
- let innerElementBodyCommentIndex = 0;
6777
-
6778
- for (let i = 0; i < node.children.length; i++) {
6779
- const currentChild = node.children[i];
6780
- const nextChild = node.children[i + 1];
6781
-
6782
- // Insert any element-body comments that appear before this child.
6783
- if (innerElementBodyCommentIndex < sortedInnerElementBodyComments.length) {
6784
- const currentChildStart = typeof currentChild.start === 'number' ? currentChild.start : null;
6785
- if (currentChildStart != null) {
6786
- const commentsBefore = [];
6787
- while (innerElementBodyCommentIndex < sortedInnerElementBodyComments.length) {
6788
- /** @type {AST.Comment} */
6789
- const comment = sortedInnerElementBodyComments[innerElementBodyCommentIndex];
6790
- if (typeof comment.start !== 'number' || comment.start >= currentChildStart) {
6791
- break;
6792
- }
6793
- commentsBefore.push(comment);
6794
- innerElementBodyCommentIndex++;
6795
- }
6796
- if (commentsBefore.length > 0) {
6797
- if (finalChildren.length > 0) {
6798
- finalChildren.push(hardline);
6799
- }
6800
- finalChildren.push(...createElementLevelCommentPartsTrimmed(commentsBefore));
6801
- finalChildren.push(hardline);
6802
- }
6803
- }
6804
- }
6805
-
6806
- const isTextLikeChild =
6807
- currentChild.type === 'JSXExpressionContainer' || currentChild.type === 'JSXText';
6808
- const hasTextLeadingComments =
6809
- shouldLiftTextLevelComments &&
6810
- isTextLikeChild &&
6811
- Array.isArray(currentChild.leadingComments) &&
6812
- currentChild.leadingComments.length > 0;
6813
- const currentChildAny = /** @type {any} */ (currentChild);
6814
- const rawExpressionLeadingComments =
6815
- isTextLikeChild && Array.isArray(currentChildAny.expression?.leadingComments)
6816
- ? currentChildAny.expression.leadingComments
6817
- : null;
6818
- const elementBodyLeadingComments =
6819
- hasTextLeadingComments && node.openingElement
6820
- ? /** @type {AST.Comment[]} */ (currentChild.leadingComments).filter(
6821
- (comment) =>
6822
- comment.context?.containerId === node.metadata?.commentContainerId &&
6823
- comment.context?.beforeMeaningfulChild &&
6824
- typeof comment.start === 'number' &&
6825
- comment.start >= /** @type {AST.NodeWithLocation} */ (node.openingElement).end &&
6826
- comment.start < /** @type {AST.NodeWithLocation} */ (currentChild).start,
6827
- )
6828
- : [];
6829
-
6830
- if (hasTextLeadingComments) {
6831
- for (let j = 0; j < /** @type {AST.Comment[]} */ (currentChild.leadingComments).length; j++) {
6832
- const comment = /** @type {AST.Comment[]} */ (currentChild.leadingComments)[j];
6833
- // Don't lift comments that belong inside the opening tag (handled in attribute section)
6834
- if (!openingTagCommentsSet.has(comment) && !elementBodyLeadingComments.includes(comment)) {
6835
- fallbackElementComments.push(comment);
6836
- }
6837
- }
6838
- }
6839
-
6840
- const childPrintArgs = /** @type {PrintArgs} */ ({});
6841
- if (hasTextLeadingComments) {
6842
- childPrintArgs.suppressLeadingComments = true;
6843
- }
6844
- if (rawExpressionLeadingComments && rawExpressionLeadingComments.length > 0) {
6845
- childPrintArgs.suppressExpressionLeadingComments = true;
6846
- }
6847
-
6848
- const printedChild =
6849
- Object.keys(childPrintArgs).length > 0
6850
- ? path.call((childPath) => print(childPath, childPrintArgs), 'children', i)
6851
- : path.call(print, 'children', i);
6852
-
6853
- const childLeadingCommentParts =
6854
- elementBodyLeadingComments.length > 0
6855
- ? createElementLevelCommentParts(elementBodyLeadingComments)
6856
- : rawExpressionLeadingComments && rawExpressionLeadingComments.length > 0
6857
- ? createElementLevelCommentParts(rawExpressionLeadingComments)
6858
- : null;
6859
- const childDoc = childLeadingCommentParts
6860
- ? [...childLeadingCommentParts, printedChild]
6861
- : printedChild;
6862
- finalChildren.push(childDoc);
6863
-
6864
- // Insert element-body comments that fall between this child and the next child (or the closing tag).
6865
- let insertedBodyCommentsBetween = false;
6866
- if (innerElementBodyCommentIndex < sortedInnerElementBodyComments.length) {
6867
- const currentChildEnd = /** @type {AST.NodeWithLocation} */ (currentChild).end;
6868
- const nextChildStart =
6869
- nextChild && typeof nextChild.start === 'number' ? nextChild.start : null;
6870
- const commentsBetween = [];
6871
- while (innerElementBodyCommentIndex < sortedInnerElementBodyComments.length) {
6872
- /** @type {AST.Comment} */
6873
- const comment = sortedInnerElementBodyComments[innerElementBodyCommentIndex];
6874
- if (typeof comment.start !== 'number') {
6875
- innerElementBodyCommentIndex++;
6876
- continue;
6877
- }
6878
- if (comment.start < currentChildEnd) {
6879
- break;
6880
- }
6881
- if (nextChildStart != null && comment.start >= nextChildStart) {
6882
- break;
6883
- }
6884
- commentsBetween.push(comment);
6885
- innerElementBodyCommentIndex++;
6886
- }
6887
- if (commentsBetween.length > 0) {
6888
- const firstComment = commentsBetween[0];
6889
- const lastComment = commentsBetween[commentsBetween.length - 1];
6890
-
6891
- // Preserve any blank line(s) that existed between the previous child and the comment block.
6892
- const blankLinesBefore = getBlankLinesBetweenNodes(currentChild, firstComment);
6893
- finalChildren.push(hardline);
6894
- if (blankLinesBefore > 0) {
6895
- finalChildren.push(hardline);
6896
- }
6897
-
6898
- finalChildren.push(...createElementLevelCommentPartsTrimmed(commentsBetween));
6899
-
6900
- if (nextChild) {
6901
- // Preserve any blank line(s) that existed between the comment block and the next child.
6902
- const blankLinesAfter = getBlankLinesBetweenNodes(lastComment, nextChild);
6903
- finalChildren.push(hardline);
6904
- if (blankLinesAfter > 0) {
6905
- finalChildren.push(hardline);
6906
- }
6907
- }
6908
- insertedBodyCommentsBetween = true;
6909
- }
6910
- }
6911
-
6912
- if (nextChild) {
6913
- if (insertedBodyCommentsBetween) {
6914
- continue;
6915
- }
6916
- const whitespaceTarget =
6917
- nextChild.leadingComments && nextChild.leadingComments.length > 0
6918
- ? nextChild.leadingComments[0]
6919
- : nextChild;
6920
- const whitespaceLinesCount = getBlankLinesBetweenNodes(currentChild, whitespaceTarget);
6921
- const isTextOrExpressionChild =
6922
- currentChild.type === 'JSXExpressionContainer' ||
6923
- currentChild.type === 'JSXText' ||
6924
- nextChild.type === 'JSXExpressionContainer' ||
6925
- nextChild.type === 'JSXText';
6926
-
6927
- if (whitespaceLinesCount > 0) {
6928
- finalChildren.push(hardline);
6929
- finalChildren.push(hardline);
6930
- } else if (!isTextOrExpressionChild && shouldAddBlankLine(currentChild, nextChild)) {
6931
- finalChildren.push(hardline);
6932
- finalChildren.push(hardline);
6933
- } else {
6934
- finalChildren.push(hardline);
6935
- }
6936
- }
6937
- }
6938
-
6939
- // Collect comments attached to the closing element (comments that appear after the last child
6940
- // but before the closing tag, e.g. `</div>`). The parser attaches these as leadingComments
6941
- // on either the closingElement node or the closingElement.name node depending on context.
6942
- const closingElementComments = [
6943
- ...(node.closingElement && Array.isArray(node.closingElement.leadingComments)
6944
- ? node.closingElement.leadingComments
6945
- : []),
6946
- ...(node.closingElement &&
6947
- node.closingElement.name &&
6948
- Array.isArray(node.closingElement.name.leadingComments)
6949
- ? node.closingElement.name.leadingComments
6950
- : []),
6951
- ];
6952
-
6953
- if (closingElementComments.length > 0) {
6954
- const lastChild = node.children[node.children.length - 1];
6955
- if (lastChild) {
6956
- const blankLinesBefore = getBlankLinesBetweenNodes(lastChild, closingElementComments[0]);
6957
- finalChildren.push(hardline);
6958
- if (blankLinesBefore > 0) {
6959
- finalChildren.push(hardline);
6960
- }
6961
- }
6962
- finalChildren.push(...createElementLevelCommentPartsTrimmed(closingElementComments));
6963
- }
6964
-
6965
- const fallbackCommentParts =
6966
- fallbackElementComments.length > 0
6967
- ? createElementLevelCommentParts(fallbackElementComments)
6968
- : [];
6969
- const leadingCommentParts =
6970
- metadataCommentParts.length > 0
6971
- ? [...metadataCommentParts, ...fallbackCommentParts]
6972
- : fallbackCommentParts;
6973
-
6974
- const closingTag = ['</', tagName, '>'];
6975
- let elementOutput;
6976
- const shouldTryInlineMultipleChildren =
6977
- !openingTagAlwaysBreaks &&
6978
- fallbackCommentParts.length === 0 &&
6979
- closingElementComments.length === 0 &&
6980
- shouldTryInlineMultipleTextChildren(node);
6981
-
6982
- if (finalChildren.length === 1) {
6983
- const child = finalChildren[0];
6984
- const firstChild = /** @type {any} */ (node.children[0]);
6985
- const isNonSelfClosingElement =
6986
- firstChild && firstChild.type === 'Element' && !firstChild.selfClosing;
6987
- const isElementChild = firstChild && firstChild.type === 'Element';
6988
- const isRawTextChild =
6989
- firstChild && firstChild.type === 'Text' && typeof firstChild.raw === 'string';
6990
-
6991
- if (
6992
- (typeof child === 'string' || isRawTextChild) &&
6993
- shouldInlineSingleChild(node, firstChild, child)
6994
- ) {
6995
- elementOutput = openingTagAlwaysBreaks
6996
- ? [openingTag, indent([hardline, child]), hardline, closingTag]
6997
- : conditionalGroup([
6998
- group([openingTag, child, closingTag]),
6999
- [openingTag, indent([hardline, child]), hardline, closingTag],
7000
- ]);
7001
- } else if (
7002
- child &&
7003
- typeof child === 'object' &&
7004
- !isNonSelfClosingElement &&
7005
- shouldInlineSingleChild(node, firstChild, child)
7006
- ) {
7007
- if (isElementChild && hasAttributes) {
7008
- elementOutput = [openingTag, indent([hardline, child]), hardline, closingTag];
7009
- } else {
7010
- elementOutput = group([openingTag, indent([softline, child]), softline, closingTag]);
7011
- }
7012
- } else {
7013
- elementOutput = [openingTag, indent([hardline, ...finalChildren]), hardline, closingTag];
7014
- }
7015
- } else if (shouldTryInlineMultipleChildren) {
7016
- const inlineChildren = path.map(print, 'children');
7017
- elementOutput = conditionalGroup([
7018
- group([openingTag, ...inlineChildren, closingTag]),
7019
- [openingTag, indent([hardline, ...finalChildren]), hardline, closingTag],
7020
- ]);
7021
- } else {
7022
- elementOutput = group([openingTag, indent([hardline, ...finalChildren]), hardline, closingTag]);
7023
- }
7024
-
7025
- return leadingCommentParts.length > 0 ? [...leadingCommentParts, elementOutput] : elementOutput;
7026
- }
package/src/index.test.js CHANGED
@@ -2753,6 +2753,104 @@ function test() {
2753
2753
  expect(result).toBeWithNewline(expected);
2754
2754
  });
2755
2755
 
2756
+ it('should preserve template comments and blank lines from unformatted input', async () => {
2757
+ const input = `function TodoList() @{
2758
+ <>
2759
+ /* world 0 */
2760
+ // hello
2761
+ /* world 1 */
2762
+ <ul>
2763
+ // hello
2764
+ /* world 2 */
2765
+
2766
+ </ul>
2767
+
2768
+ <ul>
2769
+ // hello
2770
+ /* world 3 */
2771
+ // hello
2772
+ </ul>
2773
+ /* world 4 */
2774
+ </>
2775
+ }`;
2776
+
2777
+ const expected = `function TodoList() @{
2778
+ <>
2779
+ /* world 0 */
2780
+ // hello
2781
+ /* world 1 */
2782
+ <ul>
2783
+ // hello
2784
+ /* world 2 */
2785
+ </ul>
2786
+
2787
+ <ul>
2788
+ // hello
2789
+ /* world 3 */
2790
+ // hello
2791
+ </ul>
2792
+ /* world 4 */
2793
+ </>
2794
+ }`;
2795
+
2796
+ const result = await format(input, { singleQuote: true });
2797
+ expect(result).toBeWithNewline(expected);
2798
+ // Reformatting the output must be stable.
2799
+ expect(await format(result, { singleQuote: true })).toBeWithNewline(expected);
2800
+ });
2801
+
2802
+ it('should preserve block comments before a closing fragment', async () => {
2803
+ const expected = `function App() @{
2804
+ <>
2805
+ <span>{'child'}</span>
2806
+
2807
+ /* block comment */
2808
+ </>
2809
+ }`;
2810
+
2811
+ const result = await format(expected, { singleQuote: true });
2812
+ expect(result).toBeWithNewline(expected);
2813
+ });
2814
+
2815
+ it('should preserve a blank line before a trailing block comment in elements', async () => {
2816
+ const expected = `function App() {
2817
+ <div>
2818
+ <span>{'child'}</span>
2819
+
2820
+ /* block comment */
2821
+ </div>
2822
+ }`;
2823
+
2824
+ const result = await format(expected, { singleQuote: true });
2825
+ expect(result).toBeWithNewline(expected);
2826
+ });
2827
+
2828
+ it('should preserve a comment-only fragment body', async () => {
2829
+ const expected = `function App() @{
2830
+ <>
2831
+ /* only */
2832
+ </>
2833
+ }`;
2834
+
2835
+ const result = await format(expected, { singleQuote: true });
2836
+ expect(result).toBeWithNewline(expected);
2837
+ });
2838
+
2839
+ it('should keep blank lines around comments between template siblings', async () => {
2840
+ const expected = `function App() @{
2841
+ <>
2842
+ <ul></ul>
2843
+
2844
+ /* between */
2845
+
2846
+ <ul></ul>
2847
+ </>
2848
+ }`;
2849
+
2850
+ const result = await format(expected, { singleQuote: true });
2851
+ expect(result).toBeWithNewline(expected);
2852
+ });
2853
+
2756
2854
  it('should preserve trailing comments in function parameters', async () => {
2757
2855
  const expected = `function test(
2758
2856
  // comment in params