@herb-tools/formatter 0.7.5 → 0.8.0

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.
@@ -1,4 +1,7 @@
1
1
  import dedent from "dedent"
2
+
3
+ import { Printer, IdentityPrinter } from "@herb-tools/printer"
4
+
2
5
  import {
3
6
  getTagName,
4
7
  getCombinedAttributeName,
@@ -6,15 +9,54 @@ import {
6
9
  isNode,
7
10
  isToken,
8
11
  isParseResult,
9
- isAnyOf,
10
12
  isNoneOf,
11
13
  isERBNode,
12
14
  isCommentNode,
13
15
  isERBControlFlowNode,
14
16
  filterNodes,
15
- hasERBOutput,
16
17
  } from "@herb-tools/core"
17
- import { Printer, IdentityPrinter } from "@herb-tools/printer"
18
+
19
+ import {
20
+ areAllNestedElementsInline,
21
+ buildLineWithWord,
22
+ countAdjacentInlineElements,
23
+ endsWithWhitespace,
24
+ filterEmptyNodesForHerbDisable,
25
+ filterSignificantChildren,
26
+ findPreviousMeaningfulSibling,
27
+ hasComplexERBControlFlow,
28
+ hasMixedTextAndInlineContent,
29
+ hasMultilineTextContent,
30
+ hasWhitespaceBetween,
31
+ isBlockLevelNode,
32
+ isClosingPunctuation,
33
+ isContentPreserving,
34
+ isFrontmatter,
35
+ isHerbDisableComment,
36
+ isInlineElement,
37
+ isLineBreakingElement,
38
+ isNonWhitespaceNode,
39
+ isPureWhitespaceNode,
40
+ needsSpaceBetween,
41
+ normalizeAndSplitWords,
42
+ shouldAppendToLastLine,
43
+ shouldPreserveUserSpacing,
44
+ } from "./format-helpers.js"
45
+
46
+ import {
47
+ FORMATTABLE_ATTRIBUTES,
48
+ INLINE_ELEMENTS,
49
+ SPACEABLE_CONTAINERS,
50
+ SPACING_THRESHOLD,
51
+ TIGHT_GROUP_CHILDREN,
52
+ TIGHT_GROUP_PARENTS,
53
+ TOKEN_LIST_ATTRIBUTES,
54
+ } from "./format-helpers.js"
55
+
56
+ import type {
57
+ ContentUnitWithNode,
58
+ ElementFormattingAnalysis,
59
+ } from "./format-helpers.js"
18
60
 
19
61
  import {
20
62
  ParseResult,
@@ -56,21 +98,6 @@ import {
56
98
  import type { ERBNode } from "@herb-tools/core"
57
99
  import type { FormatOptions } from "./options.js"
58
100
 
59
- /**
60
- * Analysis result for HTMLElementNode formatting decisions
61
- */
62
- interface ElementFormattingAnalysis {
63
- openTagInline: boolean
64
- elementContentInline: boolean
65
- closeTagInline: boolean
66
- }
67
-
68
- // TODO: we can probably expand this list with more tags/attributes
69
- const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
70
- '*': ['class'],
71
- 'img': ['srcset', 'sizes']
72
- }
73
-
74
101
  /**
75
102
  * Printer traverses the Herb AST using the Visitor pattern
76
103
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
@@ -98,34 +125,6 @@ export class FormatPrinter extends Printer {
98
125
 
99
126
  public source: string
100
127
 
101
- // TODO: extract
102
- private static readonly INLINE_ELEMENTS = new Set([
103
- 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
104
- 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
105
- 'samp', 'small', 'span', 'strong', 'sub', 'sup',
106
- 'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
107
- ])
108
-
109
- private static readonly CONTENT_PRESERVING_ELEMENTS = new Set([
110
- 'script', 'style', 'pre', 'textarea'
111
- ])
112
-
113
- private static readonly SPACEABLE_CONTAINERS = new Set([
114
- 'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
115
- 'figure', 'details', 'summary', 'dialog', 'fieldset'
116
- ])
117
-
118
- private static readonly TIGHT_GROUP_PARENTS = new Set([
119
- 'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
120
- 'tbody', 'tfoot'
121
- ])
122
-
123
- private static readonly TIGHT_GROUP_CHILDREN = new Set([
124
- 'li', 'option', 'td', 'th', 'dt', 'dd'
125
- ])
126
-
127
- private static readonly SPACING_THRESHOLD = 3
128
-
129
128
  constructor(source: string, options: Required<FormatOptions>) {
130
129
  super()
131
130
 
@@ -320,26 +319,26 @@ export class FormatPrinter extends Printer {
320
319
  return false
321
320
  }
322
321
 
323
- const meaningfulSiblings = siblings.filter(child => this.isNonWhitespaceNode(child))
322
+ const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child))
324
323
 
325
- if (meaningfulSiblings.length < FormatPrinter.SPACING_THRESHOLD) {
324
+ if (meaningfulSiblings.length < SPACING_THRESHOLD) {
326
325
  return false
327
326
  }
328
327
 
329
328
  const parentTagName = parentElement ? getTagName(parentElement) : null
330
329
 
331
- if (parentTagName && FormatPrinter.TIGHT_GROUP_PARENTS.has(parentTagName)) {
330
+ if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
332
331
  return false
333
332
  }
334
333
 
335
- const isSpaceableContainer = !parentTagName || (parentTagName && FormatPrinter.SPACEABLE_CONTAINERS.has(parentTagName))
334
+ const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName))
336
335
 
337
336
  if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
338
337
  return false
339
338
  }
340
339
 
341
340
  const currentNode = siblings[currentIndex]
342
- const previousMeaningfulIndex = this.findPreviousMeaningfulSibling(siblings, currentIndex)
341
+ const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex)
343
342
  const isCurrentComment = isCommentNode(currentNode)
344
343
 
345
344
  if (previousMeaningfulIndex !== -1) {
@@ -358,11 +357,11 @@ export class FormatPrinter extends Printer {
358
357
  if (isNode(currentNode, HTMLElementNode)) {
359
358
  const currentTagName = getTagName(currentNode)
360
359
 
361
- if (FormatPrinter.INLINE_ELEMENTS.has(currentTagName)) {
360
+ if (INLINE_ELEMENTS.has(currentTagName)) {
362
361
  return false
363
362
  }
364
363
 
365
- if (FormatPrinter.TIGHT_GROUP_CHILDREN.has(currentTagName)) {
364
+ if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
366
365
  return false
367
366
  }
368
367
 
@@ -371,56 +370,18 @@ export class FormatPrinter extends Printer {
371
370
  }
372
371
  }
373
372
 
374
- const isBlockElement = this.isBlockLevelNode(currentNode)
373
+ const isBlockElement = isBlockLevelNode(currentNode)
375
374
  const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode)
376
375
  const isComment = isCommentNode(currentNode)
377
376
 
378
377
  return isBlockElement || isERBBlock || isComment
379
378
  }
380
379
 
381
- /**
382
- * Token list attributes that contain space-separated values and benefit from
383
- * spacing around ERB content for readability
384
- */
385
- private static readonly TOKEN_LIST_ATTRIBUTES = new Set([
386
- 'class', 'data-controller', 'data-action'
387
- ])
388
-
389
380
  /**
390
381
  * Check if we're currently processing a token list attribute that needs spacing
391
382
  */
392
- private isInTokenListAttribute(): boolean {
393
- return this.currentAttributeName !== null &&
394
- FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
395
- }
396
-
397
- /**
398
- * Find the previous meaningful (non-whitespace) sibling
399
- */
400
- private findPreviousMeaningfulSibling(siblings: Node[], currentIndex: number): number {
401
- for (let i = currentIndex - 1; i >= 0; i--) {
402
- if (this.isNonWhitespaceNode(siblings[i])) {
403
- return i
404
- }
405
- }
406
- return -1
407
- }
408
-
409
- /**
410
- * Check if a node represents a block-level element
411
- */
412
- private isBlockLevelNode(node: Node): boolean {
413
- if (!isNode(node, HTMLElementNode)) {
414
- return false
415
- }
416
-
417
- const tagName = getTagName(node)
418
-
419
- if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
420
- return false
421
- }
422
-
423
- return true
383
+ private get isInTokenListAttribute(): boolean {
384
+ return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
424
385
  }
425
386
 
426
387
  /**
@@ -473,16 +434,13 @@ export class FormatPrinter extends Printer {
473
434
  return true
474
435
  }
475
436
 
476
- private getAttributeName(attribute: HTMLAttributeNode): string {
477
- return attribute.name ? getCombinedAttributeName(attribute.name) : ""
478
- }
479
-
480
437
  private wouldClassAttributeBeMultiline(content: string, indentLength: number): boolean {
481
438
  const normalizedContent = content.replace(/\s+/g, ' ').trim()
482
439
  const hasActualNewlines = /\r?\n/.test(content)
483
440
 
484
441
  if (hasActualNewlines && normalizedContent.length > 80) {
485
442
  const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
443
+
486
444
  if (lines.length > 1) {
487
445
  return true
488
446
  }
@@ -504,6 +462,12 @@ export class FormatPrinter extends Printer {
504
462
  return false
505
463
  }
506
464
 
465
+ // TOOD: extract to core or reuse function from core
466
+ private getAttributeName(attribute: HTMLAttributeNode): string {
467
+ return attribute.name ? getCombinedAttributeName(attribute.name) : ""
468
+ }
469
+
470
+ // TOOD: extract to core or reuse function from core
507
471
  private getAttributeValue(attribute: HTMLAttributeNode): string {
508
472
  if (isNode(attribute.value, HTMLAttributeValueNode)) {
509
473
  return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('')
@@ -674,37 +638,50 @@ export class FormatPrinter extends Printer {
674
638
  // --- Visitor methods ---
675
639
 
676
640
  visitDocumentNode(node: DocumentNode) {
677
- let lastWasMeaningful = false
678
- let hasHandledSpacing = false
641
+ const children = this.formatFrontmatter(node)
642
+ const hasTextFlow = this.isInTextFlowContext(null, children)
679
643
 
680
- for (let i = 0; i < node.children.length; i++) {
681
- const child = node.children[i]
644
+ if (hasTextFlow) {
645
+ const wasInlineMode = this.inlineMode
646
+ this.inlineMode = true
682
647
 
683
- if (isNode(child, HTMLTextNode)) {
684
- const isWhitespaceOnly = child.content.trim() === ""
648
+ this.visitTextFlowChildren(children)
685
649
 
686
- if (isWhitespaceOnly) {
687
- const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1])
688
- const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1])
650
+ this.inlineMode = wasInlineMode
689
651
 
690
- const hasMultipleNewlines = child.content.includes('\n\n')
652
+ return
653
+ }
691
654
 
692
- if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
693
- this.push("")
694
- hasHandledSpacing = true
695
- }
655
+ let lastWasMeaningful = false
656
+ let hasHandledSpacing = false
696
657
 
697
- continue
698
- }
658
+ for (let i = 0; i < children.length; i++) {
659
+ const child = children[i]
660
+
661
+ if (shouldPreserveUserSpacing(child, children, i)) {
662
+ this.push("")
663
+ hasHandledSpacing = true
664
+ continue
665
+ }
666
+
667
+ if (isPureWhitespaceNode(child)) {
668
+ continue
699
669
  }
700
670
 
701
- if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
671
+ if (shouldAppendToLastLine(child, children, i)) {
672
+ this.appendChildToLastLine(child, children, i)
673
+ lastWasMeaningful = true
674
+ hasHandledSpacing = false
675
+ continue
676
+ }
677
+
678
+ if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
702
679
  this.push("")
703
680
  }
704
681
 
705
682
  this.visit(child)
706
683
 
707
- if (this.isNonWhitespaceNode(child)) {
684
+ if (isNonWhitespaceNode(child)) {
708
685
  lastWasMeaningful = true
709
686
  hasHandledSpacing = false
710
687
  }
@@ -715,6 +692,14 @@ export class FormatPrinter extends Printer {
715
692
  this.elementStack.push(node)
716
693
  this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node))
717
694
 
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
+ }
702
+
718
703
  this.visit(node.open_tag)
719
704
 
720
705
  if (node.body.length > 0) {
@@ -729,7 +714,9 @@ export class FormatPrinter extends Printer {
729
714
  }
730
715
 
731
716
  visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
732
- if (this.isContentPreserving(element)) {
717
+ const tagName = getTagName(element)
718
+
719
+ if (isContentPreserving(element)) {
733
720
  element.body.map(child => {
734
721
  if (isNode(child, HTMLElementNode)) {
735
722
  const wasInlineMode = this.inlineMode
@@ -749,7 +736,7 @@ export class FormatPrinter extends Printer {
749
736
 
750
737
  const analysis = this.elementFormattingAnalysis.get(element)
751
738
  const hasTextFlow = this.isInTextFlowContext(null, body)
752
- const children = this.filterSignificantChildren(body, hasTextFlow)
739
+ const children = filterSignificantChildren(body)
753
740
 
754
741
  if (analysis?.elementContentInline) {
755
742
  if (children.length === 0) return
@@ -757,6 +744,9 @@ export class FormatPrinter extends Printer {
757
744
  const oldInlineMode = this.inlineMode
758
745
  const nodesToRender = hasTextFlow ? body : children
759
746
 
747
+ const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
748
+ const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName)
749
+
760
750
  this.inlineMode = true
761
751
 
762
752
  const lines = this.capture(() => {
@@ -771,10 +761,18 @@ export class FormatPrinter extends Printer {
771
761
  this.push(' ')
772
762
  }
773
763
  } else {
774
- const normalizedContent = child.content.replace(/\s+/g, ' ').trim()
764
+ const normalizedContent = child.content.replace(/\s+/g, ' ')
775
765
 
776
- if (normalizedContent) {
766
+ if (shouldPreserveSpaces && normalizedContent) {
777
767
  this.push(normalizedContent)
768
+ } else {
769
+ const trimmedContent = normalizedContent.trim()
770
+
771
+ if (trimmedContent) {
772
+ this.push(trimmedContent)
773
+ } else if (normalizedContent === ' ') {
774
+ this.push(' ')
775
+ }
778
776
  }
779
777
  }
780
778
  } else if (isNode(child, WhitespaceNode)) {
@@ -786,7 +784,10 @@ export class FormatPrinter extends Printer {
786
784
  })
787
785
 
788
786
  const content = lines.join('')
789
- const inlineContent = hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim()
787
+
788
+ const inlineContent = shouldPreserveSpaces
789
+ ? (hasTextFlow ? content.replace(/\s+/g, ' ') : content)
790
+ : (hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim())
790
791
 
791
792
  if (inlineContent) {
792
793
  this.pushToLastLine(inlineContent)
@@ -799,11 +800,83 @@ export class FormatPrinter extends Printer {
799
800
 
800
801
  if (children.length === 0) return
801
802
 
803
+ let leadingHerbDisableComment: Node | null = null
804
+ let leadingHerbDisableIndex = -1
805
+ let firstWhitespaceIndex = -1
806
+ let remainingChildren = children
807
+ let remainingBodyUnfiltered = body
808
+
809
+ for (let i = 0; i < children.length; i++) {
810
+ const child = children[i]
811
+
812
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
813
+ if (firstWhitespaceIndex < 0) {
814
+ firstWhitespaceIndex = i
815
+ }
816
+
817
+ continue
818
+ }
819
+
820
+ if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
821
+ leadingHerbDisableComment = child
822
+ leadingHerbDisableIndex = i
823
+ }
824
+
825
+ break
826
+ }
827
+
828
+ if (leadingHerbDisableComment && leadingHerbDisableIndex >= 0) {
829
+ remainingChildren = children.filter((_, index) => {
830
+ if (index === leadingHerbDisableIndex) return false
831
+
832
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
833
+ const child = children[index]
834
+
835
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
836
+ return false
837
+ }
838
+ }
839
+
840
+ return true
841
+ })
842
+
843
+ remainingBodyUnfiltered = body.filter((_, index) => {
844
+ if (index === leadingHerbDisableIndex) return false
845
+
846
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
847
+ const child = body[index]
848
+
849
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
850
+ return false
851
+ }
852
+ }
853
+
854
+ return true
855
+ })
856
+ }
857
+
858
+ if (leadingHerbDisableComment) {
859
+ const herbDisableString = this.capture(() => {
860
+ const savedIndentLevel = this.indentLevel
861
+ this.indentLevel = 0
862
+ this.inlineMode = true
863
+ this.visit(leadingHerbDisableComment)
864
+ this.inlineMode = false
865
+ this.indentLevel = savedIndentLevel
866
+ }).join("")
867
+
868
+ const hasLeadingWhitespace = firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex
869
+
870
+ this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString)
871
+ }
872
+
873
+ if (remainingChildren.length === 0) return
874
+
802
875
  this.withIndent(() => {
803
876
  if (hasTextFlow) {
804
- this.visitTextFlowChildren(children)
877
+ this.visitTextFlowChildren(remainingBodyUnfiltered)
805
878
  } else {
806
- this.visitElementChildren(body, element)
879
+ this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element)
807
880
  }
808
881
  })
809
882
  }
@@ -822,8 +895,8 @@ export class FormatPrinter extends Printer {
822
895
  const isWhitespaceOnly = child.content.trim() === ""
823
896
 
824
897
  if (isWhitespaceOnly) {
825
- const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(body[i - 1])
826
- const hasNextNonWhitespace = i < body.length - 1 && this.isNonWhitespaceNode(body[i + 1])
898
+ const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1])
899
+ const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1])
827
900
 
828
901
  const hasMultipleNewlines = child.content.includes('\n\n')
829
902
 
@@ -836,7 +909,7 @@ export class FormatPrinter extends Printer {
836
909
  }
837
910
  }
838
911
 
839
- if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
912
+ if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
840
913
  const element = body[i - 1]
841
914
  const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2)
842
915
 
@@ -852,9 +925,46 @@ export class FormatPrinter extends Printer {
852
925
  }
853
926
  }
854
927
 
855
- this.visit(child)
928
+ let hasTrailingHerbDisable = false
929
+
930
+ if (isNode(child, HTMLElementNode) && child.close_tag) {
931
+ for (let j = i + 1; j < body.length; j++) {
932
+ const nextChild = body[j]
933
+
934
+ if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
935
+ continue
936
+ }
937
+
938
+ if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
939
+ hasTrailingHerbDisable = true
940
+
941
+ this.visit(child)
942
+
943
+ const herbDisableString = this.capture(() => {
944
+ const savedIndentLevel = this.indentLevel
945
+ this.indentLevel = 0
946
+ this.inlineMode = true
947
+ this.visit(nextChild)
948
+ this.inlineMode = false
949
+ this.indentLevel = savedIndentLevel
950
+ }).join("")
951
+
952
+ this.pushToLastLine(' ' + herbDisableString)
856
953
 
857
- if (this.isNonWhitespaceNode(child)) {
954
+ i = j
955
+
956
+ break
957
+ }
958
+
959
+ break
960
+ }
961
+ }
962
+
963
+ if (!hasTrailingHerbDisable) {
964
+ this.visit(child)
965
+ }
966
+
967
+ if (isNonWhitespaceNode(child)) {
858
968
  lastWasMeaningful = true
859
969
  hasHandledSpacing = false
860
970
  }
@@ -970,8 +1080,8 @@ export class FormatPrinter extends Printer {
970
1080
  inner = node.children.map(child => {
971
1081
  if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
972
1082
  return child.content
973
- } else if (isERBNode(child) || isNode(child, ERBContentNode)) {
974
- return this.reconstructERBNode(child, false)
1083
+ } else if (isERBNode(child)) {
1084
+ return IdentityPrinter.print(child)
975
1085
  } else {
976
1086
  return ""
977
1087
  }
@@ -1035,13 +1145,22 @@ export class FormatPrinter extends Printer {
1035
1145
  const startsWithSpace = content[0] === " "
1036
1146
  const before = startsWithSpace ? "" : " "
1037
1147
 
1038
- this.pushWithIndent(open + before + content.trimEnd() + ' ' + close)
1148
+ if (this.inlineMode) {
1149
+ this.push(open + before + content.trimEnd() + ' ' + close)
1150
+ } else {
1151
+ this.pushWithIndent(open + before + content.trimEnd() + ' ' + close)
1152
+ }
1039
1153
 
1040
1154
  return
1041
1155
  }
1042
1156
 
1043
1157
  if (contentTrimmedLines.length === 1) {
1044
- this.pushWithIndent(open + ' ' + content.trim() + ' ' + close)
1158
+ if (this.inlineMode) {
1159
+ this.push(open + ' ' + content.trim() + ' ' + close)
1160
+ } else {
1161
+ this.pushWithIndent(open + ' ' + content.trim() + ' ' + close)
1162
+ }
1163
+
1045
1164
  return
1046
1165
  }
1047
1166
 
@@ -1093,6 +1212,8 @@ export class FormatPrinter extends Printer {
1093
1212
 
1094
1213
  visitERBCaseMatchNode(node: ERBCaseMatchNode) {
1095
1214
  this.printERBNode(node)
1215
+
1216
+ this.withIndent(() => this.visitAll(node.children))
1096
1217
  this.visitAll(node.conditions)
1097
1218
 
1098
1219
  if (node.else_clause) this.visit(node.else_clause)
@@ -1101,7 +1222,16 @@ export class FormatPrinter extends Printer {
1101
1222
 
1102
1223
  visitERBBlockNode(node: ERBBlockNode) {
1103
1224
  this.printERBNode(node)
1104
- this.withIndent(() => this.visitElementChildren(node.body, null))
1225
+
1226
+ this.withIndent(() => {
1227
+ const hasTextFlow = this.isInTextFlowContext(null, node.body)
1228
+
1229
+ if (hasTextFlow) {
1230
+ this.visitTextFlowChildren(node.body)
1231
+ } else {
1232
+ this.visitElementChildren(node.body, null)
1233
+ }
1234
+ })
1105
1235
 
1106
1236
  if (node.end_node) this.visit(node.end_node)
1107
1237
  }
@@ -1115,7 +1245,7 @@ export class FormatPrinter extends Printer {
1115
1245
  this.lines.push(" ")
1116
1246
  this.lines.push(this.renderAttribute(child))
1117
1247
  } else {
1118
- const shouldAddSpaces = this.isInTokenListAttribute()
1248
+ const shouldAddSpaces = this.isInTokenListAttribute
1119
1249
 
1120
1250
  if (shouldAddSpaces) {
1121
1251
  this.lines.push(" ")
@@ -1130,13 +1260,13 @@ export class FormatPrinter extends Printer {
1130
1260
  })
1131
1261
 
1132
1262
  const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
1133
- const isTokenList = this.isInTokenListAttribute()
1263
+ const isTokenList = this.isInTokenListAttribute
1134
1264
 
1135
1265
  if ((hasHTMLAttributes || isTokenList) && node.end_node) {
1136
1266
  this.lines.push(" ")
1137
1267
  }
1138
1268
 
1139
- if (node.subsequent) this.visit(node.end_node)
1269
+ if (node.subsequent) this.visit(node.subsequent)
1140
1270
  if (node.end_node) this.visit(node.end_node)
1141
1271
  } else {
1142
1272
  this.printERBNode(node)
@@ -1151,8 +1281,13 @@ export class FormatPrinter extends Printer {
1151
1281
  }
1152
1282
 
1153
1283
  visitERBElseNode(node: ERBElseNode) {
1154
- this.printERBNode(node)
1155
- this.withIndent(() => node.statements.forEach(statement => this.visit(statement)))
1284
+ if (this.inlineMode) {
1285
+ this.printERBNode(node)
1286
+ node.statements.forEach(statement => this.visit(statement))
1287
+ } else {
1288
+ this.printERBNode(node)
1289
+ this.withIndent(() => node.statements.forEach(statement => this.visit(statement)))
1290
+ }
1156
1291
  }
1157
1292
 
1158
1293
  visitERBWhenNode(node: ERBWhenNode) {
@@ -1162,6 +1297,8 @@ export class FormatPrinter extends Printer {
1162
1297
 
1163
1298
  visitERBCaseNode(node: ERBCaseNode) {
1164
1299
  this.printERBNode(node)
1300
+
1301
+ this.withIndent(() => this.visitAll(node.children))
1165
1302
  this.visitAll(node.conditions)
1166
1303
 
1167
1304
  if (node.else_clause) this.visit(node.else_clause)
@@ -1242,7 +1379,7 @@ export class FormatPrinter extends Printer {
1242
1379
  const attributes = filterNodes(children, HTMLAttributeNode)
1243
1380
  const inlineNodes = this.extractInlineNodes(children)
1244
1381
  const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node))
1245
- const hasComplexERB = hasERBControlFlow && this.hasComplexERBControlFlow(inlineNodes)
1382
+ const hasComplexERB = hasERBControlFlow && hasComplexERBControlFlow(inlineNodes)
1246
1383
 
1247
1384
  if (hasComplexERB) return false
1248
1385
 
@@ -1275,15 +1412,30 @@ export class FormatPrinter extends Printer {
1275
1412
  */
1276
1413
  private shouldRenderElementContentInline(node: HTMLElementNode): boolean {
1277
1414
  const tagName = getTagName(node)
1278
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body))
1279
- const isInlineElement = this.isInlineElement(tagName)
1415
+ const children = filterSignificantChildren(node.body)
1280
1416
  const openTagInline = this.shouldRenderOpenTagInline(node)
1281
1417
 
1282
1418
  if (!openTagInline) return false
1283
1419
  if (children.length === 0) return true
1284
1420
 
1285
- if (isInlineElement) {
1286
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children)
1421
+ let hasLeadingHerbDisable = false
1422
+
1423
+ for (const child of node.body) {
1424
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
1425
+ continue
1426
+ }
1427
+ if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
1428
+ hasLeadingHerbDisable = true
1429
+ }
1430
+ break
1431
+ }
1432
+
1433
+ if (hasLeadingHerbDisable && !isInlineElement(tagName)) {
1434
+ return false
1435
+ }
1436
+
1437
+ if (isInlineElement(tagName)) {
1438
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body)
1287
1439
 
1288
1440
  if (fullInlineResult) {
1289
1441
  const totalLength = this.indent.length + fullInlineResult.length
@@ -1293,12 +1445,12 @@ export class FormatPrinter extends Printer {
1293
1445
  return false
1294
1446
  }
1295
1447
 
1296
- const allNestedAreInline = this.areAllNestedElementsInline(children)
1297
- const hasMultilineText = this.hasMultilineTextContent(children)
1298
- const hasMixedContent = this.hasMixedTextAndInlineContent(children)
1448
+ const allNestedAreInline = areAllNestedElementsInline(children)
1449
+ const hasMultilineText = hasMultilineTextContent(children)
1450
+ const hasMixedContent = hasMixedTextAndInlineContent(children)
1299
1451
 
1300
1452
  if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
1301
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children)
1453
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body)
1302
1454
 
1303
1455
  if (fullInlineResult) {
1304
1456
  const totalLength = this.indent.length + fullInlineResult.length
@@ -1337,9 +1489,9 @@ export class FormatPrinter extends Printer {
1337
1489
  private shouldRenderCloseTagInline(node: HTMLElementNode, elementContentInline: boolean): boolean {
1338
1490
  if (node.is_void) return true
1339
1491
  if (node.open_tag?.tag_closing?.value === "/>") return true
1340
- if (this.isContentPreserving(node)) return true
1492
+ if (isContentPreserving(node)) return true
1341
1493
 
1342
- const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body))
1494
+ const children = filterSignificantChildren(node.body)
1343
1495
 
1344
1496
  if (children.length === 0) return true
1345
1497
 
@@ -1349,152 +1501,690 @@ export class FormatPrinter extends Printer {
1349
1501
 
1350
1502
  // --- Utility methods ---
1351
1503
 
1352
- private isNonWhitespaceNode(node: Node): boolean {
1353
- if (isNode(node, WhitespaceNode)) return false
1354
- if (isNode(node, HTMLTextNode)) return node.content.trim() !== ""
1504
+ private formatFrontmatter(node: DocumentNode): Node[] {
1505
+ const firstChild = node.children[0]
1506
+ const hasFrontmatter = firstChild && isFrontmatter(firstChild)
1355
1507
 
1356
- return true
1508
+ if (!hasFrontmatter) return node.children
1509
+
1510
+ this.push(firstChild.content.trimEnd())
1511
+
1512
+ const remaining = node.children.slice(1)
1513
+
1514
+ if (remaining.length > 0) this.push("")
1515
+
1516
+ return remaining
1357
1517
  }
1358
1518
 
1359
1519
  /**
1360
- * Check if an element should be treated as inline based on its tag name
1520
+ * Append a child node to the last output line
1361
1521
  */
1362
- private isInlineElement(tagName: string): boolean {
1363
- return FormatPrinter.INLINE_ELEMENTS.has(tagName.toLowerCase())
1522
+ private appendChildToLastLine(child: Node, siblings?: Node[], index?: number): void {
1523
+ if (isNode(child, HTMLTextNode)) {
1524
+ this.pushToLastLine(child.content.trim())
1525
+ } else {
1526
+ let hasSpaceBefore = false
1527
+
1528
+ if (siblings && index !== undefined && index > 0) {
1529
+ const prevSibling = siblings[index - 1]
1530
+
1531
+ if (isPureWhitespaceNode(prevSibling) || isNode(prevSibling, WhitespaceNode)) {
1532
+ hasSpaceBefore = true
1533
+ }
1534
+ }
1535
+
1536
+ const oldInlineMode = this.inlineMode
1537
+ this.inlineMode = true
1538
+ const inlineContent = this.capture(() => this.visit(child)).join("")
1539
+ this.inlineMode = oldInlineMode
1540
+ this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent)
1541
+ }
1364
1542
  }
1365
1543
 
1366
1544
  /**
1367
- * Check if we're in a text flow context (parent contains mixed text and inline elements)
1545
+ * Visit children in a text flow context (mixed text and inline elements)
1546
+ * Handles word wrapping and keeps adjacent inline elements together
1368
1547
  */
1369
1548
  private visitTextFlowChildren(children: Node[]) {
1370
- let currentLineContent = ""
1371
-
1372
- for (const child of children) {
1373
- if (isNode(child, HTMLTextNode)) {
1374
- const content = child.content
1549
+ const adjacentInlineCount = countAdjacentInlineElements(children)
1375
1550
 
1376
- let processedContent = content.replace(/\s+/g, ' ').trim()
1551
+ if (adjacentInlineCount >= 2) {
1552
+ const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount)
1553
+ this.visitRemainingChildren(children, processedIndices)
1377
1554
 
1378
- if (processedContent) {
1379
- const hasLeadingSpace = /^\s/.test(content)
1380
-
1381
- if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
1382
- currentLineContent += ' '
1383
- }
1555
+ return
1556
+ }
1384
1557
 
1385
- currentLineContent += processedContent
1558
+ this.buildAndWrapTextFlow(children)
1559
+ }
1386
1560
 
1387
- const hasTrailingSpace = /\s$/.test(content)
1561
+ /**
1562
+ * Wrap remaining words that don't fit on the current line
1563
+ * Returns the wrapped lines with proper indentation
1564
+ */
1565
+ private wrapRemainingWords(words: string[], wrapWidth: number): string[] {
1566
+ const lines: string[] = []
1567
+ let line = ""
1388
1568
 
1389
- if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
1390
- currentLineContent += ' '
1391
- }
1569
+ for (const word of words) {
1570
+ const testLine = line + (line ? " " : "") + word
1392
1571
 
1393
- if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1394
- children.forEach(child => this.visit(child))
1572
+ if (testLine.length > wrapWidth && line) {
1573
+ lines.push(this.indent + line)
1574
+ line = word
1575
+ } else {
1576
+ line = testLine
1577
+ }
1578
+ }
1395
1579
 
1396
- return
1397
- }
1398
- }
1399
- } else if (isNode(child, HTMLElementNode)) {
1400
- const childTagName = getTagName(child)
1580
+ if (line) {
1581
+ lines.push(this.indent + line)
1582
+ }
1401
1583
 
1402
- if (this.isInlineElement(childTagName)) {
1403
- const childInline = this.tryRenderInlineFull(child, childTagName,
1404
- filterNodes(child.open_tag?.children, HTMLAttributeNode),
1405
- this.filterEmptyNodes(child.body)
1406
- )
1584
+ return lines
1585
+ }
1407
1586
 
1408
- if (childInline) {
1409
- currentLineContent += childInline
1587
+ /**
1588
+ * Try to merge text starting with punctuation to inline content
1589
+ * Returns object with merged content and whether processing should stop
1590
+ */
1591
+ private tryMergePunctuationText(inlineContent: string, trimmedText: string, wrapWidth: number): { mergedContent: string, shouldStop: boolean, wrappedLines: string[] } {
1592
+ const combined = inlineContent + trimmedText
1593
+
1594
+ if (combined.length <= wrapWidth) {
1595
+ return {
1596
+ mergedContent: inlineContent + trimmedText,
1597
+ shouldStop: false,
1598
+ wrappedLines: []
1599
+ }
1600
+ }
1410
1601
 
1411
- if ((this.indent.length + currentLineContent.length) > this.maxLineLength) {
1412
- children.forEach(child => this.visit(child))
1602
+ const match = trimmedText.match(/^[.!?:;]+/)
1413
1603
 
1414
- return
1415
- }
1416
- } else {
1417
- if (currentLineContent.trim()) {
1418
- this.pushWithIndent(currentLineContent.trim())
1419
- currentLineContent = ""
1420
- }
1604
+ if (!match) {
1605
+ return {
1606
+ mergedContent: inlineContent,
1607
+ shouldStop: false,
1608
+ wrappedLines: []
1609
+ }
1610
+ }
1421
1611
 
1422
- this.visit(child)
1423
- }
1424
- } else {
1425
- if (currentLineContent.trim()) {
1426
- this.pushWithIndent(currentLineContent.trim())
1427
- currentLineContent = ""
1428
- }
1612
+ const punctuation = match[0]
1613
+ const restText = trimmedText.substring(punctuation.length).trim()
1429
1614
 
1430
- this.visit(child)
1431
- }
1432
- } else if (isNode(child, ERBContentNode)) {
1433
- const oldLines = this.lines
1434
- const oldInlineMode = this.inlineMode
1615
+ if (!restText) {
1616
+ return {
1617
+ mergedContent: inlineContent + punctuation,
1618
+ shouldStop: false,
1619
+ wrappedLines: []
1620
+ }
1621
+ }
1435
1622
 
1436
- // TODO: use this.capture
1437
- try {
1438
- this.lines = []
1439
- this.inlineMode = true
1440
- this.visit(child)
1441
- const erbContent = this.lines.join("")
1442
- currentLineContent += erbContent
1623
+ const words = restText.split(/\s+/)
1624
+ let toMerge = punctuation
1625
+ let mergedWordCount = 0
1443
1626
 
1444
- if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1445
- this.lines = oldLines
1446
- this.inlineMode = oldInlineMode
1447
- children.forEach(child => this.visit(child))
1627
+ for (const word of words) {
1628
+ const testMerge = toMerge + ' ' + word
1448
1629
 
1449
- return
1450
- }
1451
- } finally {
1452
- this.lines = oldLines
1453
- this.inlineMode = oldInlineMode
1454
- }
1630
+ if ((inlineContent + testMerge).length <= wrapWidth) {
1631
+ toMerge = testMerge
1632
+ mergedWordCount++
1455
1633
  } else {
1456
- if (currentLineContent.trim()) {
1457
- this.pushWithIndent(currentLineContent.trim())
1458
- currentLineContent = ""
1459
- }
1460
-
1461
- this.visit(child)
1634
+ break
1462
1635
  }
1463
1636
  }
1464
1637
 
1465
- if (currentLineContent.trim()) {
1466
- const finalLine = this.indent + currentLineContent.trim()
1467
-
1468
- if (finalLine.length > Math.max(this.maxLineLength, 120)) {
1469
- this.visitAll(children)
1638
+ const mergedContent = inlineContent + toMerge
1470
1639
 
1471
- return
1640
+ if (mergedWordCount >= words.length) {
1641
+ return {
1642
+ mergedContent,
1643
+ shouldStop: false,
1644
+ wrappedLines: []
1472
1645
  }
1646
+ }
1473
1647
 
1474
- this.push(finalLine)
1648
+ const remainingWords = words.slice(mergedWordCount)
1649
+ const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth)
1650
+
1651
+ return {
1652
+ mergedContent,
1653
+ shouldStop: true,
1654
+ wrappedLines
1475
1655
  }
1476
1656
  }
1477
1657
 
1478
- private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean {
1479
- const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "")
1480
- const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode))
1481
-
1482
- if (!hasTextContent) return false
1483
- if (nonTextChildren.length === 0) return false
1658
+ /**
1659
+ * Render adjacent inline elements together on one line
1660
+ */
1661
+ private renderAdjacentInlineElements(children: Node[], count: number): { processedIndices: Set<number> } {
1662
+ let inlineContent = ""
1663
+ let processedCount = 0
1664
+ let lastProcessedIndex = -1
1665
+ const processedIndices = new Set<number>()
1484
1666
 
1485
- const allInline = nonTextChildren.every(child => {
1486
- if (isNode(child, ERBContentNode)) return true
1667
+ for (let index = 0; index < children.length && processedCount < count; index++) {
1668
+ const child = children[index]
1487
1669
 
1488
- if (isNode(child, HTMLElementNode)) {
1489
- return this.isInlineElement(getTagName(child))
1670
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
1671
+ continue
1490
1672
  }
1491
1673
 
1492
- return false
1493
- })
1494
-
1495
- if (!allInline) return false
1674
+ if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
1675
+ inlineContent += this.renderInlineElementAsString(child)
1676
+ processedCount++
1677
+ lastProcessedIndex = index
1678
+ processedIndices.add(index)
1496
1679
 
1497
- return true
1680
+ if (inlineContent && isLineBreakingElement(child)) {
1681
+ this.pushWithIndent(inlineContent)
1682
+ inlineContent = ""
1683
+ }
1684
+ } else if (isNode(child, ERBContentNode)) {
1685
+ inlineContent += this.renderERBAsString(child)
1686
+ processedCount++
1687
+ lastProcessedIndex = index
1688
+ processedIndices.add(index)
1689
+ }
1690
+ }
1691
+
1692
+ if (lastProcessedIndex >= 0) {
1693
+ for (let index = lastProcessedIndex + 1; index < children.length; index++) {
1694
+ const child = children[index]
1695
+
1696
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
1697
+ continue
1698
+ }
1699
+
1700
+ if (isNode(child, ERBContentNode)) {
1701
+ inlineContent += this.renderERBAsString(child)
1702
+ processedIndices.add(index)
1703
+ continue
1704
+ }
1705
+
1706
+ if (isNode(child, HTMLTextNode)) {
1707
+ const trimmed = child.content.trim()
1708
+
1709
+ if (trimmed && /^[.!?:;]/.test(trimmed)) {
1710
+ const wrapWidth = this.maxLineLength - this.indent.length
1711
+ const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth)
1712
+
1713
+ inlineContent = result.mergedContent
1714
+ processedIndices.add(index)
1715
+
1716
+ if (result.shouldStop) {
1717
+ if (inlineContent) {
1718
+ this.pushWithIndent(inlineContent)
1719
+ }
1720
+
1721
+ result.wrappedLines.forEach(line => this.push(line))
1722
+
1723
+ return { processedIndices }
1724
+ }
1725
+ }
1726
+ }
1727
+
1728
+ break
1729
+ }
1730
+ }
1731
+
1732
+ if (inlineContent) {
1733
+ this.pushWithIndent(inlineContent)
1734
+ }
1735
+
1736
+ return { processedIndices }
1737
+ }
1738
+
1739
+ /**
1740
+ * Render an inline element as a string
1741
+ */
1742
+ private renderInlineElementAsString(element: HTMLElementNode): string {
1743
+ const tagName = getTagName(element)
1744
+
1745
+ if (element.is_void || element.open_tag?.tag_closing?.value === "/>") {
1746
+ const attributes = filterNodes(element.open_tag?.children, HTMLAttributeNode)
1747
+ const attributesString = this.renderAttributesString(attributes)
1748
+ const isSelfClosing = element.open_tag?.tag_closing?.value === "/>"
1749
+
1750
+ return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`
1751
+ }
1752
+
1753
+ const childrenToRender = this.getFilteredChildren(element.body)
1754
+
1755
+ const childInline = this.tryRenderInlineFull(element, tagName,
1756
+ filterNodes(element.open_tag?.children, HTMLAttributeNode),
1757
+ childrenToRender
1758
+ )
1759
+
1760
+ return childInline !== null ? childInline : ""
1761
+ }
1762
+
1763
+ /**
1764
+ * Render an ERB node as a string
1765
+ */
1766
+ private renderERBAsString(node: ERBContentNode): string {
1767
+ return this.capture(() => {
1768
+ this.inlineMode = true
1769
+ this.visit(node)
1770
+ }).join("")
1771
+ }
1772
+
1773
+ /**
1774
+ * Visit remaining children after processing adjacent inline elements
1775
+ */
1776
+ private visitRemainingChildren(children: Node[], processedIndices: Set<number>): void {
1777
+ for (let index = 0; index < children.length; index++) {
1778
+ const child = children[index]
1779
+
1780
+ if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
1781
+ continue
1782
+ }
1783
+
1784
+ if (processedIndices.has(index)) {
1785
+ continue
1786
+ }
1787
+
1788
+ this.visit(child)
1789
+ }
1790
+ }
1791
+
1792
+ /**
1793
+ * Build words array from text/inline/ERB and wrap them
1794
+ */
1795
+ private buildAndWrapTextFlow(children: Node[]): void {
1796
+ const unitsWithNodes: ContentUnitWithNode[] = this.buildContentUnitsWithNodes(children)
1797
+ const words: Array<{ word: string, isHerbDisable: boolean }> = []
1798
+
1799
+ for (const { unit, node } of unitsWithNodes) {
1800
+ if (unit.breaksFlow) {
1801
+ this.flushWords(words)
1802
+
1803
+ if (node) {
1804
+ this.visit(node)
1805
+ }
1806
+ } else if (unit.isAtomic) {
1807
+ words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false })
1808
+ } else {
1809
+ const text = unit.content.replace(/\s+/g, ' ')
1810
+ const hasLeadingSpace = text.startsWith(' ')
1811
+ const hasTrailingSpace = text.endsWith(' ')
1812
+ const trimmedText = text.trim()
1813
+
1814
+ if (trimmedText) {
1815
+ if (hasLeadingSpace && words.length > 0) {
1816
+ const lastWord = words[words.length - 1]
1817
+
1818
+ if (!lastWord.word.endsWith(' ')) {
1819
+ lastWord.word += ' '
1820
+ }
1821
+ }
1822
+
1823
+ const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false }))
1824
+ words.push(...textWords)
1825
+
1826
+ if (hasTrailingSpace && words.length > 0) {
1827
+ const lastWord = words[words.length - 1]
1828
+
1829
+ if (!isClosingPunctuation(lastWord.word)) {
1830
+ lastWord.word += ' '
1831
+ }
1832
+ }
1833
+ } else if (text === ' ' && words.length > 0) {
1834
+ const lastWord = words[words.length - 1]
1835
+
1836
+ if (!lastWord.word.endsWith(' ')) {
1837
+ lastWord.word += ' '
1838
+ }
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ this.flushWords(words)
1844
+ }
1845
+
1846
+ /**
1847
+ * Try to merge text that follows an atomic unit (ERB/inline) with no whitespace
1848
+ * Returns true if merge was performed
1849
+ */
1850
+ private tryMergeTextAfterAtomic(result: ContentUnitWithNode[], textNode: HTMLTextNode): boolean {
1851
+ if (result.length === 0) return false
1852
+
1853
+ const lastUnit = result[result.length - 1]
1854
+
1855
+ if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) {
1856
+ return false
1857
+ }
1858
+
1859
+ const words = normalizeAndSplitWords(textNode.content)
1860
+ if (words.length === 0 || !words[0]) return false
1861
+
1862
+ const firstWord = words[0]
1863
+ const firstChar = firstWord[0]
1864
+
1865
+ if (!/[a-zA-Z0-9.!?:;]/.test(firstChar)) {
1866
+ return false
1867
+ }
1868
+
1869
+ lastUnit.unit.content += firstWord
1870
+
1871
+ if (words.length > 1) {
1872
+ let remainingText = words.slice(1).join(' ')
1873
+
1874
+ if (endsWithWhitespace(textNode.content)) {
1875
+ remainingText += ' '
1876
+ }
1877
+
1878
+ result.push({
1879
+ unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
1880
+ node: textNode
1881
+ })
1882
+ }
1883
+
1884
+ return true
1885
+ }
1886
+
1887
+ /**
1888
+ * Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
1889
+ * Returns true if merge was performed
1890
+ */
1891
+ private tryMergeAtomicAfterText(result: ContentUnitWithNode[], children: Node[], lastProcessedIndex: number, atomicContent: string, atomicType: 'erb' | 'inline', atomicNode: Node): boolean {
1892
+ if (result.length === 0) return false
1893
+
1894
+ const lastUnit = result[result.length - 1]
1895
+
1896
+ if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic) return false
1897
+
1898
+ const words = normalizeAndSplitWords(lastUnit.unit.content)
1899
+ const lastWord = words[words.length - 1]
1900
+
1901
+ if (!lastWord) return false
1902
+
1903
+ result.pop()
1904
+
1905
+ if (words.length > 1) {
1906
+ const remainingText = words.slice(0, -1).join(' ')
1907
+
1908
+ result.push({
1909
+ unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
1910
+ node: children[lastProcessedIndex]
1911
+ })
1912
+ }
1913
+
1914
+ result.push({
1915
+ unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
1916
+ node: atomicNode
1917
+ })
1918
+
1919
+ return true
1920
+ }
1921
+
1922
+ /**
1923
+ * Check if there's whitespace between current node and last processed node
1924
+ */
1925
+ private hasWhitespaceBeforeNode(children: Node[], lastProcessedIndex: number, currentIndex: number, currentNode: Node): boolean {
1926
+ if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
1927
+ return true
1928
+ }
1929
+
1930
+ if (isNode(currentNode, HTMLTextNode) && /^\s/.test(currentNode.content)) {
1931
+ return true
1932
+ }
1933
+
1934
+ return false
1935
+ }
1936
+
1937
+ /**
1938
+ * Check if last unit in result ends with whitespace
1939
+ */
1940
+ private lastUnitEndsWithWhitespace(result: ContentUnitWithNode[]): boolean {
1941
+ if (result.length === 0) return false
1942
+
1943
+ const lastUnit = result[result.length - 1]
1944
+
1945
+ return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content)
1946
+ }
1947
+
1948
+ /**
1949
+ * Process a text node and add it to results (with potential merging)
1950
+ */
1951
+ private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void {
1952
+ const isAtomic = child.content === ' '
1953
+
1954
+ if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
1955
+ const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child)
1956
+ const lastUnit = result[result.length - 1]
1957
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline')
1958
+ const trimmed = child.content.trim()
1959
+ const startsWithClosingPunct = trimmed.length > 0 && /^[.!?:;]/.test(trimmed)
1960
+
1961
+ if (lastIsAtomic && (!hasWhitespace || startsWithClosingPunct) && this.tryMergeTextAfterAtomic(result, child)) {
1962
+ return
1963
+ }
1964
+ }
1965
+
1966
+ result.push({
1967
+ unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
1968
+ node: child
1969
+ })
1970
+ }
1971
+
1972
+ /**
1973
+ * Process an inline element and add it to results (with potential merging)
1974
+ */
1975
+ private processInlineElement(result: ContentUnitWithNode[], children: Node[], child: HTMLElementNode, index: number, lastProcessedIndex: number): boolean {
1976
+ const tagName = getTagName(child)
1977
+ const childrenToRender = this.getFilteredChildren(child.body)
1978
+ const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender)
1979
+
1980
+ if (inlineContent === null) {
1981
+ result.push({
1982
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
1983
+ node: child
1984
+ })
1985
+
1986
+ return false
1987
+ }
1988
+
1989
+ if (lastProcessedIndex >= 0) {
1990
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
1991
+
1992
+ if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
1993
+ return true
1994
+ }
1995
+ }
1996
+
1997
+ result.push({
1998
+ unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
1999
+ node: child
2000
+ })
2001
+
2002
+ return false
2003
+ }
2004
+
2005
+ /**
2006
+ * Process an ERB content node and add it to results (with potential merging)
2007
+ */
2008
+ private processERBContentNode(result: ContentUnitWithNode[], children: Node[], child: ERBContentNode, index: number, lastProcessedIndex: number): boolean {
2009
+ const erbContent = this.renderERBAsString(child)
2010
+ const isHerbDisable = isHerbDisableComment(child)
2011
+
2012
+ if (lastProcessedIndex >= 0) {
2013
+ const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
2014
+
2015
+ if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
2016
+ return true
2017
+ }
2018
+
2019
+ if (hasWhitespace && result.length > 0) {
2020
+ const lastUnit = result[result.length - 1]
2021
+ const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb')
2022
+
2023
+ if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
2024
+ result.push({
2025
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
2026
+ node: null
2027
+ })
2028
+ }
2029
+ }
2030
+ }
2031
+
2032
+ result.push({
2033
+ unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
2034
+ node: child
2035
+ })
2036
+
2037
+ return false
2038
+ }
2039
+
2040
+ /**
2041
+ * Convert AST nodes to content units with node references
2042
+ */
2043
+ private buildContentUnitsWithNodes(children: Node[]): ContentUnitWithNode[] {
2044
+ const result: ContentUnitWithNode[] = []
2045
+ let lastProcessedIndex = -1
2046
+
2047
+ for (let i = 0; i < children.length; i++) {
2048
+ const child = children[i]
2049
+
2050
+ if (isNode(child, WhitespaceNode)) continue
2051
+
2052
+ if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
2053
+ if (lastProcessedIndex >= 0) {
2054
+ const hasNonWhitespaceAfter = children.slice(i + 1).some(node =>
2055
+ !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node)
2056
+ )
2057
+
2058
+ if (hasNonWhitespaceAfter) {
2059
+ const previousNode = children[lastProcessedIndex]
2060
+
2061
+ if (!isLineBreakingElement(previousNode)) {
2062
+ result.push({
2063
+ unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
2064
+ node: child
2065
+ })
2066
+ }
2067
+ }
2068
+ }
2069
+
2070
+ continue
2071
+ }
2072
+
2073
+ if (isNode(child, HTMLTextNode)) {
2074
+ this.processTextNode(result, children, child, i, lastProcessedIndex)
2075
+
2076
+ lastProcessedIndex = i
2077
+ } else if (isNode(child, HTMLElementNode)) {
2078
+ const tagName = getTagName(child)
2079
+
2080
+ if (isInlineElement(tagName)) {
2081
+ const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex)
2082
+
2083
+ if (merged) {
2084
+ lastProcessedIndex = i
2085
+
2086
+ continue
2087
+ }
2088
+ } else {
2089
+ result.push({
2090
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
2091
+ node: child
2092
+ })
2093
+ }
2094
+
2095
+ lastProcessedIndex = i
2096
+ } else if (isNode(child, ERBContentNode)) {
2097
+ const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex)
2098
+
2099
+ if (merged) {
2100
+ lastProcessedIndex = i
2101
+
2102
+ continue
2103
+ }
2104
+
2105
+ lastProcessedIndex = i
2106
+ } else {
2107
+ result.push({
2108
+ unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
2109
+ node: child
2110
+ })
2111
+
2112
+ lastProcessedIndex = i
2113
+ }
2114
+ }
2115
+
2116
+ return result
2117
+ }
2118
+
2119
+ /**
2120
+ * Flush accumulated words to output with wrapping
2121
+ */
2122
+ private flushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
2123
+ if (words.length > 0) {
2124
+ this.wrapAndPushWords(words)
2125
+ words.length = 0
2126
+ }
2127
+ }
2128
+
2129
+ /**
2130
+ * Wrap words to fit within line length and push to output
2131
+ * Handles punctuation spacing intelligently
2132
+ * Excludes herb:disable comments from line length calculations
2133
+ */
2134
+ private wrapAndPushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
2135
+ const wrapWidth = this.maxLineLength - this.indent.length
2136
+ const lines: string[] = []
2137
+ let currentLine = ""
2138
+ let effectiveLength = 0
2139
+
2140
+ for (const { word, isHerbDisable } of words) {
2141
+ const nextLine = buildLineWithWord(currentLine, word)
2142
+
2143
+ let nextEffectiveLength = effectiveLength
2144
+
2145
+ if (!isHerbDisable) {
2146
+ const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0
2147
+ nextEffectiveLength = effectiveLength + spaceBefore + word.length
2148
+ }
2149
+
2150
+ if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
2151
+ lines.push(this.indent + currentLine.trimEnd())
2152
+
2153
+ currentLine = word
2154
+ effectiveLength = isHerbDisable ? 0 : word.length
2155
+ } else {
2156
+ currentLine = nextLine
2157
+ effectiveLength = nextEffectiveLength
2158
+ }
2159
+ }
2160
+
2161
+ if (currentLine) {
2162
+ lines.push(this.indent + currentLine.trimEnd())
2163
+ }
2164
+
2165
+ lines.forEach(line => this.push(line))
2166
+ }
2167
+
2168
+ private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean {
2169
+ const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "")
2170
+ const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode))
2171
+
2172
+ if (!hasTextContent) return false
2173
+ if (nonTextChildren.length === 0) return false
2174
+
2175
+ const allInline = nonTextChildren.every(child => {
2176
+ if (isNode(child, ERBContentNode)) return true
2177
+
2178
+ if (isNode(child, HTMLElementNode)) {
2179
+ return isInlineElement(getTagName(child))
2180
+ }
2181
+
2182
+ return false
2183
+ })
2184
+
2185
+ if (!allInline) return false
2186
+
2187
+ return true
1498
2188
  }
1499
2189
 
1500
2190
  private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
@@ -1576,7 +2266,7 @@ export class FormatPrinter extends Printer {
1576
2266
  } else {
1577
2267
  const printed = IdentityPrinter.print(child)
1578
2268
 
1579
- if (this.currentAttributeName && FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)) {
2269
+ if (this.isInTokenListAttribute) {
1580
2270
  return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%')
1581
2271
  }
1582
2272
 
@@ -1617,7 +2307,7 @@ export class FormatPrinter extends Printer {
1617
2307
  result += this.renderAttributesString(attributes)
1618
2308
  result += ">"
1619
2309
 
1620
- const childrenContent = this.tryRenderChildrenInline(children)
2310
+ const childrenContent = this.tryRenderChildrenInline(children, tagName)
1621
2311
 
1622
2312
  if (!childrenContent) return null
1623
2313
 
@@ -1627,11 +2317,32 @@ export class FormatPrinter extends Printer {
1627
2317
  return result
1628
2318
  }
1629
2319
 
2320
+ /**
2321
+ * Check if children contain a leading herb:disable comment (after optional whitespace)
2322
+ */
2323
+ private hasLeadingHerbDisable(children: Node[]): boolean {
2324
+ for (const child of children) {
2325
+ if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
2326
+ continue
2327
+ }
2328
+
2329
+ return isNode(child, ERBContentNode) && isHerbDisableComment(child)
2330
+ }
2331
+
2332
+ return false
2333
+ }
2334
+
1630
2335
  /**
1631
2336
  * Try to render just the children inline (without tags)
1632
2337
  */
1633
- private tryRenderChildrenInline(children: Node[]): string | null {
2338
+ private tryRenderChildrenInline(children: Node[], tagName?: string): string | null {
1634
2339
  let result = ""
2340
+ let hasInternalWhitespace = false
2341
+ let addedLeadingSpace = false
2342
+
2343
+ const hasHerbDisable = this.hasLeadingHerbDisable(children)
2344
+ const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
2345
+ const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName)
1635
2346
 
1636
2347
  for (const child of children) {
1637
2348
  if (isNode(child, HTMLTextNode)) {
@@ -1641,33 +2352,41 @@ export class FormatPrinter extends Printer {
1641
2352
  const trimmedContent = normalizedContent.trim()
1642
2353
 
1643
2354
  if (trimmedContent) {
1644
- let finalContent = trimmedContent
1645
-
1646
- if (hasLeadingSpace && result && !result.endsWith(' ')) {
1647
- finalContent = ' ' + finalContent
2355
+ if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
2356
+ result += ' '
1648
2357
  }
1649
2358
 
1650
- if (hasTrailingSpace) {
1651
- finalContent = finalContent + ' '
1652
- }
2359
+ result += trimmedContent
1653
2360
 
1654
- result += finalContent
1655
- } else if (hasLeadingSpace || hasTrailingSpace) {
1656
- if (result && !result.endsWith(' ')) {
2361
+ if (hasTrailingSpace) {
1657
2362
  result += ' '
1658
2363
  }
2364
+
2365
+ continue
1659
2366
  }
2367
+ }
1660
2368
 
2369
+ const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")
2370
+
2371
+ if (isWhitespace && !result.endsWith(' ')) {
2372
+ if (!result && hasHerbDisable && !addedLeadingSpace) {
2373
+ result += ' '
2374
+ addedLeadingSpace = true
2375
+ } else if (result) {
2376
+ result += ' '
2377
+ hasInternalWhitespace = true
2378
+ }
1661
2379
  } else if (isNode(child, HTMLElementNode)) {
1662
2380
  const tagName = getTagName(child)
1663
2381
 
1664
- if (!this.isInlineElement(tagName)) {
2382
+ if (!isInlineElement(tagName)) {
1665
2383
  return null
1666
2384
  }
1667
2385
 
2386
+ const childrenToRender = this.getFilteredChildren(child.body)
1668
2387
  const childInline = this.tryRenderInlineFull(child, tagName,
1669
2388
  filterNodes(child.open_tag?.children, HTMLAttributeNode),
1670
- this.filterEmptyNodes(child.body)
2389
+ childrenToRender
1671
2390
  )
1672
2391
 
1673
2392
  if (!childInline) {
@@ -1675,11 +2394,23 @@ export class FormatPrinter extends Printer {
1675
2394
  }
1676
2395
 
1677
2396
  result += childInline
1678
- } else {
1679
- result += this.capture(() => this.visit(child)).join("")
2397
+ } else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
2398
+ const wasInlineMode = this.inlineMode
2399
+ this.inlineMode = true
2400
+ const captured = this.capture(() => this.visit(child)).join("")
2401
+ this.inlineMode = wasInlineMode
2402
+ result += captured
1680
2403
  }
1681
2404
  }
1682
2405
 
2406
+ if (shouldPreserveSpaces) {
2407
+ return result
2408
+ }
2409
+
2410
+ if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
2411
+ return result.trimEnd()
2412
+ }
2413
+
1683
2414
  return result.trim()
1684
2415
  }
1685
2416
 
@@ -1694,9 +2425,7 @@ export class FormatPrinter extends Printer {
1694
2425
  return null
1695
2426
  }
1696
2427
  } else if (isNode(child, HTMLElementNode)) {
1697
- const isInlineElement = this.isInlineElement(getTagName(child))
1698
-
1699
- if (!isInlineElement) {
2428
+ if (!isInlineElement(getTagName(child))) {
1700
2429
  return null
1701
2430
  }
1702
2431
  } else if (isNode(child, ERBContentNode)) {
@@ -1716,121 +2445,18 @@ export class FormatPrinter extends Printer {
1716
2445
  }
1717
2446
 
1718
2447
  /**
1719
- * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
1720
- * or mixed ERB output and text (like "<%= value %> text")
1721
- * This indicates content that should be formatted inline even with structural newlines
1722
- */
1723
- private hasMixedTextAndInlineContent(children: Node[]): boolean {
1724
- let hasText = false
1725
- let hasInlineElements = false
1726
-
1727
- for (const child of children) {
1728
- if (isNode(child, HTMLTextNode)) {
1729
- if (child.content.trim() !== "") {
1730
- hasText = true
1731
- }
1732
- } else if (isNode(child, HTMLElementNode)) {
1733
- if (this.isInlineElement(getTagName(child))) {
1734
- hasInlineElements = true
1735
- }
1736
- }
1737
- }
1738
-
1739
- return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText)
1740
- }
1741
-
1742
- /**
1743
- * Check if children contain any text content with newlines
2448
+ * Get filtered children, using smart herb:disable filtering if needed
1744
2449
  */
1745
- private hasMultilineTextContent(children: Node[]): boolean {
1746
- for (const child of children) {
1747
- if (isNode(child, HTMLTextNode)) {
1748
- return child.content.includes('\n')
1749
- }
1750
-
1751
- if (isNode(child, HTMLElementNode)) {
1752
- const nestedChildren = this.filterEmptyNodes(child.body)
1753
-
1754
- if (this.hasMultilineTextContent(nestedChildren)) {
1755
- return true
1756
- }
1757
- }
1758
- }
1759
-
1760
- return false
1761
- }
1762
-
1763
- /**
1764
- * Check if all nested elements in the children are inline elements
1765
- */
1766
- private areAllNestedElementsInline(children: Node[]): boolean {
1767
- for (const child of children) {
1768
- if (isNode(child, HTMLElementNode)) {
1769
- if (!this.isInlineElement(getTagName(child))) {
1770
- return false
1771
- }
1772
-
1773
- const nestedChildren = this.filterEmptyNodes(child.body)
1774
-
1775
- if (!this.areAllNestedElementsInline(nestedChildren)) {
1776
- return false
1777
- }
1778
- } else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
1779
- return false
1780
- }
1781
- }
1782
-
1783
- return true
1784
- }
1785
-
1786
- /**
1787
- * Check if element has complex ERB control flow
1788
- */
1789
- private hasComplexERBControlFlow(inlineNodes: Node[]): boolean {
1790
- return inlineNodes.some(node => {
1791
- if (isNode(node, ERBIfNode)) {
1792
- if (node.statements.length > 0 && node.location) {
1793
- const startLine = node.location.start.line
1794
- const endLine = node.location.end.line
1795
-
1796
- return startLine !== endLine
1797
- }
1798
-
1799
- return false
1800
- }
1801
-
1802
- return false
1803
- })
1804
- }
1805
-
1806
- /**
1807
- * Filter children to remove insignificant whitespace
1808
- */
1809
- private filterSignificantChildren(body: Node[], hasTextFlow: boolean): Node[] {
1810
- return body.filter(child => {
1811
- if (isNode(child, WhitespaceNode)) return false
1812
-
1813
- if (isNode(child, HTMLTextNode)) {
1814
- if (hasTextFlow && child.content === " ") return true
1815
-
1816
- return child.content.trim() !== ""
1817
- }
1818
-
1819
- return true
1820
- })
1821
- }
1822
-
1823
- /**
1824
- * Filter out empty text nodes and whitespace nodes
1825
- */
1826
- private filterEmptyNodes(nodes: Node[]): Node[] {
1827
- return nodes.filter(child =>
1828
- !isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === "")
2450
+ private getFilteredChildren(body: Node[]): Node[] {
2451
+ const hasHerbDisable = body.some(child =>
2452
+ isNode(child, ERBContentNode) && isHerbDisableComment(child)
1829
2453
  )
2454
+
2455
+ return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body
1830
2456
  }
1831
2457
 
1832
2458
  private renderElementInline(element: HTMLElementNode): string {
1833
- const children = this.filterEmptyNodes(element.body)
2459
+ const children = this.getFilteredChildren(element.body)
1834
2460
 
1835
2461
  return this.renderChildrenInline(children)
1836
2462
  }
@@ -1855,10 +2481,4 @@ export class FormatPrinter extends Printer {
1855
2481
 
1856
2482
  return content.replace(/\s+/g, ' ').trim()
1857
2483
  }
1858
-
1859
- private isContentPreserving(element: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): boolean {
1860
- const tagName = getTagName(element)
1861
-
1862
- return FormatPrinter.CONTENT_PRESERVING_ELEMENTS.has(tagName)
1863
- }
1864
2484
  }