@herb-tools/formatter 0.8.1 → 0.8.2

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.
@@ -13,6 +13,8 @@ import {
13
13
  isERBNode,
14
14
  isCommentNode,
15
15
  isERBControlFlowNode,
16
+ isERBCommentNode,
17
+ isERBOutputNode,
16
18
  filterNodes,
17
19
  } from "@herb-tools/core"
18
20
 
@@ -47,9 +49,6 @@ import {
47
49
  FORMATTABLE_ATTRIBUTES,
48
50
  INLINE_ELEMENTS,
49
51
  SPACEABLE_CONTAINERS,
50
- SPACING_THRESHOLD,
51
- TIGHT_GROUP_CHILDREN,
52
- TIGHT_GROUP_PARENTS,
53
52
  TOKEN_LIST_ATTRIBUTES,
54
53
  } from "./format-helpers.js"
55
54
 
@@ -122,6 +121,11 @@ export class FormatPrinter extends Printer {
122
121
  private currentAttributeName: string | null = null
123
122
  private elementStack: HTMLElementNode[] = []
124
123
  private elementFormattingAnalysis = new Map<HTMLElementNode, ElementFormattingAnalysis>()
124
+ private nodeIsMultiline = new Map<Node, boolean>()
125
+ private stringLineCount: number = 0
126
+ private tagGroupsCache = new Map<Node[], Map<number, { tagName: string; groupStart: number; groupEnd: number }>>()
127
+ private allSingleLineCache = new Map<Node[], boolean>()
128
+
125
129
 
126
130
  public source: string
127
131
 
@@ -141,6 +145,10 @@ export class FormatPrinter extends Printer {
141
145
  // TODO: refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
142
146
  this.lines = []
143
147
  this.indentLevel = 0
148
+ this.stringLineCount = 0
149
+ this.nodeIsMultiline.clear()
150
+ this.tagGroupsCache.clear()
151
+ this.allSingleLineCache.clear()
144
152
 
145
153
  this.visit(node)
146
154
 
@@ -179,19 +187,32 @@ export class FormatPrinter extends Printer {
179
187
  private capture(callback: () => void): string[] {
180
188
  const previousLines = this.lines
181
189
  const previousInlineMode = this.inlineMode
190
+ const previousStringLineCount = this.stringLineCount
182
191
 
183
192
  this.lines = []
193
+ this.stringLineCount = 0
184
194
 
185
195
  try {
186
196
  callback()
187
-
188
197
  return this.lines
189
198
  } finally {
190
199
  this.lines = previousLines
191
200
  this.inlineMode = previousInlineMode
201
+ this.stringLineCount = previousStringLineCount
192
202
  }
193
203
  }
194
204
 
205
+ /**
206
+ * Track a boundary node's multiline status by comparing line count before/after rendering.
207
+ */
208
+ private trackBoundary(node: Node, callback: () => void): void {
209
+ const startLineCount = this.stringLineCount
210
+ callback()
211
+ const endLineCount = this.stringLineCount
212
+
213
+ this.nodeIsMultiline.set(node, (endLineCount - startLineCount) > 1)
214
+ }
215
+
195
216
  /**
196
217
  * Capture all nodes that would be visited during a callback
197
218
  * Returns a flat list of all nodes without generating any output
@@ -232,6 +253,7 @@ export class FormatPrinter extends Printer {
232
253
  */
233
254
  private push(line: string) {
234
255
  this.lines.push(line)
256
+ this.stringLineCount++
235
257
  }
236
258
 
237
259
  /**
@@ -288,6 +310,96 @@ export class FormatPrinter extends Printer {
288
310
  return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode))
289
311
  }
290
312
 
313
+ /**
314
+ * Check if a node will render as multiple lines when formatted.
315
+ */
316
+ private isMultilineElement(node: Node): boolean {
317
+ if (isNode(node, ERBContentNode)) {
318
+ return (node.content?.value || "").includes("\n")
319
+ }
320
+
321
+ if (isNode(node, HTMLElementNode) && isContentPreserving(node)) {
322
+ return true
323
+ }
324
+
325
+ const tracked = this.nodeIsMultiline.get(node)
326
+
327
+ if (tracked !== undefined) {
328
+ return tracked
329
+ }
330
+
331
+ return false
332
+ }
333
+
334
+ /**
335
+ * Get a grouping key for a node (tag name for HTML, ERB type for ERB)
336
+ */
337
+ private getGroupingKey(node: Node): string | null {
338
+ if (isNode(node, HTMLElementNode)) {
339
+ return getTagName(node)
340
+ }
341
+
342
+ if (isERBOutputNode(node)) return "erb-output"
343
+ if (isERBCommentNode(node)) return "erb-comment"
344
+ if (isERBNode(node)) return "erb-code"
345
+
346
+ return null
347
+ }
348
+
349
+ /**
350
+ * Detect groups of consecutive same-tag/same-type single-line elements
351
+ * Returns a map of index -> group info for efficient lookup
352
+ */
353
+ private detectTagGroups(siblings: Node[]): Map<number, { tagName: string; groupStart: number; groupEnd: number }> {
354
+ const cached = this.tagGroupsCache.get(siblings)
355
+ if (cached) return cached
356
+
357
+ const groupMap = new Map<number, { tagName: string; groupStart: number; groupEnd: number }>()
358
+ const meaningfulNodes: Array<{ index: number; groupKey: string }> = []
359
+
360
+ for (let i = 0; i < siblings.length; i++) {
361
+ const node = siblings[i]
362
+
363
+ if (!this.isMultilineElement(node)) {
364
+ const groupKey = this.getGroupingKey(node)
365
+
366
+ if (groupKey) {
367
+ meaningfulNodes.push({ index: i, groupKey })
368
+ }
369
+ }
370
+ }
371
+
372
+ let groupStart = 0
373
+
374
+ while (groupStart < meaningfulNodes.length) {
375
+ const startGroupKey = meaningfulNodes[groupStart].groupKey
376
+ let groupEnd = groupStart
377
+
378
+ while (groupEnd + 1 < meaningfulNodes.length && meaningfulNodes[groupEnd + 1].groupKey === startGroupKey) {
379
+ groupEnd++
380
+ }
381
+
382
+ if (groupEnd > groupStart) {
383
+ const groupStartIndex = meaningfulNodes[groupStart].index
384
+ const groupEndIndex = meaningfulNodes[groupEnd].index
385
+
386
+ for (let i = groupStart; i <= groupEnd; i++) {
387
+ groupMap.set(meaningfulNodes[i].index, {
388
+ tagName: startGroupKey,
389
+ groupStart: groupStartIndex,
390
+ groupEnd: groupEndIndex
391
+ })
392
+ }
393
+ }
394
+
395
+ groupStart = groupEnd + 1
396
+ }
397
+
398
+ this.tagGroupsCache.set(siblings, groupMap)
399
+
400
+ return groupMap
401
+ }
402
+
291
403
  /**
292
404
  * Determine if spacing should be added between sibling elements
293
405
  *
@@ -303,69 +415,73 @@ export class FormatPrinter extends Printer {
303
415
  * @param hasExistingSpacing - Whether user-added spacing already exists
304
416
  * @returns true if spacing should be added before the current element
305
417
  */
306
- private shouldAddSpacingBetweenSiblings(
307
- parentElement: HTMLElementNode | null,
308
- siblings: Node[],
309
- currentIndex: number,
310
- hasExistingSpacing: boolean
311
- ): boolean {
312
- if (hasExistingSpacing) {
418
+ private shouldAddSpacingBetweenSiblings(parentElement: HTMLElementNode | null, siblings: Node[], currentIndex: number): boolean {
419
+ const currentNode = siblings[currentIndex]
420
+ const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex)
421
+ const previousNode = previousMeaningfulIndex !== -1 ? siblings[previousMeaningfulIndex] : null
422
+
423
+ if (previousNode && (isNode(previousNode, XMLDeclarationNode) || isNode(previousNode, HTMLDoctypeNode))) {
313
424
  return true
314
425
  }
315
426
 
316
427
  const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "")
317
428
 
318
- if (hasMixedContent) {
319
- return false
320
- }
429
+ if (hasMixedContent) return false
321
430
 
322
- const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child))
431
+ const isCurrentComment = isCommentNode(currentNode)
432
+ const isPreviousComment = previousNode ? isCommentNode(previousNode) : false
433
+ const isCurrentMultiline = this.isMultilineElement(currentNode)
434
+ const isPreviousMultiline = previousNode ? this.isMultilineElement(previousNode) : false
323
435
 
324
- if (meaningfulSiblings.length < SPACING_THRESHOLD) {
436
+ if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
437
+ return isPreviousMultiline && isCurrentMultiline
438
+ }
439
+
440
+ if (isPreviousComment && isCurrentComment) {
325
441
  return false
326
442
  }
327
443
 
444
+ if (isCurrentMultiline || isPreviousMultiline) {
445
+ return true
446
+ }
447
+
448
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child))
328
449
  const parentTagName = parentElement ? getTagName(parentElement) : null
450
+ const isSpaceableContainer = !parentTagName || SPACEABLE_CONTAINERS.has(parentTagName)
451
+ const tagGroups = this.detectTagGroups(siblings)
329
452
 
330
- if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
331
- return false
453
+ const cached = this.allSingleLineCache.get(siblings)
454
+ let allSingleLineHTMLElements: boolean
455
+ if (cached !== undefined) {
456
+ allSingleLineHTMLElements = cached
457
+ } else {
458
+ allSingleLineHTMLElements = meaningfulSiblings.every(node => isNode(node, HTMLElementNode) && !this.isMultilineElement(node))
459
+ this.allSingleLineCache.set(siblings, allSingleLineHTMLElements)
332
460
  }
333
461
 
334
- const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName))
335
-
336
462
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
337
463
  return false
338
464
  }
339
465
 
340
- const currentNode = siblings[currentIndex]
341
- const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex)
342
- const isCurrentComment = isCommentNode(currentNode)
466
+ const currentGroup = tagGroups.get(currentIndex)
467
+ const previousGroup = previousNode ? tagGroups.get(previousMeaningfulIndex) : undefined
343
468
 
344
- if (previousMeaningfulIndex !== -1) {
345
- const previousNode = siblings[previousMeaningfulIndex]
346
- const isPreviousComment = isCommentNode(previousNode)
469
+ if (currentGroup && previousGroup && currentGroup.groupStart === previousGroup.groupStart && currentGroup.groupEnd === previousGroup.groupEnd) {
470
+ return false
471
+ }
347
472
 
348
- if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
349
- return false
350
- }
473
+ if (previousGroup && previousGroup.groupEnd === previousMeaningfulIndex) {
474
+ return true
475
+ }
351
476
 
352
- if (isPreviousComment && isCurrentComment) {
353
- return false
354
- }
477
+ if (allSingleLineHTMLElements && tagGroups.size === 0) {
478
+ return false
355
479
  }
356
480
 
357
481
  if (isNode(currentNode, HTMLElementNode)) {
358
482
  const currentTagName = getTagName(currentNode)
359
483
 
360
- if (INLINE_ELEMENTS.has(currentTagName)) {
361
- return false
362
- }
363
-
364
- if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
365
- return false
366
- }
367
-
368
- if (currentTagName === 'a' && parentTagName === 'nav') {
484
+ if (currentTagName && INLINE_ELEMENTS.has(currentTagName)) {
369
485
  return false
370
486
  }
371
487
  }
@@ -652,7 +768,7 @@ export class FormatPrinter extends Printer {
652
768
  return
653
769
  }
654
770
 
655
- let lastWasMeaningful = false
771
+ let lastMeaningfulNode: Node | null = null
656
772
  let hasHandledSpacing = false
657
773
 
658
774
  for (let i = 0; i < children.length; i++) {
@@ -670,21 +786,27 @@ export class FormatPrinter extends Printer {
670
786
 
671
787
  if (shouldAppendToLastLine(child, children, i)) {
672
788
  this.appendChildToLastLine(child, children, i)
673
- lastWasMeaningful = true
789
+ lastMeaningfulNode = child
674
790
  hasHandledSpacing = false
675
791
  continue
676
792
  }
677
793
 
678
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
679
- this.push("")
680
- }
794
+ if (!isNonWhitespaceNode(child)) continue
681
795
 
796
+ const childStartLine = this.stringLineCount
682
797
  this.visit(child)
683
798
 
684
- if (isNonWhitespaceNode(child)) {
685
- lastWasMeaningful = true
686
- hasHandledSpacing = false
799
+ if (lastMeaningfulNode && !hasHandledSpacing) {
800
+ const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings( null, children, i)
801
+
802
+ if (shouldAddSpacing) {
803
+ this.lines.splice(childStartLine, 0, "")
804
+ this.stringLineCount++
805
+ }
687
806
  }
807
+
808
+ lastMeaningfulNode = child
809
+ hasHandledSpacing = false
688
810
  }
689
811
  }
690
812
 
@@ -692,23 +814,23 @@ export class FormatPrinter extends Printer {
692
814
  this.elementStack.push(node)
693
815
  this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node))
694
816
 
695
- if (this.inlineMode && node.is_void && this.indentLevel === 0) {
696
- const openTag = this.capture(() => this.visit(node.open_tag)).join('')
697
- this.pushToLastLine(openTag)
698
- this.elementStack.pop()
699
-
700
- return
701
- }
817
+ this.trackBoundary(node, () => {
818
+ if (this.inlineMode && node.is_void && this.indentLevel === 0) {
819
+ const openTag = this.capture(() => this.visit(node.open_tag)).join('')
820
+ this.pushToLastLine(openTag)
821
+ return
822
+ }
702
823
 
703
- this.visit(node.open_tag)
824
+ this.visit(node.open_tag)
704
825
 
705
- if (node.body.length > 0) {
706
- this.visitHTMLElementBody(node.body, node)
707
- }
826
+ if (node.body.length > 0) {
827
+ this.visitHTMLElementBody(node.body, node)
828
+ }
708
829
 
709
- if (node.close_tag) {
710
- this.visit(node.close_tag)
711
- }
830
+ if (node.close_tag) {
831
+ this.visit(node.close_tag)
832
+ }
833
+ })
712
834
 
713
835
  this.elementStack.pop()
714
836
  }
@@ -883,21 +1005,22 @@ export class FormatPrinter extends Printer {
883
1005
 
884
1006
  /**
885
1007
  * Visit element children with intelligent spacing logic
1008
+ *
1009
+ * Tracks line positions and immediately splices blank lines after rendering each child.
886
1010
  */
887
1011
  private visitElementChildren(body: Node[], parentElement: HTMLElementNode | null) {
888
- let lastWasMeaningful = false
1012
+ let lastMeaningfulNode: Node | null = null
889
1013
  let hasHandledSpacing = false
890
1014
 
891
- for (let i = 0; i < body.length; i++) {
892
- const child = body[i]
1015
+ for (let index = 0; index < body.length; index++) {
1016
+ const child = body[index]
893
1017
 
894
1018
  if (isNode(child, HTMLTextNode)) {
895
1019
  const isWhitespaceOnly = child.content.trim() === ""
896
1020
 
897
1021
  if (isWhitespaceOnly) {
898
- const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1])
899
- const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1])
900
-
1022
+ const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(body[index - 1])
1023
+ const hasNextNonWhitespace = index < body.length - 1 && isNonWhitespaceNode(body[index + 1])
901
1024
  const hasMultipleNewlines = child.content.includes('\n\n')
902
1025
 
903
1026
  if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
@@ -909,26 +1032,12 @@ export class FormatPrinter extends Printer {
909
1032
  }
910
1033
  }
911
1034
 
912
- if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
913
- const element = body[i - 1]
914
- const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2)
915
-
916
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(
917
- parentElement,
918
- body,
919
- i,
920
- hasExistingSpacing
921
- )
922
-
923
- if (shouldAddSpacing) {
924
- this.push("")
925
- }
926
- }
1035
+ if (!isNonWhitespaceNode(child)) continue
927
1036
 
928
1037
  let hasTrailingHerbDisable = false
929
1038
 
930
1039
  if (isNode(child, HTMLElementNode) && child.close_tag) {
931
- for (let j = i + 1; j < body.length; j++) {
1040
+ for (let j = index + 1; j < body.length; j++) {
932
1041
  const nextChild = body[j]
933
1042
 
934
1043
  if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
@@ -938,8 +1047,18 @@ export class FormatPrinter extends Printer {
938
1047
  if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
939
1048
  hasTrailingHerbDisable = true
940
1049
 
1050
+ const childStartLine = this.stringLineCount
941
1051
  this.visit(child)
942
1052
 
1053
+ if (lastMeaningfulNode && !hasHandledSpacing) {
1054
+ const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index)
1055
+
1056
+ if (shouldAddSpacing) {
1057
+ this.lines.splice(childStartLine, 0, "")
1058
+ this.stringLineCount++
1059
+ }
1060
+ }
1061
+
943
1062
  const herbDisableString = this.capture(() => {
944
1063
  const savedIndentLevel = this.indentLevel
945
1064
  this.indentLevel = 0
@@ -951,7 +1070,9 @@ export class FormatPrinter extends Printer {
951
1070
 
952
1071
  this.pushToLastLine(' ' + herbDisableString)
953
1072
 
954
- i = j
1073
+ index = j
1074
+ lastMeaningfulNode = child
1075
+ hasHandledSpacing = false
955
1076
 
956
1077
  break
957
1078
  }
@@ -961,11 +1082,19 @@ export class FormatPrinter extends Printer {
961
1082
  }
962
1083
 
963
1084
  if (!hasTrailingHerbDisable) {
1085
+ const childStartLine = this.stringLineCount
964
1086
  this.visit(child)
965
- }
966
1087
 
967
- if (isNonWhitespaceNode(child)) {
968
- lastWasMeaningful = true
1088
+ if (lastMeaningfulNode && !hasHandledSpacing) {
1089
+ const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index)
1090
+
1091
+ if (shouldAddSpacing) {
1092
+ this.lines.splice(childStartLine, 0, "")
1093
+ this.stringLineCount++
1094
+ }
1095
+ }
1096
+
1097
+ lastMeaningfulNode = child
969
1098
  hasHandledSpacing = false
970
1099
  }
971
1100
  }
@@ -1087,6 +1216,14 @@ export class FormatPrinter extends Printer {
1087
1216
  }
1088
1217
  }).join("")
1089
1218
 
1219
+ const trimmedInner = inner.trim()
1220
+
1221
+ if (trimmedInner.startsWith('[if ') && trimmedInner.endsWith('<![endif]')) {
1222
+ this.pushWithIndent(open + inner + close)
1223
+
1224
+ return
1225
+ }
1226
+
1090
1227
  const hasNewlines = inner.includes('\n')
1091
1228
 
1092
1229
  if (hasNewlines) {
@@ -1189,8 +1326,7 @@ export class FormatPrinter extends Printer {
1189
1326
  }
1190
1327
 
1191
1328
  visitERBContentNode(node: ERBContentNode) {
1192
- // TODO: this feels hacky
1193
- if (node.tag_opening?.value === "<%#") {
1329
+ if (isERBCommentNode(node)) {
1194
1330
  this.visitERBCommentNode(node)
1195
1331
  } else {
1196
1332
  this.printERBNode(node)
@@ -1202,91 +1338,101 @@ export class FormatPrinter extends Printer {
1202
1338
  }
1203
1339
 
1204
1340
  visitERBYieldNode(node: ERBYieldNode) {
1205
- this.printERBNode(node)
1341
+ this.trackBoundary(node, () => {
1342
+ this.printERBNode(node)
1343
+ })
1206
1344
  }
1207
1345
 
1208
1346
  visitERBInNode(node: ERBInNode) {
1209
- this.printERBNode(node)
1210
- this.withIndent(() => this.visitAll(node.statements))
1347
+ this.trackBoundary(node, () => {
1348
+ this.printERBNode(node)
1349
+ this.withIndent(() => this.visitAll(node.statements))
1350
+ })
1211
1351
  }
1212
1352
 
1213
1353
  visitERBCaseMatchNode(node: ERBCaseMatchNode) {
1214
- this.printERBNode(node)
1354
+ this.trackBoundary(node, () => {
1355
+ this.printERBNode(node)
1215
1356
 
1216
- this.withIndent(() => this.visitAll(node.children))
1217
- this.visitAll(node.conditions)
1357
+ this.withIndent(() => this.visitAll(node.children))
1358
+ this.visitAll(node.conditions)
1218
1359
 
1219
- if (node.else_clause) this.visit(node.else_clause)
1220
- if (node.end_node) this.visit(node.end_node)
1360
+ if (node.else_clause) this.visit(node.else_clause)
1361
+ if (node.end_node) this.visit(node.end_node)
1362
+ })
1221
1363
  }
1222
1364
 
1223
1365
  visitERBBlockNode(node: ERBBlockNode) {
1224
- this.printERBNode(node)
1366
+ this.trackBoundary(node, () => {
1367
+ this.printERBNode(node)
1225
1368
 
1226
- this.withIndent(() => {
1227
- const hasTextFlow = this.isInTextFlowContext(null, node.body)
1369
+ this.withIndent(() => {
1370
+ const hasTextFlow = this.isInTextFlowContext(null, node.body)
1228
1371
 
1229
- if (hasTextFlow) {
1230
- this.visitTextFlowChildren(node.body)
1231
- } else {
1232
- this.visitElementChildren(node.body, null)
1233
- }
1234
- })
1372
+ if (hasTextFlow) {
1373
+ this.visitTextFlowChildren(node.body)
1374
+ } else {
1375
+ this.visitElementChildren(node.body, null)
1376
+ }
1377
+ })
1235
1378
 
1236
- if (node.end_node) this.visit(node.end_node)
1379
+ if (node.end_node) this.visit(node.end_node)
1380
+ })
1237
1381
  }
1238
1382
 
1239
1383
  visitERBIfNode(node: ERBIfNode) {
1240
- if (this.inlineMode) {
1241
- this.printERBNode(node)
1242
-
1243
- node.statements.forEach(child => {
1244
- if (isNode(child, HTMLAttributeNode)) {
1245
- this.lines.push(" ")
1246
- this.lines.push(this.renderAttribute(child))
1247
- } else {
1248
- const shouldAddSpaces = this.isInTokenListAttribute
1384
+ this.trackBoundary(node, () => {
1385
+ if (this.inlineMode) {
1386
+ this.printERBNode(node)
1249
1387
 
1250
- if (shouldAddSpaces) {
1388
+ node.statements.forEach(child => {
1389
+ if (isNode(child, HTMLAttributeNode)) {
1251
1390
  this.lines.push(" ")
1252
- }
1391
+ this.lines.push(this.renderAttribute(child))
1392
+ } else {
1393
+ const shouldAddSpaces = this.isInTokenListAttribute
1253
1394
 
1254
- this.visit(child)
1395
+ if (shouldAddSpaces) {
1396
+ this.lines.push(" ")
1397
+ }
1255
1398
 
1256
- if (shouldAddSpaces) {
1257
- this.lines.push(" ")
1399
+ this.visit(child)
1400
+
1401
+ if (shouldAddSpaces) {
1402
+ this.lines.push(" ")
1403
+ }
1258
1404
  }
1259
- }
1260
- })
1405
+ })
1261
1406
 
1262
- const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
1263
- const isTokenList = this.isInTokenListAttribute
1407
+ const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
1408
+ const isTokenList = this.isInTokenListAttribute
1264
1409
 
1265
- if ((hasHTMLAttributes || isTokenList) && node.end_node) {
1266
- this.lines.push(" ")
1267
- }
1410
+ if ((hasHTMLAttributes || isTokenList) && node.end_node) {
1411
+ this.lines.push(" ")
1412
+ }
1268
1413
 
1269
- if (node.subsequent) this.visit(node.subsequent)
1270
- if (node.end_node) this.visit(node.end_node)
1271
- } else {
1272
- this.printERBNode(node)
1414
+ if (node.subsequent) this.visit(node.subsequent)
1415
+ if (node.end_node) this.visit(node.end_node)
1416
+ } else {
1417
+ this.printERBNode(node)
1273
1418
 
1274
- this.withIndent(() => {
1275
- node.statements.forEach(child => this.visit(child))
1276
- })
1419
+ this.withIndent(() => {
1420
+ node.statements.forEach(child => this.visit(child))
1421
+ })
1277
1422
 
1278
- if (node.subsequent) this.visit(node.subsequent)
1279
- if (node.end_node) this.visit(node.end_node)
1280
- }
1423
+ if (node.subsequent) this.visit(node.subsequent)
1424
+ if (node.end_node) this.visit(node.end_node)
1425
+ }
1426
+ })
1281
1427
  }
1282
1428
 
1283
1429
  visitERBElseNode(node: ERBElseNode) {
1430
+ this.printERBNode(node)
1431
+
1284
1432
  if (this.inlineMode) {
1285
- this.printERBNode(node)
1286
- node.statements.forEach(statement => this.visit(statement))
1433
+ this.visitAll(node.statements)
1287
1434
  } else {
1288
- this.printERBNode(node)
1289
- this.withIndent(() => node.statements.forEach(statement => this.visit(statement)))
1435
+ this.withIndent(() => this.visitAll(node.statements))
1290
1436
  }
1291
1437
  }
1292
1438
 
@@ -1296,44 +1442,54 @@ export class FormatPrinter extends Printer {
1296
1442
  }
1297
1443
 
1298
1444
  visitERBCaseNode(node: ERBCaseNode) {
1299
- this.printERBNode(node)
1445
+ this.trackBoundary(node, () => {
1446
+ this.printERBNode(node)
1300
1447
 
1301
- this.withIndent(() => this.visitAll(node.children))
1302
- this.visitAll(node.conditions)
1448
+ this.withIndent(() => this.visitAll(node.children))
1449
+ this.visitAll(node.conditions)
1303
1450
 
1304
- if (node.else_clause) this.visit(node.else_clause)
1305
- if (node.end_node) this.visit(node.end_node)
1451
+ if (node.else_clause) this.visit(node.else_clause)
1452
+ if (node.end_node) this.visit(node.end_node)
1453
+ })
1306
1454
  }
1307
1455
 
1308
1456
  visitERBBeginNode(node: ERBBeginNode) {
1309
- this.printERBNode(node)
1310
- this.withIndent(() => this.visitAll(node.statements))
1457
+ this.trackBoundary(node, () => {
1458
+ this.printERBNode(node)
1459
+ this.withIndent(() => this.visitAll(node.statements))
1311
1460
 
1312
- if (node.rescue_clause) this.visit(node.rescue_clause)
1313
- if (node.else_clause) this.visit(node.else_clause)
1314
- if (node.ensure_clause) this.visit(node.ensure_clause)
1315
- if (node.end_node) this.visit(node.end_node)
1461
+ if (node.rescue_clause) this.visit(node.rescue_clause)
1462
+ if (node.else_clause) this.visit(node.else_clause)
1463
+ if (node.ensure_clause) this.visit(node.ensure_clause)
1464
+ if (node.end_node) this.visit(node.end_node)
1465
+ })
1316
1466
  }
1317
1467
 
1318
1468
  visitERBWhileNode(node: ERBWhileNode) {
1319
- this.printERBNode(node)
1320
- this.withIndent(() => this.visitAll(node.statements))
1469
+ this.trackBoundary(node, () => {
1470
+ this.printERBNode(node)
1471
+ this.withIndent(() => this.visitAll(node.statements))
1321
1472
 
1322
- if (node.end_node) this.visit(node.end_node)
1473
+ if (node.end_node) this.visit(node.end_node)
1474
+ })
1323
1475
  }
1324
1476
 
1325
1477
  visitERBUntilNode(node: ERBUntilNode) {
1326
- this.printERBNode(node)
1327
- this.withIndent(() => this.visitAll(node.statements))
1478
+ this.trackBoundary(node, () => {
1479
+ this.printERBNode(node)
1480
+ this.withIndent(() => this.visitAll(node.statements))
1328
1481
 
1329
- if (node.end_node) this.visit(node.end_node)
1482
+ if (node.end_node) this.visit(node.end_node)
1483
+ })
1330
1484
  }
1331
1485
 
1332
1486
  visitERBForNode(node: ERBForNode) {
1333
- this.printERBNode(node)
1334
- this.withIndent(() => this.visitAll(node.statements))
1487
+ this.trackBoundary(node, () => {
1488
+ this.printERBNode(node)
1489
+ this.withIndent(() => this.visitAll(node.statements))
1335
1490
 
1336
- if (node.end_node) this.visit(node.end_node)
1491
+ if (node.end_node) this.visit(node.end_node)
1492
+ })
1337
1493
  }
1338
1494
 
1339
1495
  visitERBRescueNode(node: ERBRescueNode) {
@@ -1347,11 +1503,13 @@ export class FormatPrinter extends Printer {
1347
1503
  }
1348
1504
 
1349
1505
  visitERBUnlessNode(node: ERBUnlessNode) {
1350
- this.printERBNode(node)
1351
- this.withIndent(() => this.visitAll(node.statements))
1506
+ this.trackBoundary(node, () => {
1507
+ this.printERBNode(node)
1508
+ this.withIndent(() => this.visitAll(node.statements))
1352
1509
 
1353
- if (node.else_clause) this.visit(node.else_clause)
1354
- if (node.end_node) this.visit(node.end_node)
1510
+ if (node.else_clause) this.visit(node.else_clause)
1511
+ if (node.end_node) this.visit(node.end_node)
1512
+ })
1355
1513
  }
1356
1514
 
1357
1515
  // --- Element Formatting Analysis Helpers ---
@@ -1418,6 +1576,16 @@ export class FormatPrinter extends Printer {
1418
1576
  if (!openTagInline) return false
1419
1577
  if (children.length === 0) return true
1420
1578
 
1579
+ const hasNonInlineChildElements = children.some(child => {
1580
+ if (isNode(child, HTMLElementNode)) {
1581
+ return !this.shouldRenderElementContentInline(child)
1582
+ }
1583
+
1584
+ return false
1585
+ })
1586
+
1587
+ if (hasNonInlineChildElements) return false
1588
+
1421
1589
  let hasLeadingHerbDisable = false
1422
1590
 
1423
1591
  for (const child of node.body) {
@@ -1439,7 +1607,7 @@ export class FormatPrinter extends Printer {
1439
1607
 
1440
1608
  if (fullInlineResult) {
1441
1609
  const totalLength = this.indent.length + fullInlineResult.length
1442
- return totalLength <= this.maxLineLength || totalLength <= 120
1610
+ return totalLength <= this.maxLineLength
1443
1611
  }
1444
1612
 
1445
1613
  return false
@@ -1474,8 +1642,9 @@ export class FormatPrinter extends Printer {
1474
1642
 
1475
1643
  const childrenContent = this.renderChildrenInline(children)
1476
1644
  const fullLine = openTagResult + childrenContent + `</${tagName}>`
1645
+ const totalLength = this.indent.length + fullLine.length
1477
1646
 
1478
- if ((this.indent.length + fullLine.length) <= this.maxLineLength) {
1647
+ if (totalLength <= this.maxLineLength) {
1479
1648
  return true
1480
1649
  }
1481
1650
  }