@herb-tools/formatter 0.4.2 → 0.5.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.
package/src/printer.ts CHANGED
@@ -57,6 +57,12 @@ type ERBNode =
57
57
 
58
58
  import type { FormatOptions } from "./options.js"
59
59
 
60
+ // TODO: we can probably expand this list with more tags/attributes
61
+ const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
62
+ '*': ['class'],
63
+ 'img': ['srcset', 'sizes']
64
+ }
65
+
60
66
  /**
61
67
  * Printer traverses the Herb AST using the Visitor pattern
62
68
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
@@ -69,9 +75,18 @@ export class Printer extends Visitor {
69
75
  private indentLevel: number = 0
70
76
  private inlineMode: boolean = false
71
77
  private isInComplexNesting: boolean = false
78
+ private currentTagName: string = ""
79
+
80
+ private static readonly INLINE_ELEMENTS = new Set([
81
+ 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
82
+ 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
83
+ 'samp', 'small', 'span', 'strong', 'sub', 'sup',
84
+ 'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
85
+ ])
72
86
 
73
87
  constructor(source: string, options: Required<FormatOptions>) {
74
88
  super()
89
+
75
90
  this.source = source
76
91
  this.indentWidth = options.indentWidth
77
92
  this.maxLineLength = options.maxLineLength
@@ -94,7 +109,7 @@ export class Printer extends Visitor {
94
109
  this.visit(node)
95
110
  }
96
111
 
97
- return this.lines.filter(Boolean).join("\n")
112
+ return this.lines.join("\n")
98
113
  }
99
114
 
100
115
  private push(line: string) {
@@ -105,6 +120,7 @@ export class Printer extends Visitor {
105
120
  this.indentLevel++
106
121
  const result = callback()
107
122
  this.indentLevel--
123
+
108
124
  return result
109
125
  }
110
126
 
@@ -112,30 +128,307 @@ export class Printer extends Visitor {
112
128
  return " ".repeat(this.indentLevel * this.indentWidth)
113
129
  }
114
130
 
131
+ /**
132
+ * Format ERB content with proper spacing around the inner content.
133
+ * Returns empty string if content is empty, otherwise wraps content with single spaces.
134
+ */
135
+ private formatERBContent(content: string): string {
136
+ return content.trim() ? ` ${content.trim()} ` : ""
137
+ }
138
+
139
+ /**
140
+ * Check if a node is an ERB control flow node (if, unless, block, case, while, for)
141
+ */
142
+ private isERBControlFlow(node: Node): boolean {
143
+ return node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE' ||
144
+ node instanceof ERBUnlessNode || (node as any).type === 'AST_ERB_UNLESS_NODE' ||
145
+ node instanceof ERBBlockNode || (node as any).type === 'AST_ERB_BLOCK_NODE' ||
146
+ node instanceof ERBCaseNode || (node as any).type === 'AST_ERB_CASE_NODE' ||
147
+ node instanceof ERBCaseMatchNode || (node as any).type === 'AST_ERB_CASE_MATCH_NODE' ||
148
+ node instanceof ERBWhileNode || (node as any).type === 'AST_ERB_WHILE_NODE' ||
149
+ node instanceof ERBForNode || (node as any).type === 'AST_ERB_FOR_NODE'
150
+ }
151
+
152
+ /**
153
+ * Count total attributes including those inside ERB conditionals
154
+ */
155
+ private getTotalAttributeCount(attributes: HTMLAttributeNode[], inlineNodes: Node[] = []): number {
156
+ let totalAttributeCount = attributes.length
157
+
158
+ inlineNodes.forEach(node => {
159
+ if (this.isERBControlFlow(node)) {
160
+ const erbNode = node as any
161
+ if (erbNode.statements) {
162
+ totalAttributeCount += erbNode.statements.length
163
+ }
164
+ }
165
+ })
166
+
167
+ return totalAttributeCount
168
+ }
169
+
170
+ /**
171
+ * Extract HTML attributes from a list of nodes
172
+ */
173
+ private extractAttributes(nodes: Node[]): HTMLAttributeNode[] {
174
+ return nodes.filter((child): child is HTMLAttributeNode =>
175
+ child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
176
+ )
177
+ }
178
+
179
+ /**
180
+ * Extract inline nodes (non-attribute, non-whitespace) from a list of nodes
181
+ */
182
+ private extractInlineNodes(nodes: Node[]): Node[] {
183
+ return nodes.filter(child =>
184
+ !(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
185
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Render attributes as a space-separated string
191
+ */
192
+ private renderAttributesString(attributes: HTMLAttributeNode[]): string {
193
+ if (attributes.length === 0) return ""
194
+ return ` ${attributes.map(attr => this.renderAttribute(attr)).join(" ")}`
195
+ }
196
+
197
+ /**
198
+ * Determine if a tag should be rendered inline based on attribute count and other factors
199
+ */
200
+ private shouldRenderInline(
201
+ totalAttributeCount: number,
202
+ inlineLength: number,
203
+ indentLength: number,
204
+ maxLineLength: number = this.maxLineLength,
205
+ hasComplexERB: boolean = false,
206
+ _nestingDepth: number = 0,
207
+ _inlineNodesLength: number = 0,
208
+ hasMultilineAttributes: boolean = false
209
+ ): boolean {
210
+ if (hasComplexERB || hasMultilineAttributes) return false
211
+
212
+ if (totalAttributeCount === 0) {
213
+ return inlineLength + indentLength <= maxLineLength
214
+ }
215
+
216
+ if (totalAttributeCount > 3 || inlineLength + indentLength > maxLineLength) {
217
+ return false
218
+ }
219
+
220
+ return true
221
+ }
222
+
223
+ private hasMultilineAttributes(attributes: HTMLAttributeNode[]): boolean {
224
+ return attributes.some(attribute => {
225
+ if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
226
+ const attributeValue = attribute.value as HTMLAttributeValueNode
227
+
228
+ const content = attributeValue.children.map((child: Node) => {
229
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' || child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
230
+ return (child as HTMLTextNode | LiteralNode).content
231
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
232
+ const erbAttribute = child as ERBContentNode
233
+
234
+ return erbAttribute.tag_opening!.value + erbAttribute.content!.value + erbAttribute.tag_closing!.value
235
+ }
236
+
237
+ return ""
238
+ }).join("")
239
+
240
+ if (/\r?\n/.test(content)) {
241
+ const name = (attribute.name as HTMLAttributeNameNode)!.name!.value ?? ""
242
+
243
+ if (name === 'class') {
244
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
245
+
246
+ return normalizedContent.length > 80
247
+ }
248
+
249
+ const lines = content.split(/\r?\n/)
250
+
251
+ if (lines.length > 1) {
252
+ return lines.slice(1).some(line => /^\s+/.test(line))
253
+ }
254
+ }
255
+ }
256
+
257
+ return false
258
+ })
259
+ }
260
+
261
+ private formatClassAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
262
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
263
+ const hasActualNewlines = /\r?\n/.test(content)
264
+
265
+ if (hasActualNewlines && normalizedContent.length > 80) {
266
+ const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
267
+
268
+ if (lines.length > 1) {
269
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
270
+ }
271
+ }
272
+
273
+ const currentIndent = this.indentLevel * this.indentWidth
274
+ const attributeLine = `${name}${equals}${open_quote}${normalizedContent}${close_quote}`
275
+
276
+ if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
277
+ const classes = normalizedContent.split(' ')
278
+ const lines = this.breakTokensIntoLines(classes, currentIndent)
279
+
280
+ if (lines.length > 1) {
281
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
282
+ }
283
+ }
284
+
285
+ return open_quote + normalizedContent + close_quote
286
+ }
287
+
288
+ private isFormattableAttribute(attributeName: string, tagName: string): boolean {
289
+ const globalFormattable = FORMATTABLE_ATTRIBUTES['*'] || []
290
+ const tagSpecificFormattable = FORMATTABLE_ATTRIBUTES[tagName.toLowerCase()] || []
291
+
292
+ return globalFormattable.includes(attributeName) || tagSpecificFormattable.includes(attributeName)
293
+ }
294
+
295
+ private formatMultilineAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
296
+ if (name === 'srcset' || name === 'sizes') {
297
+ const normalizedContent = content.replace(/\s+/g, ' ').trim()
298
+
299
+ return open_quote + normalizedContent + close_quote
300
+ }
301
+
302
+ const lines = content.split('\n')
303
+
304
+ if (lines.length <= 1) {
305
+ return open_quote + content + close_quote
306
+ }
307
+
308
+ const formattedContent = this.formatMultilineAttributeValue(lines)
309
+
310
+ return open_quote + formattedContent + close_quote
311
+ }
312
+
313
+ private formatMultilineAttributeValue(lines: string[]): string {
314
+ const indent = " ".repeat((this.indentLevel + 1) * this.indentWidth)
315
+ const closeIndent = " ".repeat(this.indentLevel * this.indentWidth)
316
+
317
+ return "\n" + lines.map(line => indent + line).join("\n") + "\n" + closeIndent
318
+ }
319
+
320
+ private breakTokensIntoLines(tokens: string[], currentIndent: number, separator: string = ' '): string[] {
321
+ const lines: string[] = []
322
+ let currentLine = ''
323
+
324
+ for (const token of tokens) {
325
+ const testLine = currentLine ? currentLine + separator + token : token
326
+
327
+ if (testLine.length > (this.maxLineLength - currentIndent - 6)) {
328
+ if (currentLine) {
329
+ lines.push(currentLine)
330
+ currentLine = token
331
+ } else {
332
+ lines.push(token)
333
+ }
334
+ } else {
335
+ currentLine = testLine
336
+ }
337
+ }
338
+
339
+ if (currentLine) lines.push(currentLine)
340
+
341
+ return lines
342
+ }
343
+
344
+ /**
345
+ * Render multiline attributes for a tag
346
+ */
347
+ private renderMultilineAttributes(
348
+ tagName: string,
349
+ _attributes: HTMLAttributeNode[],
350
+ _inlineNodes: Node[] = [],
351
+ allChildren: Node[] = [],
352
+ isSelfClosing: boolean = false,
353
+ isVoid: boolean = false,
354
+ hasBodyContent: boolean = false
355
+ ): void {
356
+ const indent = this.indent()
357
+ this.push(indent + `<${tagName}`)
358
+
359
+ this.withIndent(() => {
360
+ allChildren.forEach(child => {
361
+ if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
362
+ this.push(this.indent() + this.renderAttribute(child as HTMLAttributeNode))
363
+ } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
364
+ this.visit(child)
365
+ }
366
+ })
367
+ })
368
+
369
+ if (isSelfClosing) {
370
+ this.push(indent + "/>")
371
+ } else if (isVoid) {
372
+ this.push(indent + ">")
373
+ } else if (!hasBodyContent) {
374
+ this.push(indent + `></${tagName}>`)
375
+ } else {
376
+ this.push(indent + ">")
377
+ }
378
+ }
379
+
115
380
  /**
116
381
  * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
117
382
  */
118
383
  private printERBNode(node: ERBNode): void {
119
- const indent = this.indent()
384
+ const indent = this.inlineMode ? "" : this.indent()
120
385
  const open = node.tag_opening?.value ?? ""
121
386
  const close = node.tag_closing?.value ?? ""
122
- let inner: string
123
- if (node.tag_opening && node.tag_closing) {
124
- const [, openingEnd] = node.tag_opening.range.toArray()
125
- const [closingStart] = node.tag_closing.range.toArray()
126
- const rawInner = this.source.slice(openingEnd, closingStart)
127
- inner = ` ${rawInner.trim()} `
128
- } else {
129
- const txt = node.content?.value ?? ""
130
- inner = txt.trim() ? ` ${txt.trim()} ` : ""
131
- }
387
+ const content = node.content?.value ?? ""
388
+ const inner = this.formatERBContent(content)
389
+
132
390
  this.push(indent + open + inner + close)
133
391
  }
134
392
 
135
393
  // --- Visitor methods ---
136
394
 
137
395
  visitDocumentNode(node: DocumentNode): void {
138
- node.children.forEach(child => this.visit(child))
396
+ let lastWasMeaningful = false
397
+ let hasHandledSpacing = false
398
+
399
+ for (let i = 0; i < node.children.length; i++) {
400
+ const child = node.children[i]
401
+
402
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
403
+ const textNode = child as HTMLTextNode
404
+ const isWhitespaceOnly = textNode.content.trim() === ""
405
+
406
+ if (isWhitespaceOnly) {
407
+ const hasPrevNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1])
408
+ const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1])
409
+
410
+ const hasMultipleNewlines = textNode.content.includes('\n\n')
411
+
412
+ if (hasPrevNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
413
+ this.push("")
414
+ hasHandledSpacing = true
415
+ }
416
+
417
+ continue
418
+ }
419
+ }
420
+
421
+ if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
422
+ this.push("")
423
+ }
424
+
425
+ this.visit(child)
426
+
427
+ if (this.isNonWhitespaceNode(child)) {
428
+ lastWasMeaningful = true
429
+ hasHandledSpacing = false
430
+ }
431
+ }
139
432
  }
140
433
 
141
434
  visitHTMLElementNode(node: HTMLElementNode): void {
@@ -143,21 +436,32 @@ export class Printer extends Visitor {
143
436
  const tagName = open.tag_name?.value ?? ""
144
437
  const indent = this.indent()
145
438
 
146
- const attributes = open.children.filter((child): child is HTMLAttributeNode =>
147
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
148
- )
439
+ this.currentTagName = tagName
149
440
 
150
- const inlineNodes = open.children.filter(child =>
151
- !(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
152
- !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
153
- )
441
+ const attributes = this.extractAttributes(open.children)
442
+ const inlineNodes = this.extractInlineNodes(open.children)
154
443
 
155
- const children = node.body.filter(
156
- child =>
157
- !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
158
- !((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
159
- )
444
+ const hasTextFlow = this.isInTextFlowContext(null, node.body)
445
+
446
+ const children = node.body.filter(child => {
447
+ if (child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') {
448
+ return false
449
+ }
450
+
451
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
452
+ const content = (child as HTMLTextNode).content
453
+
454
+ if (hasTextFlow && content === " ") {
455
+ return true
456
+ }
160
457
 
458
+ return content.trim() !== ""
459
+ }
460
+
461
+ return true
462
+ })
463
+
464
+ const isInlineElement = this.isInlineElement(tagName)
161
465
  const hasClosing = open.tag_closing?.value === ">" || open.tag_closing?.value === "/>"
162
466
  const isSelfClosing = open.tag_closing?.value === "/>"
163
467
 
@@ -191,15 +495,59 @@ export class Printer extends Visitor {
191
495
 
192
496
  if (!textContent.includes('\n') && (indent.length + singleLine.length) <= this.maxLineLength) {
193
497
  this.push(indent + singleLine)
498
+
194
499
  return
195
500
  }
196
501
  }
197
502
  }
198
503
  } else {
199
- const inlineResult = this.tryRenderInline(children, tagName)
504
+ const inlineResult = this.tryRenderInline(children, tagName, 0, false, hasTextFlow)
200
505
 
201
506
  if (inlineResult && (indent.length + inlineResult.length) <= this.maxLineLength) {
202
507
  this.push(indent + inlineResult)
508
+
509
+ return
510
+ }
511
+
512
+ if (hasTextFlow) {
513
+ const hasAnyNewlines = children.some(child => {
514
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
515
+ return (child as HTMLTextNode).content.includes('\n')
516
+ }
517
+
518
+ return false
519
+ })
520
+
521
+ if (!hasAnyNewlines) {
522
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
523
+
524
+ if (fullInlineResult) {
525
+ const totalLength = indent.length + fullInlineResult.length
526
+ const maxNesting = this.getMaxNestingDepth(children, 0)
527
+ const maxInlineLength = maxNesting <= 1 ? this.maxLineLength : 60
528
+
529
+ if (totalLength <= maxInlineLength) {
530
+ this.push(indent + fullInlineResult)
531
+
532
+ return
533
+ }
534
+ }
535
+ }
536
+ }
537
+ }
538
+ }
539
+
540
+ if (hasTextFlow) {
541
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, [], children)
542
+
543
+ if (fullInlineResult) {
544
+ const totalLength = indent.length + fullInlineResult.length
545
+ const maxNesting = this.getMaxNestingDepth(children, 0)
546
+ const maxInlineLength = maxNesting <= 1 ? this.maxLineLength : 60
547
+
548
+ if (totalLength <= maxInlineLength) {
549
+ this.push(indent + fullInlineResult)
550
+
203
551
  return
204
552
  }
205
553
  }
@@ -208,7 +556,11 @@ export class Printer extends Visitor {
208
556
  this.push(indent + `<${tagName}>`)
209
557
 
210
558
  this.withIndent(() => {
211
- children.forEach(child => this.visit(child))
559
+ if (hasTextFlow) {
560
+ this.visitTextFlowChildren(children)
561
+ } else {
562
+ children.forEach(child => this.visit(child))
563
+ }
212
564
  })
213
565
 
214
566
  if (!node.is_void && !isSelfClosing) {
@@ -242,25 +594,40 @@ export class Printer extends Visitor {
242
594
  return
243
595
  }
244
596
 
245
- const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
246
- const singleAttribute = attributes[0]
247
- const hasEmptyValue =
248
- singleAttribute &&
249
- (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
250
- (singleAttribute.value as any)?.children.length === 0
597
+ const hasERBControlFlow = inlineNodes.some(node => this.isERBControlFlow(node)) ||
598
+ open.children.some(node => this.isERBControlFlow(node))
251
599
 
252
- const hasERBControlFlow = inlineNodes.some(node =>
253
- node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE' ||
254
- node instanceof ERBUnlessNode || (node as any).type === 'AST_ERB_UNLESS_NODE' ||
255
- node instanceof ERBBlockNode || (node as any).type === 'AST_ERB_BLOCK_NODE' ||
256
- node instanceof ERBCaseNode || (node as any).type === 'AST_ERB_CASE_NODE' ||
257
- node instanceof ERBWhileNode || (node as any).type === 'AST_ERB_WHILE_NODE' ||
258
- node instanceof ERBForNode || (node as any).type === 'AST_ERB_FOR_NODE'
259
- )
600
+ const hasComplexERB = hasERBControlFlow && inlineNodes.some(node => {
601
+ if (node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE') {
602
+ const erbNode = node as ERBIfNode
260
603
 
261
- const shouldKeepInline = (attributes.length <= 3 &&
262
- inline.length + indent.length <= this.maxLineLength) ||
263
- (inlineNodes.length > 0 && !hasERBControlFlow)
604
+ if (erbNode.statements.length > 0 && erbNode.location) {
605
+ const startLine = erbNode.location.start.line
606
+ const endLine = erbNode.location.end.line
607
+
608
+ return startLine !== endLine
609
+ }
610
+
611
+ return false
612
+ }
613
+
614
+ return false
615
+ })
616
+
617
+ const inline = hasComplexERB ? "" : this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
618
+ const nestingDepth = this.getMaxNestingDepth(children, 0)
619
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
620
+
621
+ const shouldKeepInline = this.shouldRenderInline(
622
+ totalAttributeCount,
623
+ inline.length,
624
+ indent.length,
625
+ this.maxLineLength,
626
+ hasComplexERB,
627
+ nestingDepth,
628
+ inlineNodes.length,
629
+ this.hasMultilineAttributes(attributes)
630
+ )
264
631
 
265
632
  if (shouldKeepInline) {
266
633
  if (children.length === 0) {
@@ -269,7 +636,69 @@ export class Printer extends Visitor {
269
636
  } else if (node.is_void) {
270
637
  this.push(indent + inline)
271
638
  } else {
272
- this.push(indent + inline.replace('>', `></${tagName}>`))
639
+ let result = `<${tagName}`
640
+
641
+ result += this.renderAttributesString(attributes)
642
+
643
+ if (inlineNodes.length > 0) {
644
+ const currentIndentLevel = this.indentLevel
645
+ this.indentLevel = 0
646
+ const tempLines = this.lines
647
+ this.lines = []
648
+
649
+ inlineNodes.forEach(node => {
650
+ const wasInlineMode = this.inlineMode
651
+
652
+ if (!this.isERBControlFlow(node)) {
653
+ this.inlineMode = true
654
+ }
655
+
656
+ this.visit(node)
657
+ this.inlineMode = wasInlineMode
658
+ })
659
+
660
+ const inlineContent = this.lines.join("")
661
+
662
+ this.lines = tempLines
663
+ this.indentLevel = currentIndentLevel
664
+
665
+ result += inlineContent
666
+ }
667
+
668
+ result += `></${tagName}>`
669
+ this.push(indent + result)
670
+ }
671
+
672
+ return
673
+ }
674
+
675
+ if (isInlineElement && children.length > 0 && !hasERBControlFlow) {
676
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
677
+
678
+ if (fullInlineResult) {
679
+ const totalLength = indent.length + fullInlineResult.length
680
+
681
+ if (totalLength <= this.maxLineLength || totalLength <= 120) {
682
+ this.push(indent + fullInlineResult)
683
+
684
+ return
685
+ }
686
+ }
687
+ }
688
+
689
+ if (!isInlineElement && children.length > 0 && !hasERBControlFlow) {
690
+ this.push(indent + inline)
691
+
692
+ this.withIndent(() => {
693
+ if (hasTextFlow) {
694
+ this.visitTextFlowChildren(children)
695
+ } else {
696
+ children.forEach(child => this.visit(child))
697
+ }
698
+ })
699
+
700
+ if (!node.is_void && !isSelfClosing) {
701
+ this.push(indent + `</${tagName}>`)
273
702
  }
274
703
 
275
704
  return
@@ -282,7 +711,11 @@ export class Printer extends Visitor {
282
711
  }
283
712
 
284
713
  this.withIndent(() => {
285
- children.forEach(child => this.visit(child))
714
+ if (hasTextFlow) {
715
+ this.visitTextFlowChildren(children)
716
+ } else {
717
+ children.forEach(child => this.visit(child))
718
+ }
286
719
  })
287
720
 
288
721
  if (!node.is_void && !isSelfClosing) {
@@ -293,25 +726,9 @@ export class Printer extends Visitor {
293
726
  }
294
727
 
295
728
  if (inlineNodes.length > 0 && hasERBControlFlow) {
296
- this.push(indent + `<${tagName}`)
297
- this.withIndent(() => {
298
- open.children.forEach(child => {
299
- if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
300
- this.push(this.indent() + this.renderAttribute(child as HTMLAttributeNode))
301
- } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
302
- this.visit(child)
303
- }
304
- })
305
- })
729
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
306
730
 
307
- if (isSelfClosing) {
308
- this.push(indent + "/>")
309
- } else if (node.is_void) {
310
- this.push(indent + ">")
311
- } else if (children.length === 0) {
312
- this.push(indent + ">" + `</${tagName}>`)
313
- } else {
314
- this.push(indent + ">")
731
+ if (!isSelfClosing && !node.is_void && children.length > 0) {
315
732
  this.withIndent(() => {
316
733
  children.forEach(child => this.visit(child))
317
734
  })
@@ -327,24 +744,57 @@ export class Printer extends Visitor {
327
744
  this.push(indent + `</${tagName}>`)
328
745
  }
329
746
  } else {
330
- this.push(indent + `<${tagName}`)
331
- this.withIndent(() => {
332
- attributes.forEach(attribute => {
333
- this.push(this.indent() + this.renderAttribute(attribute))
334
- })
335
- })
747
+ if (isInlineElement && children.length > 0) {
748
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
336
749
 
337
- if (isSelfClosing) {
338
- this.push(indent + "/>")
339
- } else if (node.is_void) {
340
- this.push(indent + ">")
341
- } else if (children.length === 0) {
342
- this.push(indent + ">" + `</${tagName}>`)
343
- } else {
344
- this.push(indent + ">")
750
+ if (fullInlineResult) {
751
+ const totalLength = indent.length + fullInlineResult.length
752
+
753
+ if (totalLength <= this.maxLineLength || totalLength <= 120) {
754
+ this.push(indent + fullInlineResult)
755
+ return
756
+ }
757
+ }
758
+ }
759
+
760
+ if (isInlineElement && children.length === 0) {
761
+ const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
762
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
763
+ const shouldKeepInline = this.shouldRenderInline(
764
+ totalAttributeCount,
765
+ inline.length,
766
+ indent.length,
767
+ this.maxLineLength,
768
+ false,
769
+ 0,
770
+ inlineNodes.length,
771
+ this.hasMultilineAttributes(attributes)
772
+ )
345
773
 
774
+ if (shouldKeepInline) {
775
+ let result = `<${tagName}`
776
+ result += this.renderAttributesString(attributes)
777
+ if (isSelfClosing) {
778
+ result += " />"
779
+ } else if (node.is_void) {
780
+ result += ">"
781
+ } else {
782
+ result += `></${tagName}>`
783
+ }
784
+ this.push(indent + result)
785
+ return
786
+ }
787
+ }
788
+
789
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
790
+
791
+ if (!isSelfClosing && !node.is_void && children.length > 0) {
346
792
  this.withIndent(() => {
347
- children.forEach(child => this.visit(child))
793
+ if (hasTextFlow) {
794
+ this.visitTextFlowChildren(children)
795
+ } else {
796
+ children.forEach(child => this.visit(child))
797
+ }
348
798
  })
349
799
 
350
800
  this.push(indent + `</${tagName}>`)
@@ -355,9 +805,8 @@ export class Printer extends Visitor {
355
805
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
356
806
  const tagName = node.tag_name?.value ?? ""
357
807
  const indent = this.indent()
358
- const attributes = node.children.filter((attribute): attribute is HTMLAttributeNode =>
359
- attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
360
- )
808
+ const attributes = this.extractAttributes(node.children)
809
+ const inlineNodes = this.extractInlineNodes(node.children)
361
810
 
362
811
  const hasClosing = node.tag_closing?.value === ">"
363
812
 
@@ -366,52 +815,55 @@ export class Printer extends Visitor {
366
815
  return
367
816
  }
368
817
 
369
- const inline = this.renderInlineOpen(tagName, attributes, node.is_void)
818
+ const inline = this.renderInlineOpen(tagName, attributes, node.is_void, inlineNodes, node.children)
819
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
820
+ const shouldKeepInline = this.shouldRenderInline(
821
+ totalAttributeCount,
822
+ inline.length,
823
+ indent.length,
824
+ this.maxLineLength,
825
+ false,
826
+ 0,
827
+ inlineNodes.length,
828
+ this.hasMultilineAttributes(attributes)
829
+ )
370
830
 
371
- if (attributes.length === 0 || inline.length + indent.length <= this.maxLineLength) {
831
+ if (shouldKeepInline) {
372
832
  this.push(indent + inline)
373
833
 
374
834
  return
375
835
  }
376
836
 
377
- this.push(indent + `<${tagName}`)
378
- this.withIndent(() => {
379
- attributes.forEach(attribute => {
380
- this.push(this.indent() + this.renderAttribute(attribute))
381
- })
382
- })
383
- this.push(indent + (node.is_void ? "/>" : ">"))
837
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.children, false, node.is_void, false)
384
838
  }
385
839
 
386
840
  visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
387
841
  const tagName = node.tag_name?.value ?? ""
388
842
  const indent = this.indent()
389
- const attributes = node.attributes.filter((attribute): attribute is HTMLAttributeNode =>
390
- attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
391
- )
392
- const inline = this.renderInlineOpen(tagName, attributes, true)
393
-
394
- const singleAttribute = attributes[0]
395
- const hasEmptyValue =
396
- singleAttribute &&
397
- (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
398
- (singleAttribute.value as any)?.children.length === 0
399
843
 
400
- const shouldKeepInline = attributes.length <= 3 &&
401
- inline.length + indent.length <= this.maxLineLength
844
+ const attributes = this.extractAttributes(node.attributes)
845
+ const inlineNodes = this.extractInlineNodes(node.attributes)
846
+
847
+ const inline = this.renderInlineOpen(tagName, attributes, true, inlineNodes, node.attributes)
848
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
849
+ const shouldKeepInline = this.shouldRenderInline(
850
+ totalAttributeCount,
851
+ inline.length,
852
+ indent.length,
853
+ this.maxLineLength,
854
+ false,
855
+ 0,
856
+ inlineNodes.length,
857
+ this.hasMultilineAttributes(attributes)
858
+ )
402
859
 
403
860
  if (shouldKeepInline) {
404
861
  this.push(indent + inline)
862
+
405
863
  return
406
864
  }
407
865
 
408
- this.push(indent + `<${tagName}`)
409
- this.withIndent(() => {
410
- attributes.forEach(attribute => {
411
- this.push(this.indent() + this.renderAttribute(attribute))
412
- })
413
- })
414
- this.push(indent + "/>")
866
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.attributes, true, false, false)
415
867
  }
416
868
 
417
869
  visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
@@ -424,6 +876,16 @@ export class Printer extends Visitor {
424
876
  }
425
877
 
426
878
  visitHTMLTextNode(node: HTMLTextNode): void {
879
+ if (this.inlineMode) {
880
+ const normalizedContent = node.content.replace(/\s+/g, ' ').trim()
881
+
882
+ if (normalizedContent) {
883
+ this.push(normalizedContent)
884
+ }
885
+
886
+ return
887
+ }
888
+
427
889
  const indent = this.indent()
428
890
  let text = node.content.trim()
429
891
 
@@ -464,16 +926,21 @@ export class Printer extends Visitor {
464
926
  const indent = this.indent()
465
927
  const open_quote = node.open_quote?.value ?? ""
466
928
  const close_quote = node.close_quote?.value ?? ""
929
+
467
930
  const attribute_value = node.children.map(child => {
468
931
  if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' ||
469
932
  child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
933
+
470
934
  return (child as HTMLTextNode | LiteralNode).content
471
935
  } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
472
936
  const erbChild = child as ERBContentNode
937
+
473
938
  return (erbChild.tag_opening!.value + erbChild.content!.value + erbChild.tag_closing!.value)
474
939
  }
940
+
475
941
  return ""
476
942
  }).join("")
943
+
477
944
  this.push(indent + open_quote + attribute_value + close_quote)
478
945
  }
479
946
 
@@ -481,20 +948,25 @@ export class Printer extends Visitor {
481
948
  const indent = this.indent()
482
949
  const open = node.comment_start?.value ?? ""
483
950
  const close = node.comment_end?.value ?? ""
951
+
484
952
  let inner: string
485
953
 
486
- if (node.comment_start && node.comment_end) {
487
- // TODO: use .value
488
- const [_, startIndex] = node.comment_start.range.toArray()
489
- const [endIndex] = node.comment_end.range.toArray()
490
- const rawInner = this.source.slice(startIndex, endIndex)
491
- inner = ` ${rawInner.trim()} `
492
- } else {
954
+ if (node.children && node.children.length > 0) {
493
955
  inner = node.children.map(child => {
494
- const prevLines = this.lines.length
495
- this.visit(child)
496
- return this.lines.slice(prevLines).join("")
956
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
957
+ return (child as HTMLTextNode).content
958
+ } else if (child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
959
+ return (child as LiteralNode).content
960
+ } else {
961
+ const prevLines = this.lines.length
962
+ this.visit(child)
963
+ return this.lines.slice(prevLines).join("")
964
+ }
497
965
  }).join("")
966
+
967
+ inner = ` ${inner.trim()} `
968
+ } else {
969
+ inner = ""
498
970
  }
499
971
 
500
972
  this.push(indent + open + inner + close)
@@ -506,26 +978,28 @@ export class Printer extends Visitor {
506
978
  const close = node.tag_closing?.value ?? ""
507
979
  let inner: string
508
980
 
509
- if (node.tag_opening && node.tag_closing) {
510
- const [, openingEnd] = node.tag_opening.range.toArray()
511
- const [closingStart] = node.tag_closing.range.toArray()
512
- const rawInner = this.source.slice(openingEnd, closingStart)
981
+ if (node.content && node.content.value) {
982
+ const rawInner = node.content.value
513
983
  const lines = rawInner.split("\n")
984
+
514
985
  if (lines.length > 2) {
515
986
  const childIndent = indent + " ".repeat(this.indentWidth)
516
987
  const innerLines = lines.slice(1, -1).map(line => childIndent + line.trim())
988
+
517
989
  inner = "\n" + innerLines.join("\n") + "\n"
518
990
  } else {
519
991
  inner = ` ${rawInner.trim()} `
520
992
  }
993
+ } else if ((node as any).children) {
994
+ inner = (node as any).children.map((child: any) => {
995
+ const prevLines = this.lines.length
996
+
997
+ this.visit(child)
998
+
999
+ return this.lines.slice(prevLines).join("")
1000
+ }).join("")
521
1001
  } else {
522
- inner = (node as any).children
523
- .map((child: any) => {
524
- const prevLines = this.lines.length
525
- this.visit(child)
526
- return this.lines.slice(prevLines).join("")
527
- })
528
- .join("")
1002
+ inner = ""
529
1003
  }
530
1004
 
531
1005
  this.push(indent + open + inner + close)
@@ -534,22 +1008,30 @@ export class Printer extends Visitor {
534
1008
  visitHTMLDoctypeNode(node: HTMLDoctypeNode): void {
535
1009
  const indent = this.indent()
536
1010
  const open = node.tag_opening?.value ?? ""
537
- let innerDoctype: string
538
1011
 
539
- if (node.tag_opening && node.tag_closing) {
540
- // TODO: use .value
541
- const [, openingEnd] = node.tag_opening.range.toArray()
542
- const [closingStart] = node.tag_closing.range.toArray()
543
- innerDoctype = this.source.slice(openingEnd, closingStart)
544
- } else {
545
- innerDoctype = node.children
546
- .map(child =>
547
- child instanceof HTMLTextNode ? child.content : (() => { const prevLines = this.lines.length; this.visit(child); return this.lines.slice(prevLines).join("") })(),
548
- )
549
- .join("")
550
- }
1012
+ let innerDoctype = node.children.map(child => {
1013
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1014
+ return (child as HTMLTextNode).content
1015
+ } else if (child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
1016
+ return (child as LiteralNode).content
1017
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1018
+ const erbNode = child as ERBContentNode
1019
+ const erbOpen = erbNode.tag_opening?.value ?? ""
1020
+ const erbContent = erbNode.content?.value ?? ""
1021
+ const erbClose = erbNode.tag_closing?.value ?? ""
1022
+
1023
+ return erbOpen + (erbContent ? ` ${erbContent.trim()} ` : "") + erbClose
1024
+ } else {
1025
+ const prevLines = this.lines.length
1026
+
1027
+ this.visit(child)
1028
+
1029
+ return this.lines.slice(prevLines).join("")
1030
+ }
1031
+ }).join("")
551
1032
 
552
1033
  const close = node.tag_closing?.value ?? ""
1034
+
553
1035
  this.push(indent + open + innerDoctype + close)
554
1036
  }
555
1037
 
@@ -572,10 +1054,19 @@ export class Printer extends Visitor {
572
1054
 
573
1055
  visitERBInNode(node: ERBInNode): void {
574
1056
  this.printERBNode(node)
1057
+
1058
+ this.withIndent(() => {
1059
+ node.statements.forEach(stmt => this.visit(stmt))
1060
+ })
575
1061
  }
576
1062
 
577
1063
  visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
578
1064
  this.printERBNode(node)
1065
+
1066
+ node.conditions.forEach(condition => this.visit(condition))
1067
+
1068
+ if (node.else_clause) this.visit(node.else_clause)
1069
+ if (node.end_node) this.visit(node.end_node)
579
1070
  }
580
1071
 
581
1072
  visitERBBlockNode(node: ERBBlockNode): void {
@@ -600,26 +1091,21 @@ export class Printer extends Visitor {
600
1091
  const open = node.tag_opening?.value ?? ""
601
1092
  const content = node.content?.value ?? ""
602
1093
  const close = node.tag_closing?.value ?? ""
1094
+ const inner = this.formatERBContent(content)
603
1095
 
604
- this.lines.push(open + content + close)
1096
+ this.lines.push(open + inner + close)
605
1097
 
606
- if (node.statements.length > 0) {
1098
+ node.statements.forEach((child, _index) => {
607
1099
  this.lines.push(" ")
608
- }
609
1100
 
610
- node.statements.forEach((child, index) => {
611
1101
  if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
612
1102
  this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
613
1103
  } else {
614
1104
  this.visit(child)
615
1105
  }
616
-
617
- if (index < node.statements.length - 1) {
618
- this.lines.push(" ")
619
- }
620
1106
  })
621
1107
 
622
- if (node.statements.length > 0) {
1108
+ if (node.statements.length > 0 && node.end_node) {
623
1109
  this.lines.push(" ")
624
1110
  }
625
1111
 
@@ -632,8 +1118,9 @@ export class Printer extends Visitor {
632
1118
  const endOpen = endNode.tag_opening?.value ?? ""
633
1119
  const endContent = endNode.content?.value ?? ""
634
1120
  const endClose = endNode.tag_closing?.value ?? ""
1121
+ const endInner = this.formatERBContent(endContent)
635
1122
 
636
- this.lines.push(endOpen + endContent + endClose)
1123
+ this.lines.push(endOpen + endInner + endClose)
637
1124
  }
638
1125
  } else {
639
1126
  this.printERBNode(node)
@@ -669,11 +1156,11 @@ export class Printer extends Visitor {
669
1156
  }
670
1157
 
671
1158
  visitERBCaseNode(node: ERBCaseNode): void {
672
- const baseLevel = this.indentLevel
673
1159
  const indent = this.indent()
674
1160
  const open = node.tag_opening?.value ?? ""
675
1161
  const content = node.content?.value ?? ""
676
1162
  const close = node.tag_closing?.value ?? ""
1163
+
677
1164
  this.push(indent + open + content + close)
678
1165
 
679
1166
  node.conditions.forEach(condition => this.visit(condition))
@@ -746,6 +1233,188 @@ export class Printer extends Visitor {
746
1233
 
747
1234
  // --- Utility methods ---
748
1235
 
1236
+ private isNonWhitespaceNode(node: Node): boolean {
1237
+ if (node instanceof HTMLTextNode || (node as any).type === 'AST_HTML_TEXT_NODE') {
1238
+ return (node as HTMLTextNode).content.trim() !== ""
1239
+ }
1240
+
1241
+ if (node instanceof WhitespaceNode || (node as any).type === 'AST_WHITESPACE_NODE') {
1242
+ return false
1243
+ }
1244
+
1245
+ return true
1246
+ }
1247
+
1248
+ /**
1249
+ * Check if an element should be treated as inline based on its tag name
1250
+ */
1251
+ private isInlineElement(tagName: string): boolean {
1252
+ return Printer.INLINE_ELEMENTS.has(tagName.toLowerCase())
1253
+ }
1254
+
1255
+ /**
1256
+ * Check if we're in a text flow context (parent contains mixed text and inline elements)
1257
+ */
1258
+ private visitTextFlowChildren(children: Node[]): void {
1259
+ const indent = this.indent()
1260
+ let currentLineContent = ""
1261
+
1262
+ for (const child of children) {
1263
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1264
+ const content = (child as HTMLTextNode).content
1265
+
1266
+ let processedContent = content.replace(/\s+/g, ' ').trim()
1267
+
1268
+ if (processedContent) {
1269
+ const hasLeadingSpace = /^\s/.test(content)
1270
+
1271
+ if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
1272
+ currentLineContent += ' '
1273
+ }
1274
+
1275
+ currentLineContent += processedContent
1276
+
1277
+ const hasTrailingSpace = /\s$/.test(content)
1278
+
1279
+ if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
1280
+ currentLineContent += ' '
1281
+ }
1282
+
1283
+ if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1284
+ this.visitTextFlowChildrenMultiline(children)
1285
+
1286
+ return
1287
+ }
1288
+ }
1289
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1290
+ const element = child as HTMLElementNode
1291
+ const openTag = element.open_tag as HTMLOpenTagNode
1292
+ const childTagName = openTag?.tag_name?.value || ''
1293
+
1294
+ if (this.isInlineElement(childTagName)) {
1295
+ const childInline = this.tryRenderInlineFull(element, childTagName,
1296
+ this.extractAttributes(openTag.children),
1297
+ element.body.filter(c => !(c instanceof WhitespaceNode || (c as any).type === 'AST_WHITESPACE_NODE') &&
1298
+ !((c instanceof HTMLTextNode || (c as any).type === 'AST_HTML_TEXT_NODE') && (c as any)?.content.trim() === "")))
1299
+
1300
+ if (childInline) {
1301
+ currentLineContent += childInline
1302
+
1303
+ if ((indent.length + currentLineContent.length) > this.maxLineLength) {
1304
+ this.visitTextFlowChildrenMultiline(children)
1305
+
1306
+ return
1307
+ }
1308
+ } else {
1309
+ if (currentLineContent.trim()) {
1310
+ this.push(indent + currentLineContent.trim())
1311
+ currentLineContent = ""
1312
+ }
1313
+
1314
+ this.visit(child)
1315
+ }
1316
+ } else {
1317
+ if (currentLineContent.trim()) {
1318
+ this.push(indent + currentLineContent.trim())
1319
+ currentLineContent = ""
1320
+ }
1321
+
1322
+ this.visit(child)
1323
+ }
1324
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1325
+ const oldLines = this.lines
1326
+ const oldInlineMode = this.inlineMode
1327
+
1328
+ try {
1329
+ this.lines = []
1330
+ this.inlineMode = true
1331
+ this.visit(child)
1332
+ const erbContent = this.lines.join("")
1333
+ currentLineContent += erbContent
1334
+
1335
+ if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1336
+ this.lines = oldLines
1337
+ this.inlineMode = oldInlineMode
1338
+ this.visitTextFlowChildrenMultiline(children)
1339
+
1340
+ return
1341
+ }
1342
+ } finally {
1343
+ this.lines = oldLines
1344
+ this.inlineMode = oldInlineMode
1345
+ }
1346
+ } else {
1347
+ if (currentLineContent.trim()) {
1348
+ this.push(indent + currentLineContent.trim())
1349
+ currentLineContent = ""
1350
+ }
1351
+
1352
+ this.visit(child)
1353
+ }
1354
+ }
1355
+
1356
+ if (currentLineContent.trim()) {
1357
+ const finalLine = indent + currentLineContent.trim()
1358
+ if (finalLine.length > Math.max(this.maxLineLength, 120)) {
1359
+ this.visitTextFlowChildrenMultiline(children)
1360
+
1361
+ return
1362
+ }
1363
+ this.push(finalLine)
1364
+ }
1365
+ }
1366
+
1367
+ private visitTextFlowChildrenMultiline(children: Node[]): void {
1368
+ children.forEach(child => this.visit(child))
1369
+ }
1370
+
1371
+ private isInTextFlowContext(parent: Node | null, children: Node[]): boolean {
1372
+ const hasTextContent = children.some(child =>
1373
+ (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') &&
1374
+ (child as HTMLTextNode).content.trim() !== ""
1375
+ )
1376
+
1377
+ if (!hasTextContent) {
1378
+ return false
1379
+ }
1380
+
1381
+ const nonTextChildren = children.filter(child =>
1382
+ !(child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE')
1383
+ )
1384
+
1385
+ if (nonTextChildren.length === 0) {
1386
+ return false
1387
+ }
1388
+
1389
+ const allInline = nonTextChildren.every(child => {
1390
+ if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1391
+ return true
1392
+ }
1393
+
1394
+ if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1395
+ const element = child as HTMLElementNode
1396
+ const openTag = element.open_tag as HTMLOpenTagNode
1397
+ const tagName = openTag?.tag_name?.value || ''
1398
+
1399
+ return this.isInlineElement(tagName)
1400
+ }
1401
+
1402
+ return false
1403
+ })
1404
+
1405
+ if (!allInline) {
1406
+ return false
1407
+ }
1408
+
1409
+ const maxNestingDepth = this.getMaxNestingDepth(children, 0)
1410
+
1411
+ if (maxNestingDepth > 2) {
1412
+ return false
1413
+ }
1414
+
1415
+ return true
1416
+ }
1417
+
749
1418
  private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
750
1419
  const parts = attributes.map(attribute => this.renderAttribute(attribute))
751
1420
 
@@ -763,10 +1432,10 @@ export class Printer extends Visitor {
763
1432
  this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
764
1433
  } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
765
1434
  const wasInlineMode = this.inlineMode
1435
+
766
1436
  this.inlineMode = true
767
1437
 
768
1438
  this.lines.push(" ")
769
-
770
1439
  this.visit(child)
771
1440
  this.inlineMode = wasInlineMode
772
1441
  }
@@ -789,8 +1458,13 @@ export class Printer extends Visitor {
789
1458
 
790
1459
  inlineNodes.forEach(node => {
791
1460
  const wasInlineMode = this.inlineMode
792
- this.inlineMode = true
1461
+
1462
+ if (!this.isERBControlFlow(node)) {
1463
+ this.inlineMode = true
1464
+ }
1465
+
793
1466
  this.visit(node)
1467
+
794
1468
  this.inlineMode = wasInlineMode
795
1469
  })
796
1470
 
@@ -816,41 +1490,169 @@ export class Printer extends Visitor {
816
1490
  let value = ""
817
1491
 
818
1492
  if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
819
- const attrValue = attribute.value as HTMLAttributeValueNode
820
- const open_quote = (attrValue.open_quote?.value ?? "")
821
- const close_quote = (attrValue.close_quote?.value ?? "")
822
- const attribute_value = attrValue.children.map((attr: any) => {
823
- if (attr instanceof HTMLTextNode || (attr as any).type === 'AST_HTML_TEXT_NODE' || attr instanceof LiteralNode || (attr as any).type === 'AST_LITERAL_NODE') {
1493
+ const attributeValue = attribute.value as HTMLAttributeValueNode
824
1494
 
825
- return (attr as HTMLTextNode | LiteralNode).content
826
- } else if (attr instanceof ERBContentNode || (attr as any).type === 'AST_ERB_CONTENT_NODE') {
827
- const erbAttr = attr as ERBContentNode
1495
+ let open_quote = attributeValue.open_quote?.value ?? ""
1496
+ let close_quote = attributeValue.close_quote?.value ?? ""
1497
+ let htmlTextContent = ""
828
1498
 
829
- return (erbAttr.tag_opening!.value + erbAttr.content!.value + erbAttr.tag_closing!.value)
1499
+ const content = attributeValue.children.map((child: Node) => {
1500
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' || child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
1501
+ const textContent = (child as HTMLTextNode | LiteralNode).content
1502
+ htmlTextContent += textContent
1503
+
1504
+ return textContent
1505
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1506
+ const erbAttribute = child as ERBContentNode
1507
+
1508
+ return erbAttribute.tag_opening!.value + erbAttribute.content!.value + erbAttribute.tag_closing!.value
830
1509
  }
831
1510
 
832
1511
  return ""
833
1512
  }).join("")
834
1513
 
835
- value = open_quote + attribute_value + close_quote
1514
+ if (open_quote === "" && close_quote === "") {
1515
+ open_quote = '"'
1516
+ close_quote = '"'
1517
+ } else if (open_quote === "'" && close_quote === "'" && !htmlTextContent.includes('"')) {
1518
+ open_quote = '"'
1519
+ close_quote = '"'
1520
+ }
1521
+
1522
+ if (this.isFormattableAttribute(name, this.currentTagName)) {
1523
+ if (name === 'class') {
1524
+ value = this.formatClassAttribute(content, name, equals, open_quote, close_quote)
1525
+ } else {
1526
+ value = this.formatMultilineAttribute(content, name, equals, open_quote, close_quote)
1527
+ }
1528
+ } else {
1529
+ value = open_quote + content + close_quote
1530
+ }
836
1531
  }
837
1532
 
838
1533
  return name + equals + value
839
1534
  }
840
1535
 
1536
+ /**
1537
+ * Try to render a complete element inline including opening tag, children, and closing tag
1538
+ */
1539
+ private tryRenderInlineFull(_node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
1540
+ let result = `<${tagName}`
1541
+
1542
+ result += this.renderAttributesString(attributes)
1543
+
1544
+ result += ">"
1545
+
1546
+ const childrenContent = this.tryRenderChildrenInline(children)
1547
+ if (!childrenContent) {
1548
+ return null
1549
+ }
1550
+
1551
+ result += childrenContent
1552
+ result += `</${tagName}>`
1553
+
1554
+ return result
1555
+ }
1556
+
1557
+ /**
1558
+ * Try to render just the children inline (without tags)
1559
+ */
1560
+ private tryRenderChildrenInline(children: Node[]): string | null {
1561
+ let result = ""
1562
+
1563
+ for (const child of children) {
1564
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1565
+ const content = (child as HTMLTextNode).content
1566
+ const normalizedContent = content.replace(/\s+/g, ' ')
1567
+ const hasLeadingSpace = /^\s/.test(content)
1568
+ const hasTrailingSpace = /\s$/.test(content)
1569
+ const trimmedContent = normalizedContent.trim()
1570
+
1571
+ if (trimmedContent) {
1572
+ let finalContent = trimmedContent
1573
+
1574
+ if (hasLeadingSpace && result && !result.endsWith(' ')) {
1575
+ finalContent = ' ' + finalContent
1576
+ }
1577
+
1578
+ if (hasTrailingSpace) {
1579
+ finalContent = finalContent + ' '
1580
+ }
1581
+
1582
+ result += finalContent
1583
+ } else if (hasLeadingSpace || hasTrailingSpace) {
1584
+ if (result && !result.endsWith(' ')) {
1585
+ result += ' '
1586
+ }
1587
+ }
1588
+
1589
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1590
+ const element = child as HTMLElementNode
1591
+ const openTag = element.open_tag as HTMLOpenTagNode
1592
+ const childTagName = openTag?.tag_name?.value || ''
1593
+
1594
+ if (!this.isInlineElement(childTagName)) {
1595
+ return null
1596
+ }
1597
+
1598
+ const childInline = this.tryRenderInlineFull(element, childTagName,
1599
+ this.extractAttributes(openTag.children),
1600
+ element.body.filter(c => !(c instanceof WhitespaceNode || (c as any).type === 'AST_WHITESPACE_NODE') &&
1601
+ !((c instanceof HTMLTextNode || (c as any).type === 'AST_HTML_TEXT_NODE') && (c as any)?.content.trim() === ""))
1602
+ )
1603
+
1604
+ if (!childInline) {
1605
+ return null
1606
+ }
1607
+
1608
+ result += childInline
1609
+ } else {
1610
+ const oldLines = this.lines
1611
+ const oldInlineMode = this.inlineMode
1612
+ const oldIndentLevel = this.indentLevel
1613
+
1614
+ try {
1615
+ this.lines = []
1616
+ this.inlineMode = true
1617
+ this.indentLevel = 0
1618
+ this.visit(child)
1619
+
1620
+ result += this.lines.join("")
1621
+ } finally {
1622
+ this.lines = oldLines
1623
+ this.inlineMode = oldInlineMode
1624
+ this.indentLevel = oldIndentLevel
1625
+ }
1626
+ }
1627
+ }
1628
+
1629
+ return result.trim()
1630
+ }
1631
+
841
1632
  /**
842
1633
  * Try to render children inline if they are simple enough.
843
1634
  * Returns the inline string if possible, null otherwise.
844
1635
  */
845
- private tryRenderInline(children: Node[], tagName: string, depth: number = 0): string | null {
846
- if (children.length > 10) {
1636
+ private tryRenderInline(children: Node[], tagName: string, depth: number = 0, forceInline: boolean = false, hasTextFlow: boolean = false): string | null {
1637
+ if (!forceInline && children.length > 10) {
847
1638
  return null
848
1639
  }
849
1640
 
850
1641
  const maxNestingDepth = this.getMaxNestingDepth(children, 0)
851
1642
 
852
- if (maxNestingDepth > 1) {
1643
+ let maxAllowedDepth = forceInline ? 5 : (tagName && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'].includes(tagName) ? 1 : 2)
1644
+
1645
+ if (hasTextFlow && maxNestingDepth >= 2) {
1646
+ const roughContentLength = this.estimateContentLength(children)
1647
+
1648
+ if (roughContentLength > 47) {
1649
+ maxAllowedDepth = 1
1650
+ }
1651
+ }
1652
+
1653
+ if (!forceInline && maxNestingDepth > maxAllowedDepth) {
853
1654
  this.isInComplexNesting = true
1655
+
854
1656
  return null
855
1657
  }
856
1658
 
@@ -864,12 +1666,10 @@ export class Printer extends Visitor {
864
1666
  } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
865
1667
  const element = child as HTMLElementNode
866
1668
  const openTag = element.open_tag as HTMLOpenTagNode
1669
+ const elementTagName = openTag?.tag_name?.value || ''
1670
+ const isInlineElement = this.isInlineElement(elementTagName)
867
1671
 
868
- const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
869
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
870
- )
871
-
872
- if (attributes.length > 0) {
1672
+ if (!isInlineElement) {
873
1673
  return null
874
1674
  }
875
1675
 
@@ -897,13 +1697,9 @@ export class Printer extends Visitor {
897
1697
  const openTag = element.open_tag as HTMLOpenTagNode
898
1698
  const childTagName = openTag?.tag_name?.value || ''
899
1699
 
900
- const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
901
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
902
- )
1700
+ const attributes = this.extractAttributes(openTag.children)
903
1701
 
904
- const attributesString = attributes.length > 0
905
- ? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
906
- : ''
1702
+ const attributesString = this.renderAttributesString(attributes)
907
1703
 
908
1704
  const elementContent = this.renderElementInline(element)
909
1705
 
@@ -914,7 +1710,7 @@ export class Printer extends Visitor {
914
1710
  const erbContent = erbNode.content?.value ?? ""
915
1711
  const close = erbNode.tag_closing?.value ?? ""
916
1712
 
917
- content += `${open} ${erbContent.trim()} ${close}`
1713
+ content += `${open}${this.formatERBContent(erbContent)}${close}`
918
1714
  }
919
1715
  }
920
1716
 
@@ -928,6 +1724,29 @@ export class Printer extends Visitor {
928
1724
  }
929
1725
  }
930
1726
 
1727
+ /**
1728
+ * Estimate the total content length of children nodes for decision making.
1729
+ */
1730
+ private estimateContentLength(children: Node[]): number {
1731
+ let length = 0
1732
+
1733
+ for (const child of children) {
1734
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1735
+ length += (child as HTMLTextNode).content.length
1736
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1737
+ const element = child as HTMLElementNode
1738
+ const openTag = element.open_tag as HTMLOpenTagNode
1739
+ const tagName = openTag?.tag_name?.value || ''
1740
+
1741
+ length += tagName.length + 5 // Rough estimate for tag overhead
1742
+ length += this.estimateContentLength(element.body)
1743
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1744
+ length += (child as ERBContentNode).content?.value.length || 0
1745
+ }
1746
+ }
1747
+ return length
1748
+ }
1749
+
931
1750
  /**
932
1751
  * Calculate the maximum nesting depth in a subtree of nodes.
933
1752
  */
@@ -962,6 +1781,7 @@ export class Printer extends Visitor {
962
1781
  )
963
1782
 
964
1783
  let content = ''
1784
+
965
1785
  for (const child of children) {
966
1786
  if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
967
1787
  content += (child as HTMLTextNode).content
@@ -970,15 +1790,12 @@ export class Printer extends Visitor {
970
1790
  const openTag = childElement.open_tag as HTMLOpenTagNode
971
1791
  const childTagName = openTag?.tag_name?.value || ''
972
1792
 
973
- const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
974
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
975
- )
1793
+ const attributes = this.extractAttributes(openTag.children)
976
1794
 
977
- const attributesString = attributes.length > 0
978
- ? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
979
- : ''
1795
+ const attributesString = this.renderAttributesString(attributes)
980
1796
 
981
1797
  const childContent = this.renderElementInline(childElement)
1798
+
982
1799
  content += `<${childTagName}${attributesString}>${childContent}</${childTagName}>`
983
1800
  } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
984
1801
  const erbNode = child as ERBContentNode
@@ -986,7 +1803,7 @@ export class Printer extends Visitor {
986
1803
  const erbContent = erbNode.content?.value ?? ""
987
1804
  const close = erbNode.tag_closing?.value ?? ""
988
1805
 
989
- content += `${open} ${erbContent.trim()} ${close}`
1806
+ content += `${open}${this.formatERBContent(erbContent)}${close}`
990
1807
  }
991
1808
  }
992
1809