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