@herb-tools/formatter 0.4.2 → 0.4.3

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
@@ -70,8 +70,16 @@ export class Printer extends Visitor {
70
70
  private inlineMode: boolean = false
71
71
  private isInComplexNesting: boolean = false
72
72
 
73
+ private static readonly INLINE_ELEMENTS = new Set([
74
+ 'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
75
+ 'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
76
+ 'samp', 'small', 'span', 'strong', 'sub', 'sup',
77
+ 'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
78
+ ])
79
+
73
80
  constructor(source: string, options: Required<FormatOptions>) {
74
81
  super()
82
+
75
83
  this.source = source
76
84
  this.indentWidth = options.indentWidth
77
85
  this.maxLineLength = options.maxLineLength
@@ -94,7 +102,7 @@ export class Printer extends Visitor {
94
102
  this.visit(node)
95
103
  }
96
104
 
97
- return this.lines.filter(Boolean).join("\n")
105
+ return this.lines.join("\n")
98
106
  }
99
107
 
100
108
  private push(line: string) {
@@ -105,6 +113,7 @@ export class Printer extends Visitor {
105
113
  this.indentLevel++
106
114
  const result = callback()
107
115
  this.indentLevel--
116
+
108
117
  return result
109
118
  }
110
119
 
@@ -112,30 +121,188 @@ export class Printer extends Visitor {
112
121
  return " ".repeat(this.indentLevel * this.indentWidth)
113
122
  }
114
123
 
124
+ /**
125
+ * Format ERB content with proper spacing around the inner content.
126
+ * Returns empty string if content is empty, otherwise wraps content with single spaces.
127
+ */
128
+ private formatERBContent(content: string): string {
129
+ return content.trim() ? ` ${content.trim()} ` : ""
130
+ }
131
+
132
+ /**
133
+ * Check if a node is an ERB control flow node (if, unless, block, case, while, for)
134
+ */
135
+ private isERBControlFlow(node: Node): boolean {
136
+ return node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE' ||
137
+ node instanceof ERBUnlessNode || (node as any).type === 'AST_ERB_UNLESS_NODE' ||
138
+ node instanceof ERBBlockNode || (node as any).type === 'AST_ERB_BLOCK_NODE' ||
139
+ node instanceof ERBCaseNode || (node as any).type === 'AST_ERB_CASE_NODE' ||
140
+ node instanceof ERBCaseMatchNode || (node as any).type === 'AST_ERB_CASE_MATCH_NODE' ||
141
+ node instanceof ERBWhileNode || (node as any).type === 'AST_ERB_WHILE_NODE' ||
142
+ node instanceof ERBForNode || (node as any).type === 'AST_ERB_FOR_NODE'
143
+ }
144
+
145
+ /**
146
+ * Count total attributes including those inside ERB conditionals
147
+ */
148
+ private getTotalAttributeCount(attributes: HTMLAttributeNode[], inlineNodes: Node[] = []): number {
149
+ let totalAttributeCount = attributes.length
150
+
151
+ inlineNodes.forEach(node => {
152
+ if (this.isERBControlFlow(node)) {
153
+ const erbNode = node as any
154
+ if (erbNode.statements) {
155
+ totalAttributeCount += erbNode.statements.length
156
+ }
157
+ }
158
+ })
159
+
160
+ return totalAttributeCount
161
+ }
162
+
163
+ /**
164
+ * Extract HTML attributes from a list of nodes
165
+ */
166
+ private extractAttributes(nodes: Node[]): HTMLAttributeNode[] {
167
+ return nodes.filter((child): child is HTMLAttributeNode =>
168
+ child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
169
+ )
170
+ }
171
+
172
+ /**
173
+ * Extract inline nodes (non-attribute, non-whitespace) from a list of nodes
174
+ */
175
+ private extractInlineNodes(nodes: Node[]): Node[] {
176
+ return nodes.filter(child =>
177
+ !(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
178
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
179
+ )
180
+ }
181
+
182
+ /**
183
+ * Render attributes as a space-separated string
184
+ */
185
+ private renderAttributesString(attributes: HTMLAttributeNode[]): string {
186
+ if (attributes.length === 0) return ""
187
+ return ` ${attributes.map(attr => this.renderAttribute(attr)).join(" ")}`
188
+ }
189
+
190
+ /**
191
+ * Determine if a tag should be rendered inline based on attribute count and other factors
192
+ */
193
+ private shouldRenderInline(
194
+ totalAttributeCount: number,
195
+ inlineLength: number,
196
+ indentLength: number,
197
+ maxLineLength: number = this.maxLineLength,
198
+ hasComplexERB: boolean = false,
199
+ nestingDepth: number = 0,
200
+ inlineNodesLength: number = 0
201
+ ): boolean {
202
+ if (hasComplexERB) return false
203
+
204
+ // Special case: no attributes at all, always inline if it fits
205
+ if (totalAttributeCount === 0) {
206
+ return inlineLength + indentLength <= maxLineLength
207
+ }
208
+
209
+ const basicInlineCondition = totalAttributeCount <= 3 &&
210
+ inlineLength + indentLength <= maxLineLength
211
+
212
+ const erbInlineCondition = inlineNodesLength > 0 && totalAttributeCount <= 3
213
+
214
+ return basicInlineCondition || erbInlineCondition
215
+ }
216
+
217
+ /**
218
+ * Render multiline attributes for a tag
219
+ */
220
+ private renderMultilineAttributes(
221
+ tagName: string,
222
+ attributes: HTMLAttributeNode[],
223
+ inlineNodes: Node[] = [],
224
+ allChildren: Node[] = [],
225
+ isSelfClosing: boolean = false,
226
+ isVoid: boolean = false,
227
+ hasBodyContent: boolean = false
228
+ ): void {
229
+ const indent = this.indent()
230
+ this.push(indent + `<${tagName}`)
231
+
232
+ this.withIndent(() => {
233
+ // Render children in order, handling both attributes and ERB nodes
234
+ allChildren.forEach(child => {
235
+ if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
236
+ this.push(this.indent() + this.renderAttribute(child as HTMLAttributeNode))
237
+ } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
238
+ this.visit(child)
239
+ }
240
+ })
241
+ })
242
+
243
+ if (isSelfClosing) {
244
+ this.push(indent + "/>")
245
+ } else if (isVoid) {
246
+ this.push(indent + ">")
247
+ } else if (!hasBodyContent) {
248
+ this.push(indent + `></${tagName}>`)
249
+ } else {
250
+ this.push(indent + ">")
251
+ }
252
+ }
253
+
115
254
  /**
116
255
  * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
117
256
  */
118
257
  private printERBNode(node: ERBNode): void {
119
- const indent = this.indent()
258
+ const indent = this.inlineMode ? "" : this.indent()
120
259
  const open = node.tag_opening?.value ?? ""
121
260
  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
- }
261
+ const content = node.content?.value ?? ""
262
+ const inner = this.formatERBContent(content)
263
+
132
264
  this.push(indent + open + inner + close)
133
265
  }
134
266
 
135
267
  // --- Visitor methods ---
136
268
 
137
269
  visitDocumentNode(node: DocumentNode): void {
138
- node.children.forEach(child => this.visit(child))
270
+ let lastWasMeaningful = false
271
+ let hasHandledSpacing = false
272
+
273
+ for (let i = 0; i < node.children.length; i++) {
274
+ const child = node.children[i]
275
+
276
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
277
+ const textNode = child as HTMLTextNode
278
+ const isWhitespaceOnly = textNode.content.trim() === ""
279
+
280
+ if (isWhitespaceOnly) {
281
+ const hasPrevNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1])
282
+ const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1])
283
+
284
+ const hasMultipleNewlines = textNode.content.includes('\n\n')
285
+
286
+ if (hasPrevNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
287
+ this.push("")
288
+ hasHandledSpacing = true
289
+ }
290
+
291
+ continue
292
+ }
293
+ }
294
+
295
+ if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
296
+ this.push("")
297
+ }
298
+
299
+ this.visit(child)
300
+
301
+ if (this.isNonWhitespaceNode(child)) {
302
+ lastWasMeaningful = true
303
+ hasHandledSpacing = false
304
+ }
305
+ }
139
306
  }
140
307
 
141
308
  visitHTMLElementNode(node: HTMLElementNode): void {
@@ -143,21 +310,31 @@ export class Printer extends Visitor {
143
310
  const tagName = open.tag_name?.value ?? ""
144
311
  const indent = this.indent()
145
312
 
146
- const attributes = open.children.filter((child): child is HTMLAttributeNode =>
147
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
148
- )
149
313
 
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
- )
314
+ const attributes = this.extractAttributes(open.children)
315
+ const inlineNodes = this.extractInlineNodes(open.children)
154
316
 
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
- )
317
+ const hasTextFlow = this.isInTextFlowContext(null, node.body)
318
+
319
+ const children = node.body.filter(child => {
320
+ if (child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') {
321
+ return false
322
+ }
323
+
324
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
325
+ const content = (child as HTMLTextNode).content
326
+
327
+ if (hasTextFlow && content === " ") {
328
+ return true
329
+ }
330
+
331
+ return content.trim() !== ""
332
+ }
333
+
334
+ return true
335
+ })
160
336
 
337
+ const isInlineElement = this.isInlineElement(tagName)
161
338
  const hasClosing = open.tag_closing?.value === ">" || open.tag_closing?.value === "/>"
162
339
  const isSelfClosing = open.tag_closing?.value === "/>"
163
340
 
@@ -191,15 +368,59 @@ export class Printer extends Visitor {
191
368
 
192
369
  if (!textContent.includes('\n') && (indent.length + singleLine.length) <= this.maxLineLength) {
193
370
  this.push(indent + singleLine)
371
+
194
372
  return
195
373
  }
196
374
  }
197
375
  }
198
376
  } else {
199
- const inlineResult = this.tryRenderInline(children, tagName)
377
+ const inlineResult = this.tryRenderInline(children, tagName, 0, false, hasTextFlow)
200
378
 
201
379
  if (inlineResult && (indent.length + inlineResult.length) <= this.maxLineLength) {
202
380
  this.push(indent + inlineResult)
381
+
382
+ return
383
+ }
384
+
385
+ if (hasTextFlow) {
386
+ const hasAnyNewlines = children.some(child => {
387
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
388
+ return (child as HTMLTextNode).content.includes('\n')
389
+ }
390
+
391
+ return false
392
+ })
393
+
394
+ if (!hasAnyNewlines) {
395
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
396
+
397
+ if (fullInlineResult) {
398
+ const totalLength = indent.length + fullInlineResult.length
399
+ const maxNesting = this.getMaxNestingDepth(children, 0)
400
+ const maxInlineLength = maxNesting <= 1 ? this.maxLineLength : 60
401
+
402
+ if (totalLength <= maxInlineLength) {
403
+ this.push(indent + fullInlineResult)
404
+
405
+ return
406
+ }
407
+ }
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ if (hasTextFlow) {
414
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, [], children)
415
+
416
+ if (fullInlineResult) {
417
+ const totalLength = indent.length + fullInlineResult.length
418
+ const maxNesting = this.getMaxNestingDepth(children, 0)
419
+ const maxInlineLength = maxNesting <= 1 ? this.maxLineLength : 60
420
+
421
+ if (totalLength <= maxInlineLength) {
422
+ this.push(indent + fullInlineResult)
423
+
203
424
  return
204
425
  }
205
426
  }
@@ -208,7 +429,11 @@ export class Printer extends Visitor {
208
429
  this.push(indent + `<${tagName}>`)
209
430
 
210
431
  this.withIndent(() => {
211
- children.forEach(child => this.visit(child))
432
+ if (hasTextFlow) {
433
+ this.visitTextFlowChildren(children)
434
+ } else {
435
+ children.forEach(child => this.visit(child))
436
+ }
212
437
  })
213
438
 
214
439
  if (!node.is_void && !isSelfClosing) {
@@ -242,25 +467,41 @@ export class Printer extends Visitor {
242
467
  return
243
468
  }
244
469
 
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
470
+ const hasERBControlFlow = inlineNodes.some(node => this.isERBControlFlow(node)) ||
471
+ open.children.some(node => this.isERBControlFlow(node))
251
472
 
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'
473
+ const hasComplexERB = hasERBControlFlow && inlineNodes.some(node => {
474
+ if (node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE') {
475
+ const erbNode = node as ERBIfNode
476
+
477
+ if (erbNode.statements.length > 0 && erbNode.location) {
478
+ const startLine = erbNode.location.start.line
479
+ const endLine = erbNode.location.end.line
480
+
481
+ return startLine !== endLine
482
+ }
483
+
484
+ return false
485
+ }
486
+
487
+ return false
488
+ })
489
+
490
+ const inline = hasComplexERB ? "" : this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
491
+ const nestingDepth = this.getMaxNestingDepth(children, 0)
492
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
493
+
494
+ const shouldKeepInline = this.shouldRenderInline(
495
+ totalAttributeCount,
496
+ inline.length,
497
+ indent.length,
498
+ this.maxLineLength,
499
+ hasComplexERB,
500
+ nestingDepth,
501
+ inlineNodes.length
259
502
  )
260
503
 
261
- const shouldKeepInline = (attributes.length <= 3 &&
262
- inline.length + indent.length <= this.maxLineLength) ||
263
- (inlineNodes.length > 0 && !hasERBControlFlow)
504
+
264
505
 
265
506
  if (shouldKeepInline) {
266
507
  if (children.length === 0) {
@@ -269,7 +510,69 @@ export class Printer extends Visitor {
269
510
  } else if (node.is_void) {
270
511
  this.push(indent + inline)
271
512
  } else {
272
- this.push(indent + inline.replace('>', `></${tagName}>`))
513
+ let result = `<${tagName}`
514
+
515
+ result += this.renderAttributesString(attributes)
516
+
517
+ if (inlineNodes.length > 0) {
518
+ const currentIndentLevel = this.indentLevel
519
+ this.indentLevel = 0
520
+ const tempLines = this.lines
521
+ this.lines = []
522
+
523
+ inlineNodes.forEach(node => {
524
+ const wasInlineMode = this.inlineMode
525
+
526
+ if (!this.isERBControlFlow(node)) {
527
+ this.inlineMode = true
528
+ }
529
+
530
+ this.visit(node)
531
+ this.inlineMode = wasInlineMode
532
+ })
533
+
534
+ const inlineContent = this.lines.join("")
535
+
536
+ this.lines = tempLines
537
+ this.indentLevel = currentIndentLevel
538
+
539
+ result += inlineContent
540
+ }
541
+
542
+ result += `></${tagName}>`
543
+ this.push(indent + result)
544
+ }
545
+
546
+ return
547
+ }
548
+
549
+ if (isInlineElement && children.length > 0 && !hasERBControlFlow) {
550
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
551
+
552
+ if (fullInlineResult) {
553
+ const totalLength = indent.length + fullInlineResult.length
554
+
555
+ if (totalLength <= this.maxLineLength || totalLength <= 120) {
556
+ this.push(indent + fullInlineResult)
557
+
558
+ return
559
+ }
560
+ }
561
+ }
562
+
563
+ if (!isInlineElement && children.length > 0 && !hasERBControlFlow) {
564
+ this.push(indent + inline)
565
+
566
+ this.withIndent(() => {
567
+ if (hasTextFlow) {
568
+ this.visitTextFlowChildren(children)
569
+ } else {
570
+ children.forEach(child => this.visit(child))
571
+ }
572
+ })
573
+
574
+ if (!node.is_void && !isSelfClosing) {
575
+ this.push(indent + `</${tagName}>`)
273
576
  }
274
577
 
275
578
  return
@@ -282,7 +585,11 @@ export class Printer extends Visitor {
282
585
  }
283
586
 
284
587
  this.withIndent(() => {
285
- children.forEach(child => this.visit(child))
588
+ if (hasTextFlow) {
589
+ this.visitTextFlowChildren(children)
590
+ } else {
591
+ children.forEach(child => this.visit(child))
592
+ }
286
593
  })
287
594
 
288
595
  if (!node.is_void && !isSelfClosing) {
@@ -293,25 +600,9 @@ export class Printer extends Visitor {
293
600
  }
294
601
 
295
602
  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
- })
603
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
306
604
 
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 + ">")
605
+ if (!isSelfClosing && !node.is_void && children.length > 0) {
315
606
  this.withIndent(() => {
316
607
  children.forEach(child => this.visit(child))
317
608
  })
@@ -327,24 +618,42 @@ export class Printer extends Visitor {
327
618
  this.push(indent + `</${tagName}>`)
328
619
  }
329
620
  } else {
330
- this.push(indent + `<${tagName}`)
331
- this.withIndent(() => {
332
- attributes.forEach(attribute => {
333
- this.push(this.indent() + this.renderAttribute(attribute))
334
- })
335
- })
621
+ if (isInlineElement && children.length > 0) {
622
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
336
623
 
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 + ">")
624
+ if (fullInlineResult) {
625
+ const totalLength = indent.length + fullInlineResult.length
626
+
627
+ if (totalLength <= this.maxLineLength || totalLength <= 120) {
628
+ this.push(indent + fullInlineResult)
629
+ return
630
+ }
631
+ }
632
+ }
345
633
 
634
+ if (isInlineElement && children.length === 0) {
635
+ let result = `<${tagName}`
636
+ result += this.renderAttributesString(attributes)
637
+ if (isSelfClosing) {
638
+ result += " />"
639
+ } else if (node.is_void) {
640
+ result += ">"
641
+ } else {
642
+ result += `></${tagName}>`
643
+ }
644
+ this.push(indent + result)
645
+ return
646
+ }
647
+
648
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
649
+
650
+ if (!isSelfClosing && !node.is_void && children.length > 0) {
346
651
  this.withIndent(() => {
347
- children.forEach(child => this.visit(child))
652
+ if (hasTextFlow) {
653
+ this.visitTextFlowChildren(children)
654
+ } else {
655
+ children.forEach(child => this.visit(child))
656
+ }
348
657
  })
349
658
 
350
659
  this.push(indent + `</${tagName}>`)
@@ -355,9 +664,8 @@ export class Printer extends Visitor {
355
664
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
356
665
  const tagName = node.tag_name?.value ?? ""
357
666
  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
- )
667
+ const attributes = this.extractAttributes(node.children)
668
+ const inlineNodes = this.extractInlineNodes(node.children)
361
669
 
362
670
  const hasClosing = node.tag_closing?.value === ">"
363
671
 
@@ -366,52 +674,53 @@ export class Printer extends Visitor {
366
674
  return
367
675
  }
368
676
 
369
- const inline = this.renderInlineOpen(tagName, attributes, node.is_void)
677
+ const inline = this.renderInlineOpen(tagName, attributes, node.is_void, inlineNodes, node.children)
678
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
679
+ const shouldKeepInline = this.shouldRenderInline(
680
+ totalAttributeCount,
681
+ inline.length,
682
+ indent.length,
683
+ this.maxLineLength,
684
+ false,
685
+ 0,
686
+ inlineNodes.length
687
+ )
370
688
 
371
- if (attributes.length === 0 || inline.length + indent.length <= this.maxLineLength) {
689
+ if (shouldKeepInline) {
372
690
  this.push(indent + inline)
373
691
 
374
692
  return
375
693
  }
376
694
 
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 ? "/>" : ">"))
695
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.children, false, node.is_void, false)
384
696
  }
385
697
 
386
698
  visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
387
699
  const tagName = node.tag_name?.value ?? ""
388
700
  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
701
 
400
- const shouldKeepInline = attributes.length <= 3 &&
401
- inline.length + indent.length <= this.maxLineLength
702
+ const attributes = this.extractAttributes(node.attributes)
703
+ const inlineNodes = this.extractInlineNodes(node.attributes)
704
+
705
+ const inline = this.renderInlineOpen(tagName, attributes, true, inlineNodes, node.attributes)
706
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
707
+ const shouldKeepInline = this.shouldRenderInline(
708
+ totalAttributeCount,
709
+ inline.length,
710
+ indent.length,
711
+ this.maxLineLength,
712
+ false,
713
+ 0,
714
+ inlineNodes.length
715
+ )
402
716
 
403
717
  if (shouldKeepInline) {
404
718
  this.push(indent + inline)
719
+
405
720
  return
406
721
  }
407
722
 
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 + "/>")
723
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.attributes, true, false, false)
415
724
  }
416
725
 
417
726
  visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
@@ -424,6 +733,16 @@ export class Printer extends Visitor {
424
733
  }
425
734
 
426
735
  visitHTMLTextNode(node: HTMLTextNode): void {
736
+ if (this.inlineMode) {
737
+ const normalizedContent = node.content.replace(/\s+/g, ' ').trim()
738
+
739
+ if (normalizedContent) {
740
+ this.push(normalizedContent)
741
+ }
742
+
743
+ return
744
+ }
745
+
427
746
  const indent = this.indent()
428
747
  let text = node.content.trim()
429
748
 
@@ -464,16 +783,21 @@ export class Printer extends Visitor {
464
783
  const indent = this.indent()
465
784
  const open_quote = node.open_quote?.value ?? ""
466
785
  const close_quote = node.close_quote?.value ?? ""
786
+
467
787
  const attribute_value = node.children.map(child => {
468
788
  if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' ||
469
789
  child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
790
+
470
791
  return (child as HTMLTextNode | LiteralNode).content
471
792
  } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
472
793
  const erbChild = child as ERBContentNode
794
+
473
795
  return (erbChild.tag_opening!.value + erbChild.content!.value + erbChild.tag_closing!.value)
474
796
  }
797
+
475
798
  return ""
476
799
  }).join("")
800
+
477
801
  this.push(indent + open_quote + attribute_value + close_quote)
478
802
  }
479
803
 
@@ -572,10 +896,19 @@ export class Printer extends Visitor {
572
896
 
573
897
  visitERBInNode(node: ERBInNode): void {
574
898
  this.printERBNode(node)
899
+
900
+ this.withIndent(() => {
901
+ node.statements.forEach(stmt => this.visit(stmt))
902
+ })
575
903
  }
576
904
 
577
905
  visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
578
906
  this.printERBNode(node)
907
+
908
+ node.conditions.forEach(condition => this.visit(condition))
909
+
910
+ if (node.else_clause) this.visit(node.else_clause)
911
+ if (node.end_node) this.visit(node.end_node)
579
912
  }
580
913
 
581
914
  visitERBBlockNode(node: ERBBlockNode): void {
@@ -600,26 +933,21 @@ export class Printer extends Visitor {
600
933
  const open = node.tag_opening?.value ?? ""
601
934
  const content = node.content?.value ?? ""
602
935
  const close = node.tag_closing?.value ?? ""
936
+ const inner = this.formatERBContent(content)
603
937
 
604
- this.lines.push(open + content + close)
938
+ this.lines.push(open + inner + close)
605
939
 
606
- if (node.statements.length > 0) {
940
+ node.statements.forEach((child, _index) => {
607
941
  this.lines.push(" ")
608
- }
609
942
 
610
- node.statements.forEach((child, index) => {
611
943
  if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
612
944
  this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
613
945
  } else {
614
946
  this.visit(child)
615
947
  }
616
-
617
- if (index < node.statements.length - 1) {
618
- this.lines.push(" ")
619
- }
620
948
  })
621
949
 
622
- if (node.statements.length > 0) {
950
+ if (node.statements.length > 0 && node.end_node) {
623
951
  this.lines.push(" ")
624
952
  }
625
953
 
@@ -632,8 +960,9 @@ export class Printer extends Visitor {
632
960
  const endOpen = endNode.tag_opening?.value ?? ""
633
961
  const endContent = endNode.content?.value ?? ""
634
962
  const endClose = endNode.tag_closing?.value ?? ""
963
+ const endInner = this.formatERBContent(endContent)
635
964
 
636
- this.lines.push(endOpen + endContent + endClose)
965
+ this.lines.push(endOpen + endInner + endClose)
637
966
  }
638
967
  } else {
639
968
  this.printERBNode(node)
@@ -669,11 +998,11 @@ export class Printer extends Visitor {
669
998
  }
670
999
 
671
1000
  visitERBCaseNode(node: ERBCaseNode): void {
672
- const baseLevel = this.indentLevel
673
1001
  const indent = this.indent()
674
1002
  const open = node.tag_opening?.value ?? ""
675
1003
  const content = node.content?.value ?? ""
676
1004
  const close = node.tag_closing?.value ?? ""
1005
+
677
1006
  this.push(indent + open + content + close)
678
1007
 
679
1008
  node.conditions.forEach(condition => this.visit(condition))
@@ -746,6 +1075,186 @@ export class Printer extends Visitor {
746
1075
 
747
1076
  // --- Utility methods ---
748
1077
 
1078
+ private isNonWhitespaceNode(node: Node): boolean {
1079
+ if (node instanceof HTMLTextNode || (node as any).type === 'AST_HTML_TEXT_NODE') {
1080
+ return (node as HTMLTextNode).content.trim() !== ""
1081
+ }
1082
+
1083
+ if (node instanceof WhitespaceNode || (node as any).type === 'AST_WHITESPACE_NODE') {
1084
+ return false
1085
+ }
1086
+
1087
+ return true
1088
+ }
1089
+
1090
+ /**
1091
+ * Check if an element should be treated as inline based on its tag name
1092
+ */
1093
+ private isInlineElement(tagName: string): boolean {
1094
+ return Printer.INLINE_ELEMENTS.has(tagName.toLowerCase())
1095
+ }
1096
+
1097
+ /**
1098
+ * Check if we're in a text flow context (parent contains mixed text and inline elements)
1099
+ */
1100
+ private visitTextFlowChildren(children: Node[]): void {
1101
+ const indent = this.indent()
1102
+ let currentLineContent = ""
1103
+
1104
+ for (const child of children) {
1105
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1106
+ const content = (child as HTMLTextNode).content
1107
+
1108
+ let processedContent = content.replace(/\s+/g, ' ').trim()
1109
+
1110
+ if (processedContent) {
1111
+ const hasLeadingSpace = /^\s/.test(content)
1112
+
1113
+ if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
1114
+ currentLineContent += ' '
1115
+ }
1116
+
1117
+ currentLineContent += processedContent
1118
+
1119
+ const hasTrailingSpace = /\s$/.test(content)
1120
+
1121
+ if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
1122
+ currentLineContent += ' '
1123
+ }
1124
+
1125
+ if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1126
+ this.visitTextFlowChildrenMultiline(children)
1127
+
1128
+ return
1129
+ }
1130
+ }
1131
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1132
+ const element = child as HTMLElementNode
1133
+ const openTag = element.open_tag as HTMLOpenTagNode
1134
+ const childTagName = openTag?.tag_name?.value || ''
1135
+
1136
+ if (this.isInlineElement(childTagName)) {
1137
+ const childInline = this.tryRenderInlineFull(element, childTagName,
1138
+ this.extractAttributes(openTag.children),
1139
+ element.body.filter(c => !(c instanceof WhitespaceNode || (c as any).type === 'AST_WHITESPACE_NODE') &&
1140
+ !((c instanceof HTMLTextNode || (c as any).type === 'AST_HTML_TEXT_NODE') && (c as any)?.content.trim() === "")))
1141
+
1142
+ if (childInline) {
1143
+ currentLineContent += childInline
1144
+
1145
+ if ((indent.length + currentLineContent.length) > this.maxLineLength) {
1146
+ this.visitTextFlowChildrenMultiline(children)
1147
+
1148
+ return
1149
+ }
1150
+ } else {
1151
+ if (currentLineContent.trim()) {
1152
+ this.push(indent + currentLineContent.trim())
1153
+ currentLineContent = ""
1154
+ }
1155
+
1156
+ this.visit(child)
1157
+ }
1158
+ } else {
1159
+ if (currentLineContent.trim()) {
1160
+ this.push(indent + currentLineContent.trim())
1161
+ currentLineContent = ""
1162
+ }
1163
+
1164
+ this.visit(child)
1165
+ }
1166
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1167
+ const oldLines = this.lines
1168
+ const oldInlineMode = this.inlineMode
1169
+
1170
+ try {
1171
+ this.lines = []
1172
+ this.inlineMode = true
1173
+ this.visit(child)
1174
+ const erbContent = this.lines.join("")
1175
+ currentLineContent += erbContent
1176
+
1177
+ if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
1178
+ this.visitTextFlowChildrenMultiline(children)
1179
+
1180
+ return
1181
+ }
1182
+ } finally {
1183
+ this.lines = oldLines
1184
+ this.inlineMode = oldInlineMode
1185
+ }
1186
+ } else {
1187
+ if (currentLineContent.trim()) {
1188
+ this.push(indent + currentLineContent.trim())
1189
+ currentLineContent = ""
1190
+ }
1191
+
1192
+ this.visit(child)
1193
+ }
1194
+ }
1195
+
1196
+ if (currentLineContent.trim()) {
1197
+ const finalLine = indent + currentLineContent.trim()
1198
+ if (finalLine.length > Math.max(this.maxLineLength, 120)) {
1199
+ this.visitTextFlowChildrenMultiline(children)
1200
+
1201
+ return
1202
+ }
1203
+ this.push(finalLine)
1204
+ }
1205
+ }
1206
+
1207
+ private visitTextFlowChildrenMultiline(children: Node[]): void {
1208
+ children.forEach(child => this.visit(child))
1209
+ }
1210
+
1211
+ private isInTextFlowContext(parent: Node | null, children: Node[]): boolean {
1212
+ const hasTextContent = children.some(child =>
1213
+ (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') &&
1214
+ (child as HTMLTextNode).content.trim() !== ""
1215
+ )
1216
+
1217
+ if (!hasTextContent) {
1218
+ return false
1219
+ }
1220
+
1221
+ const nonTextChildren = children.filter(child =>
1222
+ !(child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE')
1223
+ )
1224
+
1225
+ if (nonTextChildren.length === 0) {
1226
+ return false
1227
+ }
1228
+
1229
+ const allInline = nonTextChildren.every(child => {
1230
+ if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1231
+ return true
1232
+ }
1233
+
1234
+ if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1235
+ const element = child as HTMLElementNode
1236
+ const openTag = element.open_tag as HTMLOpenTagNode
1237
+ const tagName = openTag?.tag_name?.value || ''
1238
+
1239
+ return this.isInlineElement(tagName)
1240
+ }
1241
+
1242
+ return false
1243
+ })
1244
+
1245
+ if (!allInline) {
1246
+ return false
1247
+ }
1248
+
1249
+ const maxNestingDepth = this.getMaxNestingDepth(children, 0)
1250
+
1251
+ if (maxNestingDepth > 2) {
1252
+ return false
1253
+ }
1254
+
1255
+ return true
1256
+ }
1257
+
749
1258
  private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
750
1259
  const parts = attributes.map(attribute => this.renderAttribute(attribute))
751
1260
 
@@ -763,10 +1272,10 @@ export class Printer extends Visitor {
763
1272
  this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
764
1273
  } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
765
1274
  const wasInlineMode = this.inlineMode
1275
+
766
1276
  this.inlineMode = true
767
1277
 
768
1278
  this.lines.push(" ")
769
-
770
1279
  this.visit(child)
771
1280
  this.inlineMode = wasInlineMode
772
1281
  }
@@ -789,8 +1298,13 @@ export class Printer extends Visitor {
789
1298
 
790
1299
  inlineNodes.forEach(node => {
791
1300
  const wasInlineMode = this.inlineMode
792
- this.inlineMode = true
1301
+
1302
+ if (!this.isERBControlFlow(node)) {
1303
+ this.inlineMode = true
1304
+ }
1305
+
793
1306
  this.visit(node)
1307
+
794
1308
  this.inlineMode = wasInlineMode
795
1309
  })
796
1310
 
@@ -816,41 +1330,161 @@ export class Printer extends Visitor {
816
1330
  let value = ""
817
1331
 
818
1332
  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') {
1333
+ const attributeValue = attribute.value as HTMLAttributeValueNode
824
1334
 
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
1335
+ let open_quote = attributeValue.open_quote?.value ?? ""
1336
+ let close_quote = attributeValue.close_quote?.value ?? ""
1337
+ let htmlTextContent = ""
828
1338
 
829
- return (erbAttr.tag_opening!.value + erbAttr.content!.value + erbAttr.tag_closing!.value)
1339
+ const content = attributeValue.children.map((child: Node) => {
1340
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' || child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
1341
+ const textContent = (child as HTMLTextNode | LiteralNode).content
1342
+ htmlTextContent += textContent
1343
+
1344
+ return textContent
1345
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1346
+ const erbAttribute = child as ERBContentNode
1347
+
1348
+ return erbAttribute.tag_opening!.value + erbAttribute.content!.value + erbAttribute.tag_closing!.value
830
1349
  }
831
1350
 
832
1351
  return ""
833
1352
  }).join("")
834
1353
 
835
- value = open_quote + attribute_value + close_quote
1354
+ if (open_quote === "" && close_quote === "") {
1355
+ open_quote = '"'
1356
+ close_quote = '"'
1357
+ } else if (open_quote === "'" && close_quote === "'" && !htmlTextContent.includes('"')) {
1358
+ open_quote = '"'
1359
+ close_quote = '"'
1360
+ }
1361
+
1362
+ value = open_quote + content + close_quote
836
1363
  }
837
1364
 
838
1365
  return name + equals + value
839
1366
  }
840
1367
 
1368
+ /**
1369
+ * Try to render a complete element inline including opening tag, children, and closing tag
1370
+ */
1371
+ private tryRenderInlineFull(node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
1372
+ let result = `<${tagName}`
1373
+
1374
+ result += this.renderAttributesString(attributes)
1375
+
1376
+ result += ">"
1377
+
1378
+ const childrenContent = this.tryRenderChildrenInline(children)
1379
+ if (!childrenContent) {
1380
+ return null
1381
+ }
1382
+
1383
+ result += childrenContent
1384
+ result += `</${tagName}>`
1385
+
1386
+ return result
1387
+ }
1388
+
1389
+ /**
1390
+ * Try to render just the children inline (without tags)
1391
+ */
1392
+ private tryRenderChildrenInline(children: Node[]): string | null {
1393
+ let result = ""
1394
+
1395
+ for (const child of children) {
1396
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1397
+ const content = (child as HTMLTextNode).content
1398
+ const normalizedContent = content.replace(/\s+/g, ' ')
1399
+ const hasLeadingSpace = /^\s/.test(content)
1400
+ const hasTrailingSpace = /\s$/.test(content)
1401
+ const trimmedContent = normalizedContent.trim()
1402
+
1403
+ if (trimmedContent) {
1404
+ let finalContent = trimmedContent
1405
+
1406
+ if (hasLeadingSpace && result && !result.endsWith(' ')) {
1407
+ finalContent = ' ' + finalContent
1408
+ }
1409
+
1410
+ if (hasTrailingSpace) {
1411
+ finalContent = finalContent + ' '
1412
+ }
1413
+
1414
+ result += finalContent
1415
+ } else if (hasLeadingSpace || hasTrailingSpace) {
1416
+ if (result && !result.endsWith(' ')) {
1417
+ result += ' '
1418
+ }
1419
+ }
1420
+
1421
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1422
+ const element = child as HTMLElementNode
1423
+ const openTag = element.open_tag as HTMLOpenTagNode
1424
+ const childTagName = openTag?.tag_name?.value || ''
1425
+
1426
+ if (!this.isInlineElement(childTagName)) {
1427
+ return null
1428
+ }
1429
+
1430
+ const childInline = this.tryRenderInlineFull(element, childTagName,
1431
+ this.extractAttributes(openTag.children),
1432
+ element.body.filter(c => !(c instanceof WhitespaceNode || (c as any).type === 'AST_WHITESPACE_NODE') &&
1433
+ !((c instanceof HTMLTextNode || (c as any).type === 'AST_HTML_TEXT_NODE') && (c as any)?.content.trim() === ""))
1434
+ )
1435
+
1436
+ if (!childInline) {
1437
+ return null
1438
+ }
1439
+
1440
+ result += childInline
1441
+ } else {
1442
+ const oldLines = this.lines
1443
+ const oldInlineMode = this.inlineMode
1444
+ const oldIndentLevel = this.indentLevel
1445
+
1446
+ try {
1447
+ this.lines = []
1448
+ this.inlineMode = true
1449
+ this.indentLevel = 0
1450
+ this.visit(child)
1451
+
1452
+ result += this.lines.join("")
1453
+ } finally {
1454
+ this.lines = oldLines
1455
+ this.inlineMode = oldInlineMode
1456
+ this.indentLevel = oldIndentLevel
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ return result.trim()
1462
+ }
1463
+
841
1464
  /**
842
1465
  * Try to render children inline if they are simple enough.
843
1466
  * Returns the inline string if possible, null otherwise.
844
1467
  */
845
- private tryRenderInline(children: Node[], tagName: string, depth: number = 0): string | null {
846
- if (children.length > 10) {
1468
+ private tryRenderInline(children: Node[], tagName: string, depth: number = 0, forceInline: boolean = false, hasTextFlow: boolean = false): string | null {
1469
+ if (!forceInline && children.length > 10) {
847
1470
  return null
848
1471
  }
849
1472
 
850
1473
  const maxNestingDepth = this.getMaxNestingDepth(children, 0)
851
1474
 
852
- if (maxNestingDepth > 1) {
1475
+ let maxAllowedDepth = forceInline ? 5 : (tagName && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'].includes(tagName) ? 1 : 2)
1476
+
1477
+ if (hasTextFlow && maxNestingDepth >= 2) {
1478
+ const roughContentLength = this.estimateContentLength(children)
1479
+
1480
+ if (roughContentLength > 47) {
1481
+ maxAllowedDepth = 1
1482
+ }
1483
+ }
1484
+
1485
+ if (!forceInline && maxNestingDepth > maxAllowedDepth) {
853
1486
  this.isInComplexNesting = true
1487
+
854
1488
  return null
855
1489
  }
856
1490
 
@@ -864,12 +1498,10 @@ export class Printer extends Visitor {
864
1498
  } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
865
1499
  const element = child as HTMLElementNode
866
1500
  const openTag = element.open_tag as HTMLOpenTagNode
1501
+ const elementTagName = openTag?.tag_name?.value || ''
1502
+ const isInlineElement = this.isInlineElement(elementTagName)
867
1503
 
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) {
1504
+ if (!isInlineElement) {
873
1505
  return null
874
1506
  }
875
1507
 
@@ -897,13 +1529,9 @@ export class Printer extends Visitor {
897
1529
  const openTag = element.open_tag as HTMLOpenTagNode
898
1530
  const childTagName = openTag?.tag_name?.value || ''
899
1531
 
900
- const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
901
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
902
- )
1532
+ const attributes = this.extractAttributes(openTag.children)
903
1533
 
904
- const attributesString = attributes.length > 0
905
- ? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
906
- : ''
1534
+ const attributesString = this.renderAttributesString(attributes)
907
1535
 
908
1536
  const elementContent = this.renderElementInline(element)
909
1537
 
@@ -914,7 +1542,7 @@ export class Printer extends Visitor {
914
1542
  const erbContent = erbNode.content?.value ?? ""
915
1543
  const close = erbNode.tag_closing?.value ?? ""
916
1544
 
917
- content += `${open} ${erbContent.trim()} ${close}`
1545
+ content += `${open}${this.formatERBContent(erbContent)}${close}`
918
1546
  }
919
1547
  }
920
1548
 
@@ -928,6 +1556,29 @@ export class Printer extends Visitor {
928
1556
  }
929
1557
  }
930
1558
 
1559
+ /**
1560
+ * Estimate the total content length of children nodes for decision making.
1561
+ */
1562
+ private estimateContentLength(children: Node[]): number {
1563
+ let length = 0
1564
+
1565
+ for (const child of children) {
1566
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1567
+ length += (child as HTMLTextNode).content.length
1568
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1569
+ const element = child as HTMLElementNode
1570
+ const openTag = element.open_tag as HTMLOpenTagNode
1571
+ const tagName = openTag?.tag_name?.value || ''
1572
+
1573
+ length += tagName.length + 5 // Rough estimate for tag overhead
1574
+ length += this.estimateContentLength(element.body)
1575
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1576
+ length += (child as ERBContentNode).content?.value.length || 0
1577
+ }
1578
+ }
1579
+ return length
1580
+ }
1581
+
931
1582
  /**
932
1583
  * Calculate the maximum nesting depth in a subtree of nodes.
933
1584
  */
@@ -962,6 +1613,7 @@ export class Printer extends Visitor {
962
1613
  )
963
1614
 
964
1615
  let content = ''
1616
+
965
1617
  for (const child of children) {
966
1618
  if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
967
1619
  content += (child as HTMLTextNode).content
@@ -970,15 +1622,12 @@ export class Printer extends Visitor {
970
1622
  const openTag = childElement.open_tag as HTMLOpenTagNode
971
1623
  const childTagName = openTag?.tag_name?.value || ''
972
1624
 
973
- const attributes = openTag.children.filter((child): child is HTMLAttributeNode =>
974
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
975
- )
1625
+ const attributes = this.extractAttributes(openTag.children)
976
1626
 
977
- const attributesString = attributes.length > 0
978
- ? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
979
- : ''
1627
+ const attributesString = this.renderAttributesString(attributes)
980
1628
 
981
1629
  const childContent = this.renderElementInline(childElement)
1630
+
982
1631
  content += `<${childTagName}${attributesString}>${childContent}</${childTagName}>`
983
1632
  } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
984
1633
  const erbNode = child as ERBContentNode
@@ -986,7 +1635,7 @@ export class Printer extends Visitor {
986
1635
  const erbContent = erbNode.content?.value ?? ""
987
1636
  const close = erbNode.tag_closing?.value ?? ""
988
1637
 
989
- content += `${open} ${erbContent.trim()} ${close}`
1638
+ content += `${open}${this.formatERBContent(erbContent)}${close}`
990
1639
  }
991
1640
  }
992
1641