@herb-tools/formatter 0.5.0 → 0.6.1

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