@herb-tools/formatter 0.8.10 → 0.9.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,60 +1,57 @@
1
- import dedent from "dedent"
2
-
3
1
  import { Printer, IdentityPrinter } from "@herb-tools/printer"
2
+ import { TextFlowEngine } from "./text-flow-engine.js"
3
+ import { AttributeRenderer } from "./attribute-renderer.js"
4
+ import { SpacingAnalyzer } from "./spacing-analyzer.js"
5
+ import { isTextFlowNode } from "./text-flow-helpers.js"
6
+ import { extractHTMLCommentContent, formatHTMLCommentInner, formatERBCommentLines } from "./comment-helpers.js"
7
+
8
+ import type { ERBNode } from "@herb-tools/core"
9
+ import type { FormatOptions } from "./options.js"
10
+ import type { TextFlowDelegate } from "./text-flow-engine.js"
11
+ import type { AttributeRendererDelegate } from "./attribute-renderer.js"
12
+ import type { ElementFormattingAnalysis } from "./format-helpers.js"
13
+
14
+ interface ChildVisitResult {
15
+ newIndex: number
16
+ lastMeaningfulNode: Node | null
17
+ hasHandledSpacing: boolean
18
+ }
4
19
 
5
20
  import {
6
21
  getTagName,
7
22
  getCombinedAttributeName,
8
- getCombinedStringFromNodes,
9
23
  isNode,
10
24
  isToken,
11
25
  isParseResult,
12
26
  isNoneOf,
13
27
  isERBNode,
14
- isCommentNode,
15
28
  isERBControlFlowNode,
16
29
  isERBCommentNode,
17
- isERBOutputNode,
30
+ isHTMLOpenTagNode,
31
+ isPureWhitespaceNode,
18
32
  filterNodes,
19
33
  } from "@herb-tools/core"
20
34
 
21
35
  import {
22
36
  areAllNestedElementsInline,
23
- buildLineWithWord,
24
- countAdjacentInlineElements,
25
- endsWithWhitespace,
26
37
  filterEmptyNodesForHerbDisable,
27
38
  filterSignificantChildren,
28
- findPreviousMeaningfulSibling,
29
39
  hasComplexERBControlFlow,
30
40
  hasMixedTextAndInlineContent,
31
41
  hasMultilineTextContent,
32
- hasWhitespaceBetween,
33
- isBlockLevelNode,
34
- isClosingPunctuation,
35
42
  isContentPreserving,
36
43
  isFrontmatter,
44
+ hasLeadingHerbDisable,
37
45
  isHerbDisableComment,
38
46
  isInlineElement,
39
- isLineBreakingElement,
40
47
  isNonWhitespaceNode,
41
- isPureWhitespaceNode,
42
- needsSpaceBetween,
43
- normalizeAndSplitWords,
44
48
  shouldAppendToLastLine,
45
49
  shouldPreserveUserSpacing,
46
50
  } from "./format-helpers.js"
47
51
 
48
52
  import {
49
- FORMATTABLE_ATTRIBUTES,
50
- INLINE_ELEMENTS,
53
+ ASCII_WHITESPACE,
51
54
  SPACEABLE_CONTAINERS,
52
- TOKEN_LIST_ATTRIBUTES,
53
- } from "./format-helpers.js"
54
-
55
- import type {
56
- ContentUnitWithNode,
57
- ElementFormattingAnalysis,
58
55
  } from "./format-helpers.js"
59
56
 
60
57
  import {
@@ -62,15 +59,16 @@ import {
62
59
  Node,
63
60
  DocumentNode,
64
61
  HTMLOpenTagNode,
62
+ HTMLConditionalOpenTagNode,
65
63
  HTMLCloseTagNode,
66
64
  HTMLElementNode,
65
+ HTMLConditionalElementNode,
67
66
  HTMLAttributeNode,
68
67
  HTMLAttributeValueNode,
69
68
  HTMLAttributeNameNode,
70
69
  HTMLTextNode,
71
70
  HTMLCommentNode,
72
71
  HTMLDoctypeNode,
73
- LiteralNode,
74
72
  WhitespaceNode,
75
73
  ERBContentNode,
76
74
  ERBBlockNode,
@@ -89,25 +87,34 @@ import {
89
87
  ERBUnlessNode,
90
88
  ERBYieldNode,
91
89
  ERBInNode,
90
+ ERBOpenTagNode,
91
+ HTMLVirtualCloseTagNode,
92
92
  XMLDeclarationNode,
93
93
  CDATANode,
94
94
  Token
95
95
  } from "@herb-tools/core"
96
96
 
97
- import type { ERBNode } from "@herb-tools/core"
98
- import type { FormatOptions } from "./options.js"
97
+ /**
98
+ * Gets the children of an open tag, narrowing from the union type.
99
+ * Returns empty array for conditional open tags.
100
+ */
101
+ function getOpenTagChildren(element: HTMLElementNode): Node[] {
102
+ return isHTMLOpenTagNode(element.open_tag) ? element.open_tag.children : []
103
+ }
99
104
 
100
105
  /**
101
- * ASCII whitespace pattern - use instead of \s to preserve Unicode whitespace
102
- * characters like NBSP (U+00A0) and full-width space (U+3000)
106
+ * Gets the tag_closing token of an open tag, narrowing from the union type.
107
+ * Returns null for conditional open tags.
103
108
  */
104
- const ASCII_WHITESPACE = /[ \t\n\r]+/g
109
+ function getOpenTagClosing(element: HTMLElementNode): Token | null {
110
+ return isHTMLOpenTagNode(element.open_tag) ? element.open_tag.tag_closing : null
111
+ }
105
112
 
106
113
  /**
107
114
  * Printer traverses the Herb AST using the Visitor pattern
108
115
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
109
116
  */
110
- export class FormatPrinter extends Printer {
117
+ export class FormatPrinter extends Printer implements TextFlowDelegate, AttributeRendererDelegate {
111
118
  /**
112
119
  * @deprecated integrate indentWidth into this.options and update FormatOptions to extend from @herb-tools/printer options
113
120
  */
@@ -116,7 +123,7 @@ export class FormatPrinter extends Printer {
116
123
  /**
117
124
  * @deprecated integrate maxLineLength into this.options and update FormatOptions to extend from @herb-tools/printer options
118
125
  */
119
- private maxLineLength: number
126
+ maxLineLength: number
120
127
 
121
128
  /**
122
129
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
@@ -124,14 +131,15 @@ export class FormatPrinter extends Printer {
124
131
  private lines: string[] = []
125
132
  private indentLevel: number = 0
126
133
  private inlineMode: boolean = false
127
- private currentAttributeName: string | null = null
134
+ private inContentPreservingContext: boolean = false
135
+ private inConditionalOpenTagContext: boolean = false
128
136
  private elementStack: HTMLElementNode[] = []
129
137
  private elementFormattingAnalysis = new Map<HTMLElementNode, ElementFormattingAnalysis>()
130
138
  private nodeIsMultiline = new Map<Node, boolean>()
131
139
  private stringLineCount: number = 0
132
- private tagGroupsCache = new Map<Node[], Map<number, { tagName: string; groupStart: number; groupEnd: number }>>()
133
- private allSingleLineCache = new Map<Node[], boolean>()
134
-
140
+ private textFlow: TextFlowEngine
141
+ private attributeRenderer: AttributeRenderer
142
+ private spacingAnalyzer: SpacingAnalyzer
135
143
 
136
144
  public source: string
137
145
 
@@ -141,6 +149,9 @@ export class FormatPrinter extends Printer {
141
149
  this.source = source
142
150
  this.indentWidth = options.indentWidth
143
151
  this.maxLineLength = options.maxLineLength
152
+ this.textFlow = new TextFlowEngine(this)
153
+ this.attributeRenderer = new AttributeRenderer(this, this.maxLineLength, this.indentWidth)
154
+ this.spacingAnalyzer = new SpacingAnalyzer(this.nodeIsMultiline)
144
155
  }
145
156
 
146
157
  print(input: Node | ParseResult | Token): string {
@@ -153,8 +164,7 @@ export class FormatPrinter extends Printer {
153
164
  this.indentLevel = 0
154
165
  this.stringLineCount = 0
155
166
  this.nodeIsMultiline.clear()
156
- this.tagGroupsCache.clear()
157
- this.allSingleLineCache.clear()
167
+ this.spacingAnalyzer.clear()
158
168
 
159
169
  this.visit(node)
160
170
 
@@ -172,7 +182,7 @@ export class FormatPrinter extends Printer {
172
182
  * Get the current tag name from the current element context
173
183
  */
174
184
  private get currentTagName(): string {
175
- return this.currentElement?.open_tag?.tag_name?.value ?? ""
185
+ return this.currentElement?.tag_name?.value ?? ""
176
186
  }
177
187
 
178
188
  /**
@@ -257,7 +267,7 @@ export class FormatPrinter extends Printer {
257
267
  /**
258
268
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
259
269
  */
260
- private push(line: string) {
270
+ push(line: string) {
261
271
  this.lines.push(line)
262
272
  this.stringLineCount++
263
273
  }
@@ -265,7 +275,7 @@ export class FormatPrinter extends Printer {
265
275
  /**
266
276
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
267
277
  */
268
- private pushWithIndent(line: string) {
278
+ pushWithIndent(line: string) {
269
279
  const indent = line.trim() === "" ? "" : this.indent
270
280
 
271
281
  this.push(indent + line)
@@ -279,7 +289,25 @@ export class FormatPrinter extends Printer {
279
289
  return result
280
290
  }
281
291
 
282
- private get indent(): string {
292
+ private withInlineMode<T>(callback: () => T): T {
293
+ const was = this.inlineMode
294
+ this.inlineMode = true
295
+ const result = callback()
296
+ this.inlineMode = was
297
+
298
+ return result
299
+ }
300
+
301
+ private withContentPreserving<T>(callback: () => T): T {
302
+ const was = this.inContentPreservingContext
303
+ this.inContentPreservingContext = true
304
+ const result = callback()
305
+ this.inContentPreservingContext = was
306
+
307
+ return result
308
+ }
309
+
310
+ get indent(): string {
283
311
  return " ".repeat(this.indentLevel * this.indentWidth)
284
312
  }
285
313
 
@@ -289,11 +317,11 @@ export class FormatPrinter extends Printer {
289
317
  * and a trailing space (or newline for heredoc content starting with "<<").
290
318
  */
291
319
  private formatERBContent(content: string): string {
292
- let trimmedContent = content.trim();
320
+ const trimmedContent = content.trim();
293
321
 
294
322
  // See: https://github.com/marcoroth/herb/issues/476
295
323
  // TODO: revisit once we have access to Prism nodes
296
- let suffix = trimmedContent.startsWith("<<") ? "\n" : " "
324
+ const suffix = trimmedContent.startsWith("<<") ? "\n" : " "
297
325
 
298
326
  return trimmedContent ? ` ${trimmedContent}${suffix}` : ""
299
327
  }
@@ -323,401 +351,6 @@ export class FormatPrinter extends Printer {
323
351
  return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode))
324
352
  }
325
353
 
326
- /**
327
- * Check if a node will render as multiple lines when formatted.
328
- */
329
- private isMultilineElement(node: Node): boolean {
330
- if (isNode(node, ERBContentNode)) {
331
- return (node.content?.value || "").includes("\n")
332
- }
333
-
334
- if (isNode(node, HTMLElementNode) && isContentPreserving(node)) {
335
- return true
336
- }
337
-
338
- const tracked = this.nodeIsMultiline.get(node)
339
-
340
- if (tracked !== undefined) {
341
- return tracked
342
- }
343
-
344
- return false
345
- }
346
-
347
- /**
348
- * Get a grouping key for a node (tag name for HTML, ERB type for ERB)
349
- */
350
- private getGroupingKey(node: Node): string | null {
351
- if (isNode(node, HTMLElementNode)) {
352
- return getTagName(node)
353
- }
354
-
355
- if (isERBOutputNode(node)) return "erb-output"
356
- if (isERBCommentNode(node)) return "erb-comment"
357
- if (isERBNode(node)) return "erb-code"
358
-
359
- return null
360
- }
361
-
362
- /**
363
- * Detect groups of consecutive same-tag/same-type single-line elements
364
- * Returns a map of index -> group info for efficient lookup
365
- */
366
- private detectTagGroups(siblings: Node[]): Map<number, { tagName: string; groupStart: number; groupEnd: number }> {
367
- const cached = this.tagGroupsCache.get(siblings)
368
- if (cached) return cached
369
-
370
- const groupMap = new Map<number, { tagName: string; groupStart: number; groupEnd: number }>()
371
- const meaningfulNodes: Array<{ index: number; groupKey: string }> = []
372
-
373
- for (let i = 0; i < siblings.length; i++) {
374
- const node = siblings[i]
375
-
376
- if (!this.isMultilineElement(node)) {
377
- const groupKey = this.getGroupingKey(node)
378
-
379
- if (groupKey) {
380
- meaningfulNodes.push({ index: i, groupKey })
381
- }
382
- }
383
- }
384
-
385
- let groupStart = 0
386
-
387
- while (groupStart < meaningfulNodes.length) {
388
- const startGroupKey = meaningfulNodes[groupStart].groupKey
389
- let groupEnd = groupStart
390
-
391
- while (groupEnd + 1 < meaningfulNodes.length && meaningfulNodes[groupEnd + 1].groupKey === startGroupKey) {
392
- groupEnd++
393
- }
394
-
395
- if (groupEnd > groupStart) {
396
- const groupStartIndex = meaningfulNodes[groupStart].index
397
- const groupEndIndex = meaningfulNodes[groupEnd].index
398
-
399
- for (let i = groupStart; i <= groupEnd; i++) {
400
- groupMap.set(meaningfulNodes[i].index, {
401
- tagName: startGroupKey,
402
- groupStart: groupStartIndex,
403
- groupEnd: groupEndIndex
404
- })
405
- }
406
- }
407
-
408
- groupStart = groupEnd + 1
409
- }
410
-
411
- this.tagGroupsCache.set(siblings, groupMap)
412
-
413
- return groupMap
414
- }
415
-
416
- /**
417
- * Determine if spacing should be added between sibling elements
418
- *
419
- * This implements the "rule of three" intelligent spacing system:
420
- * - Adds spacing between 3 or more meaningful siblings
421
- * - Respects semantic groupings (e.g., ul/li, nav/a stay tight)
422
- * - Groups comments with following elements
423
- * - Preserves user-added spacing
424
- *
425
- * @param parentElement - The parent element containing the siblings
426
- * @param siblings - Array of all sibling nodes
427
- * @param currentIndex - Index of the current node being evaluated
428
- * @param hasExistingSpacing - Whether user-added spacing already exists
429
- * @returns true if spacing should be added before the current element
430
- */
431
- private shouldAddSpacingBetweenSiblings(parentElement: HTMLElementNode | null, siblings: Node[], currentIndex: number): boolean {
432
- const currentNode = siblings[currentIndex]
433
- const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex)
434
- const previousNode = previousMeaningfulIndex !== -1 ? siblings[previousMeaningfulIndex] : null
435
-
436
- if (previousNode && (isNode(previousNode, XMLDeclarationNode) || isNode(previousNode, HTMLDoctypeNode))) {
437
- return true
438
- }
439
-
440
- const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "")
441
-
442
- if (hasMixedContent) return false
443
-
444
- const isCurrentComment = isCommentNode(currentNode)
445
- const isPreviousComment = previousNode ? isCommentNode(previousNode) : false
446
- const isCurrentMultiline = this.isMultilineElement(currentNode)
447
- const isPreviousMultiline = previousNode ? this.isMultilineElement(previousNode) : false
448
-
449
- if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
450
- return isPreviousMultiline && isCurrentMultiline
451
- }
452
-
453
- if (isPreviousComment && isCurrentComment) {
454
- return false
455
- }
456
-
457
- if (isCurrentMultiline || isPreviousMultiline) {
458
- return true
459
- }
460
-
461
- const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child))
462
- const parentTagName = parentElement ? getTagName(parentElement) : null
463
- const isSpaceableContainer = !parentTagName || SPACEABLE_CONTAINERS.has(parentTagName)
464
- const tagGroups = this.detectTagGroups(siblings)
465
-
466
- const cached = this.allSingleLineCache.get(siblings)
467
- let allSingleLineHTMLElements: boolean
468
- if (cached !== undefined) {
469
- allSingleLineHTMLElements = cached
470
- } else {
471
- allSingleLineHTMLElements = meaningfulSiblings.every(node => isNode(node, HTMLElementNode) && !this.isMultilineElement(node))
472
- this.allSingleLineCache.set(siblings, allSingleLineHTMLElements)
473
- }
474
-
475
- if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
476
- return false
477
- }
478
-
479
- const currentGroup = tagGroups.get(currentIndex)
480
- const previousGroup = previousNode ? tagGroups.get(previousMeaningfulIndex) : undefined
481
-
482
- if (currentGroup && previousGroup && currentGroup.groupStart === previousGroup.groupStart && currentGroup.groupEnd === previousGroup.groupEnd) {
483
- return false
484
- }
485
-
486
- if (previousGroup && previousGroup.groupEnd === previousMeaningfulIndex) {
487
- return true
488
- }
489
-
490
- if (allSingleLineHTMLElements && tagGroups.size === 0) {
491
- return false
492
- }
493
-
494
- if (isNode(currentNode, HTMLElementNode)) {
495
- const currentTagName = getTagName(currentNode)
496
-
497
- if (currentTagName && INLINE_ELEMENTS.has(currentTagName)) {
498
- return false
499
- }
500
- }
501
-
502
- const isBlockElement = isBlockLevelNode(currentNode)
503
- const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode)
504
- const isComment = isCommentNode(currentNode)
505
-
506
- return isBlockElement || isERBBlock || isComment
507
- }
508
-
509
- /**
510
- * Check if we're currently processing a token list attribute that needs spacing
511
- */
512
- private get isInTokenListAttribute(): boolean {
513
- return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
514
- }
515
-
516
- /**
517
- * Render attributes as a space-separated string
518
- */
519
- private renderAttributesString(attributes: HTMLAttributeNode[]): string {
520
- if (attributes.length === 0) return ""
521
-
522
- return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`
523
- }
524
-
525
- /**
526
- * Determine if a tag should be rendered inline based on attribute count and other factors
527
- */
528
- private shouldRenderInline(
529
- totalAttributeCount: number,
530
- inlineLength: number,
531
- indentLength: number,
532
- maxLineLength: number = this.maxLineLength,
533
- hasComplexERB: boolean = false,
534
- hasMultilineAttributes: boolean = false,
535
- attributes: HTMLAttributeNode[] = []
536
- ): boolean {
537
- if (hasComplexERB || hasMultilineAttributes) return false
538
-
539
- if (totalAttributeCount === 0) {
540
- return inlineLength + indentLength <= maxLineLength
541
- }
542
-
543
- if (totalAttributeCount === 1 && attributes.length === 1) {
544
- const attribute = attributes[0]
545
- const attributeName = this.getAttributeName(attribute)
546
-
547
- if (attributeName === 'class') {
548
- const attributeValue = this.getAttributeValue(attribute)
549
- const wouldBeMultiline = this.wouldClassAttributeBeMultiline(attributeValue, indentLength)
550
-
551
- if (!wouldBeMultiline) {
552
- return true
553
- } else {
554
- return false
555
- }
556
- }
557
- }
558
-
559
- if (totalAttributeCount > 3 || inlineLength + indentLength > maxLineLength) {
560
- return false
561
- }
562
-
563
- return true
564
- }
565
-
566
- private wouldClassAttributeBeMultiline(content: string, indentLength: number): boolean {
567
- const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
568
- const hasActualNewlines = /\r?\n/.test(content)
569
-
570
- if (hasActualNewlines && normalizedContent.length > 80) {
571
- const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
572
-
573
- if (lines.length > 1) {
574
- return true
575
- }
576
- }
577
-
578
- const attributeLine = `class="${normalizedContent}"`
579
- const currentIndent = indentLength
580
-
581
- if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
582
- if (/<%[^%]*%>/.test(normalizedContent)) {
583
- return false
584
- }
585
-
586
- const classes = normalizedContent.split(' ')
587
- const lines = this.breakTokensIntoLines(classes, currentIndent)
588
- return lines.length > 1
589
- }
590
-
591
- return false
592
- }
593
-
594
- // TOOD: extract to core or reuse function from core
595
- private getAttributeName(attribute: HTMLAttributeNode): string {
596
- return attribute.name ? getCombinedAttributeName(attribute.name) : ""
597
- }
598
-
599
- // TOOD: extract to core or reuse function from core
600
- private getAttributeValue(attribute: HTMLAttributeNode): string {
601
- if (isNode(attribute.value, HTMLAttributeValueNode)) {
602
- return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('')
603
- }
604
-
605
- return ''
606
- }
607
-
608
- private hasMultilineAttributes(attributes: HTMLAttributeNode[]): boolean {
609
- return attributes.some(attribute => {
610
- if (isNode(attribute.value, HTMLAttributeValueNode)) {
611
- const content = getCombinedStringFromNodes(attribute.value.children)
612
-
613
- if (/\r?\n/.test(content)) {
614
- const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
615
-
616
- if (name === "class") {
617
- const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
618
-
619
- return normalizedContent.length > 80
620
- }
621
-
622
- const lines = content.split(/\r?\n/)
623
-
624
- if (lines.length > 1) {
625
- return lines.slice(1).some(line => /^[ \t\n\r]+/.test(line))
626
- }
627
- }
628
- }
629
-
630
- return false
631
- })
632
- }
633
-
634
- private formatClassAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
635
- const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
636
- const hasActualNewlines = /\r?\n/.test(content)
637
-
638
- if (hasActualNewlines && normalizedContent.length > 80) {
639
- const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
640
-
641
- if (lines.length > 1) {
642
- return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
643
- }
644
- }
645
-
646
- const currentIndent = this.indentLevel * this.indentWidth
647
- const attributeLine = `${name}${equals}${open_quote}${normalizedContent}${close_quote}`
648
-
649
- if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
650
- if (/<%[^%]*%>/.test(normalizedContent)) {
651
- return open_quote + normalizedContent + close_quote
652
- }
653
-
654
- const classes = normalizedContent.split(' ')
655
- const lines = this.breakTokensIntoLines(classes, currentIndent)
656
-
657
- if (lines.length > 1) {
658
- return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
659
- }
660
- }
661
-
662
- return open_quote + normalizedContent + close_quote
663
- }
664
-
665
- private isFormattableAttribute(attributeName: string, tagName: string): boolean {
666
- const globalFormattable = FORMATTABLE_ATTRIBUTES['*'] || []
667
- const tagSpecificFormattable = FORMATTABLE_ATTRIBUTES[tagName.toLowerCase()] || []
668
-
669
- return globalFormattable.includes(attributeName) || tagSpecificFormattable.includes(attributeName)
670
- }
671
-
672
- private formatMultilineAttribute(content: string, name: string, open_quote: string, close_quote: string): string {
673
- if (name === 'srcset' || name === 'sizes') {
674
- const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
675
-
676
- return open_quote + normalizedContent + close_quote
677
- }
678
-
679
- const lines = content.split('\n')
680
-
681
- if (lines.length <= 1) {
682
- return open_quote + content + close_quote
683
- }
684
-
685
- const formattedContent = this.formatMultilineAttributeValue(lines)
686
-
687
- return open_quote + formattedContent + close_quote
688
- }
689
-
690
- private formatMultilineAttributeValue(lines: string[]): string {
691
- const indent = " ".repeat((this.indentLevel + 1) * this.indentWidth)
692
- const closeIndent = " ".repeat(this.indentLevel * this.indentWidth)
693
-
694
- return "\n" + lines.map(line => indent + line).join("\n") + "\n" + closeIndent
695
- }
696
-
697
- private breakTokensIntoLines(tokens: string[], currentIndent: number, separator: string = ' '): string[] {
698
- const lines: string[] = []
699
- let currentLine = ''
700
-
701
- for (const token of tokens) {
702
- const testLine = currentLine ? currentLine + separator + token : token
703
-
704
- if (testLine.length > (this.maxLineLength - currentIndent - 6)) {
705
- if (currentLine) {
706
- lines.push(currentLine)
707
- currentLine = token
708
- } else {
709
- lines.push(token)
710
- }
711
- } else {
712
- currentLine = testLine
713
- }
714
- }
715
-
716
- if (currentLine) lines.push(currentLine)
717
-
718
- return lines
719
- }
720
-
721
354
  /**
722
355
  * Render multiline attributes for a tag
723
356
  */
@@ -731,11 +364,10 @@ export class FormatPrinter extends Printer {
731
364
  if (herbDisableComments.length > 0) {
732
365
  const commentLines = this.capture(() => {
733
366
  herbDisableComments.forEach(comment => {
734
- const wasInlineMode = this.inlineMode
735
- this.inlineMode = true
736
- this.lines.push(" ")
737
- this.visit(comment)
738
- this.inlineMode = wasInlineMode
367
+ this.withInlineMode(() => {
368
+ this.lines.push(" ")
369
+ this.visit(comment)
370
+ })
739
371
  })
740
372
  })
741
373
 
@@ -745,9 +377,10 @@ export class FormatPrinter extends Printer {
745
377
  this.pushWithIndent(openingLine)
746
378
 
747
379
  this.withIndent(() => {
380
+ this.attributeRenderer.indentLevel = this.indentLevel
748
381
  allChildren.forEach(child => {
749
382
  if (isNode(child, HTMLAttributeNode)) {
750
- this.pushWithIndent(this.renderAttribute(child))
383
+ this.pushWithIndent(this.attributeRenderer.renderAttribute(child, tagName))
751
384
  } else if (!isNode(child, WhitespaceNode)) {
752
385
  if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
753
386
  return
@@ -769,7 +402,7 @@ export class FormatPrinter extends Printer {
769
402
  * Reconstruct the text representation of an ERB node
770
403
  * @param withFormatting - if true, format the content; if false, preserve original
771
404
  */
772
- private reconstructERBNode(node: ERBNode, withFormatting: boolean = true): string {
405
+ reconstructERBNode(node: ERBNode, withFormatting: boolean = true): string {
773
406
  const open = node.tag_opening?.value ?? ""
774
407
  const close = node.tag_closing?.value ?? ""
775
408
  const content = node.content?.value ?? ""
@@ -792,15 +425,12 @@ export class FormatPrinter extends Printer {
792
425
 
793
426
  visitDocumentNode(node: DocumentNode) {
794
427
  const children = this.formatFrontmatter(node)
795
- const hasTextFlow = this.isInTextFlowContext(null, children)
428
+ const hasTextFlow = this.textFlow.isInTextFlowContext(children)
796
429
 
797
430
  if (hasTextFlow) {
798
- const wasInlineMode = this.inlineMode
799
- this.inlineMode = true
800
-
801
- this.visitTextFlowChildren(children)
802
-
803
- this.inlineMode = wasInlineMode
431
+ this.withInlineMode(() => {
432
+ this.textFlow.visitTextFlowChildren(children)
433
+ })
804
434
 
805
435
  return
806
436
  }
@@ -834,7 +464,7 @@ export class FormatPrinter extends Printer {
834
464
  this.visit(child)
835
465
 
836
466
  if (lastMeaningfulNode && !hasHandledSpacing) {
837
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings( null, children, i)
467
+ const shouldAddSpacing = this.spacingAnalyzer.shouldAddSpacingBetweenSiblings( null, children, i)
838
468
 
839
469
  if (shouldAddSpacing) {
840
470
  this.lines.splice(childStartLine, 0, "")
@@ -872,43 +502,104 @@ export class FormatPrinter extends Printer {
872
502
  this.elementStack.pop()
873
503
  }
874
504
 
875
- visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
876
- const tagName = getTagName(element)
505
+ visitHTMLConditionalElementNode(node: HTMLConditionalElementNode) {
506
+ this.trackBoundary(node, () => {
507
+ if (node.open_conditional) {
508
+ this.visit(node.open_conditional)
509
+ }
877
510
 
878
- if (isContentPreserving(element)) {
879
- element.body.map(child => {
880
- if (isNode(child, HTMLElementNode)) {
881
- const wasInlineMode = this.inlineMode
882
- this.inlineMode = true
511
+ if (node.body.length > 0) {
512
+ this.push("")
883
513
 
884
- const formattedElement = this.capture(() => this.visit(child)).join("")
885
- this.pushToLastLine(formattedElement)
514
+ this.withIndent(() => {
515
+ for (const child of node.body) {
516
+ if (!isPureWhitespaceNode(child)) {
517
+ this.visit(child)
518
+ }
519
+ }
520
+ })
886
521
 
887
- this.inlineMode = wasInlineMode
888
- } else {
889
- this.pushToLastLine(IdentityPrinter.print(child))
890
- }
891
- })
522
+ this.push("")
523
+ }
524
+
525
+ if (node.close_conditional) {
526
+ this.visit(node.close_conditional)
527
+ }
528
+ })
529
+ }
530
+
531
+ visitHTMLConditionalOpenTagNode(node: HTMLConditionalOpenTagNode) {
532
+ const wasInConditionalOpenTagContext = this.inConditionalOpenTagContext
533
+ this.inConditionalOpenTagContext = true
892
534
 
535
+ this.trackBoundary(node, () => {
536
+ if (node.conditional) {
537
+ this.visit(node.conditional)
538
+ }
539
+ })
540
+
541
+ this.inConditionalOpenTagContext = wasInConditionalOpenTagContext
542
+ }
543
+
544
+ visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
545
+ if (isContentPreserving(element) || this.inContentPreservingContext) {
546
+ this.visitContentPreservingBody(element)
893
547
  return
894
548
  }
895
549
 
550
+ const tagName = getTagName(element)
896
551
  const analysis = this.elementFormattingAnalysis.get(element)
897
- const hasTextFlow = this.isInTextFlowContext(null, body)
552
+ const hasTextFlow = this.textFlow.isInTextFlowContext(body)
898
553
  const children = filterSignificantChildren(body)
899
554
 
900
555
  if (analysis?.elementContentInline) {
901
- if (children.length === 0) return
556
+ this.visitInlineElementBody(body, tagName, hasTextFlow, children)
557
+ return
558
+ }
559
+
560
+ if (children.length === 0) return
561
+
562
+ const { comment, hasLeadingWhitespace, remainingChildren, remainingBody } = this.stripLeadingHerbDisable(children, body)
563
+
564
+ if (comment) {
565
+ const herbDisableString = this.captureHerbDisableInline(comment)
566
+ this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString)
567
+ }
568
+
569
+ if (remainingChildren.length === 0) return
570
+
571
+ this.withIndent(() => {
572
+ if (hasTextFlow) {
573
+ this.textFlow.visitTextFlowChildren(remainingBody)
574
+ } else {
575
+ this.visitElementChildren(comment ? remainingChildren : body, element)
576
+ }
577
+ })
578
+ }
579
+
580
+ private visitContentPreservingBody(element: HTMLElementNode) {
581
+ this.withContentPreserving(() => {
582
+ element.body.map(child => {
583
+ if (isNode(child, HTMLElementNode)) {
584
+ const formattedElement = this.withInlineMode(() => this.capture(() => this.visit(child)).join(""))
585
+ this.pushToLastLine(formattedElement)
586
+ } else {
587
+ this.pushToLastLine(IdentityPrinter.print(child))
588
+ }
589
+ })
590
+ })
591
+ }
902
592
 
903
- const oldInlineMode = this.inlineMode
904
- const nodesToRender = hasTextFlow ? body : children
593
+ private visitInlineElementBody(body: Node[], tagName: string, hasTextFlow: boolean, children: Node[]) {
594
+ if (children.length === 0) return
905
595
 
906
- const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
907
- const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName)
596
+ const nodesToRender = hasTextFlow ? body : children
908
597
 
909
- this.inlineMode = true
598
+ const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
599
+ const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName)
910
600
 
911
- const lines = this.capture(() => {
601
+ const lines = this.withInlineMode(() => {
602
+ return this.capture(() => {
912
603
  nodesToRender.forEach(child => {
913
604
  if (isNode(child, HTMLTextNode)) {
914
605
  if (hasTextFlow) {
@@ -941,29 +632,28 @@ export class FormatPrinter extends Printer {
941
632
  }
942
633
  })
943
634
  })
635
+ })
944
636
 
945
- const content = lines.join('')
946
-
947
- const inlineContent = shouldPreserveSpaces
948
- ? (hasTextFlow ? content.replace(ASCII_WHITESPACE, ' ') : content)
949
- : (hasTextFlow ? content.replace(ASCII_WHITESPACE, ' ').trim() : content.trim())
950
-
951
- if (inlineContent) {
952
- this.pushToLastLine(inlineContent)
953
- }
637
+ const content = lines.join('')
954
638
 
955
- this.inlineMode = oldInlineMode
639
+ const inlineContent = shouldPreserveSpaces
640
+ ? (hasTextFlow ? content.replace(ASCII_WHITESPACE, ' ') : content)
641
+ : (hasTextFlow ? content.replace(ASCII_WHITESPACE, ' ').trim() : content.trim())
956
642
 
957
- return
643
+ if (inlineContent) {
644
+ this.pushToLastLine(inlineContent)
958
645
  }
646
+ }
959
647
 
960
- if (children.length === 0) return
961
-
648
+ private stripLeadingHerbDisable(children: Node[], body: Node[]): {
649
+ comment: Node | null
650
+ hasLeadingWhitespace: boolean
651
+ remainingChildren: Node[]
652
+ remainingBody: Node[]
653
+ } {
962
654
  let leadingHerbDisableComment: Node | null = null
963
655
  let leadingHerbDisableIndex = -1
964
656
  let firstWhitespaceIndex = -1
965
- let remainingChildren = children
966
- let remainingBodyUnfiltered = body
967
657
 
968
658
  for (let i = 0; i < children.length; i++) {
969
659
  const child = children[i]
@@ -984,60 +674,30 @@ export class FormatPrinter extends Printer {
984
674
  break
985
675
  }
986
676
 
987
- if (leadingHerbDisableComment && leadingHerbDisableIndex >= 0) {
988
- remainingChildren = children.filter((_, index) => {
989
- if (index === leadingHerbDisableIndex) return false
990
-
991
- if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
992
- const child = children[index]
993
-
994
- if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
995
- return false
996
- }
997
- }
998
-
999
- return true
1000
- })
677
+ if (!leadingHerbDisableComment || leadingHerbDisableIndex < 0) {
678
+ return { comment: null, hasLeadingWhitespace: false, remainingChildren: children, remainingBody: body }
679
+ }
1001
680
 
1002
- remainingBodyUnfiltered = body.filter((_, index) => {
1003
- if (index === leadingHerbDisableIndex) return false
681
+ const filterOut = (nodes: Node[]) => nodes.filter((_, index) => {
682
+ if (index === leadingHerbDisableIndex) return false
1004
683
 
1005
- if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
1006
- const child = body[index]
684
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
685
+ const child = nodes[index]
1007
686
 
1008
- if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
1009
- return false
1010
- }
687
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
688
+ return false
1011
689
  }
690
+ }
1012
691
 
1013
- return true
1014
- })
1015
- }
1016
-
1017
- if (leadingHerbDisableComment) {
1018
- const herbDisableString = this.capture(() => {
1019
- const savedIndentLevel = this.indentLevel
1020
- this.indentLevel = 0
1021
- this.inlineMode = true
1022
- this.visit(leadingHerbDisableComment)
1023
- this.inlineMode = false
1024
- this.indentLevel = savedIndentLevel
1025
- }).join("")
1026
-
1027
- const hasLeadingWhitespace = firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex
692
+ return true
693
+ })
1028
694
 
1029
- this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString)
695
+ return {
696
+ comment: leadingHerbDisableComment,
697
+ hasLeadingWhitespace: firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex,
698
+ remainingChildren: filterOut(children),
699
+ remainingBody: filterOut(body),
1030
700
  }
1031
-
1032
- if (remainingChildren.length === 0) return
1033
-
1034
- this.withIndent(() => {
1035
- if (hasTextFlow) {
1036
- this.visitTextFlowChildren(remainingBodyUnfiltered)
1037
- } else {
1038
- this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element)
1039
- }
1040
- })
1041
701
  }
1042
702
 
1043
703
  /**
@@ -1053,9 +713,7 @@ export class FormatPrinter extends Printer {
1053
713
  const child = body[index]
1054
714
 
1055
715
  if (isNode(child, HTMLTextNode)) {
1056
- const isWhitespaceOnly = child.content.trim() === ""
1057
-
1058
- if (isWhitespaceOnly) {
716
+ if (isPureWhitespaceNode(child)) {
1059
717
  const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(body[index - 1])
1060
718
  const hasNextNonWhitespace = index < body.length - 1 && isNonWhitespaceNode(body[index + 1])
1061
719
  const hasMultipleNewlines = child.content.includes('\n\n')
@@ -1071,59 +729,99 @@ export class FormatPrinter extends Printer {
1071
729
 
1072
730
  if (!isNonWhitespaceNode(child)) continue
1073
731
 
1074
- let hasTrailingHerbDisable = false
732
+ const textFlowResult = this.visitTextFlowRunInChildren(body, index, lastMeaningfulNode, hasHandledSpacing)
1075
733
 
1076
- if (isNode(child, HTMLElementNode) && child.close_tag) {
1077
- for (let j = index + 1; j < body.length; j++) {
1078
- const nextChild = body[j]
734
+ if (textFlowResult) {
735
+ index = textFlowResult.newIndex
736
+ lastMeaningfulNode = textFlowResult.lastMeaningfulNode
737
+ hasHandledSpacing = textFlowResult.hasHandledSpacing
738
+ continue
739
+ }
1079
740
 
1080
- if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
1081
- continue
1082
- }
741
+ const herbDisableResult: ChildVisitResult | null =
742
+ isNode(child, HTMLElementNode) && child.close_tag
743
+ ? this.visitChildWithTrailingHerbDisable(child, body, index, parentElement, lastMeaningfulNode, hasHandledSpacing)
744
+ : null
1083
745
 
1084
- if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
1085
- hasTrailingHerbDisable = true
746
+ if (herbDisableResult) {
747
+ index = herbDisableResult.newIndex
748
+ lastMeaningfulNode = herbDisableResult.lastMeaningfulNode
749
+ hasHandledSpacing = herbDisableResult.hasHandledSpacing
750
+ continue
751
+ }
1086
752
 
1087
- const childStartLine = this.stringLineCount
1088
- this.visit(child)
753
+ if (shouldAppendToLastLine(child, body, index)) {
754
+ this.appendChildToLastLine(child, body, index)
755
+ lastMeaningfulNode = child
756
+ hasHandledSpacing = false
757
+ continue
758
+ }
1089
759
 
1090
- if (lastMeaningfulNode && !hasHandledSpacing) {
1091
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index)
760
+ const childStartLine = this.stringLineCount
761
+ this.visit(child)
1092
762
 
1093
- if (shouldAddSpacing) {
1094
- this.lines.splice(childStartLine, 0, "")
1095
- this.stringLineCount++
1096
- }
1097
- }
763
+ if (lastMeaningfulNode && !hasHandledSpacing) {
764
+ const shouldAddSpacing = this.spacingAnalyzer.shouldAddSpacingBetweenSiblings(parentElement, body, index)
765
+
766
+ if (shouldAddSpacing) {
767
+ this.lines.splice(childStartLine, 0, "")
768
+ this.stringLineCount++
769
+ }
770
+ }
771
+
772
+ lastMeaningfulNode = child
773
+ hasHandledSpacing = false
774
+ }
775
+ }
1098
776
 
1099
- const herbDisableString = this.capture(() => {
1100
- const savedIndentLevel = this.indentLevel
1101
- this.indentLevel = 0
1102
- this.inlineMode = true
1103
- this.visit(nextChild)
1104
- this.inlineMode = false
1105
- this.indentLevel = savedIndentLevel
1106
- }).join("")
777
+ private visitTextFlowRunInChildren(body: Node[], index: number, lastMeaningfulNode: Node | null, hasHandledSpacing: boolean): ChildVisitResult | null {
778
+ const child = body[index]
1107
779
 
1108
- this.pushToLastLine(' ' + herbDisableString)
780
+ if (!isTextFlowNode(child)) return null
1109
781
 
1110
- index = j
1111
- lastMeaningfulNode = child
1112
- hasHandledSpacing = false
782
+ const run = this.textFlow.collectTextFlowRun(body, index)
1113
783
 
1114
- break
1115
- }
784
+ if (!run) return null
1116
785
 
1117
- break
1118
- }
786
+ if (lastMeaningfulNode && !hasHandledSpacing) {
787
+ const hasBlankLineBefore = this.spacingAnalyzer.hasBlankLineBetween(body, index)
788
+
789
+ if (hasBlankLineBefore) {
790
+ this.push("")
791
+ }
792
+ }
793
+
794
+ this.textFlow.visitTextFlowChildren(run.nodes)
795
+
796
+ const lastRunNode = run.nodes[run.nodes.length - 1]
797
+ const hasBlankLineInTrailing = isNode(lastRunNode, HTMLTextNode) && lastRunNode.content.includes('\n\n')
798
+ const hasBlankLineAfter = hasBlankLineInTrailing || this.spacingAnalyzer.hasBlankLineBetween(body, run.endIndex)
799
+
800
+ if (hasBlankLineAfter) {
801
+ this.push("")
802
+ }
803
+
804
+ return {
805
+ newIndex: run.endIndex - 1,
806
+ lastMeaningfulNode: run.nodes[run.nodes.length - 1],
807
+ hasHandledSpacing: hasBlankLineAfter,
808
+ }
809
+ }
810
+
811
+ private visitChildWithTrailingHerbDisable(child: HTMLElementNode, body: Node[], index: number, parentElement: HTMLElementNode | null, lastMeaningfulNode: Node | null, hasHandledSpacing: boolean): ChildVisitResult | null {
812
+ for (let j = index + 1; j < body.length; j++) {
813
+ const nextChild = body[j]
814
+
815
+ if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
816
+ continue
1119
817
  }
1120
818
 
1121
- if (!hasTrailingHerbDisable) {
819
+ if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
1122
820
  const childStartLine = this.stringLineCount
1123
821
  this.visit(child)
1124
822
 
1125
823
  if (lastMeaningfulNode && !hasHandledSpacing) {
1126
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index)
824
+ const shouldAddSpacing = this.spacingAnalyzer.shouldAddSpacingBetweenSiblings(parentElement, body, index)
1127
825
 
1128
826
  if (shouldAddSpacing) {
1129
827
  this.lines.splice(childStartLine, 0, "")
@@ -1131,10 +829,21 @@ export class FormatPrinter extends Printer {
1131
829
  }
1132
830
  }
1133
831
 
1134
- lastMeaningfulNode = child
1135
- hasHandledSpacing = false
832
+ const herbDisableString = this.captureHerbDisableInline(nextChild)
833
+
834
+ this.pushToLastLine(' ' + herbDisableString)
835
+
836
+ return {
837
+ newIndex: j,
838
+ lastMeaningfulNode: child,
839
+ hasHandledSpacing: false,
840
+ }
1136
841
  }
842
+
843
+ break
1137
844
  }
845
+
846
+ return null
1138
847
  }
1139
848
 
1140
849
  visitHTMLOpenTagNode(node: HTMLOpenTagNode) {
@@ -1142,6 +851,12 @@ export class FormatPrinter extends Printer {
1142
851
  const inlineNodes = this.extractInlineNodes(node.children)
1143
852
  const isSelfClosing = node.tag_closing?.value === "/>"
1144
853
 
854
+ if (this.inConditionalOpenTagContext) {
855
+ const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
856
+ this.push(this.indent + inline)
857
+ return
858
+ }
859
+
1145
860
  if (this.currentElement && this.elementFormattingAnalysis.has(this.currentElement)) {
1146
861
  const analysis = this.elementFormattingAnalysis.get(this.currentElement)!
1147
862
 
@@ -1159,13 +874,14 @@ export class FormatPrinter extends Printer {
1159
874
 
1160
875
  const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
1161
876
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
1162
- const shouldKeepInline = this.shouldRenderInline(
877
+ this.attributeRenderer.indentLevel = this.indentLevel
878
+ const shouldKeepInline = this.attributeRenderer.shouldRenderInline(
1163
879
  totalAttributeCount,
1164
880
  inline.length,
1165
881
  this.indent.length,
1166
882
  this.maxLineLength,
1167
883
  false,
1168
- this.hasMultilineAttributes(attributes),
884
+ this.attributeRenderer.hasMultilineAttributes(attributes),
1169
885
  attributes
1170
886
  )
1171
887
 
@@ -1199,7 +915,7 @@ export class FormatPrinter extends Printer {
1199
915
  return
1200
916
  }
1201
917
 
1202
- let text = node.content.trim()
918
+ const text = node.content.trim()
1203
919
 
1204
920
  if (!text) return
1205
921
 
@@ -1224,7 +940,8 @@ export class FormatPrinter extends Printer {
1224
940
  }
1225
941
 
1226
942
  visitHTMLAttributeNode(node: HTMLAttributeNode) {
1227
- this.pushWithIndent(this.renderAttribute(node))
943
+ this.attributeRenderer.indentLevel = this.indentLevel
944
+ this.pushWithIndent(this.attributeRenderer.renderAttribute(node, this.currentTagName))
1228
945
  }
1229
946
 
1230
947
  visitHTMLAttributeNameNode(node: HTMLAttributeNameNode) {
@@ -1235,119 +952,39 @@ export class FormatPrinter extends Printer {
1235
952
  this.pushWithIndent(IdentityPrinter.print(node))
1236
953
  }
1237
954
 
1238
- // TODO: rework
1239
955
  visitHTMLCommentNode(node: HTMLCommentNode) {
1240
956
  const open = node.comment_start?.value ?? ""
1241
957
  const close = node.comment_end?.value ?? ""
1242
-
1243
- let inner: string
1244
-
1245
- if (node.children && node.children.length > 0) {
1246
- inner = node.children.map(child => {
1247
- if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
1248
- return child.content
1249
- } else if (isERBNode(child)) {
1250
- return IdentityPrinter.print(child)
1251
- } else {
1252
- return ""
1253
- }
1254
- }).join("")
1255
-
1256
- const trimmedInner = inner.trim()
1257
-
1258
- if (trimmedInner.startsWith('[if ') && trimmedInner.endsWith('<![endif]')) {
1259
- this.pushWithIndent(open + inner + close)
1260
-
1261
- return
1262
- }
1263
-
1264
- const hasNewlines = inner.includes('\n')
1265
-
1266
- if (hasNewlines) {
1267
- const lines = inner.split('\n')
1268
- const childIndent = " ".repeat(this.indentWidth)
1269
- const firstLineHasContent = lines[0].trim() !== ''
1270
-
1271
- if (firstLineHasContent && lines.length > 1) {
1272
- const contentLines = lines.map(line => line.trim()).filter(line => line !== '')
1273
- inner = '\n' + contentLines.map(line => childIndent + line).join('\n') + '\n'
1274
- } else {
1275
- const contentLines = lines.filter((line, index) => {
1276
- return line.trim() !== '' && !(index === 0 || index === lines.length - 1)
1277
- })
1278
-
1279
- const minIndent = contentLines.length > 0 ? Math.min(...contentLines.map(line => line.length - line.trimStart().length)) : 0
1280
-
1281
- const processedLines = lines.map((line, index) => {
1282
- const trimmedLine = line.trim()
1283
-
1284
- if ((index === 0 || index === lines.length - 1) && trimmedLine === '') {
1285
- return line
1286
- }
1287
-
1288
- if (trimmedLine !== '') {
1289
- const currentIndent = line.length - line.trimStart().length
1290
- const relativeIndent = Math.max(0, currentIndent - minIndent)
1291
-
1292
- return childIndent + " ".repeat(relativeIndent) + trimmedLine
1293
- }
1294
-
1295
- return line
1296
- })
1297
-
1298
- inner = processedLines.join('\n')
1299
- }
1300
- } else {
1301
- inner = ` ${inner.trim()} `
1302
- }
1303
- } else {
1304
- inner = ""
1305
- }
958
+ const rawInner = node.children && node.children.length > 0
959
+ ? extractHTMLCommentContent(node.children)
960
+ : ""
961
+ const inner = rawInner ? formatHTMLCommentInner(rawInner, this.indentWidth) : ""
1306
962
 
1307
963
  this.pushWithIndent(open + inner + close)
1308
964
  }
1309
965
 
1310
966
  visitERBCommentNode(node: ERBContentNode) {
1311
- const open = node.tag_opening?.value || "<%#"
1312
- const content = node?.content?.value || ""
1313
- const close = node.tag_closing?.value || "%>"
1314
-
1315
- const contentLines = content.split("\n")
1316
- const contentTrimmedLines = content.trim().split("\n")
1317
-
1318
- if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
1319
- const startsWithSpace = content[0] === " "
1320
- const before = startsWithSpace ? "" : " "
967
+ const result = formatERBCommentLines(
968
+ node.tag_opening?.value || "<%#",
969
+ node?.content?.value || "",
970
+ node.tag_closing?.value || "%>"
971
+ )
1321
972
 
973
+ if (result.type === 'single-line') {
1322
974
  if (this.inlineMode) {
1323
- this.push(open + before + content.trimEnd() + ' ' + close)
975
+ this.push(result.text)
1324
976
  } else {
1325
- this.pushWithIndent(open + before + content.trimEnd() + ' ' + close)
977
+ this.pushWithIndent(result.text)
1326
978
  }
979
+ } else {
980
+ this.pushWithIndent(result.header)
1327
981
 
1328
- return
1329
- }
1330
-
1331
- if (contentTrimmedLines.length === 1) {
1332
- if (this.inlineMode) {
1333
- this.push(open + ' ' + content.trim() + ' ' + close)
1334
- } else {
1335
- this.pushWithIndent(open + ' ' + content.trim() + ' ' + close)
1336
- }
982
+ this.withIndent(() => {
983
+ result.contentLines.forEach(line => this.pushWithIndent(line))
984
+ })
1337
985
 
1338
- return
986
+ this.pushWithIndent(result.footer)
1339
987
  }
1340
-
1341
- const firstLineEmpty = contentLines[0].trim() === ""
1342
- const dedentedContent = dedent(firstLineEmpty ? content : content.trimStart())
1343
-
1344
- this.pushWithIndent(open)
1345
-
1346
- this.withIndent(() => {
1347
- dedentedContent.split("\n").forEach(line => this.pushWithIndent(line))
1348
- })
1349
-
1350
- this.pushWithIndent(close)
1351
988
  }
1352
989
 
1353
990
  visitHTMLDoctypeNode(node: HTMLDoctypeNode) {
@@ -1370,6 +1007,14 @@ export class FormatPrinter extends Printer {
1370
1007
  }
1371
1008
  }
1372
1009
 
1010
+ visitERBOpenTagNode(node: ERBOpenTagNode) {
1011
+ this.printERBNode(node)
1012
+ }
1013
+
1014
+ visitHTMLVirtualCloseTagNode(_node: HTMLVirtualCloseTagNode) {
1015
+ // Virtual closing tags don't print anything (they are synthetic)
1016
+ }
1017
+
1373
1018
  visitERBEndNode(node: ERBEndNode) {
1374
1019
  this.printERBNode(node)
1375
1020
  }
@@ -1404,10 +1049,10 @@ export class FormatPrinter extends Printer {
1404
1049
  this.printERBNode(node)
1405
1050
 
1406
1051
  this.withIndent(() => {
1407
- const hasTextFlow = this.isInTextFlowContext(null, node.body)
1052
+ const hasTextFlow = this.textFlow.isInTextFlowContext(node.body)
1408
1053
 
1409
1054
  if (hasTextFlow) {
1410
- this.visitTextFlowChildren(node.body)
1055
+ this.textFlow.visitTextFlowChildren(node.body)
1411
1056
  } else {
1412
1057
  this.visitElementChildren(node.body, null)
1413
1058
  }
@@ -1425,9 +1070,10 @@ export class FormatPrinter extends Printer {
1425
1070
  node.statements.forEach(child => {
1426
1071
  if (isNode(child, HTMLAttributeNode)) {
1427
1072
  this.lines.push(" ")
1428
- this.lines.push(this.renderAttribute(child))
1073
+ this.attributeRenderer.indentLevel = this.indentLevel
1074
+ this.lines.push(this.attributeRenderer.renderAttribute(child, this.currentTagName))
1429
1075
  } else {
1430
- const shouldAddSpaces = this.isInTokenListAttribute
1076
+ const shouldAddSpaces = this.attributeRenderer.isInTokenListAttribute
1431
1077
 
1432
1078
  if (shouldAddSpaces) {
1433
1079
  this.lines.push(" ")
@@ -1442,7 +1088,7 @@ export class FormatPrinter extends Printer {
1442
1088
  })
1443
1089
 
1444
1090
  const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
1445
- const isTokenList = this.isInTokenListAttribute
1091
+ const isTokenList = this.attributeRenderer.isInTokenListAttribute
1446
1092
 
1447
1093
  if ((hasHTMLAttributes || isTokenList) && node.end_node) {
1448
1094
  this.lines.push(" ")
@@ -1570,7 +1216,12 @@ export class FormatPrinter extends Printer {
1570
1216
  * Determines if the open tag should be rendered inline
1571
1217
  */
1572
1218
  private shouldRenderOpenTagInline(node: HTMLElementNode): boolean {
1573
- const children = node.open_tag?.children || []
1219
+ if (isNode(node.open_tag, HTMLConditionalOpenTagNode)) {
1220
+ return false
1221
+ }
1222
+
1223
+ const openTag = node.open_tag
1224
+ const children = openTag?.children || []
1574
1225
  const attributes = filterNodes(children, HTMLAttributeNode)
1575
1226
  const inlineNodes = this.extractInlineNodes(children)
1576
1227
  const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node))
@@ -1579,19 +1230,20 @@ export class FormatPrinter extends Printer {
1579
1230
  if (hasComplexERB) return false
1580
1231
 
1581
1232
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
1582
- const hasMultilineAttrs = this.hasMultilineAttributes(attributes)
1233
+ this.attributeRenderer.indentLevel = this.indentLevel
1234
+ const hasMultilineAttrs = this.attributeRenderer.hasMultilineAttributes(attributes)
1583
1235
 
1584
1236
  if (hasMultilineAttrs) return false
1585
1237
 
1586
1238
  const inline = this.renderInlineOpen(
1587
1239
  getTagName(node),
1588
1240
  attributes,
1589
- node.open_tag?.tag_closing?.value === "/>",
1241
+ openTag?.tag_closing?.value === "/>",
1590
1242
  inlineNodes,
1591
1243
  children
1592
1244
  )
1593
1245
 
1594
- return this.shouldRenderInline(
1246
+ return this.attributeRenderer.shouldRenderInline(
1595
1247
  totalAttributeCount,
1596
1248
  inline.length,
1597
1249
  this.indent.length,
@@ -1609,6 +1261,7 @@ export class FormatPrinter extends Printer {
1609
1261
  const tagName = getTagName(node)
1610
1262
  const children = filterSignificantChildren(node.body)
1611
1263
  const openTagInline = this.shouldRenderOpenTagInline(node)
1264
+ const openTagClosing = getOpenTagClosing(node)
1612
1265
 
1613
1266
  if (!openTagInline) return false
1614
1267
  if (children.length === 0) return true
@@ -1623,46 +1276,46 @@ export class FormatPrinter extends Printer {
1623
1276
 
1624
1277
  if (hasNonInlineChildElements) return false
1625
1278
 
1626
- let hasLeadingHerbDisable = false
1627
-
1628
- for (const child of node.body) {
1629
- if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
1630
- continue
1631
- }
1632
- if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
1633
- hasLeadingHerbDisable = true
1634
- }
1635
- break
1636
- }
1637
-
1638
- if (hasLeadingHerbDisable && !isInlineElement(tagName)) {
1279
+ if (hasLeadingHerbDisable(node.body) && !isInlineElement(tagName)) {
1639
1280
  return false
1640
1281
  }
1641
1282
 
1642
1283
  if (isInlineElement(tagName)) {
1643
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body)
1284
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(getOpenTagChildren(node), HTMLAttributeNode), node.body)
1644
1285
 
1645
1286
  if (fullInlineResult) {
1646
- const totalLength = this.indent.length + fullInlineResult.length
1647
- return totalLength <= this.maxLineLength
1287
+ return this.fitsOnCurrentLine(fullInlineResult)
1648
1288
  }
1649
1289
 
1650
1290
  return false
1651
1291
  }
1652
1292
 
1293
+ if (SPACEABLE_CONTAINERS.has(tagName)) {
1294
+ const allChildrenAreERB = children.length > 1 && children.every(child => isERBNode(child))
1295
+
1296
+ if (allChildrenAreERB) return false
1297
+ }
1298
+
1299
+ if (!isInlineElement(tagName) && openTagClosing) {
1300
+ const first = children[0]
1301
+ const startsOnNewLine = first.location.start.line > openTagClosing.location.end.line
1302
+ const hasLeadingNewline = isNode(first, HTMLTextNode) && /^\s*\n/.test(first.content)
1303
+ const contentStartsOnNewLine = startsOnNewLine || hasLeadingNewline
1304
+
1305
+ if (contentStartsOnNewLine) {
1306
+ return false
1307
+ }
1308
+ }
1309
+
1653
1310
  const allNestedAreInline = areAllNestedElementsInline(children)
1654
1311
  const hasMultilineText = hasMultilineTextContent(children)
1655
1312
  const hasMixedContent = hasMixedTextAndInlineContent(children)
1656
1313
 
1657
1314
  if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
1658
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body)
1659
-
1660
- if (fullInlineResult) {
1661
- const totalLength = this.indent.length + fullInlineResult.length
1315
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(getOpenTagChildren(node), HTMLAttributeNode), node.body)
1662
1316
 
1663
- if (totalLength <= this.maxLineLength) {
1664
- return true
1665
- }
1317
+ if (fullInlineResult && this.fitsOnCurrentLine(fullInlineResult)) {
1318
+ return true
1666
1319
  }
1667
1320
  }
1668
1321
 
@@ -1671,17 +1324,16 @@ export class FormatPrinter extends Printer {
1671
1324
  if (inlineResult) {
1672
1325
  const openTagResult = this.renderInlineOpen(
1673
1326
  tagName,
1674
- filterNodes(node.open_tag?.children, HTMLAttributeNode),
1327
+ filterNodes(getOpenTagChildren(node), HTMLAttributeNode),
1675
1328
  false,
1676
1329
  [],
1677
- node.open_tag?.children || []
1330
+ getOpenTagChildren(node)
1678
1331
  )
1679
1332
 
1680
1333
  const childrenContent = this.renderChildrenInline(children)
1681
1334
  const fullLine = openTagResult + childrenContent + `</${tagName}>`
1682
- const totalLength = this.indent.length + fullLine.length
1683
1335
 
1684
- if (totalLength <= this.maxLineLength) {
1336
+ if (this.fitsOnCurrentLine(fullLine)) {
1685
1337
  return true
1686
1338
  }
1687
1339
  }
@@ -1694,7 +1346,7 @@ export class FormatPrinter extends Printer {
1694
1346
  */
1695
1347
  private shouldRenderCloseTagInline(node: HTMLElementNode, elementContentInline: boolean): boolean {
1696
1348
  if (node.is_void) return true
1697
- if (node.open_tag?.tag_closing?.value === "/>") return true
1349
+ if (getOpenTagClosing(node)?.value === "/>") return true
1698
1350
  if (isContentPreserving(node)) return true
1699
1351
 
1700
1352
  const children = filterSignificantChildren(node.body)
@@ -1707,6 +1359,19 @@ export class FormatPrinter extends Printer {
1707
1359
 
1708
1360
  // --- Utility methods ---
1709
1361
 
1362
+ private captureHerbDisableInline(node: Node): string {
1363
+ return this.capture(() => {
1364
+ const savedIndentLevel = this.indentLevel
1365
+ this.indentLevel = 0
1366
+ this.withInlineMode(() => this.visit(node))
1367
+ this.indentLevel = savedIndentLevel
1368
+ }).join("")
1369
+ }
1370
+
1371
+ private fitsOnCurrentLine(content: string): boolean {
1372
+ return this.indent.length + content.length <= this.maxLineLength
1373
+ }
1374
+
1710
1375
  private formatFrontmatter(node: DocumentNode): Node[] {
1711
1376
  const firstChild = node.children[0]
1712
1377
  const hasFrontmatter = firstChild && isFrontmatter(firstChild)
@@ -1739,687 +1404,80 @@ export class FormatPrinter extends Printer {
1739
1404
  }
1740
1405
  }
1741
1406
 
1742
- const oldInlineMode = this.inlineMode
1743
- this.inlineMode = true
1744
- const inlineContent = this.capture(() => this.visit(child)).join("")
1745
- this.inlineMode = oldInlineMode
1407
+ const inlineContent = this.withInlineMode(() => this.capture(() => this.visit(child)).join(""))
1746
1408
  this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent)
1747
1409
  }
1748
1410
  }
1749
1411
 
1750
- /**
1751
- * Visit children in a text flow context (mixed text and inline elements)
1752
- * Handles word wrapping and keeps adjacent inline elements together
1753
- */
1754
- private visitTextFlowChildren(children: Node[]) {
1755
- const adjacentInlineCount = countAdjacentInlineElements(children)
1756
-
1757
- if (adjacentInlineCount >= 2) {
1758
- const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount)
1759
- this.visitRemainingChildren(children, processedIndices)
1760
-
1761
- return
1762
- }
1763
1412
 
1764
- this.buildAndWrapTextFlow(children)
1765
- }
1413
+ // --- TextFlowDelegate implementation ---
1766
1414
 
1767
1415
  /**
1768
- * Wrap remaining words that don't fit on the current line
1769
- * Returns the wrapped lines with proper indentation
1416
+ * Render an inline element as a string
1770
1417
  */
1771
- private wrapRemainingWords(words: string[], wrapWidth: number): string[] {
1772
- const lines: string[] = []
1773
- let line = ""
1418
+ renderInlineElementAsString(element: HTMLElementNode): string {
1419
+ const tagName = getTagName(element)
1420
+ const tagClosing = getOpenTagClosing(element)
1774
1421
 
1775
- for (const word of words) {
1776
- const testLine = line + (line ? " " : "") + word
1422
+ if (element.is_void || tagClosing?.value === "/>") {
1423
+ const attributes = filterNodes(getOpenTagChildren(element), HTMLAttributeNode)
1424
+ this.attributeRenderer.indentLevel = this.indentLevel
1425
+ const attributesString = this.attributeRenderer.renderAttributesString(attributes, tagName)
1426
+ const isSelfClosing = tagClosing?.value === "/>"
1777
1427
 
1778
- if (testLine.length > wrapWidth && line) {
1779
- lines.push(this.indent + line)
1780
- line = word
1781
- } else {
1782
- line = testLine
1783
- }
1428
+ return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`
1784
1429
  }
1785
1430
 
1786
- if (line) {
1787
- lines.push(this.indent + line)
1788
- }
1431
+ const childrenToRender = this.getFilteredChildren(element.body)
1432
+
1433
+ const childInline = this.tryRenderInlineFull(element, tagName,
1434
+ filterNodes(getOpenTagChildren(element), HTMLAttributeNode),
1435
+ childrenToRender
1436
+ )
1789
1437
 
1790
- return lines
1438
+ return childInline !== null ? childInline : ""
1791
1439
  }
1792
1440
 
1793
1441
  /**
1794
- * Try to merge text starting with punctuation to inline content
1795
- * Returns object with merged content and whether processing should stop
1442
+ * Render an ERB node as a string
1796
1443
  */
1797
- private tryMergePunctuationText(inlineContent: string, trimmedText: string, wrapWidth: number): { mergedContent: string, shouldStop: boolean, wrappedLines: string[] } {
1798
- const combined = inlineContent + trimmedText
1799
-
1800
- if (combined.length <= wrapWidth) {
1801
- return {
1802
- mergedContent: inlineContent + trimmedText,
1803
- shouldStop: false,
1804
- wrappedLines: []
1805
- }
1806
- }
1444
+ renderERBAsString(node: ERBContentNode): string {
1445
+ return this.withInlineMode(() => this.capture(() => this.visit(node)).join(""))
1446
+ }
1807
1447
 
1808
- const match = trimmedText.match(/^[.!?:;]+/)
1448
+ /**
1449
+ * Try to render an inline element, returning the full inline string or null if it can't be inlined.
1450
+ */
1451
+ tryRenderInlineElement(element: HTMLElementNode): string | null {
1452
+ const tagName = getTagName(element)
1453
+ const childrenToRender = this.getFilteredChildren(element.body)
1809
1454
 
1810
- if (!match) {
1811
- return {
1812
- mergedContent: inlineContent,
1813
- shouldStop: false,
1814
- wrappedLines: []
1815
- }
1816
- }
1455
+ return this.tryRenderInlineFull(element, tagName, filterNodes(getOpenTagChildren(element), HTMLAttributeNode), childrenToRender)
1456
+ }
1817
1457
 
1818
- const punctuation = match[0]
1819
- const restText = trimmedText.substring(punctuation.length).trim()
1820
1458
 
1821
- if (!restText) {
1822
- return {
1823
- mergedContent: inlineContent + punctuation,
1824
- shouldStop: false,
1825
- wrappedLines: []
1826
- }
1827
- }
1459
+ private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
1460
+ this.attributeRenderer.indentLevel = this.indentLevel
1461
+ const parts = attributes.map(attribute => this.attributeRenderer.renderAttribute(attribute, name))
1828
1462
 
1829
- const words = restText.split(/[ \t\n\r]+/)
1830
- let toMerge = punctuation
1831
- let mergedWordCount = 0
1463
+ if (inlineNodes.length > 0) {
1464
+ let result = `<${name}`
1832
1465
 
1833
- for (const word of words) {
1834
- const testMerge = toMerge + ' ' + word
1466
+ if (allChildren.length > 0) {
1467
+ const lines = this.capture(() => {
1468
+ allChildren.forEach(child => {
1469
+ if (isNode(child, HTMLAttributeNode)) {
1470
+ this.lines.push(" " + this.attributeRenderer.renderAttribute(child, name))
1471
+ } else if (!(isNode(child, WhitespaceNode))) {
1472
+ this.withInlineMode(() => {
1473
+ this.lines.push(" ")
1474
+ this.visit(child)
1475
+ })
1476
+ }
1477
+ })
1478
+ })
1835
1479
 
1836
- if ((inlineContent + testMerge).length <= wrapWidth) {
1837
- toMerge = testMerge
1838
- mergedWordCount++
1839
- } else {
1840
- break
1841
- }
1842
- }
1843
-
1844
- const mergedContent = inlineContent + toMerge
1845
-
1846
- if (mergedWordCount >= words.length) {
1847
- return {
1848
- mergedContent,
1849
- shouldStop: false,
1850
- wrappedLines: []
1851
- }
1852
- }
1853
-
1854
- const remainingWords = words.slice(mergedWordCount)
1855
- const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth)
1856
-
1857
- return {
1858
- mergedContent,
1859
- shouldStop: true,
1860
- wrappedLines
1861
- }
1862
- }
1863
-
1864
- /**
1865
- * Render adjacent inline elements together on one line
1866
- */
1867
- private renderAdjacentInlineElements(children: Node[], count: number): { processedIndices: Set<number> } {
1868
- let inlineContent = ""
1869
- let processedCount = 0
1870
- let lastProcessedIndex = -1
1871
- const processedIndices = new Set<number>()
1872
-
1873
- for (let index = 0; index < children.length && processedCount < count; index++) {
1874
- const child = children[index]
1875
-
1876
- if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
1877
- continue
1878
- }
1879
-
1880
- if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
1881
- inlineContent += this.renderInlineElementAsString(child)
1882
- processedCount++
1883
- lastProcessedIndex = index
1884
- processedIndices.add(index)
1885
-
1886
- if (inlineContent && isLineBreakingElement(child)) {
1887
- this.pushWithIndent(inlineContent)
1888
- inlineContent = ""
1889
- }
1890
- } else if (isNode(child, ERBContentNode)) {
1891
- inlineContent += this.renderERBAsString(child)
1892
- processedCount++
1893
- lastProcessedIndex = index
1894
- processedIndices.add(index)
1895
- }
1896
- }
1897
-
1898
- if (lastProcessedIndex >= 0) {
1899
- for (let index = lastProcessedIndex + 1; index < children.length; index++) {
1900
- const child = children[index]
1901
-
1902
- if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
1903
- continue
1904
- }
1905
-
1906
- if (isNode(child, ERBContentNode)) {
1907
- inlineContent += this.renderERBAsString(child)
1908
- processedIndices.add(index)
1909
- continue
1910
- }
1911
-
1912
- if (isNode(child, HTMLTextNode)) {
1913
- const trimmed = child.content.trim()
1914
-
1915
- if (trimmed && /^[.!?:;]/.test(trimmed)) {
1916
- const wrapWidth = this.maxLineLength - this.indent.length
1917
- const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth)
1918
-
1919
- inlineContent = result.mergedContent
1920
- processedIndices.add(index)
1921
-
1922
- if (result.shouldStop) {
1923
- if (inlineContent) {
1924
- this.pushWithIndent(inlineContent)
1925
- }
1926
-
1927
- result.wrappedLines.forEach(line => this.push(line))
1928
-
1929
- return { processedIndices }
1930
- }
1931
- }
1932
- }
1933
-
1934
- break
1935
- }
1936
- }
1937
-
1938
- if (inlineContent) {
1939
- this.pushWithIndent(inlineContent)
1940
- }
1941
-
1942
- return { processedIndices }
1943
- }
1944
-
1945
- /**
1946
- * Render an inline element as a string
1947
- */
1948
- private renderInlineElementAsString(element: HTMLElementNode): string {
1949
- const tagName = getTagName(element)
1950
-
1951
- if (element.is_void || element.open_tag?.tag_closing?.value === "/>") {
1952
- const attributes = filterNodes(element.open_tag?.children, HTMLAttributeNode)
1953
- const attributesString = this.renderAttributesString(attributes)
1954
- const isSelfClosing = element.open_tag?.tag_closing?.value === "/>"
1955
-
1956
- return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`
1957
- }
1958
-
1959
- const childrenToRender = this.getFilteredChildren(element.body)
1960
-
1961
- const childInline = this.tryRenderInlineFull(element, tagName,
1962
- filterNodes(element.open_tag?.children, HTMLAttributeNode),
1963
- childrenToRender
1964
- )
1965
-
1966
- return childInline !== null ? childInline : ""
1967
- }
1968
-
1969
- /**
1970
- * Render an ERB node as a string
1971
- */
1972
- private renderERBAsString(node: ERBContentNode): string {
1973
- return this.capture(() => {
1974
- this.inlineMode = true
1975
- this.visit(node)
1976
- }).join("")
1977
- }
1978
-
1979
- /**
1980
- * Visit remaining children after processing adjacent inline elements
1981
- */
1982
- private visitRemainingChildren(children: Node[], processedIndices: Set<number>): void {
1983
- for (let index = 0; index < children.length; index++) {
1984
- const child = children[index]
1985
-
1986
- if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
1987
- continue
1988
- }
1989
-
1990
- if (processedIndices.has(index)) {
1991
- continue
1992
- }
1993
-
1994
- this.visit(child)
1995
- }
1996
- }
1997
-
1998
- /**
1999
- * Build words array from text/inline/ERB and wrap them
2000
- */
2001
- private buildAndWrapTextFlow(children: Node[]): void {
2002
- const unitsWithNodes: ContentUnitWithNode[] = this.buildContentUnitsWithNodes(children)
2003
- const words: Array<{ word: string, isHerbDisable: boolean }> = []
2004
-
2005
- for (const { unit, node } of unitsWithNodes) {
2006
- if (unit.breaksFlow) {
2007
- this.flushWords(words)
2008
-
2009
- if (node) {
2010
- this.visit(node)
2011
- }
2012
- } else if (unit.isAtomic) {
2013
- words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false })
2014
- } else {
2015
- const text = unit.content.replace(ASCII_WHITESPACE, ' ')
2016
- const hasLeadingSpace = text.startsWith(' ')
2017
- const hasTrailingSpace = text.endsWith(' ')
2018
- const trimmedText = text.trim()
2019
-
2020
- if (trimmedText) {
2021
- if (hasLeadingSpace && words.length > 0) {
2022
- const lastWord = words[words.length - 1]
2023
-
2024
- if (!lastWord.word.endsWith(' ')) {
2025
- lastWord.word += ' '
2026
- }
2027
- }
2028
-
2029
- const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false }))
2030
- words.push(...textWords)
2031
-
2032
- if (hasTrailingSpace && words.length > 0) {
2033
- const lastWord = words[words.length - 1]
2034
-
2035
- if (!isClosingPunctuation(lastWord.word)) {
2036
- lastWord.word += ' '
2037
- }
2038
- }
2039
- } else if (text === ' ' && words.length > 0) {
2040
- const lastWord = words[words.length - 1]
2041
-
2042
- if (!lastWord.word.endsWith(' ')) {
2043
- lastWord.word += ' '
2044
- }
2045
- }
2046
- }
2047
- }
2048
-
2049
- this.flushWords(words)
2050
- }
2051
-
2052
- /**
2053
- * Try to merge text that follows an atomic unit (ERB/inline) with no whitespace
2054
- * Returns true if merge was performed
2055
- */
2056
- private tryMergeTextAfterAtomic(result: ContentUnitWithNode[], textNode: HTMLTextNode): boolean {
2057
- if (result.length === 0) return false
2058
-
2059
- const lastUnit = result[result.length - 1]
2060
-
2061
- if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) {
2062
- return false
2063
- }
2064
-
2065
- const words = normalizeAndSplitWords(textNode.content)
2066
- if (words.length === 0 || !words[0]) return false
2067
-
2068
- const firstWord = words[0]
2069
- const firstChar = firstWord[0]
2070
-
2071
- if (' \t\n\r'.includes(firstChar)) {
2072
- return false
2073
- }
2074
-
2075
- lastUnit.unit.content += firstWord
2076
-
2077
- if (words.length > 1) {
2078
- let remainingText = words.slice(1).join(' ')
2079
-
2080
- if (endsWithWhitespace(textNode.content)) {
2081
- remainingText += ' '
2082
- }
2083
-
2084
- result.push({
2085
- unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
2086
- node: textNode
2087
- })
2088
- } else if (endsWithWhitespace(textNode.content)) {
2089
- result.push({
2090
- unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false },
2091
- node: textNode
2092
- })
2093
- }
2094
-
2095
- return true
2096
- }
2097
-
2098
- /**
2099
- * Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
2100
- * Returns true if merge was performed
2101
- */
2102
- private tryMergeAtomicAfterText(result: ContentUnitWithNode[], children: Node[], lastProcessedIndex: number, atomicContent: string, atomicType: 'erb' | 'inline', atomicNode: Node): boolean {
2103
- if (result.length === 0) return false
2104
-
2105
- const lastUnit = result[result.length - 1]
2106
-
2107
- if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic) return false
2108
-
2109
- const words = normalizeAndSplitWords(lastUnit.unit.content)
2110
- const lastWord = words[words.length - 1]
2111
-
2112
- if (!lastWord) return false
2113
-
2114
- result.pop()
2115
-
2116
- if (words.length > 1) {
2117
- const remainingText = words.slice(0, -1).join(' ')
2118
-
2119
- result.push({
2120
- unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
2121
- node: children[lastProcessedIndex]
2122
- })
2123
- }
2124
-
2125
- result.push({
2126
- unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
2127
- node: atomicNode
2128
- })
2129
-
2130
- return true
2131
- }
2132
-
2133
- /**
2134
- * Check if there's whitespace between current node and last processed node
2135
- */
2136
- private hasWhitespaceBeforeNode(children: Node[], lastProcessedIndex: number, currentIndex: number, currentNode: Node): boolean {
2137
- if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
2138
- return true
2139
- }
2140
-
2141
- if (isNode(currentNode, HTMLTextNode) && /^[ \t\n\r]/.test(currentNode.content)) {
2142
- return true
2143
- }
2144
-
2145
- return false
2146
- }
2147
-
2148
- /**
2149
- * Check if last unit in result ends with whitespace
2150
- */
2151
- private lastUnitEndsWithWhitespace(result: ContentUnitWithNode[]): boolean {
2152
- if (result.length === 0) return false
2153
-
2154
- const lastUnit = result[result.length - 1]
2155
-
2156
- return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content)
2157
- }
2158
-
2159
- /**
2160
- * Process a text node and add it to results (with potential merging)
2161
- */
2162
- private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void {
2163
- const isAtomic = child.content === ' '
2164
-
2165
- if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
2166
- const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child)
2167
- const lastUnit = result[result.length - 1]
2168
- const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline')
2169
-
2170
- if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) {
2171
- return
2172
- }
2173
- }
2174
-
2175
- result.push({
2176
- unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
2177
- node: child
2178
- })
2179
- }
2180
-
2181
- /**
2182
- * Process an inline element and add it to results (with potential merging)
2183
- */
2184
- private processInlineElement(result: ContentUnitWithNode[], children: Node[], child: HTMLElementNode, index: number, lastProcessedIndex: number): boolean {
2185
- const tagName = getTagName(child)
2186
- const childrenToRender = this.getFilteredChildren(child.body)
2187
- const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender)
2188
-
2189
- if (inlineContent === null) {
2190
- result.push({
2191
- unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
2192
- node: child
2193
- })
2194
-
2195
- return false
2196
- }
2197
-
2198
- if (lastProcessedIndex >= 0) {
2199
- const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
2200
-
2201
- if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
2202
- return true
2203
- }
2204
- }
2205
-
2206
- result.push({
2207
- unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
2208
- node: child
2209
- })
2210
-
2211
- return false
2212
- }
2213
-
2214
- /**
2215
- * Process an ERB content node and add it to results (with potential merging)
2216
- */
2217
- private processERBContentNode(result: ContentUnitWithNode[], children: Node[], child: ERBContentNode, index: number, lastProcessedIndex: number): boolean {
2218
- const erbContent = this.renderERBAsString(child)
2219
- const isHerbDisable = isHerbDisableComment(child)
2220
-
2221
- if (lastProcessedIndex >= 0) {
2222
- const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
2223
-
2224
- if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
2225
- return true
2226
- }
2227
-
2228
- if (hasWhitespace && result.length > 0) {
2229
- const lastUnit = result[result.length - 1]
2230
- const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb')
2231
-
2232
- if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
2233
- result.push({
2234
- unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
2235
- node: null
2236
- })
2237
- }
2238
- }
2239
- }
2240
-
2241
- result.push({
2242
- unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
2243
- node: child
2244
- })
2245
-
2246
- return false
2247
- }
2248
-
2249
- /**
2250
- * Convert AST nodes to content units with node references
2251
- */
2252
- private buildContentUnitsWithNodes(children: Node[]): ContentUnitWithNode[] {
2253
- const result: ContentUnitWithNode[] = []
2254
- let lastProcessedIndex = -1
2255
-
2256
- for (let i = 0; i < children.length; i++) {
2257
- const child = children[i]
2258
-
2259
- if (isNode(child, WhitespaceNode)) continue
2260
-
2261
- if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
2262
- if (lastProcessedIndex >= 0) {
2263
- const hasNonWhitespaceAfter = children.slice(i + 1).some(node =>
2264
- !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node)
2265
- )
2266
-
2267
- if (hasNonWhitespaceAfter) {
2268
- const previousNode = children[lastProcessedIndex]
2269
-
2270
- if (!isLineBreakingElement(previousNode)) {
2271
- result.push({
2272
- unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
2273
- node: child
2274
- })
2275
- }
2276
- }
2277
- }
2278
-
2279
- continue
2280
- }
2281
-
2282
- if (isNode(child, HTMLTextNode)) {
2283
- this.processTextNode(result, children, child, i, lastProcessedIndex)
2284
-
2285
- lastProcessedIndex = i
2286
- } else if (isNode(child, HTMLElementNode)) {
2287
- const tagName = getTagName(child)
2288
-
2289
- if (isInlineElement(tagName)) {
2290
- const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex)
2291
-
2292
- if (merged) {
2293
- lastProcessedIndex = i
2294
-
2295
- continue
2296
- }
2297
- } else {
2298
- result.push({
2299
- unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
2300
- node: child
2301
- })
2302
- }
2303
-
2304
- lastProcessedIndex = i
2305
- } else if (isNode(child, ERBContentNode)) {
2306
- const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex)
2307
-
2308
- if (merged) {
2309
- lastProcessedIndex = i
2310
-
2311
- continue
2312
- }
2313
-
2314
- lastProcessedIndex = i
2315
- } else {
2316
- result.push({
2317
- unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
2318
- node: child
2319
- })
2320
-
2321
- lastProcessedIndex = i
2322
- }
2323
- }
2324
-
2325
- return result
2326
- }
2327
-
2328
- /**
2329
- * Flush accumulated words to output with wrapping
2330
- */
2331
- private flushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
2332
- if (words.length > 0) {
2333
- this.wrapAndPushWords(words)
2334
- words.length = 0
2335
- }
2336
- }
2337
-
2338
- /**
2339
- * Wrap words to fit within line length and push to output
2340
- * Handles punctuation spacing intelligently
2341
- * Excludes herb:disable comments from line length calculations
2342
- */
2343
- private wrapAndPushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
2344
- const wrapWidth = this.maxLineLength - this.indent.length
2345
- const lines: string[] = []
2346
- let currentLine = ""
2347
- let effectiveLength = 0
2348
-
2349
- for (const { word, isHerbDisable } of words) {
2350
- const nextLine = buildLineWithWord(currentLine, word)
2351
-
2352
- let nextEffectiveLength = effectiveLength
2353
-
2354
- if (!isHerbDisable) {
2355
- const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0
2356
- nextEffectiveLength = effectiveLength + spaceBefore + word.length
2357
- }
2358
-
2359
- if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
2360
- lines.push(this.indent + currentLine.trimEnd())
2361
-
2362
- currentLine = word
2363
- effectiveLength = isHerbDisable ? 0 : word.length
2364
- } else {
2365
- currentLine = nextLine
2366
- effectiveLength = nextEffectiveLength
2367
- }
2368
- }
2369
-
2370
- if (currentLine) {
2371
- lines.push(this.indent + currentLine.trimEnd())
2372
- }
2373
-
2374
- lines.forEach(line => this.push(line))
2375
- }
2376
-
2377
- private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean {
2378
- const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "")
2379
- const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode))
2380
-
2381
- if (!hasTextContent) return false
2382
- if (nonTextChildren.length === 0) return false
2383
-
2384
- const allInline = nonTextChildren.every(child => {
2385
- if (isNode(child, ERBContentNode)) return true
2386
-
2387
- if (isNode(child, HTMLElementNode)) {
2388
- return isInlineElement(getTagName(child))
2389
- }
2390
-
2391
- return false
2392
- })
2393
-
2394
- if (!allInline) return false
2395
-
2396
- return true
2397
- }
2398
-
2399
- private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
2400
- const parts = attributes.map(attribute => this.renderAttribute(attribute))
2401
-
2402
- if (inlineNodes.length > 0) {
2403
- let result = `<${name}`
2404
-
2405
- if (allChildren.length > 0) {
2406
- const lines = this.capture(() => {
2407
- allChildren.forEach(child => {
2408
- if (isNode(child, HTMLAttributeNode)) {
2409
- this.lines.push(" " + this.renderAttribute(child))
2410
- } else if (!(isNode(child, WhitespaceNode))) {
2411
- const wasInlineMode = this.inlineMode
2412
-
2413
- this.inlineMode = true
2414
-
2415
- this.lines.push(" ")
2416
- this.visit(child)
2417
- this.inlineMode = wasInlineMode
2418
- }
2419
- })
2420
- })
2421
-
2422
- result += lines.join("")
1480
+ result += lines.join("")
2423
1481
  } else {
2424
1482
  if (parts.length > 0) {
2425
1483
  result += ` ${parts.join(" ")}`
@@ -2427,15 +1485,11 @@ export class FormatPrinter extends Printer {
2427
1485
 
2428
1486
  const lines = this.capture(() => {
2429
1487
  inlineNodes.forEach(node => {
2430
- const wasInlineMode = this.inlineMode
2431
-
2432
1488
  if (!isERBControlFlowNode(node)) {
2433
- this.inlineMode = true
1489
+ this.withInlineMode(() => this.visit(node))
1490
+ } else {
1491
+ this.visit(node)
2434
1492
  }
2435
-
2436
- this.visit(node)
2437
-
2438
- this.inlineMode = wasInlineMode
2439
1493
  })
2440
1494
  })
2441
1495
 
@@ -2450,70 +1504,14 @@ export class FormatPrinter extends Printer {
2450
1504
  return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " />" : ">"}`
2451
1505
  }
2452
1506
 
2453
- renderAttribute(attribute: HTMLAttributeNode): string {
2454
- const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
2455
- const equals = attribute.equals?.value ?? ""
2456
-
2457
- this.currentAttributeName = name
2458
-
2459
- let value = ""
2460
-
2461
- if (isNode(attribute.value, HTMLAttributeValueNode)) {
2462
- const attributeValue = attribute.value
2463
-
2464
- let open_quote = attributeValue.open_quote?.value ?? ""
2465
- let close_quote = attributeValue.close_quote?.value ?? ""
2466
- let htmlTextContent = ""
2467
-
2468
- const content = attributeValue.children.map((child: Node) => {
2469
- if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
2470
- htmlTextContent += child.content
2471
-
2472
- return child.content
2473
- } else if (isNode(child, ERBContentNode)) {
2474
- return this.reconstructERBNode(child, true)
2475
- } else {
2476
- const printed = IdentityPrinter.print(child)
2477
-
2478
- if (this.isInTokenListAttribute) {
2479
- return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%')
2480
- }
2481
-
2482
- return printed
2483
- }
2484
- }).join("")
2485
-
2486
- if (open_quote === "" && close_quote === "") {
2487
- open_quote = '"'
2488
- close_quote = '"'
2489
- } else if (open_quote === "'" && close_quote === "'" && !htmlTextContent.includes('"')) {
2490
- open_quote = '"'
2491
- close_quote = '"'
2492
- }
2493
-
2494
- if (this.isFormattableAttribute(name, this.currentTagName)) {
2495
- if (name === 'class') {
2496
- value = this.formatClassAttribute(content, name, equals, open_quote, close_quote)
2497
- } else {
2498
- value = this.formatMultilineAttribute(content, name, open_quote, close_quote)
2499
- }
2500
- } else {
2501
- value = open_quote + content + close_quote
2502
- }
2503
- }
2504
-
2505
- this.currentAttributeName = null
2506
-
2507
- return name + equals + value
2508
- }
2509
-
2510
1507
  /**
2511
1508
  * Try to render a complete element inline including opening tag, children, and closing tag
2512
1509
  */
2513
1510
  private tryRenderInlineFull(_node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
2514
1511
  let result = `<${tagName}`
2515
1512
 
2516
- result += this.renderAttributesString(attributes)
1513
+ this.attributeRenderer.indentLevel = this.indentLevel
1514
+ result += this.attributeRenderer.renderAttributesString(attributes, tagName)
2517
1515
  result += ">"
2518
1516
 
2519
1517
  const childrenContent = this.tryRenderChildrenInline(children, tagName)
@@ -2526,21 +1524,6 @@ export class FormatPrinter extends Printer {
2526
1524
  return result
2527
1525
  }
2528
1526
 
2529
- /**
2530
- * Check if children contain a leading herb:disable comment (after optional whitespace)
2531
- */
2532
- private hasLeadingHerbDisable(children: Node[]): boolean {
2533
- for (const child of children) {
2534
- if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
2535
- continue
2536
- }
2537
-
2538
- return isNode(child, ERBContentNode) && isHerbDisableComment(child)
2539
- }
2540
-
2541
- return false
2542
- }
2543
-
2544
1527
  /**
2545
1528
  * Try to render just the children inline (without tags)
2546
1529
  */
@@ -2549,7 +1532,7 @@ export class FormatPrinter extends Printer {
2549
1532
  let hasInternalWhitespace = false
2550
1533
  let addedLeadingSpace = false
2551
1534
 
2552
- const hasHerbDisable = this.hasLeadingHerbDisable(children)
1535
+ const hasHerbDisable = hasLeadingHerbDisable(children)
2553
1536
  const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
2554
1537
  const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName)
2555
1538
 
@@ -2575,9 +1558,7 @@ export class FormatPrinter extends Printer {
2575
1558
  }
2576
1559
  }
2577
1560
 
2578
- const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")
2579
-
2580
- if (isWhitespace && !result.endsWith(' ')) {
1561
+ if (isPureWhitespaceNode(child) && !result.endsWith(' ')) {
2581
1562
  if (!result && hasHerbDisable && !addedLeadingSpace) {
2582
1563
  result += ' '
2583
1564
  addedLeadingSpace = true
@@ -2594,7 +1575,7 @@ export class FormatPrinter extends Printer {
2594
1575
 
2595
1576
  const childrenToRender = this.getFilteredChildren(child.body)
2596
1577
  const childInline = this.tryRenderInlineFull(child, tagName,
2597
- filterNodes(child.open_tag?.children, HTMLAttributeNode),
1578
+ filterNodes(getOpenTagChildren(child), HTMLAttributeNode),
2598
1579
  childrenToRender
2599
1580
  )
2600
1581
 
@@ -2603,11 +1584,8 @@ export class FormatPrinter extends Printer {
2603
1584
  }
2604
1585
 
2605
1586
  result += childInline
2606
- } else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
2607
- const wasInlineMode = this.inlineMode
2608
- this.inlineMode = true
2609
- const captured = this.capture(() => this.visit(child)).join("")
2610
- this.inlineMode = wasInlineMode
1587
+ } else if (!isNode(child, HTMLTextNode) && !isPureWhitespaceNode(child)) {
1588
+ const captured = this.withInlineMode(() => this.capture(() => this.visit(child)).join(""))
2611
1589
  result += captured
2612
1590
  }
2613
1591
  }
@@ -2676,10 +1654,11 @@ export class FormatPrinter extends Printer {
2676
1654
  for (const child of children) {
2677
1655
  if (isNode(child, HTMLTextNode)) {
2678
1656
  content += child.content
2679
- } else if (isNode(child, HTMLElementNode) ) {
1657
+ } else if (isNode(child, HTMLElementNode)) {
2680
1658
  const tagName = getTagName(child)
2681
- const attributes = filterNodes(child.open_tag?.children, HTMLAttributeNode)
2682
- const attributesString = this.renderAttributesString(attributes)
1659
+ const attributes = filterNodes(getOpenTagChildren(child), HTMLAttributeNode)
1660
+ this.attributeRenderer.indentLevel = this.indentLevel
1661
+ const attributesString = this.attributeRenderer.renderAttributesString(attributes, tagName)
2683
1662
  const childContent = this.renderElementInline(child)
2684
1663
 
2685
1664
  content += `<${tagName}${attributesString}>${childContent}</${tagName}>`