@herb-tools/formatter 0.5.0 → 0.6.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.
@@ -0,0 +1,1852 @@
1
+ import dedent from "dedent"
2
+ import {
3
+ getTagName,
4
+ getCombinedAttributeName,
5
+ getCombinedStringFromNodes,
6
+ isNode,
7
+ isToken,
8
+ isParseResult,
9
+ isAnyOf,
10
+ isNoneOf,
11
+ isERBNode,
12
+ isCommentNode,
13
+ isERBControlFlowNode,
14
+ filterNodes,
15
+ hasERBOutput,
16
+ } from "@herb-tools/core"
17
+ import { Printer, IdentityPrinter } from "@herb-tools/printer"
18
+
19
+ import {
20
+ ParseResult,
21
+ Node,
22
+ DocumentNode,
23
+ HTMLOpenTagNode,
24
+ HTMLCloseTagNode,
25
+ HTMLElementNode,
26
+ HTMLAttributeNode,
27
+ HTMLAttributeValueNode,
28
+ HTMLAttributeNameNode,
29
+ HTMLTextNode,
30
+ HTMLCommentNode,
31
+ HTMLDoctypeNode,
32
+ LiteralNode,
33
+ WhitespaceNode,
34
+ ERBContentNode,
35
+ ERBBlockNode,
36
+ ERBEndNode,
37
+ ERBElseNode,
38
+ ERBIfNode,
39
+ ERBWhenNode,
40
+ ERBCaseNode,
41
+ ERBCaseMatchNode,
42
+ ERBWhileNode,
43
+ ERBUntilNode,
44
+ ERBForNode,
45
+ ERBRescueNode,
46
+ ERBEnsureNode,
47
+ ERBBeginNode,
48
+ ERBUnlessNode,
49
+ ERBYieldNode,
50
+ ERBInNode,
51
+ XMLDeclarationNode,
52
+ CDATANode,
53
+ Token
54
+ } from "@herb-tools/core"
55
+
56
+ import type { ERBNode } from "@herb-tools/core"
57
+ import type { FormatOptions } from "./options.js"
58
+
59
+ /**
60
+ * Analysis result for HTMLElementNode formatting decisions
61
+ */
62
+ interface ElementFormattingAnalysis {
63
+ openTagInline: boolean
64
+ elementContentInline: boolean
65
+ closeTagInline: boolean
66
+ }
67
+
68
+ // TODO: we can probably expand this list with more tags/attributes
69
+ const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
70
+ '*': ['class'],
71
+ 'img': ['srcset', 'sizes']
72
+ }
73
+
74
+ /**
75
+ * Printer traverses the Herb AST using the Visitor pattern
76
+ * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
77
+ */
78
+ export class FormatPrinter extends Printer {
79
+ /**
80
+ * @deprecated integrate indentWidth into this.options and update FormatOptions to extend from @herb-tools/printer options
81
+ */
82
+ private indentWidth: number
83
+
84
+ /**
85
+ * @deprecated integrate maxLineLength into this.options and update FormatOptions to extend from @herb-tools/printer options
86
+ */
87
+ private maxLineLength: number
88
+
89
+ /**
90
+ * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
91
+ */
92
+ private lines: string[] = []
93
+ private indentLevel: number = 0
94
+ private inlineMode: boolean = false
95
+ private currentAttributeName: string | null = null
96
+ private elementStack: HTMLElementNode[] = []
97
+ private elementFormattingAnalysis = new Map<HTMLElementNode, ElementFormattingAnalysis>()
98
+
99
+ public source: string
100
+
101
+ // TODO: extract
102
+ private static readonly INLINE_ELEMENTS = new Set([
103
+ 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
104
+ 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
105
+ 'samp', 'small', 'span', 'strong', 'sub', 'sup',
106
+ 'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
107
+ ])
108
+
109
+ private static readonly CONTENT_PRESERVING_ELEMENTS = new Set([
110
+ 'script', 'style', 'pre', 'textarea'
111
+ ])
112
+
113
+ private static readonly SPACEABLE_CONTAINERS = new Set([
114
+ 'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
115
+ 'figure', 'details', 'summary', 'dialog', 'fieldset'
116
+ ])
117
+
118
+ private static readonly TIGHT_GROUP_PARENTS = new Set([
119
+ 'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
120
+ 'tbody', 'tfoot'
121
+ ])
122
+
123
+ private static readonly TIGHT_GROUP_CHILDREN = new Set([
124
+ 'li', 'option', 'td', 'th', 'dt', 'dd'
125
+ ])
126
+
127
+ private static readonly SPACING_THRESHOLD = 3
128
+
129
+ constructor(source: string, options: Required<FormatOptions>) {
130
+ super()
131
+
132
+ this.source = source
133
+ this.indentWidth = options.indentWidth
134
+ this.maxLineLength = options.maxLineLength
135
+ }
136
+
137
+ print(input: Node | ParseResult | Token): string {
138
+ if (isToken(input)) return input.value
139
+
140
+ const node: Node = isParseResult(input) ? input.value : input
141
+
142
+ // TODO: refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
143
+ this.lines = []
144
+ this.indentLevel = 0
145
+
146
+ this.visit(node)
147
+
148
+ return this.lines.join("\n")
149
+ }
150
+
151
+ /**
152
+ * Get the current element (top of stack)
153
+ */
154
+ private get currentElement(): HTMLElementNode | null {
155
+ return this.elementStack.length > 0 ? this.elementStack[this.elementStack.length - 1] : null
156
+ }
157
+
158
+ /**
159
+ * Get the current tag name from the current element context
160
+ */
161
+ private get currentTagName(): string {
162
+ return this.currentElement?.open_tag?.tag_name?.value ?? ""
163
+ }
164
+
165
+ /**
166
+ * Append text to the last line instead of creating a new line
167
+ */
168
+ private pushToLastLine(text: string): void {
169
+ if (this.lines.length > 0) {
170
+ this.lines[this.lines.length - 1] += text
171
+ } else {
172
+ this.lines.push(text)
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Capture output from a callback into a separate lines array
178
+ * Useful for testing what output would be generated without affecting the main output
179
+ */
180
+ private capture(callback: () => void): string[] {
181
+ const previousLines = this.lines
182
+ const previousInlineMode = this.inlineMode
183
+
184
+ this.lines = []
185
+
186
+ try {
187
+ callback()
188
+
189
+ return this.lines
190
+ } finally {
191
+ this.lines = previousLines
192
+ this.inlineMode = previousInlineMode
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Capture all nodes that would be visited during a callback
198
+ * Returns a flat list of all nodes without generating any output
199
+ */
200
+ private captureNodes(callback: () => void): Node[] {
201
+ const capturedNodes: Node[] = []
202
+ const previousLines = this.lines
203
+ const previousInlineMode = this.inlineMode
204
+
205
+ const originalPush = this.push.bind(this)
206
+ const originalPushToLastLine = this.pushToLastLine.bind(this)
207
+ const originalVisit = this.visit.bind(this)
208
+
209
+ this.lines = []
210
+ this.push = () => {}
211
+ this.pushToLastLine = () => {}
212
+
213
+ this.visit = (node: Node) => {
214
+ capturedNodes.push(node)
215
+ originalVisit(node)
216
+ }
217
+
218
+ try {
219
+ callback()
220
+
221
+ return capturedNodes
222
+ } finally {
223
+ this.lines = previousLines
224
+ this.inlineMode = previousInlineMode
225
+ this.push = originalPush
226
+ this.pushToLastLine = originalPushToLastLine
227
+ this.visit = originalVisit
228
+ }
229
+ }
230
+
231
+ /**
232
+ * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
233
+ */
234
+ private push(line: string) {
235
+ this.lines.push(line)
236
+ }
237
+
238
+ /**
239
+ * @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
240
+ */
241
+ private pushWithIndent(line: string) {
242
+ const indent = line.trim() === "" ? "" : this.indent
243
+
244
+ this.push(indent + line)
245
+ }
246
+
247
+ private withIndent<T>(callback: () => T): T {
248
+ this.indentLevel++
249
+ const result = callback()
250
+ this.indentLevel--
251
+
252
+ return result
253
+ }
254
+
255
+ private get indent(): string {
256
+ return " ".repeat(this.indentLevel * this.indentWidth)
257
+ }
258
+
259
+ /**
260
+ * Format ERB content with proper spacing around the inner content.
261
+ * Returns empty string if content is empty, otherwise wraps content with single spaces.
262
+ */
263
+ private formatERBContent(content: string): string {
264
+ return content.trim() ? ` ${content.trim()} ` : ""
265
+ }
266
+
267
+ /**
268
+ * Count total attributes including those inside ERB conditionals
269
+ */
270
+ private getTotalAttributeCount(attributes: HTMLAttributeNode[], inlineNodes: Node[] = []): number {
271
+ let totalAttributeCount = attributes.length
272
+
273
+ inlineNodes.forEach(node => {
274
+ if (isERBControlFlowNode(node)) {
275
+ const capturedNodes = this.captureNodes(() => this.visit(node))
276
+ const attributeNodes = filterNodes(capturedNodes, HTMLAttributeNode)
277
+
278
+ totalAttributeCount += attributeNodes.length
279
+ }
280
+ })
281
+
282
+ return totalAttributeCount
283
+ }
284
+
285
+ /**
286
+ * Extract inline nodes (non-attribute, non-whitespace) from a list of nodes
287
+ */
288
+ private extractInlineNodes(nodes: Node[]): Node[] {
289
+ return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode))
290
+ }
291
+
292
+ /**
293
+ * Determine if spacing should be added between sibling elements
294
+ *
295
+ * This implements the "rule of three" intelligent spacing system:
296
+ * - Adds spacing between 3 or more meaningful siblings
297
+ * - Respects semantic groupings (e.g., ul/li, nav/a stay tight)
298
+ * - Groups comments with following elements
299
+ * - Preserves user-added spacing
300
+ *
301
+ * @param parentElement - The parent element containing the siblings
302
+ * @param siblings - Array of all sibling nodes
303
+ * @param currentIndex - Index of the current node being evaluated
304
+ * @param hasExistingSpacing - Whether user-added spacing already exists
305
+ * @returns true if spacing should be added before the current element
306
+ */
307
+ private shouldAddSpacingBetweenSiblings(
308
+ parentElement: HTMLElementNode | null,
309
+ siblings: Node[],
310
+ currentIndex: number,
311
+ hasExistingSpacing: boolean
312
+ ): boolean {
313
+ if (hasExistingSpacing) {
314
+ return true
315
+ }
316
+
317
+ const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "")
318
+
319
+ if (hasMixedContent) {
320
+ return false
321
+ }
322
+
323
+ const meaningfulSiblings = siblings.filter(child => this.isNonWhitespaceNode(child))
324
+
325
+ if (meaningfulSiblings.length < FormatPrinter.SPACING_THRESHOLD) {
326
+ return false
327
+ }
328
+
329
+ const parentTagName = parentElement ? getTagName(parentElement) : null
330
+
331
+ if (parentTagName && FormatPrinter.TIGHT_GROUP_PARENTS.has(parentTagName)) {
332
+ return false
333
+ }
334
+
335
+ const isSpaceableContainer = !parentTagName || (parentTagName && FormatPrinter.SPACEABLE_CONTAINERS.has(parentTagName))
336
+
337
+ if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
338
+ return false
339
+ }
340
+
341
+ const currentNode = siblings[currentIndex]
342
+ const previousMeaningfulIndex = this.findPreviousMeaningfulSibling(siblings, currentIndex)
343
+ const isCurrentComment = isCommentNode(currentNode)
344
+
345
+ if (previousMeaningfulIndex !== -1) {
346
+ const previousNode = siblings[previousMeaningfulIndex]
347
+ const isPreviousComment = isCommentNode(previousNode)
348
+
349
+ if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
350
+ return false
351
+ }
352
+
353
+ if (isPreviousComment && isCurrentComment) {
354
+ return false
355
+ }
356
+ }
357
+
358
+ if (isNode(currentNode, HTMLElementNode)) {
359
+ const currentTagName = getTagName(currentNode)
360
+
361
+ if (FormatPrinter.INLINE_ELEMENTS.has(currentTagName)) {
362
+ return false
363
+ }
364
+
365
+ if (FormatPrinter.TIGHT_GROUP_CHILDREN.has(currentTagName)) {
366
+ return false
367
+ }
368
+
369
+ if (currentTagName === 'a' && parentTagName === 'nav') {
370
+ return false
371
+ }
372
+ }
373
+
374
+ const isBlockElement = this.isBlockLevelNode(currentNode)
375
+ const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode)
376
+ const isComment = isCommentNode(currentNode)
377
+
378
+ return isBlockElement || isERBBlock || isComment
379
+ }
380
+
381
+ /**
382
+ * Token list attributes that contain space-separated values and benefit from
383
+ * spacing around ERB content for readability
384
+ */
385
+ private static readonly TOKEN_LIST_ATTRIBUTES = new Set([
386
+ 'class', 'data-controller', 'data-action'
387
+ ])
388
+
389
+ /**
390
+ * Check if we're currently processing a token list attribute that needs spacing
391
+ */
392
+ private isInTokenListAttribute(): boolean {
393
+ return this.currentAttributeName !== null &&
394
+ FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
395
+ }
396
+
397
+ /**
398
+ * Find the previous meaningful (non-whitespace) sibling
399
+ */
400
+ private findPreviousMeaningfulSibling(siblings: Node[], currentIndex: number): number {
401
+ for (let i = currentIndex - 1; i >= 0; i--) {
402
+ if (this.isNonWhitespaceNode(siblings[i])) {
403
+ return i
404
+ }
405
+ }
406
+ return -1
407
+ }
408
+
409
+ /**
410
+ * Check if a node represents a block-level element
411
+ */
412
+ private isBlockLevelNode(node: Node): boolean {
413
+ if (!isNode(node, HTMLElementNode)) {
414
+ return false
415
+ }
416
+
417
+ const tagName = getTagName(node)
418
+
419
+ if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
420
+ return false
421
+ }
422
+
423
+ return true
424
+ }
425
+
426
+ /**
427
+ * Render attributes as a space-separated string
428
+ */
429
+ private renderAttributesString(attributes: HTMLAttributeNode[]): string {
430
+ if (attributes.length === 0) return ""
431
+
432
+ return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`
433
+ }
434
+
435
+ /**
436
+ * Determine if a tag should be rendered inline based on attribute count and other factors
437
+ */
438
+ private shouldRenderInline(
439
+ totalAttributeCount: number,
440
+ inlineLength: number,
441
+ indentLength: number,
442
+ maxLineLength: number = this.maxLineLength,
443
+ hasComplexERB: boolean = false,
444
+ hasMultilineAttributes: boolean = false,
445
+ attributes: HTMLAttributeNode[] = []
446
+ ): boolean {
447
+ if (hasComplexERB || hasMultilineAttributes) return false
448
+
449
+ if (totalAttributeCount === 0) {
450
+ return inlineLength + indentLength <= maxLineLength
451
+ }
452
+
453
+ if (totalAttributeCount === 1 && attributes.length === 1) {
454
+ const attribute = attributes[0]
455
+ const attributeName = this.getAttributeName(attribute)
456
+
457
+ if (attributeName === 'class') {
458
+ const attributeValue = this.getAttributeValue(attribute)
459
+ const wouldBeMultiline = this.wouldClassAttributeBeMultiline(attributeValue, indentLength)
460
+
461
+ if (!wouldBeMultiline) {
462
+ return true
463
+ } else {
464
+ return false
465
+ }
466
+ }
467
+ }
468
+
469
+ if (totalAttributeCount > 3 || inlineLength + indentLength > maxLineLength) {
470
+ return false
471
+ }
472
+
473
+ return true
474
+ }
475
+
476
+ private getAttributeName(attribute: HTMLAttributeNode): string {
477
+ return attribute.name ? getCombinedAttributeName(attribute.name) : ""
478
+ }
479
+
480
+ private wouldClassAttributeBeMultiline(content: string, indentLength: number): boolean {
481
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
482
+ const hasActualNewlines = /\r?\n/.test(content)
483
+
484
+ if (hasActualNewlines && normalizedContent.length > 80) {
485
+ const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
486
+ if (lines.length > 1) {
487
+ return true
488
+ }
489
+ }
490
+
491
+ const attributeLine = `class="${normalizedContent}"`
492
+ const currentIndent = indentLength
493
+
494
+ if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
495
+ if (/<%[^%]*%>/.test(normalizedContent)) {
496
+ return false
497
+ }
498
+
499
+ const classes = normalizedContent.split(' ')
500
+ const lines = this.breakTokensIntoLines(classes, currentIndent)
501
+ return lines.length > 1
502
+ }
503
+
504
+ return false
505
+ }
506
+
507
+ private getAttributeValue(attribute: HTMLAttributeNode): string {
508
+ if (isNode(attribute.value, HTMLAttributeValueNode)) {
509
+ return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('')
510
+ }
511
+
512
+ return ''
513
+ }
514
+
515
+ private hasMultilineAttributes(attributes: HTMLAttributeNode[]): boolean {
516
+ return attributes.some(attribute => {
517
+ if (isNode(attribute.value, HTMLAttributeValueNode)) {
518
+ const content = getCombinedStringFromNodes(attribute.value.children)
519
+
520
+ if (/\r?\n/.test(content)) {
521
+ const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
522
+
523
+ if (name === "class") {
524
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
525
+
526
+ return normalizedContent.length > 80
527
+ }
528
+
529
+ const lines = content.split(/\r?\n/)
530
+
531
+ if (lines.length > 1) {
532
+ return lines.slice(1).some(line => /^\s+/.test(line))
533
+ }
534
+ }
535
+ }
536
+
537
+ return false
538
+ })
539
+ }
540
+
541
+ private formatClassAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
542
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
543
+ const hasActualNewlines = /\r?\n/.test(content)
544
+
545
+ if (hasActualNewlines && normalizedContent.length > 80) {
546
+ const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
547
+
548
+ if (lines.length > 1) {
549
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
550
+ }
551
+ }
552
+
553
+ const currentIndent = this.indentLevel * this.indentWidth
554
+ const attributeLine = `${name}${equals}${open_quote}${normalizedContent}${close_quote}`
555
+
556
+ if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
557
+ if (/<%[^%]*%>/.test(normalizedContent)) {
558
+ return open_quote + normalizedContent + close_quote
559
+ }
560
+
561
+ const classes = normalizedContent.split(' ')
562
+ const lines = this.breakTokensIntoLines(classes, currentIndent)
563
+
564
+ if (lines.length > 1) {
565
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
566
+ }
567
+ }
568
+
569
+ return open_quote + normalizedContent + close_quote
570
+ }
571
+
572
+ private isFormattableAttribute(attributeName: string, tagName: string): boolean {
573
+ const globalFormattable = FORMATTABLE_ATTRIBUTES['*'] || []
574
+ const tagSpecificFormattable = FORMATTABLE_ATTRIBUTES[tagName.toLowerCase()] || []
575
+
576
+ return globalFormattable.includes(attributeName) || tagSpecificFormattable.includes(attributeName)
577
+ }
578
+
579
+ private formatMultilineAttribute(content: string, name: string, open_quote: string, close_quote: string): string {
580
+ if (name === 'srcset' || name === 'sizes') {
581
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
582
+
583
+ return open_quote + normalizedContent + close_quote
584
+ }
585
+
586
+ const lines = content.split('\n')
587
+
588
+ if (lines.length <= 1) {
589
+ return open_quote + content + close_quote
590
+ }
591
+
592
+ const formattedContent = this.formatMultilineAttributeValue(lines)
593
+
594
+ return open_quote + formattedContent + close_quote
595
+ }
596
+
597
+ private formatMultilineAttributeValue(lines: string[]): string {
598
+ const indent = " ".repeat((this.indentLevel + 1) * this.indentWidth)
599
+ const closeIndent = " ".repeat(this.indentLevel * this.indentWidth)
600
+
601
+ return "\n" + lines.map(line => indent + line).join("\n") + "\n" + closeIndent
602
+ }
603
+
604
+ private breakTokensIntoLines(tokens: string[], currentIndent: number, separator: string = ' '): string[] {
605
+ const lines: string[] = []
606
+ let currentLine = ''
607
+
608
+ for (const token of tokens) {
609
+ const testLine = currentLine ? currentLine + separator + token : token
610
+
611
+ if (testLine.length > (this.maxLineLength - currentIndent - 6)) {
612
+ if (currentLine) {
613
+ lines.push(currentLine)
614
+ currentLine = token
615
+ } else {
616
+ lines.push(token)
617
+ }
618
+ } else {
619
+ currentLine = testLine
620
+ }
621
+ }
622
+
623
+ if (currentLine) lines.push(currentLine)
624
+
625
+ return lines
626
+ }
627
+
628
+ /**
629
+ * Render multiline attributes for a tag
630
+ */
631
+ private renderMultilineAttributes(tagName: string, allChildren: Node[] = [], isSelfClosing: boolean = false,) {
632
+ this.pushWithIndent(`<${tagName}`)
633
+
634
+ this.withIndent(() => {
635
+ allChildren.forEach(child => {
636
+ if (isNode(child, HTMLAttributeNode)) {
637
+ this.pushWithIndent(this.renderAttribute(child))
638
+ } else if (!isNode(child, WhitespaceNode)) {
639
+ this.visit(child)
640
+ }
641
+ })
642
+ })
643
+
644
+ if (isSelfClosing) {
645
+ this.pushWithIndent("/>")
646
+ } else {
647
+ this.pushWithIndent(">")
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Reconstruct the text representation of an ERB node
653
+ * @param withFormatting - if true, format the content; if false, preserve original
654
+ */
655
+ private reconstructERBNode(node: ERBNode, withFormatting: boolean = true): string {
656
+ const open = node.tag_opening?.value ?? ""
657
+ const close = node.tag_closing?.value ?? ""
658
+ const content = node.content?.value ?? ""
659
+ const inner = withFormatting ? this.formatERBContent(content) : content
660
+
661
+ return open + inner + close
662
+ }
663
+
664
+ /**
665
+ * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
666
+ */
667
+ printERBNode(node: ERBNode) {
668
+ const indent = this.inlineMode ? "" : this.indent
669
+ const erbText = this.reconstructERBNode(node, true)
670
+
671
+ this.push(indent + erbText)
672
+ }
673
+
674
+ // --- Visitor methods ---
675
+
676
+ visitDocumentNode(node: DocumentNode) {
677
+ let lastWasMeaningful = false
678
+ let hasHandledSpacing = false
679
+
680
+ for (let i = 0; i < node.children.length; i++) {
681
+ const child = node.children[i]
682
+
683
+ if (isNode(child, HTMLTextNode)) {
684
+ const isWhitespaceOnly = child.content.trim() === ""
685
+
686
+ if (isWhitespaceOnly) {
687
+ const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1])
688
+ const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1])
689
+
690
+ const hasMultipleNewlines = child.content.includes('\n\n')
691
+
692
+ if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
693
+ this.push("")
694
+ hasHandledSpacing = true
695
+ }
696
+
697
+ continue
698
+ }
699
+ }
700
+
701
+ if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
702
+ this.push("")
703
+ }
704
+
705
+ this.visit(child)
706
+
707
+ if (this.isNonWhitespaceNode(child)) {
708
+ lastWasMeaningful = true
709
+ hasHandledSpacing = false
710
+ }
711
+ }
712
+ }
713
+
714
+ visitHTMLElementNode(node: HTMLElementNode) {
715
+ this.elementStack.push(node)
716
+ this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node))
717
+
718
+ this.visit(node.open_tag)
719
+
720
+ if (node.body.length > 0) {
721
+ this.visitHTMLElementBody(node.body, node)
722
+ }
723
+
724
+ if (node.close_tag) {
725
+ this.visit(node.close_tag)
726
+ }
727
+
728
+ this.elementStack.pop()
729
+ }
730
+
731
+ visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
732
+ if (this.isContentPreserving(element)) {
733
+ element.body.map(child => this.pushToLastLine(IdentityPrinter.print(child)))
734
+
735
+ return
736
+ }
737
+
738
+ const analysis = this.elementFormattingAnalysis.get(element)
739
+ const hasTextFlow = this.isInTextFlowContext(null, body)
740
+ const children = this.filterSignificantChildren(body, hasTextFlow)
741
+
742
+ if (analysis?.elementContentInline) {
743
+ if (children.length === 0) return
744
+
745
+ const oldInlineMode = this.inlineMode
746
+ const nodesToRender = hasTextFlow ? body : children
747
+
748
+ this.inlineMode = true
749
+
750
+ const lines = this.capture(() => {
751
+ nodesToRender.forEach(child => {
752
+ if (isNode(child, HTMLTextNode)) {
753
+ if (hasTextFlow) {
754
+ const normalizedContent = child.content.replace(/\s+/g, ' ')
755
+
756
+ if (normalizedContent && normalizedContent !== ' ') {
757
+ this.push(normalizedContent)
758
+ } else if (normalizedContent === ' ') {
759
+ this.push(' ')
760
+ }
761
+ } else {
762
+ const normalizedContent = child.content.replace(/\s+/g, ' ').trim()
763
+
764
+ if (normalizedContent) {
765
+ this.push(normalizedContent)
766
+ }
767
+ }
768
+ } else if (isNode(child, WhitespaceNode)) {
769
+ return
770
+ } else {
771
+ this.visit(child)
772
+ }
773
+ })
774
+ })
775
+
776
+ const content = lines.join('')
777
+ const inlineContent = hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim()
778
+
779
+ if (inlineContent) {
780
+ this.pushToLastLine(inlineContent)
781
+ }
782
+
783
+ this.inlineMode = oldInlineMode
784
+
785
+ return
786
+ }
787
+
788
+ if (children.length === 0) return
789
+
790
+ this.withIndent(() => {
791
+ if (hasTextFlow) {
792
+ this.visitTextFlowChildren(children)
793
+ } else {
794
+ this.visitElementChildren(body, element)
795
+ }
796
+ })
797
+ }
798
+
799
+ /**
800
+ * Visit element children with intelligent spacing logic
801
+ */
802
+ private visitElementChildren(body: Node[], parentElement: HTMLElementNode | null) {
803
+ let lastWasMeaningful = false
804
+ let hasHandledSpacing = false
805
+
806
+ for (let i = 0; i < body.length; i++) {
807
+ const child = body[i]
808
+
809
+ if (isNode(child, HTMLTextNode)) {
810
+ const isWhitespaceOnly = child.content.trim() === ""
811
+
812
+ if (isWhitespaceOnly) {
813
+ const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(body[i - 1])
814
+ const hasNextNonWhitespace = i < body.length - 1 && this.isNonWhitespaceNode(body[i + 1])
815
+
816
+ const hasMultipleNewlines = child.content.includes('\n\n')
817
+
818
+ if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
819
+ this.push("")
820
+ hasHandledSpacing = true
821
+ }
822
+
823
+ continue
824
+ }
825
+ }
826
+
827
+ if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
828
+ const element = body[i - 1]
829
+ const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2)
830
+
831
+ const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(
832
+ parentElement,
833
+ body,
834
+ i,
835
+ hasExistingSpacing
836
+ )
837
+
838
+ if (shouldAddSpacing) {
839
+ this.push("")
840
+ }
841
+ }
842
+
843
+ this.visit(child)
844
+
845
+ if (this.isNonWhitespaceNode(child)) {
846
+ lastWasMeaningful = true
847
+ hasHandledSpacing = false
848
+ }
849
+ }
850
+ }
851
+
852
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode) {
853
+ const attributes = filterNodes(node.children, HTMLAttributeNode)
854
+ const inlineNodes = this.extractInlineNodes(node.children)
855
+ const isSelfClosing = node.tag_closing?.value === "/>"
856
+
857
+ if (this.currentElement && this.elementFormattingAnalysis.has(this.currentElement)) {
858
+ const analysis = this.elementFormattingAnalysis.get(this.currentElement)!
859
+
860
+ if (analysis.openTagInline) {
861
+ const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
862
+
863
+ this.push(this.inlineMode ? inline : this.indent + inline)
864
+ return
865
+ } else {
866
+ this.renderMultilineAttributes(getTagName(node), node.children, isSelfClosing)
867
+
868
+ return
869
+ }
870
+ }
871
+
872
+ const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
873
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
874
+ const shouldKeepInline = this.shouldRenderInline(
875
+ totalAttributeCount,
876
+ inline.length,
877
+ this.indent.length,
878
+ this.maxLineLength,
879
+ false,
880
+ this.hasMultilineAttributes(attributes),
881
+ attributes
882
+ )
883
+
884
+ if (shouldKeepInline) {
885
+ this.push(this.inlineMode ? inline : this.indent + inline)
886
+ } else {
887
+ this.renderMultilineAttributes(getTagName(node), node.children, isSelfClosing)
888
+ }
889
+ }
890
+
891
+ visitHTMLCloseTagNode(node: HTMLCloseTagNode) {
892
+ const closingTag = IdentityPrinter.print(node)
893
+ const analysis = this.currentElement && this.elementFormattingAnalysis.get(this.currentElement)
894
+ const closeTagInline = analysis?.closeTagInline
895
+
896
+ if (this.currentElement && closeTagInline) {
897
+ this.pushToLastLine(closingTag)
898
+ } else {
899
+ this.pushWithIndent(closingTag)
900
+ }
901
+ }
902
+
903
+ visitHTMLTextNode(node: HTMLTextNode) {
904
+ if (this.inlineMode) {
905
+ const normalizedContent = node.content.replace(/\s+/g, ' ').trim()
906
+
907
+ if (normalizedContent) {
908
+ this.push(normalizedContent)
909
+ }
910
+
911
+ return
912
+ }
913
+
914
+ let text = node.content.trim()
915
+
916
+ if (!text) return
917
+
918
+ const wrapWidth = this.maxLineLength - this.indent.length
919
+ const words = text.split(/\s+/)
920
+ const lines: string[] = []
921
+
922
+ let line = ""
923
+
924
+ for (const word of words) {
925
+ if ((line + (line ? " " : "") + word).length > wrapWidth && line) {
926
+ lines.push(this.indent + line)
927
+ line = word
928
+ } else {
929
+ line += (line ? " " : "") + word
930
+ }
931
+ }
932
+
933
+ if (line) lines.push(this.indent + line)
934
+
935
+ lines.forEach(line => this.push(line))
936
+ }
937
+
938
+ visitHTMLAttributeNode(node: HTMLAttributeNode) {
939
+ this.pushWithIndent(this.renderAttribute(node))
940
+ }
941
+
942
+ visitHTMLAttributeNameNode(node: HTMLAttributeNameNode) {
943
+ this.pushWithIndent(getCombinedAttributeName(node))
944
+ }
945
+
946
+ visitHTMLAttributeValueNode(node: HTMLAttributeValueNode) {
947
+ this.pushWithIndent(IdentityPrinter.print(node))
948
+ }
949
+
950
+ // TODO: rework
951
+ visitHTMLCommentNode(node: HTMLCommentNode) {
952
+ const open = node.comment_start?.value ?? ""
953
+ const close = node.comment_end?.value ?? ""
954
+
955
+ let inner: string
956
+
957
+ if (node.children && node.children.length > 0) {
958
+ inner = node.children.map(child => {
959
+ if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
960
+ return child.content
961
+ } else if (isERBNode(child) || isNode(child, ERBContentNode)) {
962
+ return this.reconstructERBNode(child, false)
963
+ } else {
964
+ return ""
965
+ }
966
+ }).join("")
967
+
968
+ const hasNewlines = inner.includes('\n')
969
+
970
+ if (hasNewlines) {
971
+ const lines = inner.split('\n')
972
+ const childIndent = " ".repeat(this.indentWidth)
973
+ const firstLineHasContent = lines[0].trim() !== ''
974
+
975
+ if (firstLineHasContent && lines.length > 1) {
976
+ const contentLines = lines.map(line => line.trim()).filter(line => line !== '')
977
+ inner = '\n' + contentLines.map(line => childIndent + line).join('\n') + '\n'
978
+ } else {
979
+ const contentLines = lines.filter((line, index) => {
980
+ return line.trim() !== '' && !(index === 0 || index === lines.length - 1)
981
+ })
982
+
983
+ const minIndent = contentLines.length > 0 ? Math.min(...contentLines.map(line => line.length - line.trimStart().length)) : 0
984
+
985
+ const processedLines = lines.map((line, index) => {
986
+ const trimmedLine = line.trim()
987
+
988
+ if ((index === 0 || index === lines.length - 1) && trimmedLine === '') {
989
+ return line
990
+ }
991
+
992
+ if (trimmedLine !== '') {
993
+ const currentIndent = line.length - line.trimStart().length
994
+ const relativeIndent = Math.max(0, currentIndent - minIndent)
995
+
996
+ return childIndent + " ".repeat(relativeIndent) + trimmedLine
997
+ }
998
+
999
+ return line
1000
+ })
1001
+
1002
+ inner = processedLines.join('\n')
1003
+ }
1004
+ } else {
1005
+ inner = ` ${inner.trim()} `
1006
+ }
1007
+ } else {
1008
+ inner = ""
1009
+ }
1010
+
1011
+ this.pushWithIndent(open + inner + close)
1012
+ }
1013
+
1014
+ visitERBCommentNode(node: ERBContentNode) {
1015
+ const open = node.tag_opening?.value || "<%#"
1016
+ const content = node?.content?.value || ""
1017
+ const close = node.tag_closing?.value || "%>"
1018
+
1019
+ const contentLines = content.split("\n")
1020
+ const contentTrimmedLines = content.trim().split("\n")
1021
+
1022
+ if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
1023
+ const startsWithSpace = content[0] === " "
1024
+ const before = startsWithSpace ? "" : " "
1025
+
1026
+ this.pushWithIndent(open + before + content.trimEnd() + ' ' + close)
1027
+
1028
+ return
1029
+ }
1030
+
1031
+ if (contentTrimmedLines.length === 1) {
1032
+ this.pushWithIndent(open + ' ' + content.trim() + ' ' + close)
1033
+ return
1034
+ }
1035
+
1036
+ const firstLineEmpty = contentLines[0].trim() === ""
1037
+ const dedentedContent = dedent(firstLineEmpty ? content : content.trimStart())
1038
+
1039
+ this.pushWithIndent(open)
1040
+
1041
+ this.withIndent(() => {
1042
+ dedentedContent.split("\n").forEach(line => this.pushWithIndent(line))
1043
+ })
1044
+
1045
+ this.pushWithIndent(close)
1046
+ }
1047
+
1048
+ visitHTMLDoctypeNode(node: HTMLDoctypeNode) {
1049
+ this.pushWithIndent(IdentityPrinter.print(node))
1050
+ }
1051
+
1052
+ visitXMLDeclarationNode(node: XMLDeclarationNode) {
1053
+ this.pushWithIndent(IdentityPrinter.print(node))
1054
+ }
1055
+
1056
+ visitCDATANode(node: CDATANode) {
1057
+ this.pushWithIndent(IdentityPrinter.print(node))
1058
+ }
1059
+
1060
+ visitERBContentNode(node: ERBContentNode) {
1061
+ // TODO: this feels hacky
1062
+ if (node.tag_opening?.value === "<%#") {
1063
+ this.visitERBCommentNode(node)
1064
+ } else {
1065
+ this.printERBNode(node)
1066
+ }
1067
+ }
1068
+
1069
+ visitERBEndNode(node: ERBEndNode) {
1070
+ this.printERBNode(node)
1071
+ }
1072
+
1073
+ visitERBYieldNode(node: ERBYieldNode) {
1074
+ this.printERBNode(node)
1075
+ }
1076
+
1077
+ visitERBInNode(node: ERBInNode) {
1078
+ this.printERBNode(node)
1079
+ this.withIndent(() => this.visitAll(node.statements))
1080
+ }
1081
+
1082
+ visitERBCaseMatchNode(node: ERBCaseMatchNode) {
1083
+ this.printERBNode(node)
1084
+ this.visitAll(node.conditions)
1085
+
1086
+ if (node.else_clause) this.visit(node.else_clause)
1087
+ if (node.end_node) this.visit(node.end_node)
1088
+ }
1089
+
1090
+ visitERBBlockNode(node: ERBBlockNode) {
1091
+ this.printERBNode(node)
1092
+ this.withIndent(() => this.visitElementChildren(node.body, null))
1093
+
1094
+ if (node.end_node) this.visit(node.end_node)
1095
+ }
1096
+
1097
+ visitERBIfNode(node: ERBIfNode) {
1098
+ if (this.inlineMode) {
1099
+ this.printERBNode(node)
1100
+
1101
+ node.statements.forEach(child => {
1102
+ if (isNode(child, HTMLAttributeNode)) {
1103
+ this.lines.push(" ")
1104
+ this.lines.push(this.renderAttribute(child))
1105
+ } else {
1106
+ const shouldAddSpaces = this.isInTokenListAttribute()
1107
+
1108
+ if (shouldAddSpaces) {
1109
+ this.lines.push(" ")
1110
+ }
1111
+
1112
+ this.visit(child)
1113
+
1114
+ if (shouldAddSpaces) {
1115
+ this.lines.push(" ")
1116
+ }
1117
+ }
1118
+ })
1119
+
1120
+ const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
1121
+ const isTokenList = this.isInTokenListAttribute()
1122
+
1123
+ if ((hasHTMLAttributes || isTokenList) && node.end_node) {
1124
+ this.lines.push(" ")
1125
+ }
1126
+
1127
+ if (node.subsequent) this.visit(node.end_node)
1128
+ if (node.end_node) this.visit(node.end_node)
1129
+ } else {
1130
+ this.printERBNode(node)
1131
+
1132
+ this.withIndent(() => {
1133
+ node.statements.forEach(child => this.visit(child))
1134
+ })
1135
+
1136
+ if (node.subsequent) this.visit(node.subsequent)
1137
+ if (node.end_node) this.visit(node.end_node)
1138
+ }
1139
+ }
1140
+
1141
+ visitERBElseNode(node: ERBElseNode) {
1142
+ this.printERBNode(node)
1143
+ this.withIndent(() => node.statements.forEach(statement => this.visit(statement)))
1144
+ }
1145
+
1146
+ visitERBWhenNode(node: ERBWhenNode) {
1147
+ this.printERBNode(node)
1148
+ this.withIndent(() => this.visitAll(node.statements))
1149
+ }
1150
+
1151
+ visitERBCaseNode(node: ERBCaseNode) {
1152
+ this.printERBNode(node)
1153
+ this.visitAll(node.conditions)
1154
+
1155
+ if (node.else_clause) this.visit(node.else_clause)
1156
+ if (node.end_node) this.visit(node.end_node)
1157
+ }
1158
+
1159
+ visitERBBeginNode(node: ERBBeginNode) {
1160
+ this.printERBNode(node)
1161
+ this.withIndent(() => this.visitAll(node.statements))
1162
+
1163
+ if (node.rescue_clause) this.visit(node.rescue_clause)
1164
+ if (node.else_clause) this.visit(node.else_clause)
1165
+ if (node.ensure_clause) this.visit(node.ensure_clause)
1166
+ if (node.end_node) this.visit(node.end_node)
1167
+ }
1168
+
1169
+ visitERBWhileNode(node: ERBWhileNode) {
1170
+ this.printERBNode(node)
1171
+ this.withIndent(() => this.visitAll(node.statements))
1172
+
1173
+ if (node.end_node) this.visit(node.end_node)
1174
+ }
1175
+
1176
+ visitERBUntilNode(node: ERBUntilNode) {
1177
+ this.printERBNode(node)
1178
+ this.withIndent(() => this.visitAll(node.statements))
1179
+
1180
+ if (node.end_node) this.visit(node.end_node)
1181
+ }
1182
+
1183
+ visitERBForNode(node: ERBForNode) {
1184
+ this.printERBNode(node)
1185
+ this.withIndent(() => this.visitAll(node.statements))
1186
+
1187
+ if (node.end_node) this.visit(node.end_node)
1188
+ }
1189
+
1190
+ visitERBRescueNode(node: ERBRescueNode) {
1191
+ this.printERBNode(node)
1192
+ this.withIndent(() => this.visitAll(node.statements))
1193
+ }
1194
+
1195
+ visitERBEnsureNode(node: ERBEnsureNode) {
1196
+ this.printERBNode(node)
1197
+ this.withIndent(() => this.visitAll(node.statements))
1198
+ }
1199
+
1200
+ visitERBUnlessNode(node: ERBUnlessNode) {
1201
+ this.printERBNode(node)
1202
+ this.withIndent(() => this.visitAll(node.statements))
1203
+
1204
+ if (node.else_clause) this.visit(node.else_clause)
1205
+ if (node.end_node) this.visit(node.end_node)
1206
+ }
1207
+
1208
+ // --- Element Formatting Analysis Helpers ---
1209
+
1210
+ /**
1211
+ * Analyzes an HTMLElementNode and returns formatting decisions for all parts
1212
+ */
1213
+ private analyzeElementFormatting(node: HTMLElementNode): ElementFormattingAnalysis {
1214
+ const openTagInline = this.shouldRenderOpenTagInline(node)
1215
+ const elementContentInline = this.shouldRenderElementContentInline(node)
1216
+ const closeTagInline = this.shouldRenderCloseTagInline(node, elementContentInline)
1217
+
1218
+ return {
1219
+ openTagInline,
1220
+ elementContentInline,
1221
+ closeTagInline
1222
+ }
1223
+ }
1224
+
1225
+ /**
1226
+ * Determines if the open tag should be rendered inline
1227
+ */
1228
+ private shouldRenderOpenTagInline(node: HTMLElementNode): boolean {
1229
+ const children = node.open_tag?.children || []
1230
+ const attributes = filterNodes(children, HTMLAttributeNode)
1231
+ const inlineNodes = this.extractInlineNodes(children)
1232
+ const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node))
1233
+ const hasComplexERB = hasERBControlFlow && this.hasComplexERBControlFlow(inlineNodes)
1234
+
1235
+ if (hasComplexERB) return false
1236
+
1237
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
1238
+ const hasMultilineAttrs = this.hasMultilineAttributes(attributes)
1239
+
1240
+ if (hasMultilineAttrs) return false
1241
+
1242
+ const inline = this.renderInlineOpen(
1243
+ getTagName(node),
1244
+ attributes,
1245
+ node.open_tag?.tag_closing?.value === "/>",
1246
+ inlineNodes,
1247
+ children
1248
+ )
1249
+
1250
+ return this.shouldRenderInline(
1251
+ totalAttributeCount,
1252
+ inline.length,
1253
+ this.indent.length,
1254
+ this.maxLineLength,
1255
+ hasComplexERB,
1256
+ hasMultilineAttrs,
1257
+ attributes
1258
+ )
1259
+ }
1260
+
1261
+ /**
1262
+ * Determines if the element content should be rendered inline
1263
+ */
1264
+ private shouldRenderElementContentInline(node: HTMLElementNode): boolean {
1265
+ const tagName = getTagName(node)
1266
+ const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body))
1267
+ const isInlineElement = this.isInlineElement(tagName)
1268
+ const openTagInline = this.shouldRenderOpenTagInline(node)
1269
+
1270
+ if (!openTagInline) return false
1271
+ if (children.length === 0) return true
1272
+
1273
+ if (isInlineElement) {
1274
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children)
1275
+
1276
+ if (fullInlineResult) {
1277
+ const totalLength = this.indent.length + fullInlineResult.length
1278
+ return totalLength <= this.maxLineLength || totalLength <= 120
1279
+ }
1280
+
1281
+ return false
1282
+ }
1283
+
1284
+ const allNestedAreInline = this.areAllNestedElementsInline(children)
1285
+ const hasMultilineText = this.hasMultilineTextContent(children)
1286
+ const hasMixedContent = this.hasMixedTextAndInlineContent(children)
1287
+
1288
+ if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
1289
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children)
1290
+
1291
+ if (fullInlineResult) {
1292
+ const totalLength = this.indent.length + fullInlineResult.length
1293
+
1294
+ if (totalLength <= this.maxLineLength) {
1295
+ return true
1296
+ }
1297
+ }
1298
+ }
1299
+
1300
+ const inlineResult = this.tryRenderInline(children, tagName)
1301
+
1302
+ if (inlineResult) {
1303
+ const openTagResult = this.renderInlineOpen(
1304
+ tagName,
1305
+ filterNodes(node.open_tag?.children, HTMLAttributeNode),
1306
+ false,
1307
+ [],
1308
+ node.open_tag?.children || []
1309
+ )
1310
+
1311
+ const childrenContent = this.renderChildrenInline(children)
1312
+ const fullLine = openTagResult + childrenContent + `</${tagName}>`
1313
+
1314
+ if ((this.indent.length + fullLine.length) <= this.maxLineLength) {
1315
+ return true
1316
+ }
1317
+ }
1318
+
1319
+ return false
1320
+ }
1321
+
1322
+ /**
1323
+ * Determines if the close tag should be rendered inline (usually follows content decision)
1324
+ */
1325
+ private shouldRenderCloseTagInline(node: HTMLElementNode, elementContentInline: boolean): boolean {
1326
+ if (node.is_void) return true
1327
+ if (node.open_tag?.tag_closing?.value === "/>") return true
1328
+ if (this.isContentPreserving(node)) return true
1329
+
1330
+ const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body))
1331
+
1332
+ if (children.length === 0) return true
1333
+
1334
+ return elementContentInline
1335
+ }
1336
+
1337
+
1338
+ // --- Utility methods ---
1339
+
1340
+ private isNonWhitespaceNode(node: Node): boolean {
1341
+ if (isNode(node, WhitespaceNode)) return false
1342
+ if (isNode(node, HTMLTextNode)) return node.content.trim() !== ""
1343
+
1344
+ return true
1345
+ }
1346
+
1347
+ /**
1348
+ * Check if an element should be treated as inline based on its tag name
1349
+ */
1350
+ private isInlineElement(tagName: string): boolean {
1351
+ return FormatPrinter.INLINE_ELEMENTS.has(tagName.toLowerCase())
1352
+ }
1353
+
1354
+ /**
1355
+ * Check if we're in a text flow context (parent contains mixed text and inline elements)
1356
+ */
1357
+ private visitTextFlowChildren(children: Node[]) {
1358
+ let currentLineContent = ""
1359
+
1360
+ for (const child of children) {
1361
+ if (isNode(child, HTMLTextNode)) {
1362
+ const content = child.content
1363
+
1364
+ let processedContent = content.replace(/\s+/g, ' ').trim()
1365
+
1366
+ if (processedContent) {
1367
+ const hasLeadingSpace = /^\s/.test(content)
1368
+
1369
+ if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
1370
+ currentLineContent += ' '
1371
+ }
1372
+
1373
+ currentLineContent += processedContent
1374
+
1375
+ const hasTrailingSpace = /\s$/.test(content)
1376
+
1377
+ if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
1378
+ currentLineContent += ' '
1379
+ }
1380
+
1381
+ if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1382
+ children.forEach(child => this.visit(child))
1383
+
1384
+ return
1385
+ }
1386
+ }
1387
+ } else if (isNode(child, HTMLElementNode)) {
1388
+ const childTagName = getTagName(child)
1389
+
1390
+ if (this.isInlineElement(childTagName)) {
1391
+ const childInline = this.tryRenderInlineFull(child, childTagName,
1392
+ filterNodes(child.open_tag?.children, HTMLAttributeNode),
1393
+ this.filterEmptyNodes(child.body)
1394
+ )
1395
+
1396
+ if (childInline) {
1397
+ currentLineContent += childInline
1398
+
1399
+ if ((this.indent.length + currentLineContent.length) > this.maxLineLength) {
1400
+ children.forEach(child => this.visit(child))
1401
+
1402
+ return
1403
+ }
1404
+ } else {
1405
+ if (currentLineContent.trim()) {
1406
+ this.pushWithIndent(currentLineContent.trim())
1407
+ currentLineContent = ""
1408
+ }
1409
+
1410
+ this.visit(child)
1411
+ }
1412
+ } else {
1413
+ if (currentLineContent.trim()) {
1414
+ this.pushWithIndent(currentLineContent.trim())
1415
+ currentLineContent = ""
1416
+ }
1417
+
1418
+ this.visit(child)
1419
+ }
1420
+ } else if (isNode(child, ERBContentNode)) {
1421
+ const oldLines = this.lines
1422
+ const oldInlineMode = this.inlineMode
1423
+
1424
+ // TODO: use this.capture
1425
+ try {
1426
+ this.lines = []
1427
+ this.inlineMode = true
1428
+ this.visit(child)
1429
+ const erbContent = this.lines.join("")
1430
+ currentLineContent += erbContent
1431
+
1432
+ if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1433
+ this.lines = oldLines
1434
+ this.inlineMode = oldInlineMode
1435
+ children.forEach(child => this.visit(child))
1436
+
1437
+ return
1438
+ }
1439
+ } finally {
1440
+ this.lines = oldLines
1441
+ this.inlineMode = oldInlineMode
1442
+ }
1443
+ } else {
1444
+ if (currentLineContent.trim()) {
1445
+ this.pushWithIndent(currentLineContent.trim())
1446
+ currentLineContent = ""
1447
+ }
1448
+
1449
+ this.visit(child)
1450
+ }
1451
+ }
1452
+
1453
+ if (currentLineContent.trim()) {
1454
+ const finalLine = this.indent + currentLineContent.trim()
1455
+
1456
+ if (finalLine.length > Math.max(this.maxLineLength, 120)) {
1457
+ this.visitAll(children)
1458
+
1459
+ return
1460
+ }
1461
+
1462
+ this.push(finalLine)
1463
+ }
1464
+ }
1465
+
1466
+ private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean {
1467
+ const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "")
1468
+ const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode))
1469
+
1470
+ if (!hasTextContent) return false
1471
+ if (nonTextChildren.length === 0) return false
1472
+
1473
+ const allInline = nonTextChildren.every(child => {
1474
+ if (isNode(child, ERBContentNode)) return true
1475
+
1476
+ if (isNode(child, HTMLElementNode)) {
1477
+ return this.isInlineElement(getTagName(child))
1478
+ }
1479
+
1480
+ return false
1481
+ })
1482
+
1483
+ if (!allInline) return false
1484
+
1485
+ return true
1486
+ }
1487
+
1488
+ private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
1489
+ const parts = attributes.map(attribute => this.renderAttribute(attribute))
1490
+
1491
+ if (inlineNodes.length > 0) {
1492
+ let result = `<${name}`
1493
+
1494
+ if (allChildren.length > 0) {
1495
+ const lines = this.capture(() => {
1496
+ allChildren.forEach(child => {
1497
+ if (isNode(child, HTMLAttributeNode)) {
1498
+ this.lines.push(" " + this.renderAttribute(child))
1499
+ } else if (!(isNode(child, WhitespaceNode))) {
1500
+ const wasInlineMode = this.inlineMode
1501
+
1502
+ this.inlineMode = true
1503
+
1504
+ this.lines.push(" ")
1505
+ this.visit(child)
1506
+ this.inlineMode = wasInlineMode
1507
+ }
1508
+ })
1509
+ })
1510
+
1511
+ result += lines.join("")
1512
+ } else {
1513
+ if (parts.length > 0) {
1514
+ result += ` ${parts.join(" ")}`
1515
+ }
1516
+
1517
+ const lines = this.capture(() => {
1518
+ inlineNodes.forEach(node => {
1519
+ const wasInlineMode = this.inlineMode
1520
+
1521
+ if (!isERBControlFlowNode(node)) {
1522
+ this.inlineMode = true
1523
+ }
1524
+
1525
+ this.visit(node)
1526
+
1527
+ this.inlineMode = wasInlineMode
1528
+ })
1529
+ })
1530
+
1531
+ result += lines.join("")
1532
+ }
1533
+
1534
+ result += selfClose ? " />" : ">"
1535
+
1536
+ return result
1537
+ }
1538
+
1539
+ return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " />" : ">"}`
1540
+ }
1541
+
1542
+ renderAttribute(attribute: HTMLAttributeNode): string {
1543
+ const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
1544
+ const equals = attribute.equals?.value ?? ""
1545
+
1546
+ this.currentAttributeName = name
1547
+
1548
+ let value = ""
1549
+
1550
+ if (isNode(attribute.value, HTMLAttributeValueNode)) {
1551
+ const attributeValue = attribute.value
1552
+
1553
+ let open_quote = attributeValue.open_quote?.value ?? ""
1554
+ let close_quote = attributeValue.close_quote?.value ?? ""
1555
+ let htmlTextContent = ""
1556
+
1557
+ const content = attributeValue.children.map((child: Node) => {
1558
+ if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
1559
+ htmlTextContent += child.content
1560
+
1561
+ return child.content
1562
+ } else if (isNode(child, ERBContentNode)) {
1563
+ return IdentityPrinter.print(child)
1564
+ } else {
1565
+ const printed = IdentityPrinter.print(child)
1566
+
1567
+ if (this.currentAttributeName && FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)) {
1568
+ return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%')
1569
+ }
1570
+
1571
+ return printed
1572
+ }
1573
+ }).join("")
1574
+
1575
+ if (open_quote === "" && close_quote === "") {
1576
+ open_quote = '"'
1577
+ close_quote = '"'
1578
+ } else if (open_quote === "'" && close_quote === "'" && !htmlTextContent.includes('"')) {
1579
+ open_quote = '"'
1580
+ close_quote = '"'
1581
+ }
1582
+
1583
+ if (this.isFormattableAttribute(name, this.currentTagName)) {
1584
+ if (name === 'class') {
1585
+ value = this.formatClassAttribute(content, name, equals, open_quote, close_quote)
1586
+ } else {
1587
+ value = this.formatMultilineAttribute(content, name, open_quote, close_quote)
1588
+ }
1589
+ } else {
1590
+ value = open_quote + content + close_quote
1591
+ }
1592
+ }
1593
+
1594
+ this.currentAttributeName = null
1595
+
1596
+ return name + equals + value
1597
+ }
1598
+
1599
+ /**
1600
+ * Try to render a complete element inline including opening tag, children, and closing tag
1601
+ */
1602
+ private tryRenderInlineFull(_node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
1603
+ let result = `<${tagName}`
1604
+
1605
+ result += this.renderAttributesString(attributes)
1606
+ result += ">"
1607
+
1608
+ const childrenContent = this.tryRenderChildrenInline(children)
1609
+
1610
+ if (!childrenContent) return null
1611
+
1612
+ result += childrenContent
1613
+ result += `</${tagName}>`
1614
+
1615
+ return result
1616
+ }
1617
+
1618
+ /**
1619
+ * Try to render just the children inline (without tags)
1620
+ */
1621
+ private tryRenderChildrenInline(children: Node[]): string | null {
1622
+ let result = ""
1623
+
1624
+ for (const child of children) {
1625
+ if (isNode(child, HTMLTextNode)) {
1626
+ const normalizedContent = child.content.replace(/\s+/g, ' ')
1627
+ const hasLeadingSpace = /^\s/.test(child.content)
1628
+ const hasTrailingSpace = /\s$/.test(child.content)
1629
+ const trimmedContent = normalizedContent.trim()
1630
+
1631
+ if (trimmedContent) {
1632
+ let finalContent = trimmedContent
1633
+
1634
+ if (hasLeadingSpace && result && !result.endsWith(' ')) {
1635
+ finalContent = ' ' + finalContent
1636
+ }
1637
+
1638
+ if (hasTrailingSpace) {
1639
+ finalContent = finalContent + ' '
1640
+ }
1641
+
1642
+ result += finalContent
1643
+ } else if (hasLeadingSpace || hasTrailingSpace) {
1644
+ if (result && !result.endsWith(' ')) {
1645
+ result += ' '
1646
+ }
1647
+ }
1648
+
1649
+ } else if (isNode(child, HTMLElementNode)) {
1650
+ const tagName = getTagName(child)
1651
+
1652
+ if (!this.isInlineElement(tagName)) {
1653
+ return null
1654
+ }
1655
+
1656
+ const childInline = this.tryRenderInlineFull(child, tagName,
1657
+ filterNodes(child.open_tag?.children, HTMLAttributeNode),
1658
+ this.filterEmptyNodes(child.body)
1659
+ )
1660
+
1661
+ if (!childInline) {
1662
+ return null
1663
+ }
1664
+
1665
+ result += childInline
1666
+ } else {
1667
+ result += this.capture(() => this.visit(child)).join("")
1668
+ }
1669
+ }
1670
+
1671
+ return result.trim()
1672
+ }
1673
+
1674
+ /**
1675
+ * Try to render children inline if they are simple enough.
1676
+ * Returns the inline string if possible, null otherwise.
1677
+ */
1678
+ private tryRenderInline(children: Node[], tagName: string): string | null {
1679
+ for (const child of children) {
1680
+ if (isNode(child, HTMLTextNode)) {
1681
+ if (child.content.includes('\n')) {
1682
+ return null
1683
+ }
1684
+ } else if (isNode(child, HTMLElementNode)) {
1685
+ const isInlineElement = this.isInlineElement(getTagName(child))
1686
+
1687
+ if (!isInlineElement) {
1688
+ return null
1689
+ }
1690
+ } else if (isNode(child, ERBContentNode)) {
1691
+ // ERB content nodes are allowed in inline rendering
1692
+ } else {
1693
+ return null
1694
+ }
1695
+ }
1696
+
1697
+ let content = ""
1698
+
1699
+ this.capture(() => {
1700
+ content = this.renderChildrenInline(children)
1701
+ })
1702
+
1703
+ return `<${tagName}>${content}</${tagName}>`
1704
+ }
1705
+
1706
+ /**
1707
+ * Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
1708
+ * or mixed ERB output and text (like "<%= value %> text")
1709
+ * This indicates content that should be formatted inline even with structural newlines
1710
+ */
1711
+ private hasMixedTextAndInlineContent(children: Node[]): boolean {
1712
+ let hasText = false
1713
+ let hasInlineElements = false
1714
+
1715
+ for (const child of children) {
1716
+ if (isNode(child, HTMLTextNode)) {
1717
+ if (child.content.trim() !== "") {
1718
+ hasText = true
1719
+ }
1720
+ } else if (isNode(child, HTMLElementNode)) {
1721
+ if (this.isInlineElement(getTagName(child))) {
1722
+ hasInlineElements = true
1723
+ }
1724
+ }
1725
+ }
1726
+
1727
+ return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText)
1728
+ }
1729
+
1730
+ /**
1731
+ * Check if children contain any text content with newlines
1732
+ */
1733
+ private hasMultilineTextContent(children: Node[]): boolean {
1734
+ for (const child of children) {
1735
+ if (isNode(child, HTMLTextNode)) {
1736
+ return child.content.includes('\n')
1737
+ }
1738
+
1739
+ if (isNode(child, HTMLElementNode)) {
1740
+ const nestedChildren = this.filterEmptyNodes(child.body)
1741
+
1742
+ if (this.hasMultilineTextContent(nestedChildren)) {
1743
+ return true
1744
+ }
1745
+ }
1746
+ }
1747
+
1748
+ return false
1749
+ }
1750
+
1751
+ /**
1752
+ * Check if all nested elements in the children are inline elements
1753
+ */
1754
+ private areAllNestedElementsInline(children: Node[]): boolean {
1755
+ for (const child of children) {
1756
+ if (isNode(child, HTMLElementNode)) {
1757
+ if (!this.isInlineElement(getTagName(child))) {
1758
+ return false
1759
+ }
1760
+
1761
+ const nestedChildren = this.filterEmptyNodes(child.body)
1762
+
1763
+ if (!this.areAllNestedElementsInline(nestedChildren)) {
1764
+ return false
1765
+ }
1766
+ } else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
1767
+ return false
1768
+ }
1769
+ }
1770
+
1771
+ return true
1772
+ }
1773
+
1774
+ /**
1775
+ * Check if element has complex ERB control flow
1776
+ */
1777
+ private hasComplexERBControlFlow(inlineNodes: Node[]): boolean {
1778
+ return inlineNodes.some(node => {
1779
+ if (isNode(node, ERBIfNode)) {
1780
+ if (node.statements.length > 0 && node.location) {
1781
+ const startLine = node.location.start.line
1782
+ const endLine = node.location.end.line
1783
+
1784
+ return startLine !== endLine
1785
+ }
1786
+
1787
+ return false
1788
+ }
1789
+
1790
+ return false
1791
+ })
1792
+ }
1793
+
1794
+ /**
1795
+ * Filter children to remove insignificant whitespace
1796
+ */
1797
+ private filterSignificantChildren(body: Node[], hasTextFlow: boolean): Node[] {
1798
+ return body.filter(child => {
1799
+ if (isNode(child, WhitespaceNode)) return false
1800
+
1801
+ if (isNode(child, HTMLTextNode)) {
1802
+ if (hasTextFlow && child.content === " ") return true
1803
+
1804
+ return child.content.trim() !== ""
1805
+ }
1806
+
1807
+ return true
1808
+ })
1809
+ }
1810
+
1811
+ /**
1812
+ * Filter out empty text nodes and whitespace nodes
1813
+ */
1814
+ private filterEmptyNodes(nodes: Node[]): Node[] {
1815
+ return nodes.filter(child =>
1816
+ !isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === "")
1817
+ )
1818
+ }
1819
+
1820
+ private renderElementInline(element: HTMLElementNode): string {
1821
+ const children = this.filterEmptyNodes(element.body)
1822
+
1823
+ return this.renderChildrenInline(children)
1824
+ }
1825
+
1826
+ private renderChildrenInline(children: Node[]) {
1827
+ let content = ''
1828
+
1829
+ for (const child of children) {
1830
+ if (isNode(child, HTMLTextNode)) {
1831
+ content += child.content
1832
+ } else if (isNode(child, HTMLElementNode) ) {
1833
+ const tagName = getTagName(child)
1834
+ const attributes = filterNodes(child.open_tag?.children, HTMLAttributeNode)
1835
+ const attributesString = this.renderAttributesString(attributes)
1836
+ const childContent = this.renderElementInline(child)
1837
+
1838
+ content += `<${tagName}${attributesString}>${childContent}</${tagName}>`
1839
+ } else if (isNode(child, ERBContentNode)) {
1840
+ content += this.reconstructERBNode(child, true)
1841
+ }
1842
+ }
1843
+
1844
+ return content.replace(/\s+/g, ' ').trim()
1845
+ }
1846
+
1847
+ private isContentPreserving(element: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): boolean {
1848
+ const tagName = getTagName(element)
1849
+
1850
+ return FormatPrinter.CONTENT_PRESERVING_ELEMENTS.has(tagName)
1851
+ }
1852
+ }