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