@herb-tools/formatter 0.4.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/printer.ts DELETED
@@ -1,1644 +0,0 @@
1
- import { Visitor } from "@herb-tools/core"
2
-
3
- import {
4
- Node,
5
- DocumentNode,
6
- HTMLOpenTagNode,
7
- HTMLCloseTagNode,
8
- HTMLSelfCloseTagNode,
9
- HTMLElementNode,
10
- HTMLAttributeNode,
11
- HTMLAttributeValueNode,
12
- HTMLAttributeNameNode,
13
- HTMLTextNode,
14
- HTMLCommentNode,
15
- HTMLDoctypeNode,
16
- LiteralNode,
17
- WhitespaceNode,
18
- ERBContentNode,
19
- ERBBlockNode,
20
- ERBEndNode,
21
- ERBElseNode,
22
- ERBIfNode,
23
- ERBWhenNode,
24
- ERBCaseNode,
25
- ERBCaseMatchNode,
26
- ERBWhileNode,
27
- ERBUntilNode,
28
- ERBForNode,
29
- ERBRescueNode,
30
- ERBEnsureNode,
31
- ERBBeginNode,
32
- ERBUnlessNode,
33
- ERBYieldNode,
34
- ERBInNode,
35
- Token
36
- } from "@herb-tools/core"
37
-
38
- type ERBNode =
39
- ERBContentNode
40
- | ERBBlockNode
41
- | ERBEndNode
42
- | ERBElseNode
43
- | ERBIfNode
44
- | ERBWhenNode
45
- | ERBCaseNode
46
- | ERBCaseMatchNode
47
- | ERBWhileNode
48
- | ERBUntilNode
49
- | ERBForNode
50
- | ERBRescueNode
51
- | ERBEnsureNode
52
- | ERBBeginNode
53
- | ERBUnlessNode
54
- | ERBYieldNode
55
- | ERBInNode
56
-
57
-
58
- import type { FormatOptions } from "./options.js"
59
-
60
- /**
61
- * Printer traverses the Herb AST using the Visitor pattern
62
- * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
63
- */
64
- export class Printer extends Visitor {
65
- private indentWidth: number
66
- private maxLineLength: number
67
- private source: string
68
- private lines: string[] = []
69
- private indentLevel: number = 0
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
- ])
79
-
80
- constructor(source: string, options: Required<FormatOptions>) {
81
- super()
82
-
83
- this.source = source
84
- this.indentWidth = options.indentWidth
85
- this.maxLineLength = options.maxLineLength
86
- }
87
-
88
- print(object: Node | Token, indentLevel: number = 0): string {
89
- if (object instanceof Token || (object as any).type?.startsWith('TOKEN_')) {
90
- return (object as Token).value
91
- }
92
-
93
- const node: Node = object
94
-
95
- this.lines = []
96
- this.indentLevel = indentLevel
97
- this.isInComplexNesting = false // Reset for each top-level element
98
-
99
- if (typeof (node as any).accept === 'function') {
100
- node.accept(this)
101
- } else {
102
- this.visit(node)
103
- }
104
-
105
- return this.lines.join("\n")
106
- }
107
-
108
- private push(line: string) {
109
- this.lines.push(line)
110
- }
111
-
112
- private withIndent<T>(callback: () => T): T {
113
- this.indentLevel++
114
- const result = callback()
115
- this.indentLevel--
116
-
117
- return result
118
- }
119
-
120
- private indent(): string {
121
- return " ".repeat(this.indentLevel * this.indentWidth)
122
- }
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
-
254
- /**
255
- * Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
256
- */
257
- private printERBNode(node: ERBNode): void {
258
- const indent = this.inlineMode ? "" : this.indent()
259
- const open = node.tag_opening?.value ?? ""
260
- const close = node.tag_closing?.value ?? ""
261
- const content = node.content?.value ?? ""
262
- const inner = this.formatERBContent(content)
263
-
264
- this.push(indent + open + inner + close)
265
- }
266
-
267
- // --- Visitor methods ---
268
-
269
- visitDocumentNode(node: DocumentNode): void {
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
- }
306
- }
307
-
308
- visitHTMLElementNode(node: HTMLElementNode): void {
309
- const open = node.open_tag as HTMLOpenTagNode
310
- const tagName = open.tag_name?.value ?? ""
311
- const indent = this.indent()
312
-
313
-
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
- }
333
-
334
- return true
335
- })
336
-
337
- const isInlineElement = this.isInlineElement(tagName)
338
- const hasClosing = open.tag_closing?.value === ">" || open.tag_closing?.value === "/>"
339
- const isSelfClosing = open.tag_closing?.value === "/>"
340
-
341
- if (!hasClosing) {
342
- this.push(indent + `<${tagName}`)
343
-
344
- return
345
- }
346
-
347
- if (attributes.length === 0 && inlineNodes.length === 0) {
348
- if (children.length === 0) {
349
- if (isSelfClosing) {
350
- this.push(indent + `<${tagName} />`)
351
- } else if (node.is_void) {
352
- this.push(indent + `<${tagName}>`)
353
- } else {
354
- this.push(indent + `<${tagName}></${tagName}>`)
355
- }
356
-
357
- return
358
- }
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
-
429
- this.push(indent + `<${tagName}>`)
430
-
431
- this.withIndent(() => {
432
- if (hasTextFlow) {
433
- this.visitTextFlowChildren(children)
434
- } else {
435
- children.forEach(child => this.visit(child))
436
- }
437
- })
438
-
439
- if (!node.is_void && !isSelfClosing) {
440
- this.push(indent + `</${tagName}>`)
441
- }
442
-
443
- return
444
- }
445
-
446
- if (attributes.length === 0 && inlineNodes.length > 0) {
447
- const inline = this.renderInlineOpen(tagName, [], isSelfClosing, inlineNodes, open.children)
448
-
449
- if (children.length === 0) {
450
- if (isSelfClosing || node.is_void) {
451
- this.push(indent + inline)
452
- } else {
453
- this.push(indent + inline + `</${tagName}>`)
454
- }
455
- return
456
- }
457
-
458
- this.push(indent + inline)
459
- this.withIndent(() => {
460
- children.forEach(child => this.visit(child))
461
- })
462
-
463
- if (!node.is_void && !isSelfClosing) {
464
- this.push(indent + `</${tagName}>`)
465
- }
466
-
467
- return
468
- }
469
-
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
-
504
-
505
-
506
- if (shouldKeepInline) {
507
- if (children.length === 0) {
508
- if (isSelfClosing) {
509
- this.push(indent + inline)
510
- } else if (node.is_void) {
511
- this.push(indent + inline)
512
- } else {
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}>`)
576
- }
577
-
578
- return
579
- }
580
-
581
- if (isSelfClosing) {
582
- this.push(indent + inline.replace(' />', '>'))
583
- } else {
584
- this.push(indent + inline)
585
- }
586
-
587
- this.withIndent(() => {
588
- if (hasTextFlow) {
589
- this.visitTextFlowChildren(children)
590
- } else {
591
- children.forEach(child => this.visit(child))
592
- }
593
- })
594
-
595
- if (!node.is_void && !isSelfClosing) {
596
- this.push(indent + `</${tagName}>`)
597
- }
598
-
599
- return
600
- }
601
-
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) {
612
- this.push(indent + this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children))
613
-
614
- if (!isSelfClosing && !node.is_void && children.length > 0) {
615
- this.withIndent(() => {
616
- children.forEach(child => this.visit(child))
617
- })
618
- this.push(indent + `</${tagName}>`)
619
- }
620
- } else {
621
- if (isInlineElement && children.length > 0) {
622
- const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
623
-
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
- }
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) {
651
- this.withIndent(() => {
652
- if (hasTextFlow) {
653
- this.visitTextFlowChildren(children)
654
- } else {
655
- children.forEach(child => this.visit(child))
656
- }
657
- })
658
-
659
- this.push(indent + `</${tagName}>`)
660
- }
661
- }
662
- }
663
-
664
- visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
665
- const tagName = node.tag_name?.value ?? ""
666
- const indent = this.indent()
667
- const attributes = this.extractAttributes(node.children)
668
- const inlineNodes = this.extractInlineNodes(node.children)
669
-
670
- const hasClosing = node.tag_closing?.value === ">"
671
-
672
- if (!hasClosing) {
673
- this.push(indent + `<${tagName}`)
674
- return
675
- }
676
-
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
- )
688
-
689
- if (shouldKeepInline) {
690
- this.push(indent + inline)
691
-
692
- return
693
- }
694
-
695
- this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.children, false, node.is_void, false)
696
- }
697
-
698
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
699
- const tagName = node.tag_name?.value ?? ""
700
- const indent = this.indent()
701
-
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
- )
716
-
717
- if (shouldKeepInline) {
718
- this.push(indent + inline)
719
-
720
- return
721
- }
722
-
723
- this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.attributes, true, false, false)
724
- }
725
-
726
- visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
727
- const indent = this.indent()
728
- const open = node.tag_opening?.value ?? ""
729
- const name = node.tag_name?.value ?? ""
730
- const close = node.tag_closing?.value ?? ""
731
-
732
- this.push(indent + open + name + close)
733
- }
734
-
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
-
746
- const indent = this.indent()
747
- let text = node.content.trim()
748
-
749
- if (!text) return
750
-
751
- const wrapWidth = this.maxLineLength - indent.length
752
- const words = text.split(/\s+/)
753
- const lines: string[] = []
754
-
755
- let line = ""
756
-
757
- for (const word of words) {
758
- if ((line + (line ? " " : "") + word).length > wrapWidth && line) {
759
- lines.push(indent + line)
760
- line = word
761
- } else {
762
- line += (line ? " " : "") + word
763
- }
764
- }
765
-
766
- if (line) lines.push(indent + line)
767
-
768
- lines.forEach(line => this.push(line))
769
- }
770
-
771
- visitHTMLAttributeNode(node: HTMLAttributeNode): void {
772
- const indent = this.indent()
773
- this.push(indent + this.renderAttribute(node))
774
- }
775
-
776
- visitHTMLAttributeNameNode(node: HTMLAttributeNameNode): void {
777
- const indent = this.indent()
778
- const name = node.name?.value ?? ""
779
- this.push(indent + name)
780
- }
781
-
782
- visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void {
783
- const indent = this.indent()
784
- const open_quote = node.open_quote?.value ?? ""
785
- const close_quote = node.close_quote?.value ?? ""
786
-
787
- const attribute_value = node.children.map(child => {
788
- if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' ||
789
- child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
790
-
791
- return (child as HTMLTextNode | LiteralNode).content
792
- } else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
793
- const erbChild = child as ERBContentNode
794
-
795
- return (erbChild.tag_opening!.value + erbChild.content!.value + erbChild.tag_closing!.value)
796
- }
797
-
798
- return ""
799
- }).join("")
800
-
801
- this.push(indent + open_quote + attribute_value + close_quote)
802
- }
803
-
804
- visitHTMLCommentNode(node: HTMLCommentNode): void {
805
- const indent = this.indent()
806
- const open = node.comment_start?.value ?? ""
807
- const close = node.comment_end?.value ?? ""
808
- let inner: string
809
-
810
- if (node.comment_start && node.comment_end) {
811
- // TODO: use .value
812
- const [_, startIndex] = node.comment_start.range.toArray()
813
- const [endIndex] = node.comment_end.range.toArray()
814
- const rawInner = this.source.slice(startIndex, endIndex)
815
- inner = ` ${rawInner.trim()} `
816
- } else {
817
- inner = node.children.map(child => {
818
- const prevLines = this.lines.length
819
- this.visit(child)
820
- return this.lines.slice(prevLines).join("")
821
- }).join("")
822
- }
823
-
824
- this.push(indent + open + inner + close)
825
- }
826
-
827
- visitERBCommentNode(node: ERBContentNode): void {
828
- const indent = this.indent()
829
- const open = node.tag_opening?.value ?? ""
830
- const close = node.tag_closing?.value ?? ""
831
- let inner: string
832
-
833
- if (node.tag_opening && node.tag_closing) {
834
- const [, openingEnd] = node.tag_opening.range.toArray()
835
- const [closingStart] = node.tag_closing.range.toArray()
836
- const rawInner = this.source.slice(openingEnd, closingStart)
837
- const lines = rawInner.split("\n")
838
- if (lines.length > 2) {
839
- const childIndent = indent + " ".repeat(this.indentWidth)
840
- const innerLines = lines.slice(1, -1).map(line => childIndent + line.trim())
841
- inner = "\n" + innerLines.join("\n") + "\n"
842
- } else {
843
- inner = ` ${rawInner.trim()} `
844
- }
845
- } else {
846
- inner = (node as any).children
847
- .map((child: any) => {
848
- const prevLines = this.lines.length
849
- this.visit(child)
850
- return this.lines.slice(prevLines).join("")
851
- })
852
- .join("")
853
- }
854
-
855
- this.push(indent + open + inner + close)
856
- }
857
-
858
- visitHTMLDoctypeNode(node: HTMLDoctypeNode): void {
859
- const indent = this.indent()
860
- const open = node.tag_opening?.value ?? ""
861
- let innerDoctype: string
862
-
863
- if (node.tag_opening && node.tag_closing) {
864
- // TODO: use .value
865
- const [, openingEnd] = node.tag_opening.range.toArray()
866
- const [closingStart] = node.tag_closing.range.toArray()
867
- innerDoctype = this.source.slice(openingEnd, closingStart)
868
- } else {
869
- innerDoctype = node.children
870
- .map(child =>
871
- child instanceof HTMLTextNode ? child.content : (() => { const prevLines = this.lines.length; this.visit(child); return this.lines.slice(prevLines).join("") })(),
872
- )
873
- .join("")
874
- }
875
-
876
- const close = node.tag_closing?.value ?? ""
877
- this.push(indent + open + innerDoctype + close)
878
- }
879
-
880
- visitERBContentNode(node: ERBContentNode): void {
881
- // TODO: this feels hacky
882
- if (node.tag_opening?.value === "<%#") {
883
- this.visitERBCommentNode(node)
884
- } else {
885
- this.printERBNode(node)
886
- }
887
- }
888
-
889
- visitERBEndNode(node: ERBEndNode): void {
890
- this.printERBNode(node)
891
- }
892
-
893
- visitERBYieldNode(node: ERBYieldNode): void {
894
- this.printERBNode(node)
895
- }
896
-
897
- visitERBInNode(node: ERBInNode): void {
898
- this.printERBNode(node)
899
-
900
- this.withIndent(() => {
901
- node.statements.forEach(stmt => this.visit(stmt))
902
- })
903
- }
904
-
905
- visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
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)
912
- }
913
-
914
- visitERBBlockNode(node: ERBBlockNode): void {
915
- const indent = this.indent()
916
- const open = node.tag_opening?.value ?? ""
917
- const content = node.content?.value ?? ""
918
- const close = node.tag_closing?.value ?? ""
919
-
920
- this.push(indent + open + content + close)
921
-
922
- this.withIndent(() => {
923
- node.body.forEach(child => this.visit(child))
924
- })
925
-
926
- if (node.end_node) {
927
- this.visit(node.end_node)
928
- }
929
- }
930
-
931
- visitERBIfNode(node: ERBIfNode): void {
932
- if (this.inlineMode) {
933
- const open = node.tag_opening?.value ?? ""
934
- const content = node.content?.value ?? ""
935
- const close = node.tag_closing?.value ?? ""
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(" ")
942
-
943
- if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
944
- this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
945
- } else {
946
- this.visit(child)
947
- }
948
- })
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
-
958
- if (node.end_node) {
959
- const endNode = node.end_node as any
960
- const endOpen = endNode.tag_opening?.value ?? ""
961
- const endContent = endNode.content?.value ?? ""
962
- const endClose = endNode.tag_closing?.value ?? ""
963
- const endInner = this.formatERBContent(endContent)
964
-
965
- this.lines.push(endOpen + endInner + endClose)
966
- }
967
- } else {
968
- this.printERBNode(node)
969
-
970
- this.withIndent(() => {
971
- node.statements.forEach(child => this.visit(child))
972
- })
973
-
974
- if (node.subsequent) {
975
- this.visit(node.subsequent)
976
- }
977
-
978
- if (node.end_node) {
979
- this.printERBNode(node.end_node as any)
980
- }
981
- }
982
- }
983
-
984
- visitERBElseNode(node: ERBElseNode): void {
985
- this.printERBNode(node)
986
-
987
- this.withIndent(() => {
988
- node.statements.forEach(child => this.visit(child))
989
- })
990
- }
991
-
992
- visitERBWhenNode(node: ERBWhenNode): void {
993
- this.printERBNode(node)
994
-
995
- this.withIndent(() => {
996
- node.statements.forEach(stmt => this.visit(stmt))
997
- })
998
- }
999
-
1000
- visitERBCaseNode(node: ERBCaseNode): void {
1001
- const indent = this.indent()
1002
- const open = node.tag_opening?.value ?? ""
1003
- const content = node.content?.value ?? ""
1004
- const close = node.tag_closing?.value ?? ""
1005
-
1006
- this.push(indent + open + content + close)
1007
-
1008
- node.conditions.forEach(condition => this.visit(condition))
1009
- if (node.else_clause) this.visit(node.else_clause)
1010
-
1011
- if (node.end_node) {
1012
- this.visit(node.end_node)
1013
- }
1014
- }
1015
-
1016
- visitERBBeginNode(node: ERBBeginNode): void {
1017
- const indent = this.indent()
1018
- const open = node.tag_opening?.value ?? ""
1019
- const content = node.content?.value ?? ""
1020
- const close = node.tag_closing?.value ?? ""
1021
-
1022
- this.push(indent + open + content + close)
1023
-
1024
- this.withIndent(() => {
1025
- node.statements.forEach(statement => this.visit(statement))
1026
- })
1027
-
1028
- if (node.rescue_clause) this.visit(node.rescue_clause)
1029
- if (node.else_clause) this.visit(node.else_clause)
1030
- if (node.ensure_clause) this.visit(node.ensure_clause)
1031
- if (node.end_node) this.visit(node.end_node)
1032
- }
1033
-
1034
- visitERBWhileNode(node: ERBWhileNode): void {
1035
- this.visitERBGeneric(node)
1036
- }
1037
-
1038
- visitERBUntilNode(node: ERBUntilNode): void {
1039
- this.visitERBGeneric(node)
1040
- }
1041
-
1042
- visitERBForNode(node: ERBForNode): void {
1043
- this.visitERBGeneric(node)
1044
- }
1045
-
1046
- visitERBRescueNode(node: ERBRescueNode): void {
1047
- this.visitERBGeneric(node)
1048
- }
1049
-
1050
- visitERBEnsureNode(node: ERBEnsureNode): void {
1051
- this.visitERBGeneric(node)
1052
- }
1053
-
1054
- visitERBUnlessNode(node: ERBUnlessNode): void {
1055
- this.visitERBGeneric(node)
1056
- }
1057
-
1058
- // TODO: don't use any
1059
- private visitERBGeneric(node: any): void {
1060
- const indent = this.indent()
1061
- const open = node.tag_opening?.value ?? ""
1062
- const content = node.content?.value ?? ""
1063
- const close = node.tag_closing?.value ?? ""
1064
-
1065
- this.push(indent + open + content + close)
1066
-
1067
- this.withIndent(() => {
1068
- const statements: any[] = node.statements ?? node.body ?? node.children ?? []
1069
-
1070
- statements.forEach(statement => this.visit(statement))
1071
- })
1072
-
1073
- if (node.end_node) this.visit(node.end_node)
1074
- }
1075
-
1076
- // --- Utility methods ---
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
-
1258
- private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
1259
- const parts = attributes.map(attribute => this.renderAttribute(attribute))
1260
-
1261
- if (inlineNodes.length > 0) {
1262
- let result = `<${name}`
1263
-
1264
- if (allChildren.length > 0) {
1265
- const currentIndentLevel = this.indentLevel
1266
- this.indentLevel = 0
1267
- const tempLines = this.lines
1268
- this.lines = []
1269
-
1270
- allChildren.forEach(child => {
1271
- if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
1272
- this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
1273
- } else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
1274
- const wasInlineMode = this.inlineMode
1275
-
1276
- this.inlineMode = true
1277
-
1278
- this.lines.push(" ")
1279
- this.visit(child)
1280
- this.inlineMode = wasInlineMode
1281
- }
1282
- })
1283
-
1284
- const inlineContent = this.lines.join("")
1285
- this.lines = tempLines
1286
- this.indentLevel = currentIndentLevel
1287
-
1288
- result += inlineContent
1289
- } else {
1290
- if (parts.length > 0) {
1291
- result += ` ${parts.join(" ")}`
1292
- }
1293
-
1294
- const currentIndentLevel = this.indentLevel
1295
- this.indentLevel = 0
1296
- const tempLines = this.lines
1297
- this.lines = []
1298
-
1299
- inlineNodes.forEach(node => {
1300
- const wasInlineMode = this.inlineMode
1301
-
1302
- if (!this.isERBControlFlow(node)) {
1303
- this.inlineMode = true
1304
- }
1305
-
1306
- this.visit(node)
1307
-
1308
- this.inlineMode = wasInlineMode
1309
- })
1310
-
1311
- const inlineContent = this.lines.join("")
1312
- this.lines = tempLines
1313
- this.indentLevel = currentIndentLevel
1314
-
1315
- result += inlineContent
1316
- }
1317
-
1318
- result += selfClose ? " />" : ">"
1319
-
1320
- return result
1321
- }
1322
-
1323
- return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " /" : ""}>`
1324
- }
1325
-
1326
- renderAttribute(attribute: HTMLAttributeNode): string {
1327
- const name = (attribute.name as HTMLAttributeNameNode)!.name!.value ?? ""
1328
- const equals = attribute.equals?.value ?? ""
1329
-
1330
- let value = ""
1331
-
1332
- if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_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
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
1349
- }
1350
-
1351
- return ""
1352
- }).join("")
1353
-
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
1363
- }
1364
-
1365
- return name + equals + value
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
- }
1644
- }