@herb-tools/formatter 0.4.1 → 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
@@ -68,9 +68,18 @@ export class Printer extends Visitor {
68
68
  private lines: string[] = []
69
69
  private indentLevel: number = 0
70
70
  private inlineMode: boolean = false
71
+ private isInComplexNesting: boolean = false
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
+ ])
71
79
 
72
80
  constructor(source: string, options: Required<FormatOptions>) {
73
81
  super()
82
+
74
83
  this.source = source
75
84
  this.indentWidth = options.indentWidth
76
85
  this.maxLineLength = options.maxLineLength
@@ -85,6 +94,7 @@ export class Printer extends Visitor {
85
94
 
86
95
  this.lines = []
87
96
  this.indentLevel = indentLevel
97
+ this.isInComplexNesting = false // Reset for each top-level element
88
98
 
89
99
  if (typeof (node as any).accept === 'function') {
90
100
  node.accept(this)
@@ -92,7 +102,7 @@ export class Printer extends Visitor {
92
102
  this.visit(node)
93
103
  }
94
104
 
95
- return this.lines.filter(Boolean).join("\n")
105
+ return this.lines.join("\n")
96
106
  }
97
107
 
98
108
  private push(line: string) {
@@ -103,6 +113,7 @@ export class Printer extends Visitor {
103
113
  this.indentLevel++
104
114
  const result = callback()
105
115
  this.indentLevel--
116
+
106
117
  return result
107
118
  }
108
119
 
@@ -110,30 +121,188 @@ export class Printer extends Visitor {
110
121
  return " ".repeat(this.indentLevel * this.indentWidth)
111
122
  }
112
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
+
113
254
  /**
114
255
  * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
115
256
  */
116
257
  private printERBNode(node: ERBNode): void {
117
- const indent = this.indent()
258
+ const indent = this.inlineMode ? "" : this.indent()
118
259
  const open = node.tag_opening?.value ?? ""
119
260
  const close = node.tag_closing?.value ?? ""
120
- let inner: string
121
- if (node.tag_opening && node.tag_closing) {
122
- const [, openingEnd] = node.tag_opening.range.toArray()
123
- const [closingStart] = node.tag_closing.range.toArray()
124
- const rawInner = this.source.slice(openingEnd, closingStart)
125
- inner = ` ${rawInner.trim()} `
126
- } else {
127
- const txt = node.content?.value ?? ""
128
- inner = txt.trim() ? ` ${txt.trim()} ` : ""
129
- }
261
+ const content = node.content?.value ?? ""
262
+ const inner = this.formatERBContent(content)
263
+
130
264
  this.push(indent + open + inner + close)
131
265
  }
132
266
 
133
267
  // --- Visitor methods ---
134
268
 
135
269
  visitDocumentNode(node: DocumentNode): void {
136
- 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
+ }
137
306
  }
138
307
 
139
308
  visitHTMLElementNode(node: HTMLElementNode): void {
@@ -141,20 +310,31 @@ export class Printer extends Visitor {
141
310
  const tagName = open.tag_name?.value ?? ""
142
311
  const indent = this.indent()
143
312
 
144
- const attributes = open.children.filter((child): child is HTMLAttributeNode =>
145
- child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
146
- )
147
- const inlineNodes = open.children.filter(child =>
148
- !(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
149
- !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
150
- )
151
313
 
152
- const children = node.body.filter(
153
- child =>
154
- !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
155
- !((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
156
- )
314
+ const attributes = this.extractAttributes(open.children)
315
+ const inlineNodes = this.extractInlineNodes(open.children)
316
+
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
+ }
157
333
 
334
+ return true
335
+ })
336
+
337
+ const isInlineElement = this.isInlineElement(tagName)
158
338
  const hasClosing = open.tag_closing?.value === ">" || open.tag_closing?.value === "/>"
159
339
  const isSelfClosing = open.tag_closing?.value === "/>"
160
340
 
@@ -177,10 +357,83 @@ export class Printer extends Visitor {
177
357
  return
178
358
  }
179
359
 
360
+ if (children.length >= 1) {
361
+ if (this.isInComplexNesting) {
362
+ if (children.length === 1) {
363
+ const child = children[0]
364
+
365
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
366
+ const textContent = (child as HTMLTextNode).content.trim()
367
+ const singleLine = `<${tagName}>${textContent}</${tagName}>`
368
+
369
+ if (!textContent.includes('\n') && (indent.length + singleLine.length) <= this.maxLineLength) {
370
+ this.push(indent + singleLine)
371
+
372
+ return
373
+ }
374
+ }
375
+ }
376
+ } else {
377
+ const inlineResult = this.tryRenderInline(children, tagName, 0, false, hasTextFlow)
378
+
379
+ if (inlineResult && (indent.length + inlineResult.length) <= this.maxLineLength) {
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
+
424
+ return
425
+ }
426
+ }
427
+ }
428
+
180
429
  this.push(indent + `<${tagName}>`)
181
430
 
182
431
  this.withIndent(() => {
183
- children.forEach(child => this.visit(child))
432
+ if (hasTextFlow) {
433
+ this.visitTextFlowChildren(children)
434
+ } else {
435
+ children.forEach(child => this.visit(child))
436
+ }
184
437
  })
185
438
 
186
439
  if (!node.is_void && !isSelfClosing) {
@@ -214,17 +467,41 @@ export class Printer extends Visitor {
214
467
  return
215
468
  }
216
469
 
217
- const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
218
- const singleAttribute = attributes[0]
219
- const hasEmptyValue =
220
- singleAttribute &&
221
- (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
222
- (singleAttribute.value as any)?.children.length === 0
470
+ const hasERBControlFlow = inlineNodes.some(node => this.isERBControlFlow(node)) ||
471
+ open.children.some(node => this.isERBControlFlow(node))
472
+
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
502
+ )
503
+
223
504
 
224
- const shouldKeepInline = (attributes.length <= 3 &&
225
- !hasEmptyValue &&
226
- inline.length + indent.length <= this.maxLineLength) ||
227
- inlineNodes.length > 0
228
505
 
229
506
  if (shouldKeepInline) {
230
507
  if (children.length === 0) {
@@ -233,8 +510,71 @@ export class Printer extends Visitor {
233
510
  } else if (node.is_void) {
234
511
  this.push(indent + inline)
235
512
  } else {
236
- 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}>`)
237
576
  }
577
+
238
578
  return
239
579
  }
240
580
 
@@ -245,7 +585,11 @@ export class Printer extends Visitor {
245
585
  }
246
586
 
247
587
  this.withIndent(() => {
248
- children.forEach(child => this.visit(child))
588
+ if (hasTextFlow) {
589
+ this.visitTextFlowChildren(children)
590
+ } else {
591
+ children.forEach(child => this.visit(child))
592
+ }
249
593
  })
250
594
 
251
595
  if (!node.is_void && !isSelfClosing) {
@@ -255,7 +599,16 @@ export class Printer extends Visitor {
255
599
  return
256
600
  }
257
601
 
258
- if (inlineNodes.length > 0) {
602
+ if (inlineNodes.length > 0 && hasERBControlFlow) {
603
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
604
+
605
+ if (!isSelfClosing && !node.is_void && children.length > 0) {
606
+ this.withIndent(() => {
607
+ children.forEach(child => this.visit(child))
608
+ })
609
+ this.push(indent + `</${tagName}>`)
610
+ }
611
+ } else if (inlineNodes.length > 0) {
259
612
  this.push(indent + this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children))
260
613
 
261
614
  if (!isSelfClosing && !node.is_void && children.length > 0) {
@@ -265,24 +618,42 @@ export class Printer extends Visitor {
265
618
  this.push(indent + `</${tagName}>`)
266
619
  }
267
620
  } else {
268
- this.push(indent + `<${tagName}`)
269
- this.withIndent(() => {
270
- attributes.forEach(attribute => {
271
- this.push(this.indent() + this.renderAttribute(attribute))
272
- })
273
- })
621
+ if (isInlineElement && children.length > 0) {
622
+ const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
274
623
 
275
- if (isSelfClosing) {
276
- this.push(indent + "/>")
277
- } else if (node.is_void) {
278
- this.push(indent + ">")
279
- } else if (children.length === 0) {
280
- this.push(indent + ">" + `</${tagName}>`)
281
- } else {
282
- 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
+ }
283
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) {
284
651
  this.withIndent(() => {
285
- children.forEach(child => this.visit(child))
652
+ if (hasTextFlow) {
653
+ this.visitTextFlowChildren(children)
654
+ } else {
655
+ children.forEach(child => this.visit(child))
656
+ }
286
657
  })
287
658
 
288
659
  this.push(indent + `</${tagName}>`)
@@ -293,9 +664,8 @@ export class Printer extends Visitor {
293
664
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
294
665
  const tagName = node.tag_name?.value ?? ""
295
666
  const indent = this.indent()
296
- const attributes = node.children.filter((attribute): attribute is HTMLAttributeNode =>
297
- attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
298
- )
667
+ const attributes = this.extractAttributes(node.children)
668
+ const inlineNodes = this.extractInlineNodes(node.children)
299
669
 
300
670
  const hasClosing = node.tag_closing?.value === ">"
301
671
 
@@ -304,53 +674,53 @@ export class Printer extends Visitor {
304
674
  return
305
675
  }
306
676
 
307
- 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
+ )
308
688
 
309
- if (attributes.length === 0 || inline.length + indent.length <= this.maxLineLength) {
689
+ if (shouldKeepInline) {
310
690
  this.push(indent + inline)
311
691
 
312
692
  return
313
693
  }
314
694
 
315
- this.push(indent + `<${tagName}`)
316
- this.withIndent(() => {
317
- attributes.forEach(attribute => {
318
- this.push(this.indent() + this.renderAttribute(attribute))
319
- })
320
- })
321
- this.push(indent + (node.is_void ? "/>" : ">"))
695
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.children, false, node.is_void, false)
322
696
  }
323
697
 
324
698
  visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
325
699
  const tagName = node.tag_name?.value ?? ""
326
700
  const indent = this.indent()
327
- const attributes = node.attributes.filter((attribute): attribute is HTMLAttributeNode =>
328
- attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
329
- )
330
- const inline = this.renderInlineOpen(tagName, attributes, true)
331
-
332
- const singleAttribute = attributes[0]
333
- const hasEmptyValue =
334
- singleAttribute &&
335
- (singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
336
- (singleAttribute.value as any)?.children.length === 0
337
701
 
338
- const shouldKeepInline = attributes.length <= 3 &&
339
- !hasEmptyValue &&
340
- 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
+ )
341
716
 
342
717
  if (shouldKeepInline) {
343
718
  this.push(indent + inline)
719
+
344
720
  return
345
721
  }
346
722
 
347
- this.push(indent + `<${tagName}`)
348
- this.withIndent(() => {
349
- attributes.forEach(attribute => {
350
- this.push(this.indent() + this.renderAttribute(attribute))
351
- })
352
- })
353
- this.push(indent + "/>")
723
+ this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.attributes, true, false, false)
354
724
  }
355
725
 
356
726
  visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
@@ -363,6 +733,16 @@ export class Printer extends Visitor {
363
733
  }
364
734
 
365
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
+
366
746
  const indent = this.indent()
367
747
  let text = node.content.trim()
368
748
 
@@ -403,16 +783,21 @@ export class Printer extends Visitor {
403
783
  const indent = this.indent()
404
784
  const open_quote = node.open_quote?.value ?? ""
405
785
  const close_quote = node.close_quote?.value ?? ""
786
+
406
787
  const attribute_value = node.children.map(child => {
407
788
  if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' ||
408
789
  child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
790
+
409
791
  return (child as HTMLTextNode | LiteralNode).content
410
792
  } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
411
793
  const erbChild = child as ERBContentNode
794
+
412
795
  return (erbChild.tag_opening!.value + erbChild.content!.value + erbChild.tag_closing!.value)
413
796
  }
797
+
414
798
  return ""
415
799
  }).join("")
800
+
416
801
  this.push(indent + open_quote + attribute_value + close_quote)
417
802
  }
418
803
 
@@ -511,10 +896,19 @@ export class Printer extends Visitor {
511
896
 
512
897
  visitERBInNode(node: ERBInNode): void {
513
898
  this.printERBNode(node)
899
+
900
+ this.withIndent(() => {
901
+ node.statements.forEach(stmt => this.visit(stmt))
902
+ })
514
903
  }
515
904
 
516
905
  visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
517
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)
518
912
  }
519
913
 
520
914
  visitERBBlockNode(node: ERBBlockNode): void {
@@ -539,22 +933,36 @@ export class Printer extends Visitor {
539
933
  const open = node.tag_opening?.value ?? ""
540
934
  const content = node.content?.value ?? ""
541
935
  const close = node.tag_closing?.value ?? ""
542
- this.lines.push(open + content + close)
936
+ const inner = this.formatERBContent(content)
937
+
938
+ this.lines.push(open + inner + close)
939
+
940
+ node.statements.forEach((child, _index) => {
941
+ this.lines.push(" ")
543
942
 
544
- node.statements.forEach(child => {
545
943
  if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
546
- this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode) + " ")
944
+ this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
547
945
  } else {
548
946
  this.visit(child)
549
947
  }
550
948
  })
551
949
 
950
+ if (node.statements.length > 0 && node.end_node) {
951
+ this.lines.push(" ")
952
+ }
953
+
954
+ if (node.subsequent) {
955
+ this.visit(node.subsequent)
956
+ }
957
+
552
958
  if (node.end_node) {
553
959
  const endNode = node.end_node as any
554
960
  const endOpen = endNode.tag_opening?.value ?? ""
555
961
  const endContent = endNode.content?.value ?? ""
556
962
  const endClose = endNode.tag_closing?.value ?? ""
557
- this.lines.push(endOpen + endContent + endClose)
963
+ const endInner = this.formatERBContent(endContent)
964
+
965
+ this.lines.push(endOpen + endInner + endClose)
558
966
  }
559
967
  } else {
560
968
  this.printERBNode(node)
@@ -590,11 +998,11 @@ export class Printer extends Visitor {
590
998
  }
591
999
 
592
1000
  visitERBCaseNode(node: ERBCaseNode): void {
593
- const baseLevel = this.indentLevel
594
1001
  const indent = this.indent()
595
1002
  const open = node.tag_opening?.value ?? ""
596
1003
  const content = node.content?.value ?? ""
597
1004
  const close = node.tag_closing?.value ?? ""
1005
+
598
1006
  this.push(indent + open + content + close)
599
1007
 
600
1008
  node.conditions.forEach(condition => this.visit(condition))
@@ -667,6 +1075,186 @@ export class Printer extends Visitor {
667
1075
 
668
1076
  // --- Utility methods ---
669
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
+
670
1258
  private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
671
1259
  const parts = attributes.map(attribute => this.renderAttribute(attribute))
672
1260
 
@@ -684,10 +1272,10 @@ export class Printer extends Visitor {
684
1272
  this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
685
1273
  } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
686
1274
  const wasInlineMode = this.inlineMode
1275
+
687
1276
  this.inlineMode = true
688
1277
 
689
1278
  this.lines.push(" ")
690
-
691
1279
  this.visit(child)
692
1280
  this.inlineMode = wasInlineMode
693
1281
  }
@@ -710,8 +1298,13 @@ export class Printer extends Visitor {
710
1298
 
711
1299
  inlineNodes.forEach(node => {
712
1300
  const wasInlineMode = this.inlineMode
713
- this.inlineMode = true
1301
+
1302
+ if (!this.isERBControlFlow(node)) {
1303
+ this.inlineMode = true
1304
+ }
1305
+
714
1306
  this.visit(node)
1307
+
715
1308
  this.inlineMode = wasInlineMode
716
1309
  })
717
1310
 
@@ -737,25 +1330,315 @@ export class Printer extends Visitor {
737
1330
  let value = ""
738
1331
 
739
1332
  if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
740
- const attrValue = attribute.value as HTMLAttributeValueNode
741
- const open_quote = (attrValue.open_quote?.value ?? "")
742
- const close_quote = (attrValue.close_quote?.value ?? "")
743
- const attribute_value = attrValue.children.map((attr: any) => {
744
- 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
1334
+
1335
+ let open_quote = attributeValue.open_quote?.value ?? ""
1336
+ let close_quote = attributeValue.close_quote?.value ?? ""
1337
+ let htmlTextContent = ""
1338
+
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
745
1343
 
746
- return (attr as HTMLTextNode | LiteralNode).content
747
- } else if (attr instanceof ERBContentNode || (attr as any).type === 'AST_ERB_CONTENT_NODE') {
748
- const erbAttr = attr as ERBContentNode
1344
+ return textContent
1345
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1346
+ const erbAttribute = child as ERBContentNode
749
1347
 
750
- return (erbAttr.tag_opening!.value + erbAttr.content!.value + erbAttr.tag_closing!.value)
1348
+ return erbAttribute.tag_opening!.value + erbAttribute.content!.value + erbAttribute.tag_closing!.value
751
1349
  }
752
1350
 
753
1351
  return ""
754
1352
  }).join("")
755
1353
 
756
- 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
757
1363
  }
758
1364
 
759
1365
  return name + equals + value
760
1366
  }
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
+
1464
+ /**
1465
+ * Try to render children inline if they are simple enough.
1466
+ * Returns the inline string if possible, null otherwise.
1467
+ */
1468
+ private tryRenderInline(children: Node[], tagName: string, depth: number = 0, forceInline: boolean = false, hasTextFlow: boolean = false): string | null {
1469
+ if (!forceInline && children.length > 10) {
1470
+ return null
1471
+ }
1472
+
1473
+ const maxNestingDepth = this.getMaxNestingDepth(children, 0)
1474
+
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) {
1486
+ this.isInComplexNesting = true
1487
+
1488
+ return null
1489
+ }
1490
+
1491
+ for (const child of children) {
1492
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1493
+ const textContent = (child as HTMLTextNode).content
1494
+
1495
+ if (textContent.includes('\n')) {
1496
+ return null
1497
+ }
1498
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1499
+ const element = child as HTMLElementNode
1500
+ const openTag = element.open_tag as HTMLOpenTagNode
1501
+ const elementTagName = openTag?.tag_name?.value || ''
1502
+ const isInlineElement = this.isInlineElement(elementTagName)
1503
+
1504
+ if (!isInlineElement) {
1505
+ return null
1506
+ }
1507
+
1508
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1509
+ // ERB content nodes are allowed in inline rendering
1510
+ } else {
1511
+ return null
1512
+ }
1513
+ }
1514
+
1515
+ const oldLines = this.lines
1516
+ const oldInlineMode = this.inlineMode
1517
+
1518
+ try {
1519
+ this.lines = []
1520
+ this.inlineMode = true
1521
+
1522
+ let content = ''
1523
+
1524
+ for (const child of children) {
1525
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1526
+ content += (child as HTMLTextNode).content
1527
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1528
+ const element = child as HTMLElementNode
1529
+ const openTag = element.open_tag as HTMLOpenTagNode
1530
+ const childTagName = openTag?.tag_name?.value || ''
1531
+
1532
+ const attributes = this.extractAttributes(openTag.children)
1533
+
1534
+ const attributesString = this.renderAttributesString(attributes)
1535
+
1536
+ const elementContent = this.renderElementInline(element)
1537
+
1538
+ content += `<${childTagName}${attributesString}>${elementContent}</${childTagName}>`
1539
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1540
+ const erbNode = child as ERBContentNode
1541
+ const open = erbNode.tag_opening?.value ?? ""
1542
+ const erbContent = erbNode.content?.value ?? ""
1543
+ const close = erbNode.tag_closing?.value ?? ""
1544
+
1545
+ content += `${open}${this.formatERBContent(erbContent)}${close}`
1546
+ }
1547
+ }
1548
+
1549
+ content = content.replace(/\s+/g, ' ').trim()
1550
+
1551
+ return `<${tagName}>${content}</${tagName}>`
1552
+
1553
+ } finally {
1554
+ this.lines = oldLines
1555
+ this.inlineMode = oldInlineMode
1556
+ }
1557
+ }
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
+
1582
+ /**
1583
+ * Calculate the maximum nesting depth in a subtree of nodes.
1584
+ */
1585
+ private getMaxNestingDepth(children: Node[], currentDepth: number): number {
1586
+ let maxDepth = currentDepth
1587
+
1588
+ for (const child of children) {
1589
+ if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1590
+ const element = child as HTMLElementNode
1591
+ const elementChildren = element.body.filter(
1592
+ child =>
1593
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
1594
+ !((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
1595
+ )
1596
+
1597
+ const childDepth = this.getMaxNestingDepth(elementChildren, currentDepth + 1)
1598
+ maxDepth = Math.max(maxDepth, childDepth)
1599
+ }
1600
+ }
1601
+
1602
+ return maxDepth
1603
+ }
1604
+
1605
+ /**
1606
+ * Render an HTML element's content inline (without the wrapping tags).
1607
+ */
1608
+ private renderElementInline(element: HTMLElementNode): string {
1609
+ const children = element.body.filter(
1610
+ child =>
1611
+ !(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
1612
+ !((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
1613
+ )
1614
+
1615
+ let content = ''
1616
+
1617
+ for (const child of children) {
1618
+ if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
1619
+ content += (child as HTMLTextNode).content
1620
+ } else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
1621
+ const childElement = child as HTMLElementNode
1622
+ const openTag = childElement.open_tag as HTMLOpenTagNode
1623
+ const childTagName = openTag?.tag_name?.value || ''
1624
+
1625
+ const attributes = this.extractAttributes(openTag.children)
1626
+
1627
+ const attributesString = this.renderAttributesString(attributes)
1628
+
1629
+ const childContent = this.renderElementInline(childElement)
1630
+
1631
+ content += `<${childTagName}${attributesString}>${childContent}</${childTagName}>`
1632
+ } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
1633
+ const erbNode = child as ERBContentNode
1634
+ const open = erbNode.tag_opening?.value ?? ""
1635
+ const erbContent = erbNode.content?.value ?? ""
1636
+ const close = erbNode.tag_closing?.value ?? ""
1637
+
1638
+ content += `${open}${this.formatERBContent(erbContent)}${close}`
1639
+ }
1640
+ }
1641
+
1642
+ return content.replace(/\s+/g, ' ').trim()
1643
+ }
761
1644
  }