@herb-tools/formatter 0.8.10 → 0.9.1

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,36 @@ import {
89
87
  ERBUnlessNode,
90
88
  ERBYieldNode,
91
89
  ERBInNode,
90
+ ERBRenderNode,
91
+ RubyRenderLocalNode,
92
+ ERBOpenTagNode,
93
+ HTMLVirtualCloseTagNode,
92
94
  XMLDeclarationNode,
93
95
  CDATANode,
94
96
  Token
95
97
  } from "@herb-tools/core"
96
98
 
97
- import type { ERBNode } from "@herb-tools/core"
98
- import type { FormatOptions } from "./options.js"
99
+ /**
100
+ * Gets the children of an open tag, narrowing from the union type.
101
+ * Returns empty array for conditional open tags.
102
+ */
103
+ function getOpenTagChildren(element: HTMLElementNode): Node[] {
104
+ return isHTMLOpenTagNode(element.open_tag) ? element.open_tag.children : []
105
+ }
99
106
 
100
107
  /**
101
- * ASCII whitespace pattern - use instead of \s to preserve Unicode whitespace
102
- * characters like NBSP (U+00A0) and full-width space (U+3000)
108
+ * Gets the tag_closing token of an open tag, narrowing from the union type.
109
+ * Returns null for conditional open tags.
103
110
  */
104
- const ASCII_WHITESPACE = /[ \t\n\r]+/g
111
+ function getOpenTagClosing(element: HTMLElementNode): Token | null {
112
+ return isHTMLOpenTagNode(element.open_tag) ? element.open_tag.tag_closing : null
113
+ }
105
114
 
106
115
  /**
107
116
  * Printer traverses the Herb AST using the Visitor pattern
108
117
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
109
118
  */
110
- export class FormatPrinter extends Printer {
119
+ export class FormatPrinter extends Printer implements TextFlowDelegate, AttributeRendererDelegate {
111
120
  /**
112
121
  * @deprecated integrate indentWidth into this.options and update FormatOptions to extend from @herb-tools/printer options
113
122
  */
@@ -116,7 +125,7 @@ export class FormatPrinter extends Printer {
116
125
  /**
117
126
  * @deprecated integrate maxLineLength into this.options and update FormatOptions to extend from @herb-tools/printer options
118
127
  */
119
- private maxLineLength: number
128
+ maxLineLength: number
120
129
 
121
130
  /**
122
131
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
@@ -124,14 +133,15 @@ export class FormatPrinter extends Printer {
124
133
  private lines: string[] = []
125
134
  private indentLevel: number = 0
126
135
  private inlineMode: boolean = false
127
- private currentAttributeName: string | null = null
136
+ private inContentPreservingContext: boolean = false
137
+ private inConditionalOpenTagContext: boolean = false
128
138
  private elementStack: HTMLElementNode[] = []
129
139
  private elementFormattingAnalysis = new Map<HTMLElementNode, ElementFormattingAnalysis>()
130
140
  private nodeIsMultiline = new Map<Node, boolean>()
131
141
  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
-
142
+ private textFlow: TextFlowEngine
143
+ private attributeRenderer: AttributeRenderer
144
+ private spacingAnalyzer: SpacingAnalyzer
135
145
 
136
146
  public source: string
137
147
 
@@ -141,6 +151,9 @@ export class FormatPrinter extends Printer {
141
151
  this.source = source
142
152
  this.indentWidth = options.indentWidth
143
153
  this.maxLineLength = options.maxLineLength
154
+ this.textFlow = new TextFlowEngine(this)
155
+ this.attributeRenderer = new AttributeRenderer(this, this.maxLineLength, this.indentWidth)
156
+ this.spacingAnalyzer = new SpacingAnalyzer(this.nodeIsMultiline)
144
157
  }
145
158
 
146
159
  print(input: Node | ParseResult | Token): string {
@@ -153,8 +166,7 @@ export class FormatPrinter extends Printer {
153
166
  this.indentLevel = 0
154
167
  this.stringLineCount = 0
155
168
  this.nodeIsMultiline.clear()
156
- this.tagGroupsCache.clear()
157
- this.allSingleLineCache.clear()
169
+ this.spacingAnalyzer.clear()
158
170
 
159
171
  this.visit(node)
160
172
 
@@ -172,7 +184,7 @@ export class FormatPrinter extends Printer {
172
184
  * Get the current tag name from the current element context
173
185
  */
174
186
  private get currentTagName(): string {
175
- return this.currentElement?.open_tag?.tag_name?.value ?? ""
187
+ return this.currentElement?.tag_name?.value ?? ""
176
188
  }
177
189
 
178
190
  /**
@@ -257,7 +269,7 @@ export class FormatPrinter extends Printer {
257
269
  /**
258
270
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
259
271
  */
260
- private push(line: string) {
272
+ push(line: string) {
261
273
  this.lines.push(line)
262
274
  this.stringLineCount++
263
275
  }
@@ -265,7 +277,7 @@ export class FormatPrinter extends Printer {
265
277
  /**
266
278
  * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
267
279
  */
268
- private pushWithIndent(line: string) {
280
+ pushWithIndent(line: string) {
269
281
  const indent = line.trim() === "" ? "" : this.indent
270
282
 
271
283
  this.push(indent + line)
@@ -279,7 +291,25 @@ export class FormatPrinter extends Printer {
279
291
  return result
280
292
  }
281
293
 
282
- private get indent(): string {
294
+ private withInlineMode<T>(callback: () => T): T {
295
+ const was = this.inlineMode
296
+ this.inlineMode = true
297
+ const result = callback()
298
+ this.inlineMode = was
299
+
300
+ return result
301
+ }
302
+
303
+ private withContentPreserving<T>(callback: () => T): T {
304
+ const was = this.inContentPreservingContext
305
+ this.inContentPreservingContext = true
306
+ const result = callback()
307
+ this.inContentPreservingContext = was
308
+
309
+ return result
310
+ }
311
+
312
+ get indent(): string {
283
313
  return " ".repeat(this.indentLevel * this.indentWidth)
284
314
  }
285
315
 
@@ -289,11 +319,11 @@ export class FormatPrinter extends Printer {
289
319
  * and a trailing space (or newline for heredoc content starting with "<<").
290
320
  */
291
321
  private formatERBContent(content: string): string {
292
- let trimmedContent = content.trim();
322
+ const trimmedContent = content.trim();
293
323
 
294
324
  // See: https://github.com/marcoroth/herb/issues/476
295
325
  // TODO: revisit once we have access to Prism nodes
296
- let suffix = trimmedContent.startsWith("<<") ? "\n" : " "
326
+ const suffix = trimmedContent.startsWith("<<") ? "\n" : " "
297
327
 
298
328
  return trimmedContent ? ` ${trimmedContent}${suffix}` : ""
299
329
  }
@@ -323,401 +353,6 @@ export class FormatPrinter extends Printer {
323
353
  return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode))
324
354
  }
325
355
 
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
356
  /**
722
357
  * Render multiline attributes for a tag
723
358
  */
@@ -731,11 +366,10 @@ export class FormatPrinter extends Printer {
731
366
  if (herbDisableComments.length > 0) {
732
367
  const commentLines = this.capture(() => {
733
368
  herbDisableComments.forEach(comment => {
734
- const wasInlineMode = this.inlineMode
735
- this.inlineMode = true
736
- this.lines.push(" ")
737
- this.visit(comment)
738
- this.inlineMode = wasInlineMode
369
+ this.withInlineMode(() => {
370
+ this.lines.push(" ")
371
+ this.visit(comment)
372
+ })
739
373
  })
740
374
  })
741
375
 
@@ -745,9 +379,10 @@ export class FormatPrinter extends Printer {
745
379
  this.pushWithIndent(openingLine)
746
380
 
747
381
  this.withIndent(() => {
382
+ this.attributeRenderer.indentLevel = this.indentLevel
748
383
  allChildren.forEach(child => {
749
384
  if (isNode(child, HTMLAttributeNode)) {
750
- this.pushWithIndent(this.renderAttribute(child))
385
+ this.pushWithIndent(this.attributeRenderer.renderAttribute(child, tagName))
751
386
  } else if (!isNode(child, WhitespaceNode)) {
752
387
  if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
753
388
  return
@@ -769,7 +404,7 @@ export class FormatPrinter extends Printer {
769
404
  * Reconstruct the text representation of an ERB node
770
405
  * @param withFormatting - if true, format the content; if false, preserve original
771
406
  */
772
- private reconstructERBNode(node: ERBNode, withFormatting: boolean = true): string {
407
+ reconstructERBNode(node: ERBNode, withFormatting: boolean = true): string {
773
408
  const open = node.tag_opening?.value ?? ""
774
409
  const close = node.tag_closing?.value ?? ""
775
410
  const content = node.content?.value ?? ""
@@ -792,15 +427,12 @@ export class FormatPrinter extends Printer {
792
427
 
793
428
  visitDocumentNode(node: DocumentNode) {
794
429
  const children = this.formatFrontmatter(node)
795
- const hasTextFlow = this.isInTextFlowContext(null, children)
430
+ const hasTextFlow = this.textFlow.isInTextFlowContext(children)
796
431
 
797
432
  if (hasTextFlow) {
798
- const wasInlineMode = this.inlineMode
799
- this.inlineMode = true
800
-
801
- this.visitTextFlowChildren(children)
802
-
803
- this.inlineMode = wasInlineMode
433
+ this.withInlineMode(() => {
434
+ this.textFlow.visitTextFlowChildren(children)
435
+ })
804
436
 
805
437
  return
806
438
  }
@@ -834,7 +466,7 @@ export class FormatPrinter extends Printer {
834
466
  this.visit(child)
835
467
 
836
468
  if (lastMeaningfulNode && !hasHandledSpacing) {
837
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings( null, children, i)
469
+ const shouldAddSpacing = this.spacingAnalyzer.shouldAddSpacingBetweenSiblings( null, children, i)
838
470
 
839
471
  if (shouldAddSpacing) {
840
472
  this.lines.splice(childStartLine, 0, "")
@@ -872,43 +504,104 @@ export class FormatPrinter extends Printer {
872
504
  this.elementStack.pop()
873
505
  }
874
506
 
875
- visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
876
- const tagName = getTagName(element)
507
+ visitHTMLConditionalElementNode(node: HTMLConditionalElementNode) {
508
+ this.trackBoundary(node, () => {
509
+ if (node.open_conditional) {
510
+ this.visit(node.open_conditional)
511
+ }
877
512
 
878
- if (isContentPreserving(element)) {
879
- element.body.map(child => {
880
- if (isNode(child, HTMLElementNode)) {
881
- const wasInlineMode = this.inlineMode
882
- this.inlineMode = true
513
+ if (node.body.length > 0) {
514
+ this.push("")
883
515
 
884
- const formattedElement = this.capture(() => this.visit(child)).join("")
885
- this.pushToLastLine(formattedElement)
516
+ this.withIndent(() => {
517
+ for (const child of node.body) {
518
+ if (!isPureWhitespaceNode(child)) {
519
+ this.visit(child)
520
+ }
521
+ }
522
+ })
886
523
 
887
- this.inlineMode = wasInlineMode
888
- } else {
889
- this.pushToLastLine(IdentityPrinter.print(child))
890
- }
891
- })
524
+ this.push("")
525
+ }
526
+
527
+ if (node.close_conditional) {
528
+ this.visit(node.close_conditional)
529
+ }
530
+ })
531
+ }
532
+
533
+ visitHTMLConditionalOpenTagNode(node: HTMLConditionalOpenTagNode) {
534
+ const wasInConditionalOpenTagContext = this.inConditionalOpenTagContext
535
+ this.inConditionalOpenTagContext = true
892
536
 
537
+ this.trackBoundary(node, () => {
538
+ if (node.conditional) {
539
+ this.visit(node.conditional)
540
+ }
541
+ })
542
+
543
+ this.inConditionalOpenTagContext = wasInConditionalOpenTagContext
544
+ }
545
+
546
+ visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
547
+ if (isContentPreserving(element) || this.inContentPreservingContext) {
548
+ this.visitContentPreservingBody(element)
893
549
  return
894
550
  }
895
551
 
552
+ const tagName = getTagName(element)
896
553
  const analysis = this.elementFormattingAnalysis.get(element)
897
- const hasTextFlow = this.isInTextFlowContext(null, body)
554
+ const hasTextFlow = this.textFlow.isInTextFlowContext(body)
898
555
  const children = filterSignificantChildren(body)
899
556
 
900
557
  if (analysis?.elementContentInline) {
901
- if (children.length === 0) return
558
+ this.visitInlineElementBody(body, tagName, hasTextFlow, children)
559
+ return
560
+ }
561
+
562
+ if (children.length === 0) return
563
+
564
+ const { comment, hasLeadingWhitespace, remainingChildren, remainingBody } = this.stripLeadingHerbDisable(children, body)
565
+
566
+ if (comment) {
567
+ const herbDisableString = this.captureHerbDisableInline(comment)
568
+ this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString)
569
+ }
570
+
571
+ if (remainingChildren.length === 0) return
572
+
573
+ this.withIndent(() => {
574
+ if (hasTextFlow) {
575
+ this.textFlow.visitTextFlowChildren(remainingBody)
576
+ } else {
577
+ this.visitElementChildren(comment ? remainingChildren : body, element)
578
+ }
579
+ })
580
+ }
581
+
582
+ private visitContentPreservingBody(element: HTMLElementNode) {
583
+ this.withContentPreserving(() => {
584
+ element.body.map(child => {
585
+ if (isNode(child, HTMLElementNode)) {
586
+ const formattedElement = this.withInlineMode(() => this.capture(() => this.visit(child)).join(""))
587
+ this.pushToLastLine(formattedElement)
588
+ } else {
589
+ this.pushToLastLine(IdentityPrinter.print(child))
590
+ }
591
+ })
592
+ })
593
+ }
902
594
 
903
- const oldInlineMode = this.inlineMode
904
- const nodesToRender = hasTextFlow ? body : children
595
+ private visitInlineElementBody(body: Node[], tagName: string, hasTextFlow: boolean, children: Node[]) {
596
+ if (children.length === 0) return
905
597
 
906
- const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
907
- const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName)
598
+ const nodesToRender = hasTextFlow ? body : children
908
599
 
909
- this.inlineMode = true
600
+ const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
601
+ const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName)
910
602
 
911
- const lines = this.capture(() => {
603
+ const lines = this.withInlineMode(() => {
604
+ return this.capture(() => {
912
605
  nodesToRender.forEach(child => {
913
606
  if (isNode(child, HTMLTextNode)) {
914
607
  if (hasTextFlow) {
@@ -941,29 +634,28 @@ export class FormatPrinter extends Printer {
941
634
  }
942
635
  })
943
636
  })
637
+ })
944
638
 
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
- }
639
+ const content = lines.join('')
954
640
 
955
- this.inlineMode = oldInlineMode
641
+ const inlineContent = shouldPreserveSpaces
642
+ ? (hasTextFlow ? content.replace(ASCII_WHITESPACE, ' ') : content)
643
+ : (hasTextFlow ? content.replace(ASCII_WHITESPACE, ' ').trim() : content.trim())
956
644
 
957
- return
645
+ if (inlineContent) {
646
+ this.pushToLastLine(inlineContent)
958
647
  }
648
+ }
959
649
 
960
- if (children.length === 0) return
961
-
650
+ private stripLeadingHerbDisable(children: Node[], body: Node[]): {
651
+ comment: Node | null
652
+ hasLeadingWhitespace: boolean
653
+ remainingChildren: Node[]
654
+ remainingBody: Node[]
655
+ } {
962
656
  let leadingHerbDisableComment: Node | null = null
963
657
  let leadingHerbDisableIndex = -1
964
658
  let firstWhitespaceIndex = -1
965
- let remainingChildren = children
966
- let remainingBodyUnfiltered = body
967
659
 
968
660
  for (let i = 0; i < children.length; i++) {
969
661
  const child = children[i]
@@ -984,60 +676,30 @@ export class FormatPrinter extends Printer {
984
676
  break
985
677
  }
986
678
 
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
- })
679
+ if (!leadingHerbDisableComment || leadingHerbDisableIndex < 0) {
680
+ return { comment: null, hasLeadingWhitespace: false, remainingChildren: children, remainingBody: body }
681
+ }
1001
682
 
1002
- remainingBodyUnfiltered = body.filter((_, index) => {
1003
- if (index === leadingHerbDisableIndex) return false
683
+ const filterOut = (nodes: Node[]) => nodes.filter((_, index) => {
684
+ if (index === leadingHerbDisableIndex) return false
1004
685
 
1005
- if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
1006
- const child = body[index]
686
+ if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
687
+ const child = nodes[index]
1007
688
 
1008
- if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
1009
- return false
1010
- }
689
+ if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
690
+ return false
1011
691
  }
692
+ }
1012
693
 
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
694
+ return true
695
+ })
1028
696
 
1029
- this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString)
697
+ return {
698
+ comment: leadingHerbDisableComment,
699
+ hasLeadingWhitespace: firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex,
700
+ remainingChildren: filterOut(children),
701
+ remainingBody: filterOut(body),
1030
702
  }
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
703
  }
1042
704
 
1043
705
  /**
@@ -1053,9 +715,7 @@ export class FormatPrinter extends Printer {
1053
715
  const child = body[index]
1054
716
 
1055
717
  if (isNode(child, HTMLTextNode)) {
1056
- const isWhitespaceOnly = child.content.trim() === ""
1057
-
1058
- if (isWhitespaceOnly) {
718
+ if (isPureWhitespaceNode(child)) {
1059
719
  const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(body[index - 1])
1060
720
  const hasNextNonWhitespace = index < body.length - 1 && isNonWhitespaceNode(body[index + 1])
1061
721
  const hasMultipleNewlines = child.content.includes('\n\n')
@@ -1071,59 +731,99 @@ export class FormatPrinter extends Printer {
1071
731
 
1072
732
  if (!isNonWhitespaceNode(child)) continue
1073
733
 
1074
- let hasTrailingHerbDisable = false
734
+ const textFlowResult = this.visitTextFlowRunInChildren(body, index, lastMeaningfulNode, hasHandledSpacing)
1075
735
 
1076
- if (isNode(child, HTMLElementNode) && child.close_tag) {
1077
- for (let j = index + 1; j < body.length; j++) {
1078
- const nextChild = body[j]
736
+ if (textFlowResult) {
737
+ index = textFlowResult.newIndex
738
+ lastMeaningfulNode = textFlowResult.lastMeaningfulNode
739
+ hasHandledSpacing = textFlowResult.hasHandledSpacing
740
+ continue
741
+ }
1079
742
 
1080
- if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
1081
- continue
1082
- }
743
+ const herbDisableResult: ChildVisitResult | null =
744
+ isNode(child, HTMLElementNode) && child.close_tag
745
+ ? this.visitChildWithTrailingHerbDisable(child, body, index, parentElement, lastMeaningfulNode, hasHandledSpacing)
746
+ : null
1083
747
 
1084
- if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
1085
- hasTrailingHerbDisable = true
748
+ if (herbDisableResult) {
749
+ index = herbDisableResult.newIndex
750
+ lastMeaningfulNode = herbDisableResult.lastMeaningfulNode
751
+ hasHandledSpacing = herbDisableResult.hasHandledSpacing
752
+ continue
753
+ }
1086
754
 
1087
- const childStartLine = this.stringLineCount
1088
- this.visit(child)
755
+ if (shouldAppendToLastLine(child, body, index)) {
756
+ this.appendChildToLastLine(child, body, index)
757
+ lastMeaningfulNode = child
758
+ hasHandledSpacing = false
759
+ continue
760
+ }
1089
761
 
1090
- if (lastMeaningfulNode && !hasHandledSpacing) {
1091
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index)
762
+ const childStartLine = this.stringLineCount
763
+ this.visit(child)
1092
764
 
1093
- if (shouldAddSpacing) {
1094
- this.lines.splice(childStartLine, 0, "")
1095
- this.stringLineCount++
1096
- }
1097
- }
765
+ if (lastMeaningfulNode && !hasHandledSpacing) {
766
+ const shouldAddSpacing = this.spacingAnalyzer.shouldAddSpacingBetweenSiblings(parentElement, body, index)
767
+
768
+ if (shouldAddSpacing) {
769
+ this.lines.splice(childStartLine, 0, "")
770
+ this.stringLineCount++
771
+ }
772
+ }
773
+
774
+ lastMeaningfulNode = child
775
+ hasHandledSpacing = false
776
+ }
777
+ }
1098
778
 
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("")
779
+ private visitTextFlowRunInChildren(body: Node[], index: number, lastMeaningfulNode: Node | null, hasHandledSpacing: boolean): ChildVisitResult | null {
780
+ const child = body[index]
1107
781
 
1108
- this.pushToLastLine(' ' + herbDisableString)
782
+ if (!isTextFlowNode(child)) return null
1109
783
 
1110
- index = j
1111
- lastMeaningfulNode = child
1112
- hasHandledSpacing = false
784
+ const run = this.textFlow.collectTextFlowRun(body, index)
1113
785
 
1114
- break
1115
- }
786
+ if (!run) return null
1116
787
 
1117
- break
1118
- }
788
+ if (lastMeaningfulNode && !hasHandledSpacing) {
789
+ const hasBlankLineBefore = this.spacingAnalyzer.hasBlankLineBetween(body, index)
790
+
791
+ if (hasBlankLineBefore) {
792
+ this.push("")
793
+ }
794
+ }
795
+
796
+ this.textFlow.visitTextFlowChildren(run.nodes)
797
+
798
+ const lastRunNode = run.nodes[run.nodes.length - 1]
799
+ const hasBlankLineInTrailing = isNode(lastRunNode, HTMLTextNode) && lastRunNode.content.includes('\n\n')
800
+ const hasBlankLineAfter = hasBlankLineInTrailing || this.spacingAnalyzer.hasBlankLineBetween(body, run.endIndex)
801
+
802
+ if (hasBlankLineAfter) {
803
+ this.push("")
804
+ }
805
+
806
+ return {
807
+ newIndex: run.endIndex - 1,
808
+ lastMeaningfulNode: run.nodes[run.nodes.length - 1],
809
+ hasHandledSpacing: hasBlankLineAfter,
810
+ }
811
+ }
812
+
813
+ private visitChildWithTrailingHerbDisable(child: HTMLElementNode, body: Node[], index: number, parentElement: HTMLElementNode | null, lastMeaningfulNode: Node | null, hasHandledSpacing: boolean): ChildVisitResult | null {
814
+ for (let j = index + 1; j < body.length; j++) {
815
+ const nextChild = body[j]
816
+
817
+ if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
818
+ continue
1119
819
  }
1120
820
 
1121
- if (!hasTrailingHerbDisable) {
821
+ if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
1122
822
  const childStartLine = this.stringLineCount
1123
823
  this.visit(child)
1124
824
 
1125
825
  if (lastMeaningfulNode && !hasHandledSpacing) {
1126
- const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, index)
826
+ const shouldAddSpacing = this.spacingAnalyzer.shouldAddSpacingBetweenSiblings(parentElement, body, index)
1127
827
 
1128
828
  if (shouldAddSpacing) {
1129
829
  this.lines.splice(childStartLine, 0, "")
@@ -1131,10 +831,21 @@ export class FormatPrinter extends Printer {
1131
831
  }
1132
832
  }
1133
833
 
1134
- lastMeaningfulNode = child
1135
- hasHandledSpacing = false
834
+ const herbDisableString = this.captureHerbDisableInline(nextChild)
835
+
836
+ this.pushToLastLine(' ' + herbDisableString)
837
+
838
+ return {
839
+ newIndex: j,
840
+ lastMeaningfulNode: child,
841
+ hasHandledSpacing: false,
842
+ }
1136
843
  }
844
+
845
+ break
1137
846
  }
847
+
848
+ return null
1138
849
  }
1139
850
 
1140
851
  visitHTMLOpenTagNode(node: HTMLOpenTagNode) {
@@ -1142,6 +853,12 @@ export class FormatPrinter extends Printer {
1142
853
  const inlineNodes = this.extractInlineNodes(node.children)
1143
854
  const isSelfClosing = node.tag_closing?.value === "/>"
1144
855
 
856
+ if (this.inConditionalOpenTagContext) {
857
+ const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
858
+ this.push(this.indent + inline)
859
+ return
860
+ }
861
+
1145
862
  if (this.currentElement && this.elementFormattingAnalysis.has(this.currentElement)) {
1146
863
  const analysis = this.elementFormattingAnalysis.get(this.currentElement)!
1147
864
 
@@ -1159,13 +876,14 @@ export class FormatPrinter extends Printer {
1159
876
 
1160
877
  const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
1161
878
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
1162
- const shouldKeepInline = this.shouldRenderInline(
879
+ this.attributeRenderer.indentLevel = this.indentLevel
880
+ const shouldKeepInline = this.attributeRenderer.shouldRenderInline(
1163
881
  totalAttributeCount,
1164
882
  inline.length,
1165
883
  this.indent.length,
1166
884
  this.maxLineLength,
1167
885
  false,
1168
- this.hasMultilineAttributes(attributes),
886
+ this.attributeRenderer.hasMultilineAttributes(attributes),
1169
887
  attributes
1170
888
  )
1171
889
 
@@ -1199,7 +917,7 @@ export class FormatPrinter extends Printer {
1199
917
  return
1200
918
  }
1201
919
 
1202
- let text = node.content.trim()
920
+ const text = node.content.trim()
1203
921
 
1204
922
  if (!text) return
1205
923
 
@@ -1224,7 +942,8 @@ export class FormatPrinter extends Printer {
1224
942
  }
1225
943
 
1226
944
  visitHTMLAttributeNode(node: HTMLAttributeNode) {
1227
- this.pushWithIndent(this.renderAttribute(node))
945
+ this.attributeRenderer.indentLevel = this.indentLevel
946
+ this.pushWithIndent(this.attributeRenderer.renderAttribute(node, this.currentTagName))
1228
947
  }
1229
948
 
1230
949
  visitHTMLAttributeNameNode(node: HTMLAttributeNameNode) {
@@ -1235,119 +954,39 @@ export class FormatPrinter extends Printer {
1235
954
  this.pushWithIndent(IdentityPrinter.print(node))
1236
955
  }
1237
956
 
1238
- // TODO: rework
1239
957
  visitHTMLCommentNode(node: HTMLCommentNode) {
1240
958
  const open = node.comment_start?.value ?? ""
1241
959
  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
- }
960
+ const rawInner = node.children && node.children.length > 0
961
+ ? extractHTMLCommentContent(node.children)
962
+ : ""
963
+ const inner = rawInner ? formatHTMLCommentInner(rawInner, this.indentWidth) : ""
1306
964
 
1307
965
  this.pushWithIndent(open + inner + close)
1308
966
  }
1309
967
 
1310
968
  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 ? "" : " "
969
+ const result = formatERBCommentLines(
970
+ node.tag_opening?.value || "<%#",
971
+ node?.content?.value || "",
972
+ node.tag_closing?.value || "%>"
973
+ )
1321
974
 
975
+ if (result.type === 'single-line') {
1322
976
  if (this.inlineMode) {
1323
- this.push(open + before + content.trimEnd() + ' ' + close)
977
+ this.push(result.text)
1324
978
  } else {
1325
- this.pushWithIndent(open + before + content.trimEnd() + ' ' + close)
979
+ this.pushWithIndent(result.text)
1326
980
  }
981
+ } else {
982
+ this.pushWithIndent(result.header)
1327
983
 
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
- }
984
+ this.withIndent(() => {
985
+ result.contentLines.forEach(line => this.pushWithIndent(line))
986
+ })
1337
987
 
1338
- return
988
+ this.pushWithIndent(result.footer)
1339
989
  }
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
990
  }
1352
991
 
1353
992
  visitHTMLDoctypeNode(node: HTMLDoctypeNode) {
@@ -1370,10 +1009,26 @@ export class FormatPrinter extends Printer {
1370
1009
  }
1371
1010
  }
1372
1011
 
1012
+ visitERBOpenTagNode(node: ERBOpenTagNode) {
1013
+ this.printERBNode(node)
1014
+ }
1015
+
1016
+ visitHTMLVirtualCloseTagNode(_node: HTMLVirtualCloseTagNode) {
1017
+ // Virtual closing tags don't print anything (they are synthetic)
1018
+ }
1019
+
1373
1020
  visitERBEndNode(node: ERBEndNode) {
1374
1021
  this.printERBNode(node)
1375
1022
  }
1376
1023
 
1024
+ visitERBRenderNode(node: ERBRenderNode) {
1025
+ this.printERBNode(node)
1026
+ }
1027
+
1028
+ visitRubyRenderLocalNode(_node: RubyRenderLocalNode) {
1029
+ // extracted metadata, nothing to print
1030
+ }
1031
+
1377
1032
  visitERBYieldNode(node: ERBYieldNode) {
1378
1033
  this.trackBoundary(node, () => {
1379
1034
  this.printERBNode(node)
@@ -1404,10 +1059,10 @@ export class FormatPrinter extends Printer {
1404
1059
  this.printERBNode(node)
1405
1060
 
1406
1061
  this.withIndent(() => {
1407
- const hasTextFlow = this.isInTextFlowContext(null, node.body)
1062
+ const hasTextFlow = this.textFlow.isInTextFlowContext(node.body)
1408
1063
 
1409
1064
  if (hasTextFlow) {
1410
- this.visitTextFlowChildren(node.body)
1065
+ this.textFlow.visitTextFlowChildren(node.body)
1411
1066
  } else {
1412
1067
  this.visitElementChildren(node.body, null)
1413
1068
  }
@@ -1425,9 +1080,10 @@ export class FormatPrinter extends Printer {
1425
1080
  node.statements.forEach(child => {
1426
1081
  if (isNode(child, HTMLAttributeNode)) {
1427
1082
  this.lines.push(" ")
1428
- this.lines.push(this.renderAttribute(child))
1083
+ this.attributeRenderer.indentLevel = this.indentLevel
1084
+ this.lines.push(this.attributeRenderer.renderAttribute(child, this.currentTagName))
1429
1085
  } else {
1430
- const shouldAddSpaces = this.isInTokenListAttribute
1086
+ const shouldAddSpaces = this.attributeRenderer.isInTokenListAttribute
1431
1087
 
1432
1088
  if (shouldAddSpaces) {
1433
1089
  this.lines.push(" ")
@@ -1442,7 +1098,7 @@ export class FormatPrinter extends Printer {
1442
1098
  })
1443
1099
 
1444
1100
  const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
1445
- const isTokenList = this.isInTokenListAttribute
1101
+ const isTokenList = this.attributeRenderer.isInTokenListAttribute
1446
1102
 
1447
1103
  if ((hasHTMLAttributes || isTokenList) && node.end_node) {
1448
1104
  this.lines.push(" ")
@@ -1570,7 +1226,12 @@ export class FormatPrinter extends Printer {
1570
1226
  * Determines if the open tag should be rendered inline
1571
1227
  */
1572
1228
  private shouldRenderOpenTagInline(node: HTMLElementNode): boolean {
1573
- const children = node.open_tag?.children || []
1229
+ if (isNode(node.open_tag, HTMLConditionalOpenTagNode)) {
1230
+ return false
1231
+ }
1232
+
1233
+ const openTag = node.open_tag
1234
+ const children = openTag?.children || []
1574
1235
  const attributes = filterNodes(children, HTMLAttributeNode)
1575
1236
  const inlineNodes = this.extractInlineNodes(children)
1576
1237
  const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node))
@@ -1579,19 +1240,20 @@ export class FormatPrinter extends Printer {
1579
1240
  if (hasComplexERB) return false
1580
1241
 
1581
1242
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
1582
- const hasMultilineAttrs = this.hasMultilineAttributes(attributes)
1243
+ this.attributeRenderer.indentLevel = this.indentLevel
1244
+ const hasMultilineAttrs = this.attributeRenderer.hasMultilineAttributes(attributes)
1583
1245
 
1584
1246
  if (hasMultilineAttrs) return false
1585
1247
 
1586
1248
  const inline = this.renderInlineOpen(
1587
1249
  getTagName(node),
1588
1250
  attributes,
1589
- node.open_tag?.tag_closing?.value === "/>",
1251
+ openTag?.tag_closing?.value === "/>",
1590
1252
  inlineNodes,
1591
1253
  children
1592
1254
  )
1593
1255
 
1594
- return this.shouldRenderInline(
1256
+ return this.attributeRenderer.shouldRenderInline(
1595
1257
  totalAttributeCount,
1596
1258
  inline.length,
1597
1259
  this.indent.length,
@@ -1609,6 +1271,7 @@ export class FormatPrinter extends Printer {
1609
1271
  const tagName = getTagName(node)
1610
1272
  const children = filterSignificantChildren(node.body)
1611
1273
  const openTagInline = this.shouldRenderOpenTagInline(node)
1274
+ const openTagClosing = getOpenTagClosing(node)
1612
1275
 
1613
1276
  if (!openTagInline) return false
1614
1277
  if (children.length === 0) return true
@@ -1623,46 +1286,46 @@ export class FormatPrinter extends Printer {
1623
1286
 
1624
1287
  if (hasNonInlineChildElements) return false
1625
1288
 
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)) {
1289
+ if (hasLeadingHerbDisable(node.body) && !isInlineElement(tagName)) {
1639
1290
  return false
1640
1291
  }
1641
1292
 
1642
1293
  if (isInlineElement(tagName)) {
1643
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body)
1294
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(getOpenTagChildren(node), HTMLAttributeNode), node.body)
1644
1295
 
1645
1296
  if (fullInlineResult) {
1646
- const totalLength = this.indent.length + fullInlineResult.length
1647
- return totalLength <= this.maxLineLength
1297
+ return this.fitsOnCurrentLine(fullInlineResult)
1648
1298
  }
1649
1299
 
1650
1300
  return false
1651
1301
  }
1652
1302
 
1303
+ if (SPACEABLE_CONTAINERS.has(tagName)) {
1304
+ const allChildrenAreERB = children.length > 1 && children.every(child => isERBNode(child))
1305
+
1306
+ if (allChildrenAreERB) return false
1307
+ }
1308
+
1309
+ if (!isInlineElement(tagName) && openTagClosing) {
1310
+ const first = children[0]
1311
+ const startsOnNewLine = first.location.start.line > openTagClosing.location.end.line
1312
+ const hasLeadingNewline = isNode(first, HTMLTextNode) && /^\s*\n/.test(first.content)
1313
+ const contentStartsOnNewLine = startsOnNewLine || hasLeadingNewline
1314
+
1315
+ if (contentStartsOnNewLine) {
1316
+ return false
1317
+ }
1318
+ }
1319
+
1653
1320
  const allNestedAreInline = areAllNestedElementsInline(children)
1654
1321
  const hasMultilineText = hasMultilineTextContent(children)
1655
1322
  const hasMixedContent = hasMixedTextAndInlineContent(children)
1656
1323
 
1657
1324
  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
1325
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(getOpenTagChildren(node), HTMLAttributeNode), node.body)
1662
1326
 
1663
- if (totalLength <= this.maxLineLength) {
1664
- return true
1665
- }
1327
+ if (fullInlineResult && this.fitsOnCurrentLine(fullInlineResult)) {
1328
+ return true
1666
1329
  }
1667
1330
  }
1668
1331
 
@@ -1671,17 +1334,16 @@ export class FormatPrinter extends Printer {
1671
1334
  if (inlineResult) {
1672
1335
  const openTagResult = this.renderInlineOpen(
1673
1336
  tagName,
1674
- filterNodes(node.open_tag?.children, HTMLAttributeNode),
1337
+ filterNodes(getOpenTagChildren(node), HTMLAttributeNode),
1675
1338
  false,
1676
1339
  [],
1677
- node.open_tag?.children || []
1340
+ getOpenTagChildren(node)
1678
1341
  )
1679
1342
 
1680
1343
  const childrenContent = this.renderChildrenInline(children)
1681
1344
  const fullLine = openTagResult + childrenContent + `</${tagName}>`
1682
- const totalLength = this.indent.length + fullLine.length
1683
1345
 
1684
- if (totalLength <= this.maxLineLength) {
1346
+ if (this.fitsOnCurrentLine(fullLine)) {
1685
1347
  return true
1686
1348
  }
1687
1349
  }
@@ -1694,7 +1356,7 @@ export class FormatPrinter extends Printer {
1694
1356
  */
1695
1357
  private shouldRenderCloseTagInline(node: HTMLElementNode, elementContentInline: boolean): boolean {
1696
1358
  if (node.is_void) return true
1697
- if (node.open_tag?.tag_closing?.value === "/>") return true
1359
+ if (getOpenTagClosing(node)?.value === "/>") return true
1698
1360
  if (isContentPreserving(node)) return true
1699
1361
 
1700
1362
  const children = filterSignificantChildren(node.body)
@@ -1707,6 +1369,19 @@ export class FormatPrinter extends Printer {
1707
1369
 
1708
1370
  // --- Utility methods ---
1709
1371
 
1372
+ private captureHerbDisableInline(node: Node): string {
1373
+ return this.capture(() => {
1374
+ const savedIndentLevel = this.indentLevel
1375
+ this.indentLevel = 0
1376
+ this.withInlineMode(() => this.visit(node))
1377
+ this.indentLevel = savedIndentLevel
1378
+ }).join("")
1379
+ }
1380
+
1381
+ private fitsOnCurrentLine(content: string): boolean {
1382
+ return this.indent.length + content.length <= this.maxLineLength
1383
+ }
1384
+
1710
1385
  private formatFrontmatter(node: DocumentNode): Node[] {
1711
1386
  const firstChild = node.children[0]
1712
1387
  const hasFrontmatter = firstChild && isFrontmatter(firstChild)
@@ -1739,682 +1414,75 @@ export class FormatPrinter extends Printer {
1739
1414
  }
1740
1415
  }
1741
1416
 
1742
- const oldInlineMode = this.inlineMode
1743
- this.inlineMode = true
1744
- const inlineContent = this.capture(() => this.visit(child)).join("")
1745
- this.inlineMode = oldInlineMode
1417
+ const inlineContent = this.withInlineMode(() => this.capture(() => this.visit(child)).join(""))
1746
1418
  this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent)
1747
1419
  }
1748
1420
  }
1749
1421
 
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
1422
 
1761
- return
1762
- }
1763
-
1764
- this.buildAndWrapTextFlow(children)
1765
- }
1423
+ // --- TextFlowDelegate implementation ---
1766
1424
 
1767
1425
  /**
1768
- * Wrap remaining words that don't fit on the current line
1769
- * Returns the wrapped lines with proper indentation
1426
+ * Render an inline element as a string
1770
1427
  */
1771
- private wrapRemainingWords(words: string[], wrapWidth: number): string[] {
1772
- const lines: string[] = []
1773
- let line = ""
1428
+ renderInlineElementAsString(element: HTMLElementNode): string {
1429
+ const tagName = getTagName(element)
1430
+ const tagClosing = getOpenTagClosing(element)
1774
1431
 
1775
- for (const word of words) {
1776
- const testLine = line + (line ? " " : "") + word
1432
+ if (element.is_void || tagClosing?.value === "/>") {
1433
+ const attributes = filterNodes(getOpenTagChildren(element), HTMLAttributeNode)
1434
+ this.attributeRenderer.indentLevel = this.indentLevel
1435
+ const attributesString = this.attributeRenderer.renderAttributesString(attributes, tagName)
1436
+ const isSelfClosing = tagClosing?.value === "/>"
1777
1437
 
1778
- if (testLine.length > wrapWidth && line) {
1779
- lines.push(this.indent + line)
1780
- line = word
1781
- } else {
1782
- line = testLine
1783
- }
1438
+ return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`
1784
1439
  }
1785
1440
 
1786
- if (line) {
1787
- lines.push(this.indent + line)
1788
- }
1441
+ const childrenToRender = this.getFilteredChildren(element.body)
1442
+
1443
+ const childInline = this.tryRenderInlineFull(element, tagName,
1444
+ filterNodes(getOpenTagChildren(element), HTMLAttributeNode),
1445
+ childrenToRender
1446
+ )
1789
1447
 
1790
- return lines
1448
+ return childInline !== null ? childInline : ""
1791
1449
  }
1792
1450
 
1793
1451
  /**
1794
- * Try to merge text starting with punctuation to inline content
1795
- * Returns object with merged content and whether processing should stop
1452
+ * Render an ERB node as a string
1796
1453
  */
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
- }
1807
-
1808
- const match = trimmedText.match(/^[.!?:;]+/)
1809
-
1810
- if (!match) {
1811
- return {
1812
- mergedContent: inlineContent,
1813
- shouldStop: false,
1814
- wrappedLines: []
1815
- }
1816
- }
1817
-
1818
- const punctuation = match[0]
1819
- const restText = trimmedText.substring(punctuation.length).trim()
1454
+ renderERBAsString(node: ERBContentNode): string {
1455
+ return this.withInlineMode(() => this.capture(() => this.visit(node)).join(""))
1456
+ }
1820
1457
 
1821
- if (!restText) {
1822
- return {
1823
- mergedContent: inlineContent + punctuation,
1824
- shouldStop: false,
1825
- wrappedLines: []
1826
- }
1827
- }
1458
+ /**
1459
+ * Try to render an inline element, returning the full inline string or null if it can't be inlined.
1460
+ */
1461
+ tryRenderInlineElement(element: HTMLElementNode): string | null {
1462
+ const tagName = getTagName(element)
1463
+ const childrenToRender = this.getFilteredChildren(element.body)
1828
1464
 
1829
- const words = restText.split(/[ \t\n\r]+/)
1830
- let toMerge = punctuation
1831
- let mergedWordCount = 0
1465
+ return this.tryRenderInlineFull(element, tagName, filterNodes(getOpenTagChildren(element), HTMLAttributeNode), childrenToRender)
1466
+ }
1832
1467
 
1833
- for (const word of words) {
1834
- const testMerge = toMerge + ' ' + word
1835
1468
 
1836
- if ((inlineContent + testMerge).length <= wrapWidth) {
1837
- toMerge = testMerge
1838
- mergedWordCount++
1839
- } else {
1840
- break
1841
- }
1842
- }
1469
+ private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
1470
+ this.attributeRenderer.indentLevel = this.indentLevel
1471
+ const parts = attributes.map(attribute => this.attributeRenderer.renderAttribute(attribute, name))
1843
1472
 
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}`
1473
+ if (inlineNodes.length > 0) {
1474
+ let result = `<${name}`
2404
1475
 
2405
1476
  if (allChildren.length > 0) {
2406
1477
  const lines = this.capture(() => {
2407
1478
  allChildren.forEach(child => {
2408
1479
  if (isNode(child, HTMLAttributeNode)) {
2409
- this.lines.push(" " + this.renderAttribute(child))
1480
+ this.lines.push(" " + this.attributeRenderer.renderAttribute(child, name))
2410
1481
  } 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
1482
+ this.withInlineMode(() => {
1483
+ this.lines.push(" ")
1484
+ this.visit(child)
1485
+ })
2418
1486
  }
2419
1487
  })
2420
1488
  })
@@ -2427,15 +1495,11 @@ export class FormatPrinter extends Printer {
2427
1495
 
2428
1496
  const lines = this.capture(() => {
2429
1497
  inlineNodes.forEach(node => {
2430
- const wasInlineMode = this.inlineMode
2431
-
2432
1498
  if (!isERBControlFlowNode(node)) {
2433
- this.inlineMode = true
1499
+ this.withInlineMode(() => this.visit(node))
1500
+ } else {
1501
+ this.visit(node)
2434
1502
  }
2435
-
2436
- this.visit(node)
2437
-
2438
- this.inlineMode = wasInlineMode
2439
1503
  })
2440
1504
  })
2441
1505
 
@@ -2450,70 +1514,14 @@ export class FormatPrinter extends Printer {
2450
1514
  return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " />" : ">"}`
2451
1515
  }
2452
1516
 
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
1517
  /**
2511
1518
  * Try to render a complete element inline including opening tag, children, and closing tag
2512
1519
  */
2513
1520
  private tryRenderInlineFull(_node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
2514
1521
  let result = `<${tagName}`
2515
1522
 
2516
- result += this.renderAttributesString(attributes)
1523
+ this.attributeRenderer.indentLevel = this.indentLevel
1524
+ result += this.attributeRenderer.renderAttributesString(attributes, tagName)
2517
1525
  result += ">"
2518
1526
 
2519
1527
  const childrenContent = this.tryRenderChildrenInline(children, tagName)
@@ -2526,21 +1534,6 @@ export class FormatPrinter extends Printer {
2526
1534
  return result
2527
1535
  }
2528
1536
 
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
1537
  /**
2545
1538
  * Try to render just the children inline (without tags)
2546
1539
  */
@@ -2549,7 +1542,7 @@ export class FormatPrinter extends Printer {
2549
1542
  let hasInternalWhitespace = false
2550
1543
  let addedLeadingSpace = false
2551
1544
 
2552
- const hasHerbDisable = this.hasLeadingHerbDisable(children)
1545
+ const hasHerbDisable = hasLeadingHerbDisable(children)
2553
1546
  const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
2554
1547
  const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName)
2555
1548
 
@@ -2575,9 +1568,7 @@ export class FormatPrinter extends Printer {
2575
1568
  }
2576
1569
  }
2577
1570
 
2578
- const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")
2579
-
2580
- if (isWhitespace && !result.endsWith(' ')) {
1571
+ if (isPureWhitespaceNode(child) && !result.endsWith(' ')) {
2581
1572
  if (!result && hasHerbDisable && !addedLeadingSpace) {
2582
1573
  result += ' '
2583
1574
  addedLeadingSpace = true
@@ -2594,7 +1585,7 @@ export class FormatPrinter extends Printer {
2594
1585
 
2595
1586
  const childrenToRender = this.getFilteredChildren(child.body)
2596
1587
  const childInline = this.tryRenderInlineFull(child, tagName,
2597
- filterNodes(child.open_tag?.children, HTMLAttributeNode),
1588
+ filterNodes(getOpenTagChildren(child), HTMLAttributeNode),
2598
1589
  childrenToRender
2599
1590
  )
2600
1591
 
@@ -2603,11 +1594,8 @@ export class FormatPrinter extends Printer {
2603
1594
  }
2604
1595
 
2605
1596
  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
1597
+ } else if (!isNode(child, HTMLTextNode) && !isPureWhitespaceNode(child)) {
1598
+ const captured = this.withInlineMode(() => this.capture(() => this.visit(child)).join(""))
2611
1599
  result += captured
2612
1600
  }
2613
1601
  }
@@ -2676,10 +1664,11 @@ export class FormatPrinter extends Printer {
2676
1664
  for (const child of children) {
2677
1665
  if (isNode(child, HTMLTextNode)) {
2678
1666
  content += child.content
2679
- } else if (isNode(child, HTMLElementNode) ) {
1667
+ } else if (isNode(child, HTMLElementNode)) {
2680
1668
  const tagName = getTagName(child)
2681
- const attributes = filterNodes(child.open_tag?.children, HTMLAttributeNode)
2682
- const attributesString = this.renderAttributesString(attributes)
1669
+ const attributes = filterNodes(getOpenTagChildren(child), HTMLAttributeNode)
1670
+ this.attributeRenderer.indentLevel = this.indentLevel
1671
+ const attributesString = this.attributeRenderer.renderAttributesString(attributes, tagName)
2683
1672
  const childContent = this.renderElementInline(child)
2684
1673
 
2685
1674
  content += `<${tagName}${attributesString}>${childContent}</${tagName}>`