@herb-tools/formatter 0.7.5 → 0.8.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/README.md +116 -11
- package/dist/herb-format.js +23158 -2213
- package/dist/herb-format.js.map +1 -1
- package/dist/index.cjs +1409 -331
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +1409 -331
- package/dist/index.esm.js.map +1 -1
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/format-helpers.d.ts +160 -0
- package/dist/types/format-printer.d.ts +87 -50
- package/dist/types/formatter.d.ts +18 -2
- package/dist/types/options.d.ts +7 -0
- package/dist/types/scaffold-template-detector.d.ts +12 -0
- package/dist/types/types.d.ts +23 -0
- package/package.json +5 -6
- package/src/cli.ts +355 -109
- package/src/format-helpers.ts +510 -0
- package/src/format-printer.ts +1013 -390
- package/src/formatter.ts +76 -4
- package/src/options.ts +12 -0
- package/src/scaffold-template-detector.ts +33 -0
- package/src/types.ts +27 -0
package/src/format-printer.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import dedent from "dedent"
|
|
2
|
+
|
|
3
|
+
import { Printer, IdentityPrinter } from "@herb-tools/printer"
|
|
4
|
+
|
|
2
5
|
import {
|
|
3
6
|
getTagName,
|
|
4
7
|
getCombinedAttributeName,
|
|
@@ -6,15 +9,54 @@ import {
|
|
|
6
9
|
isNode,
|
|
7
10
|
isToken,
|
|
8
11
|
isParseResult,
|
|
9
|
-
isAnyOf,
|
|
10
12
|
isNoneOf,
|
|
11
13
|
isERBNode,
|
|
12
14
|
isCommentNode,
|
|
13
15
|
isERBControlFlowNode,
|
|
14
16
|
filterNodes,
|
|
15
|
-
hasERBOutput,
|
|
16
17
|
} from "@herb-tools/core"
|
|
17
|
-
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
areAllNestedElementsInline,
|
|
21
|
+
buildLineWithWord,
|
|
22
|
+
countAdjacentInlineElements,
|
|
23
|
+
endsWithWhitespace,
|
|
24
|
+
filterEmptyNodesForHerbDisable,
|
|
25
|
+
filterSignificantChildren,
|
|
26
|
+
findPreviousMeaningfulSibling,
|
|
27
|
+
hasComplexERBControlFlow,
|
|
28
|
+
hasMixedTextAndInlineContent,
|
|
29
|
+
hasMultilineTextContent,
|
|
30
|
+
hasWhitespaceBetween,
|
|
31
|
+
isBlockLevelNode,
|
|
32
|
+
isClosingPunctuation,
|
|
33
|
+
isContentPreserving,
|
|
34
|
+
isFrontmatter,
|
|
35
|
+
isHerbDisableComment,
|
|
36
|
+
isInlineElement,
|
|
37
|
+
isLineBreakingElement,
|
|
38
|
+
isNonWhitespaceNode,
|
|
39
|
+
isPureWhitespaceNode,
|
|
40
|
+
needsSpaceBetween,
|
|
41
|
+
normalizeAndSplitWords,
|
|
42
|
+
shouldAppendToLastLine,
|
|
43
|
+
shouldPreserveUserSpacing,
|
|
44
|
+
} from "./format-helpers.js"
|
|
45
|
+
|
|
46
|
+
import {
|
|
47
|
+
FORMATTABLE_ATTRIBUTES,
|
|
48
|
+
INLINE_ELEMENTS,
|
|
49
|
+
SPACEABLE_CONTAINERS,
|
|
50
|
+
SPACING_THRESHOLD,
|
|
51
|
+
TIGHT_GROUP_CHILDREN,
|
|
52
|
+
TIGHT_GROUP_PARENTS,
|
|
53
|
+
TOKEN_LIST_ATTRIBUTES,
|
|
54
|
+
} from "./format-helpers.js"
|
|
55
|
+
|
|
56
|
+
import type {
|
|
57
|
+
ContentUnitWithNode,
|
|
58
|
+
ElementFormattingAnalysis,
|
|
59
|
+
} from "./format-helpers.js"
|
|
18
60
|
|
|
19
61
|
import {
|
|
20
62
|
ParseResult,
|
|
@@ -56,21 +98,6 @@ import {
|
|
|
56
98
|
import type { ERBNode } from "@herb-tools/core"
|
|
57
99
|
import type { FormatOptions } from "./options.js"
|
|
58
100
|
|
|
59
|
-
/**
|
|
60
|
-
* Analysis result for HTMLElementNode formatting decisions
|
|
61
|
-
*/
|
|
62
|
-
interface ElementFormattingAnalysis {
|
|
63
|
-
openTagInline: boolean
|
|
64
|
-
elementContentInline: boolean
|
|
65
|
-
closeTagInline: boolean
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// TODO: we can probably expand this list with more tags/attributes
|
|
69
|
-
const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
|
|
70
|
-
'*': ['class'],
|
|
71
|
-
'img': ['srcset', 'sizes']
|
|
72
|
-
}
|
|
73
|
-
|
|
74
101
|
/**
|
|
75
102
|
* Printer traverses the Herb AST using the Visitor pattern
|
|
76
103
|
* and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
|
|
@@ -98,34 +125,6 @@ export class FormatPrinter extends Printer {
|
|
|
98
125
|
|
|
99
126
|
public source: string
|
|
100
127
|
|
|
101
|
-
// TODO: extract
|
|
102
|
-
private static readonly INLINE_ELEMENTS = new Set([
|
|
103
|
-
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
|
|
104
|
-
'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
|
|
105
|
-
'samp', 'small', 'span', 'strong', 'sub', 'sup',
|
|
106
|
-
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
|
|
107
|
-
])
|
|
108
|
-
|
|
109
|
-
private static readonly CONTENT_PRESERVING_ELEMENTS = new Set([
|
|
110
|
-
'script', 'style', 'pre', 'textarea'
|
|
111
|
-
])
|
|
112
|
-
|
|
113
|
-
private static readonly SPACEABLE_CONTAINERS = new Set([
|
|
114
|
-
'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
|
|
115
|
-
'figure', 'details', 'summary', 'dialog', 'fieldset'
|
|
116
|
-
])
|
|
117
|
-
|
|
118
|
-
private static readonly TIGHT_GROUP_PARENTS = new Set([
|
|
119
|
-
'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
|
|
120
|
-
'tbody', 'tfoot'
|
|
121
|
-
])
|
|
122
|
-
|
|
123
|
-
private static readonly TIGHT_GROUP_CHILDREN = new Set([
|
|
124
|
-
'li', 'option', 'td', 'th', 'dt', 'dd'
|
|
125
|
-
])
|
|
126
|
-
|
|
127
|
-
private static readonly SPACING_THRESHOLD = 3
|
|
128
|
-
|
|
129
128
|
constructor(source: string, options: Required<FormatOptions>) {
|
|
130
129
|
super()
|
|
131
130
|
|
|
@@ -320,26 +319,26 @@ export class FormatPrinter extends Printer {
|
|
|
320
319
|
return false
|
|
321
320
|
}
|
|
322
321
|
|
|
323
|
-
const meaningfulSiblings = siblings.filter(child =>
|
|
322
|
+
const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child))
|
|
324
323
|
|
|
325
|
-
if (meaningfulSiblings.length <
|
|
324
|
+
if (meaningfulSiblings.length < SPACING_THRESHOLD) {
|
|
326
325
|
return false
|
|
327
326
|
}
|
|
328
327
|
|
|
329
328
|
const parentTagName = parentElement ? getTagName(parentElement) : null
|
|
330
329
|
|
|
331
|
-
if (parentTagName &&
|
|
330
|
+
if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
|
|
332
331
|
return false
|
|
333
332
|
}
|
|
334
333
|
|
|
335
|
-
const isSpaceableContainer = !parentTagName || (parentTagName &&
|
|
334
|
+
const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName))
|
|
336
335
|
|
|
337
336
|
if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
|
|
338
337
|
return false
|
|
339
338
|
}
|
|
340
339
|
|
|
341
340
|
const currentNode = siblings[currentIndex]
|
|
342
|
-
const previousMeaningfulIndex =
|
|
341
|
+
const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex)
|
|
343
342
|
const isCurrentComment = isCommentNode(currentNode)
|
|
344
343
|
|
|
345
344
|
if (previousMeaningfulIndex !== -1) {
|
|
@@ -358,11 +357,11 @@ export class FormatPrinter extends Printer {
|
|
|
358
357
|
if (isNode(currentNode, HTMLElementNode)) {
|
|
359
358
|
const currentTagName = getTagName(currentNode)
|
|
360
359
|
|
|
361
|
-
if (
|
|
360
|
+
if (INLINE_ELEMENTS.has(currentTagName)) {
|
|
362
361
|
return false
|
|
363
362
|
}
|
|
364
363
|
|
|
365
|
-
if (
|
|
364
|
+
if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
|
|
366
365
|
return false
|
|
367
366
|
}
|
|
368
367
|
|
|
@@ -371,56 +370,18 @@ export class FormatPrinter extends Printer {
|
|
|
371
370
|
}
|
|
372
371
|
}
|
|
373
372
|
|
|
374
|
-
const isBlockElement =
|
|
373
|
+
const isBlockElement = isBlockLevelNode(currentNode)
|
|
375
374
|
const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode)
|
|
376
375
|
const isComment = isCommentNode(currentNode)
|
|
377
376
|
|
|
378
377
|
return isBlockElement || isERBBlock || isComment
|
|
379
378
|
}
|
|
380
379
|
|
|
381
|
-
/**
|
|
382
|
-
* Token list attributes that contain space-separated values and benefit from
|
|
383
|
-
* spacing around ERB content for readability
|
|
384
|
-
*/
|
|
385
|
-
private static readonly TOKEN_LIST_ATTRIBUTES = new Set([
|
|
386
|
-
'class', 'data-controller', 'data-action'
|
|
387
|
-
])
|
|
388
|
-
|
|
389
380
|
/**
|
|
390
381
|
* Check if we're currently processing a token list attribute that needs spacing
|
|
391
382
|
*/
|
|
392
|
-
private isInTokenListAttribute(): boolean {
|
|
393
|
-
return this.currentAttributeName !== null &&
|
|
394
|
-
FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* Find the previous meaningful (non-whitespace) sibling
|
|
399
|
-
*/
|
|
400
|
-
private findPreviousMeaningfulSibling(siblings: Node[], currentIndex: number): number {
|
|
401
|
-
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
402
|
-
if (this.isNonWhitespaceNode(siblings[i])) {
|
|
403
|
-
return i
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return -1
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Check if a node represents a block-level element
|
|
411
|
-
*/
|
|
412
|
-
private isBlockLevelNode(node: Node): boolean {
|
|
413
|
-
if (!isNode(node, HTMLElementNode)) {
|
|
414
|
-
return false
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const tagName = getTagName(node)
|
|
418
|
-
|
|
419
|
-
if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
|
|
420
|
-
return false
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return true
|
|
383
|
+
private get isInTokenListAttribute(): boolean {
|
|
384
|
+
return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
|
|
424
385
|
}
|
|
425
386
|
|
|
426
387
|
/**
|
|
@@ -473,16 +434,13 @@ export class FormatPrinter extends Printer {
|
|
|
473
434
|
return true
|
|
474
435
|
}
|
|
475
436
|
|
|
476
|
-
private getAttributeName(attribute: HTMLAttributeNode): string {
|
|
477
|
-
return attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
478
|
-
}
|
|
479
|
-
|
|
480
437
|
private wouldClassAttributeBeMultiline(content: string, indentLength: number): boolean {
|
|
481
438
|
const normalizedContent = content.replace(/\s+/g, ' ').trim()
|
|
482
439
|
const hasActualNewlines = /\r?\n/.test(content)
|
|
483
440
|
|
|
484
441
|
if (hasActualNewlines && normalizedContent.length > 80) {
|
|
485
442
|
const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
|
|
443
|
+
|
|
486
444
|
if (lines.length > 1) {
|
|
487
445
|
return true
|
|
488
446
|
}
|
|
@@ -504,6 +462,12 @@ export class FormatPrinter extends Printer {
|
|
|
504
462
|
return false
|
|
505
463
|
}
|
|
506
464
|
|
|
465
|
+
// TOOD: extract to core or reuse function from core
|
|
466
|
+
private getAttributeName(attribute: HTMLAttributeNode): string {
|
|
467
|
+
return attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// TOOD: extract to core or reuse function from core
|
|
507
471
|
private getAttributeValue(attribute: HTMLAttributeNode): string {
|
|
508
472
|
if (isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
509
473
|
return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('')
|
|
@@ -674,37 +638,50 @@ export class FormatPrinter extends Printer {
|
|
|
674
638
|
// --- Visitor methods ---
|
|
675
639
|
|
|
676
640
|
visitDocumentNode(node: DocumentNode) {
|
|
677
|
-
|
|
678
|
-
|
|
641
|
+
const children = this.formatFrontmatter(node)
|
|
642
|
+
const hasTextFlow = this.isInTextFlowContext(null, children)
|
|
679
643
|
|
|
680
|
-
|
|
681
|
-
const
|
|
644
|
+
if (hasTextFlow) {
|
|
645
|
+
const wasInlineMode = this.inlineMode
|
|
646
|
+
this.inlineMode = true
|
|
682
647
|
|
|
683
|
-
|
|
684
|
-
const isWhitespaceOnly = child.content.trim() === ""
|
|
648
|
+
this.visitTextFlowChildren(children)
|
|
685
649
|
|
|
686
|
-
|
|
687
|
-
const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1])
|
|
688
|
-
const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1])
|
|
650
|
+
this.inlineMode = wasInlineMode
|
|
689
651
|
|
|
690
|
-
|
|
652
|
+
return
|
|
653
|
+
}
|
|
691
654
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
hasHandledSpacing = true
|
|
695
|
-
}
|
|
655
|
+
let lastWasMeaningful = false
|
|
656
|
+
let hasHandledSpacing = false
|
|
696
657
|
|
|
697
|
-
|
|
698
|
-
|
|
658
|
+
for (let i = 0; i < children.length; i++) {
|
|
659
|
+
const child = children[i]
|
|
660
|
+
|
|
661
|
+
if (shouldPreserveUserSpacing(child, children, i)) {
|
|
662
|
+
this.push("")
|
|
663
|
+
hasHandledSpacing = true
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (isPureWhitespaceNode(child)) {
|
|
668
|
+
continue
|
|
699
669
|
}
|
|
700
670
|
|
|
701
|
-
if (
|
|
671
|
+
if (shouldAppendToLastLine(child, children, i)) {
|
|
672
|
+
this.appendChildToLastLine(child, children, i)
|
|
673
|
+
lastWasMeaningful = true
|
|
674
|
+
hasHandledSpacing = false
|
|
675
|
+
continue
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
702
679
|
this.push("")
|
|
703
680
|
}
|
|
704
681
|
|
|
705
682
|
this.visit(child)
|
|
706
683
|
|
|
707
|
-
if (
|
|
684
|
+
if (isNonWhitespaceNode(child)) {
|
|
708
685
|
lastWasMeaningful = true
|
|
709
686
|
hasHandledSpacing = false
|
|
710
687
|
}
|
|
@@ -715,6 +692,14 @@ export class FormatPrinter extends Printer {
|
|
|
715
692
|
this.elementStack.push(node)
|
|
716
693
|
this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node))
|
|
717
694
|
|
|
695
|
+
if (this.inlineMode && node.is_void && this.indentLevel === 0) {
|
|
696
|
+
const openTag = this.capture(() => this.visit(node.open_tag)).join('')
|
|
697
|
+
this.pushToLastLine(openTag)
|
|
698
|
+
this.elementStack.pop()
|
|
699
|
+
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
|
|
718
703
|
this.visit(node.open_tag)
|
|
719
704
|
|
|
720
705
|
if (node.body.length > 0) {
|
|
@@ -729,7 +714,9 @@ export class FormatPrinter extends Printer {
|
|
|
729
714
|
}
|
|
730
715
|
|
|
731
716
|
visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
|
|
732
|
-
|
|
717
|
+
const tagName = getTagName(element)
|
|
718
|
+
|
|
719
|
+
if (isContentPreserving(element)) {
|
|
733
720
|
element.body.map(child => {
|
|
734
721
|
if (isNode(child, HTMLElementNode)) {
|
|
735
722
|
const wasInlineMode = this.inlineMode
|
|
@@ -749,7 +736,7 @@ export class FormatPrinter extends Printer {
|
|
|
749
736
|
|
|
750
737
|
const analysis = this.elementFormattingAnalysis.get(element)
|
|
751
738
|
const hasTextFlow = this.isInTextFlowContext(null, body)
|
|
752
|
-
const children =
|
|
739
|
+
const children = filterSignificantChildren(body)
|
|
753
740
|
|
|
754
741
|
if (analysis?.elementContentInline) {
|
|
755
742
|
if (children.length === 0) return
|
|
@@ -757,6 +744,9 @@ export class FormatPrinter extends Printer {
|
|
|
757
744
|
const oldInlineMode = this.inlineMode
|
|
758
745
|
const nodesToRender = hasTextFlow ? body : children
|
|
759
746
|
|
|
747
|
+
const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
|
|
748
|
+
const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName)
|
|
749
|
+
|
|
760
750
|
this.inlineMode = true
|
|
761
751
|
|
|
762
752
|
const lines = this.capture(() => {
|
|
@@ -771,10 +761,18 @@ export class FormatPrinter extends Printer {
|
|
|
771
761
|
this.push(' ')
|
|
772
762
|
}
|
|
773
763
|
} else {
|
|
774
|
-
const normalizedContent = child.content.replace(/\s+/g, ' ')
|
|
764
|
+
const normalizedContent = child.content.replace(/\s+/g, ' ')
|
|
775
765
|
|
|
776
|
-
if (normalizedContent) {
|
|
766
|
+
if (shouldPreserveSpaces && normalizedContent) {
|
|
777
767
|
this.push(normalizedContent)
|
|
768
|
+
} else {
|
|
769
|
+
const trimmedContent = normalizedContent.trim()
|
|
770
|
+
|
|
771
|
+
if (trimmedContent) {
|
|
772
|
+
this.push(trimmedContent)
|
|
773
|
+
} else if (normalizedContent === ' ') {
|
|
774
|
+
this.push(' ')
|
|
775
|
+
}
|
|
778
776
|
}
|
|
779
777
|
}
|
|
780
778
|
} else if (isNode(child, WhitespaceNode)) {
|
|
@@ -786,7 +784,10 @@ export class FormatPrinter extends Printer {
|
|
|
786
784
|
})
|
|
787
785
|
|
|
788
786
|
const content = lines.join('')
|
|
789
|
-
|
|
787
|
+
|
|
788
|
+
const inlineContent = shouldPreserveSpaces
|
|
789
|
+
? (hasTextFlow ? content.replace(/\s+/g, ' ') : content)
|
|
790
|
+
: (hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim())
|
|
790
791
|
|
|
791
792
|
if (inlineContent) {
|
|
792
793
|
this.pushToLastLine(inlineContent)
|
|
@@ -799,11 +800,83 @@ export class FormatPrinter extends Printer {
|
|
|
799
800
|
|
|
800
801
|
if (children.length === 0) return
|
|
801
802
|
|
|
803
|
+
let leadingHerbDisableComment: Node | null = null
|
|
804
|
+
let leadingHerbDisableIndex = -1
|
|
805
|
+
let firstWhitespaceIndex = -1
|
|
806
|
+
let remainingChildren = children
|
|
807
|
+
let remainingBodyUnfiltered = body
|
|
808
|
+
|
|
809
|
+
for (let i = 0; i < children.length; i++) {
|
|
810
|
+
const child = children[i]
|
|
811
|
+
|
|
812
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
813
|
+
if (firstWhitespaceIndex < 0) {
|
|
814
|
+
firstWhitespaceIndex = i
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
continue
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
|
|
821
|
+
leadingHerbDisableComment = child
|
|
822
|
+
leadingHerbDisableIndex = i
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
break
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (leadingHerbDisableComment && leadingHerbDisableIndex >= 0) {
|
|
829
|
+
remainingChildren = children.filter((_, index) => {
|
|
830
|
+
if (index === leadingHerbDisableIndex) return false
|
|
831
|
+
|
|
832
|
+
if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
|
|
833
|
+
const child = children[index]
|
|
834
|
+
|
|
835
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
836
|
+
return false
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return true
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
remainingBodyUnfiltered = body.filter((_, index) => {
|
|
844
|
+
if (index === leadingHerbDisableIndex) return false
|
|
845
|
+
|
|
846
|
+
if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
|
|
847
|
+
const child = body[index]
|
|
848
|
+
|
|
849
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
850
|
+
return false
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return true
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (leadingHerbDisableComment) {
|
|
859
|
+
const herbDisableString = this.capture(() => {
|
|
860
|
+
const savedIndentLevel = this.indentLevel
|
|
861
|
+
this.indentLevel = 0
|
|
862
|
+
this.inlineMode = true
|
|
863
|
+
this.visit(leadingHerbDisableComment)
|
|
864
|
+
this.inlineMode = false
|
|
865
|
+
this.indentLevel = savedIndentLevel
|
|
866
|
+
}).join("")
|
|
867
|
+
|
|
868
|
+
const hasLeadingWhitespace = firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex
|
|
869
|
+
|
|
870
|
+
this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString)
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
if (remainingChildren.length === 0) return
|
|
874
|
+
|
|
802
875
|
this.withIndent(() => {
|
|
803
876
|
if (hasTextFlow) {
|
|
804
|
-
this.visitTextFlowChildren(
|
|
877
|
+
this.visitTextFlowChildren(remainingBodyUnfiltered)
|
|
805
878
|
} else {
|
|
806
|
-
this.visitElementChildren(body, element)
|
|
879
|
+
this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element)
|
|
807
880
|
}
|
|
808
881
|
})
|
|
809
882
|
}
|
|
@@ -822,8 +895,8 @@ export class FormatPrinter extends Printer {
|
|
|
822
895
|
const isWhitespaceOnly = child.content.trim() === ""
|
|
823
896
|
|
|
824
897
|
if (isWhitespaceOnly) {
|
|
825
|
-
const hasPreviousNonWhitespace = i > 0 &&
|
|
826
|
-
const hasNextNonWhitespace = i < body.length - 1 &&
|
|
898
|
+
const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1])
|
|
899
|
+
const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1])
|
|
827
900
|
|
|
828
901
|
const hasMultipleNewlines = child.content.includes('\n\n')
|
|
829
902
|
|
|
@@ -836,7 +909,7 @@ export class FormatPrinter extends Printer {
|
|
|
836
909
|
}
|
|
837
910
|
}
|
|
838
911
|
|
|
839
|
-
if (
|
|
912
|
+
if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
840
913
|
const element = body[i - 1]
|
|
841
914
|
const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2)
|
|
842
915
|
|
|
@@ -852,9 +925,46 @@ export class FormatPrinter extends Printer {
|
|
|
852
925
|
}
|
|
853
926
|
}
|
|
854
927
|
|
|
855
|
-
|
|
928
|
+
let hasTrailingHerbDisable = false
|
|
929
|
+
|
|
930
|
+
if (isNode(child, HTMLElementNode) && child.close_tag) {
|
|
931
|
+
for (let j = i + 1; j < body.length; j++) {
|
|
932
|
+
const nextChild = body[j]
|
|
933
|
+
|
|
934
|
+
if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
|
|
935
|
+
continue
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
|
|
939
|
+
hasTrailingHerbDisable = true
|
|
940
|
+
|
|
941
|
+
this.visit(child)
|
|
942
|
+
|
|
943
|
+
const herbDisableString = this.capture(() => {
|
|
944
|
+
const savedIndentLevel = this.indentLevel
|
|
945
|
+
this.indentLevel = 0
|
|
946
|
+
this.inlineMode = true
|
|
947
|
+
this.visit(nextChild)
|
|
948
|
+
this.inlineMode = false
|
|
949
|
+
this.indentLevel = savedIndentLevel
|
|
950
|
+
}).join("")
|
|
951
|
+
|
|
952
|
+
this.pushToLastLine(' ' + herbDisableString)
|
|
856
953
|
|
|
857
|
-
|
|
954
|
+
i = j
|
|
955
|
+
|
|
956
|
+
break
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
break
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (!hasTrailingHerbDisable) {
|
|
964
|
+
this.visit(child)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (isNonWhitespaceNode(child)) {
|
|
858
968
|
lastWasMeaningful = true
|
|
859
969
|
hasHandledSpacing = false
|
|
860
970
|
}
|
|
@@ -970,8 +1080,8 @@ export class FormatPrinter extends Printer {
|
|
|
970
1080
|
inner = node.children.map(child => {
|
|
971
1081
|
if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
|
|
972
1082
|
return child.content
|
|
973
|
-
} else if (isERBNode(child)
|
|
974
|
-
return
|
|
1083
|
+
} else if (isERBNode(child)) {
|
|
1084
|
+
return IdentityPrinter.print(child)
|
|
975
1085
|
} else {
|
|
976
1086
|
return ""
|
|
977
1087
|
}
|
|
@@ -1035,13 +1145,22 @@ export class FormatPrinter extends Printer {
|
|
|
1035
1145
|
const startsWithSpace = content[0] === " "
|
|
1036
1146
|
const before = startsWithSpace ? "" : " "
|
|
1037
1147
|
|
|
1038
|
-
this.
|
|
1148
|
+
if (this.inlineMode) {
|
|
1149
|
+
this.push(open + before + content.trimEnd() + ' ' + close)
|
|
1150
|
+
} else {
|
|
1151
|
+
this.pushWithIndent(open + before + content.trimEnd() + ' ' + close)
|
|
1152
|
+
}
|
|
1039
1153
|
|
|
1040
1154
|
return
|
|
1041
1155
|
}
|
|
1042
1156
|
|
|
1043
1157
|
if (contentTrimmedLines.length === 1) {
|
|
1044
|
-
this.
|
|
1158
|
+
if (this.inlineMode) {
|
|
1159
|
+
this.push(open + ' ' + content.trim() + ' ' + close)
|
|
1160
|
+
} else {
|
|
1161
|
+
this.pushWithIndent(open + ' ' + content.trim() + ' ' + close)
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1045
1164
|
return
|
|
1046
1165
|
}
|
|
1047
1166
|
|
|
@@ -1093,6 +1212,8 @@ export class FormatPrinter extends Printer {
|
|
|
1093
1212
|
|
|
1094
1213
|
visitERBCaseMatchNode(node: ERBCaseMatchNode) {
|
|
1095
1214
|
this.printERBNode(node)
|
|
1215
|
+
|
|
1216
|
+
this.withIndent(() => this.visitAll(node.children))
|
|
1096
1217
|
this.visitAll(node.conditions)
|
|
1097
1218
|
|
|
1098
1219
|
if (node.else_clause) this.visit(node.else_clause)
|
|
@@ -1101,7 +1222,16 @@ export class FormatPrinter extends Printer {
|
|
|
1101
1222
|
|
|
1102
1223
|
visitERBBlockNode(node: ERBBlockNode) {
|
|
1103
1224
|
this.printERBNode(node)
|
|
1104
|
-
|
|
1225
|
+
|
|
1226
|
+
this.withIndent(() => {
|
|
1227
|
+
const hasTextFlow = this.isInTextFlowContext(null, node.body)
|
|
1228
|
+
|
|
1229
|
+
if (hasTextFlow) {
|
|
1230
|
+
this.visitTextFlowChildren(node.body)
|
|
1231
|
+
} else {
|
|
1232
|
+
this.visitElementChildren(node.body, null)
|
|
1233
|
+
}
|
|
1234
|
+
})
|
|
1105
1235
|
|
|
1106
1236
|
if (node.end_node) this.visit(node.end_node)
|
|
1107
1237
|
}
|
|
@@ -1115,7 +1245,7 @@ export class FormatPrinter extends Printer {
|
|
|
1115
1245
|
this.lines.push(" ")
|
|
1116
1246
|
this.lines.push(this.renderAttribute(child))
|
|
1117
1247
|
} else {
|
|
1118
|
-
const shouldAddSpaces = this.isInTokenListAttribute
|
|
1248
|
+
const shouldAddSpaces = this.isInTokenListAttribute
|
|
1119
1249
|
|
|
1120
1250
|
if (shouldAddSpaces) {
|
|
1121
1251
|
this.lines.push(" ")
|
|
@@ -1130,13 +1260,13 @@ export class FormatPrinter extends Printer {
|
|
|
1130
1260
|
})
|
|
1131
1261
|
|
|
1132
1262
|
const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
|
|
1133
|
-
const isTokenList = this.isInTokenListAttribute
|
|
1263
|
+
const isTokenList = this.isInTokenListAttribute
|
|
1134
1264
|
|
|
1135
1265
|
if ((hasHTMLAttributes || isTokenList) && node.end_node) {
|
|
1136
1266
|
this.lines.push(" ")
|
|
1137
1267
|
}
|
|
1138
1268
|
|
|
1139
|
-
if (node.subsequent) this.visit(node.
|
|
1269
|
+
if (node.subsequent) this.visit(node.subsequent)
|
|
1140
1270
|
if (node.end_node) this.visit(node.end_node)
|
|
1141
1271
|
} else {
|
|
1142
1272
|
this.printERBNode(node)
|
|
@@ -1151,8 +1281,13 @@ export class FormatPrinter extends Printer {
|
|
|
1151
1281
|
}
|
|
1152
1282
|
|
|
1153
1283
|
visitERBElseNode(node: ERBElseNode) {
|
|
1154
|
-
this.
|
|
1155
|
-
|
|
1284
|
+
if (this.inlineMode) {
|
|
1285
|
+
this.printERBNode(node)
|
|
1286
|
+
node.statements.forEach(statement => this.visit(statement))
|
|
1287
|
+
} else {
|
|
1288
|
+
this.printERBNode(node)
|
|
1289
|
+
this.withIndent(() => node.statements.forEach(statement => this.visit(statement)))
|
|
1290
|
+
}
|
|
1156
1291
|
}
|
|
1157
1292
|
|
|
1158
1293
|
visitERBWhenNode(node: ERBWhenNode) {
|
|
@@ -1162,6 +1297,8 @@ export class FormatPrinter extends Printer {
|
|
|
1162
1297
|
|
|
1163
1298
|
visitERBCaseNode(node: ERBCaseNode) {
|
|
1164
1299
|
this.printERBNode(node)
|
|
1300
|
+
|
|
1301
|
+
this.withIndent(() => this.visitAll(node.children))
|
|
1165
1302
|
this.visitAll(node.conditions)
|
|
1166
1303
|
|
|
1167
1304
|
if (node.else_clause) this.visit(node.else_clause)
|
|
@@ -1242,7 +1379,7 @@ export class FormatPrinter extends Printer {
|
|
|
1242
1379
|
const attributes = filterNodes(children, HTMLAttributeNode)
|
|
1243
1380
|
const inlineNodes = this.extractInlineNodes(children)
|
|
1244
1381
|
const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node))
|
|
1245
|
-
const hasComplexERB = hasERBControlFlow &&
|
|
1382
|
+
const hasComplexERB = hasERBControlFlow && hasComplexERBControlFlow(inlineNodes)
|
|
1246
1383
|
|
|
1247
1384
|
if (hasComplexERB) return false
|
|
1248
1385
|
|
|
@@ -1275,15 +1412,30 @@ export class FormatPrinter extends Printer {
|
|
|
1275
1412
|
*/
|
|
1276
1413
|
private shouldRenderElementContentInline(node: HTMLElementNode): boolean {
|
|
1277
1414
|
const tagName = getTagName(node)
|
|
1278
|
-
const children =
|
|
1279
|
-
const isInlineElement = this.isInlineElement(tagName)
|
|
1415
|
+
const children = filterSignificantChildren(node.body)
|
|
1280
1416
|
const openTagInline = this.shouldRenderOpenTagInline(node)
|
|
1281
1417
|
|
|
1282
1418
|
if (!openTagInline) return false
|
|
1283
1419
|
if (children.length === 0) return true
|
|
1284
1420
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1421
|
+
let hasLeadingHerbDisable = false
|
|
1422
|
+
|
|
1423
|
+
for (const child of node.body) {
|
|
1424
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
1425
|
+
continue
|
|
1426
|
+
}
|
|
1427
|
+
if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
|
|
1428
|
+
hasLeadingHerbDisable = true
|
|
1429
|
+
}
|
|
1430
|
+
break
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (hasLeadingHerbDisable && !isInlineElement(tagName)) {
|
|
1434
|
+
return false
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (isInlineElement(tagName)) {
|
|
1438
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body)
|
|
1287
1439
|
|
|
1288
1440
|
if (fullInlineResult) {
|
|
1289
1441
|
const totalLength = this.indent.length + fullInlineResult.length
|
|
@@ -1293,12 +1445,12 @@ export class FormatPrinter extends Printer {
|
|
|
1293
1445
|
return false
|
|
1294
1446
|
}
|
|
1295
1447
|
|
|
1296
|
-
const allNestedAreInline =
|
|
1297
|
-
const hasMultilineText =
|
|
1298
|
-
const hasMixedContent =
|
|
1448
|
+
const allNestedAreInline = areAllNestedElementsInline(children)
|
|
1449
|
+
const hasMultilineText = hasMultilineTextContent(children)
|
|
1450
|
+
const hasMixedContent = hasMixedTextAndInlineContent(children)
|
|
1299
1451
|
|
|
1300
1452
|
if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
|
|
1301
|
-
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode),
|
|
1453
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body)
|
|
1302
1454
|
|
|
1303
1455
|
if (fullInlineResult) {
|
|
1304
1456
|
const totalLength = this.indent.length + fullInlineResult.length
|
|
@@ -1337,9 +1489,9 @@ export class FormatPrinter extends Printer {
|
|
|
1337
1489
|
private shouldRenderCloseTagInline(node: HTMLElementNode, elementContentInline: boolean): boolean {
|
|
1338
1490
|
if (node.is_void) return true
|
|
1339
1491
|
if (node.open_tag?.tag_closing?.value === "/>") return true
|
|
1340
|
-
if (
|
|
1492
|
+
if (isContentPreserving(node)) return true
|
|
1341
1493
|
|
|
1342
|
-
const children =
|
|
1494
|
+
const children = filterSignificantChildren(node.body)
|
|
1343
1495
|
|
|
1344
1496
|
if (children.length === 0) return true
|
|
1345
1497
|
|
|
@@ -1349,152 +1501,693 @@ export class FormatPrinter extends Printer {
|
|
|
1349
1501
|
|
|
1350
1502
|
// --- Utility methods ---
|
|
1351
1503
|
|
|
1352
|
-
private
|
|
1353
|
-
|
|
1354
|
-
|
|
1504
|
+
private formatFrontmatter(node: DocumentNode): Node[] {
|
|
1505
|
+
const firstChild = node.children[0]
|
|
1506
|
+
const hasFrontmatter = firstChild && isFrontmatter(firstChild)
|
|
1355
1507
|
|
|
1356
|
-
return
|
|
1508
|
+
if (!hasFrontmatter) return node.children
|
|
1509
|
+
|
|
1510
|
+
this.push(firstChild.content.trimEnd())
|
|
1511
|
+
|
|
1512
|
+
const remaining = node.children.slice(1)
|
|
1513
|
+
|
|
1514
|
+
if (remaining.length > 0) this.push("")
|
|
1515
|
+
|
|
1516
|
+
return remaining
|
|
1357
1517
|
}
|
|
1358
1518
|
|
|
1359
1519
|
/**
|
|
1360
|
-
*
|
|
1520
|
+
* Append a child node to the last output line
|
|
1361
1521
|
*/
|
|
1362
|
-
private
|
|
1363
|
-
|
|
1522
|
+
private appendChildToLastLine(child: Node, siblings?: Node[], index?: number): void {
|
|
1523
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1524
|
+
this.pushToLastLine(child.content.trim())
|
|
1525
|
+
} else {
|
|
1526
|
+
let hasSpaceBefore = false
|
|
1527
|
+
|
|
1528
|
+
if (siblings && index !== undefined && index > 0) {
|
|
1529
|
+
const prevSibling = siblings[index - 1]
|
|
1530
|
+
|
|
1531
|
+
if (isPureWhitespaceNode(prevSibling) || isNode(prevSibling, WhitespaceNode)) {
|
|
1532
|
+
hasSpaceBefore = true
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
const oldInlineMode = this.inlineMode
|
|
1537
|
+
this.inlineMode = true
|
|
1538
|
+
const inlineContent = this.capture(() => this.visit(child)).join("")
|
|
1539
|
+
this.inlineMode = oldInlineMode
|
|
1540
|
+
this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent)
|
|
1541
|
+
}
|
|
1364
1542
|
}
|
|
1365
1543
|
|
|
1366
1544
|
/**
|
|
1367
|
-
*
|
|
1545
|
+
* Visit children in a text flow context (mixed text and inline elements)
|
|
1546
|
+
* Handles word wrapping and keeps adjacent inline elements together
|
|
1368
1547
|
*/
|
|
1369
1548
|
private visitTextFlowChildren(children: Node[]) {
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
for (const child of children) {
|
|
1373
|
-
if (isNode(child, HTMLTextNode)) {
|
|
1374
|
-
const content = child.content
|
|
1549
|
+
const adjacentInlineCount = countAdjacentInlineElements(children)
|
|
1375
1550
|
|
|
1376
|
-
|
|
1551
|
+
if (adjacentInlineCount >= 2) {
|
|
1552
|
+
const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount)
|
|
1553
|
+
this.visitRemainingChildren(children, processedIndices)
|
|
1377
1554
|
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
|
|
1382
|
-
currentLineContent += ' '
|
|
1383
|
-
}
|
|
1555
|
+
return
|
|
1556
|
+
}
|
|
1384
1557
|
|
|
1385
|
-
|
|
1558
|
+
this.buildAndWrapTextFlow(children)
|
|
1559
|
+
}
|
|
1386
1560
|
|
|
1387
|
-
|
|
1561
|
+
/**
|
|
1562
|
+
* Wrap remaining words that don't fit on the current line
|
|
1563
|
+
* Returns the wrapped lines with proper indentation
|
|
1564
|
+
*/
|
|
1565
|
+
private wrapRemainingWords(words: string[], wrapWidth: number): string[] {
|
|
1566
|
+
const lines: string[] = []
|
|
1567
|
+
let line = ""
|
|
1388
1568
|
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
}
|
|
1569
|
+
for (const word of words) {
|
|
1570
|
+
const testLine = line + (line ? " " : "") + word
|
|
1392
1571
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1572
|
+
if (testLine.length > wrapWidth && line) {
|
|
1573
|
+
lines.push(this.indent + line)
|
|
1574
|
+
line = word
|
|
1575
|
+
} else {
|
|
1576
|
+
line = testLine
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1395
1579
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
} else if (isNode(child, HTMLElementNode)) {
|
|
1400
|
-
const childTagName = getTagName(child)
|
|
1580
|
+
if (line) {
|
|
1581
|
+
lines.push(this.indent + line)
|
|
1582
|
+
}
|
|
1401
1583
|
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
filterNodes(child.open_tag?.children, HTMLAttributeNode),
|
|
1405
|
-
this.filterEmptyNodes(child.body)
|
|
1406
|
-
)
|
|
1584
|
+
return lines
|
|
1585
|
+
}
|
|
1407
1586
|
|
|
1408
|
-
|
|
1409
|
-
|
|
1587
|
+
/**
|
|
1588
|
+
* Try to merge text starting with punctuation to inline content
|
|
1589
|
+
* Returns object with merged content and whether processing should stop
|
|
1590
|
+
*/
|
|
1591
|
+
private tryMergePunctuationText(inlineContent: string, trimmedText: string, wrapWidth: number): { mergedContent: string, shouldStop: boolean, wrappedLines: string[] } {
|
|
1592
|
+
const combined = inlineContent + trimmedText
|
|
1593
|
+
|
|
1594
|
+
if (combined.length <= wrapWidth) {
|
|
1595
|
+
return {
|
|
1596
|
+
mergedContent: inlineContent + trimmedText,
|
|
1597
|
+
shouldStop: false,
|
|
1598
|
+
wrappedLines: []
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1410
1601
|
|
|
1411
|
-
|
|
1412
|
-
children.forEach(child => this.visit(child))
|
|
1602
|
+
const match = trimmedText.match(/^[.!?:;]+/)
|
|
1413
1603
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1604
|
+
if (!match) {
|
|
1605
|
+
return {
|
|
1606
|
+
mergedContent: inlineContent,
|
|
1607
|
+
shouldStop: false,
|
|
1608
|
+
wrappedLines: []
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1421
1611
|
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
} else {
|
|
1425
|
-
if (currentLineContent.trim()) {
|
|
1426
|
-
this.pushWithIndent(currentLineContent.trim())
|
|
1427
|
-
currentLineContent = ""
|
|
1428
|
-
}
|
|
1612
|
+
const punctuation = match[0]
|
|
1613
|
+
const restText = trimmedText.substring(punctuation.length).trim()
|
|
1429
1614
|
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1615
|
+
if (!restText) {
|
|
1616
|
+
return {
|
|
1617
|
+
mergedContent: inlineContent + punctuation,
|
|
1618
|
+
shouldStop: false,
|
|
1619
|
+
wrappedLines: []
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1435
1622
|
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
this.inlineMode = true
|
|
1440
|
-
this.visit(child)
|
|
1441
|
-
const erbContent = this.lines.join("")
|
|
1442
|
-
currentLineContent += erbContent
|
|
1623
|
+
const words = restText.split(/\s+/)
|
|
1624
|
+
let toMerge = punctuation
|
|
1625
|
+
let mergedWordCount = 0
|
|
1443
1626
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
this.inlineMode = oldInlineMode
|
|
1447
|
-
children.forEach(child => this.visit(child))
|
|
1627
|
+
for (const word of words) {
|
|
1628
|
+
const testMerge = toMerge + ' ' + word
|
|
1448
1629
|
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
this.lines = oldLines
|
|
1453
|
-
this.inlineMode = oldInlineMode
|
|
1454
|
-
}
|
|
1630
|
+
if ((inlineContent + testMerge).length <= wrapWidth) {
|
|
1631
|
+
toMerge = testMerge
|
|
1632
|
+
mergedWordCount++
|
|
1455
1633
|
} else {
|
|
1456
|
-
|
|
1457
|
-
this.pushWithIndent(currentLineContent.trim())
|
|
1458
|
-
currentLineContent = ""
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
this.visit(child)
|
|
1634
|
+
break
|
|
1462
1635
|
}
|
|
1463
1636
|
}
|
|
1464
1637
|
|
|
1465
|
-
|
|
1466
|
-
const finalLine = this.indent + currentLineContent.trim()
|
|
1467
|
-
|
|
1468
|
-
if (finalLine.length > Math.max(this.maxLineLength, 120)) {
|
|
1469
|
-
this.visitAll(children)
|
|
1638
|
+
const mergedContent = inlineContent + toMerge
|
|
1470
1639
|
|
|
1471
|
-
|
|
1640
|
+
if (mergedWordCount >= words.length) {
|
|
1641
|
+
return {
|
|
1642
|
+
mergedContent,
|
|
1643
|
+
shouldStop: false,
|
|
1644
|
+
wrappedLines: []
|
|
1472
1645
|
}
|
|
1646
|
+
}
|
|
1473
1647
|
|
|
1474
|
-
|
|
1648
|
+
const remainingWords = words.slice(mergedWordCount)
|
|
1649
|
+
const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth)
|
|
1650
|
+
|
|
1651
|
+
return {
|
|
1652
|
+
mergedContent,
|
|
1653
|
+
shouldStop: true,
|
|
1654
|
+
wrappedLines
|
|
1475
1655
|
}
|
|
1476
1656
|
}
|
|
1477
1657
|
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1658
|
+
/**
|
|
1659
|
+
* Render adjacent inline elements together on one line
|
|
1660
|
+
*/
|
|
1661
|
+
private renderAdjacentInlineElements(children: Node[], count: number): { processedIndices: Set<number> } {
|
|
1662
|
+
let inlineContent = ""
|
|
1663
|
+
let processedCount = 0
|
|
1664
|
+
let lastProcessedIndex = -1
|
|
1665
|
+
const processedIndices = new Set<number>()
|
|
1484
1666
|
|
|
1485
|
-
|
|
1486
|
-
|
|
1667
|
+
for (let index = 0; index < children.length && processedCount < count; index++) {
|
|
1668
|
+
const child = children[index]
|
|
1487
1669
|
|
|
1488
|
-
if (isNode(child,
|
|
1489
|
-
|
|
1670
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
1671
|
+
continue
|
|
1490
1672
|
}
|
|
1491
1673
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1674
|
+
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
|
|
1675
|
+
inlineContent += this.renderInlineElementAsString(child)
|
|
1676
|
+
processedCount++
|
|
1677
|
+
lastProcessedIndex = index
|
|
1678
|
+
processedIndices.add(index)
|
|
1496
1679
|
|
|
1497
|
-
|
|
1680
|
+
if (inlineContent && isLineBreakingElement(child)) {
|
|
1681
|
+
this.pushWithIndent(inlineContent)
|
|
1682
|
+
inlineContent = ""
|
|
1683
|
+
}
|
|
1684
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
1685
|
+
inlineContent += this.renderERBAsString(child)
|
|
1686
|
+
processedCount++
|
|
1687
|
+
lastProcessedIndex = index
|
|
1688
|
+
processedIndices.add(index)
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
if (lastProcessedIndex >= 0) {
|
|
1693
|
+
for (let index = lastProcessedIndex + 1; index < children.length; index++) {
|
|
1694
|
+
const child = children[index]
|
|
1695
|
+
|
|
1696
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
1697
|
+
continue
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
if (isNode(child, ERBContentNode)) {
|
|
1701
|
+
inlineContent += this.renderERBAsString(child)
|
|
1702
|
+
processedIndices.add(index)
|
|
1703
|
+
continue
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1707
|
+
const trimmed = child.content.trim()
|
|
1708
|
+
|
|
1709
|
+
if (trimmed && /^[.!?:;]/.test(trimmed)) {
|
|
1710
|
+
const wrapWidth = this.maxLineLength - this.indent.length
|
|
1711
|
+
const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth)
|
|
1712
|
+
|
|
1713
|
+
inlineContent = result.mergedContent
|
|
1714
|
+
processedIndices.add(index)
|
|
1715
|
+
|
|
1716
|
+
if (result.shouldStop) {
|
|
1717
|
+
if (inlineContent) {
|
|
1718
|
+
this.pushWithIndent(inlineContent)
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
result.wrappedLines.forEach(line => this.push(line))
|
|
1722
|
+
|
|
1723
|
+
return { processedIndices }
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
break
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if (inlineContent) {
|
|
1733
|
+
this.pushWithIndent(inlineContent)
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
return { processedIndices }
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Render an inline element as a string
|
|
1741
|
+
*/
|
|
1742
|
+
private renderInlineElementAsString(element: HTMLElementNode): string {
|
|
1743
|
+
const tagName = getTagName(element)
|
|
1744
|
+
|
|
1745
|
+
if (element.is_void || element.open_tag?.tag_closing?.value === "/>") {
|
|
1746
|
+
const attributes = filterNodes(element.open_tag?.children, HTMLAttributeNode)
|
|
1747
|
+
const attributesString = this.renderAttributesString(attributes)
|
|
1748
|
+
const isSelfClosing = element.open_tag?.tag_closing?.value === "/>"
|
|
1749
|
+
|
|
1750
|
+
return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const childrenToRender = this.getFilteredChildren(element.body)
|
|
1754
|
+
|
|
1755
|
+
const childInline = this.tryRenderInlineFull(element, tagName,
|
|
1756
|
+
filterNodes(element.open_tag?.children, HTMLAttributeNode),
|
|
1757
|
+
childrenToRender
|
|
1758
|
+
)
|
|
1759
|
+
|
|
1760
|
+
return childInline !== null ? childInline : ""
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
/**
|
|
1764
|
+
* Render an ERB node as a string
|
|
1765
|
+
*/
|
|
1766
|
+
private renderERBAsString(node: ERBContentNode): string {
|
|
1767
|
+
return this.capture(() => {
|
|
1768
|
+
this.inlineMode = true
|
|
1769
|
+
this.visit(node)
|
|
1770
|
+
}).join("")
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* Visit remaining children after processing adjacent inline elements
|
|
1775
|
+
*/
|
|
1776
|
+
private visitRemainingChildren(children: Node[], processedIndices: Set<number>): void {
|
|
1777
|
+
for (let index = 0; index < children.length; index++) {
|
|
1778
|
+
const child = children[index]
|
|
1779
|
+
|
|
1780
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
1781
|
+
continue
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
if (processedIndices.has(index)) {
|
|
1785
|
+
continue
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
this.visit(child)
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
/**
|
|
1793
|
+
* Build words array from text/inline/ERB and wrap them
|
|
1794
|
+
*/
|
|
1795
|
+
private buildAndWrapTextFlow(children: Node[]): void {
|
|
1796
|
+
const unitsWithNodes: ContentUnitWithNode[] = this.buildContentUnitsWithNodes(children)
|
|
1797
|
+
const words: Array<{ word: string, isHerbDisable: boolean }> = []
|
|
1798
|
+
|
|
1799
|
+
for (const { unit, node } of unitsWithNodes) {
|
|
1800
|
+
if (unit.breaksFlow) {
|
|
1801
|
+
this.flushWords(words)
|
|
1802
|
+
|
|
1803
|
+
if (node) {
|
|
1804
|
+
this.visit(node)
|
|
1805
|
+
}
|
|
1806
|
+
} else if (unit.isAtomic) {
|
|
1807
|
+
words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false })
|
|
1808
|
+
} else {
|
|
1809
|
+
const text = unit.content.replace(/\s+/g, ' ')
|
|
1810
|
+
const hasLeadingSpace = text.startsWith(' ')
|
|
1811
|
+
const hasTrailingSpace = text.endsWith(' ')
|
|
1812
|
+
const trimmedText = text.trim()
|
|
1813
|
+
|
|
1814
|
+
if (trimmedText) {
|
|
1815
|
+
if (hasLeadingSpace && words.length > 0) {
|
|
1816
|
+
const lastWord = words[words.length - 1]
|
|
1817
|
+
|
|
1818
|
+
if (!lastWord.word.endsWith(' ')) {
|
|
1819
|
+
lastWord.word += ' '
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false }))
|
|
1824
|
+
words.push(...textWords)
|
|
1825
|
+
|
|
1826
|
+
if (hasTrailingSpace && words.length > 0) {
|
|
1827
|
+
const lastWord = words[words.length - 1]
|
|
1828
|
+
|
|
1829
|
+
if (!isClosingPunctuation(lastWord.word)) {
|
|
1830
|
+
lastWord.word += ' '
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
} else if (text === ' ' && words.length > 0) {
|
|
1834
|
+
const lastWord = words[words.length - 1]
|
|
1835
|
+
|
|
1836
|
+
if (!lastWord.word.endsWith(' ')) {
|
|
1837
|
+
lastWord.word += ' '
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
this.flushWords(words)
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Try to merge text that follows an atomic unit (ERB/inline) with no whitespace
|
|
1848
|
+
* Returns true if merge was performed
|
|
1849
|
+
*/
|
|
1850
|
+
private tryMergeTextAfterAtomic(result: ContentUnitWithNode[], textNode: HTMLTextNode): boolean {
|
|
1851
|
+
if (result.length === 0) return false
|
|
1852
|
+
|
|
1853
|
+
const lastUnit = result[result.length - 1]
|
|
1854
|
+
|
|
1855
|
+
if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) {
|
|
1856
|
+
return false
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const words = normalizeAndSplitWords(textNode.content)
|
|
1860
|
+
if (words.length === 0 || !words[0]) return false
|
|
1861
|
+
|
|
1862
|
+
const firstWord = words[0]
|
|
1863
|
+
const firstChar = firstWord[0]
|
|
1864
|
+
|
|
1865
|
+
if (/\s/.test(firstChar)) {
|
|
1866
|
+
return false
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
lastUnit.unit.content += firstWord
|
|
1870
|
+
|
|
1871
|
+
if (words.length > 1) {
|
|
1872
|
+
let remainingText = words.slice(1).join(' ')
|
|
1873
|
+
|
|
1874
|
+
if (endsWithWhitespace(textNode.content)) {
|
|
1875
|
+
remainingText += ' '
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
result.push({
|
|
1879
|
+
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
|
|
1880
|
+
node: textNode
|
|
1881
|
+
})
|
|
1882
|
+
} else if (endsWithWhitespace(textNode.content)) {
|
|
1883
|
+
result.push({
|
|
1884
|
+
unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false },
|
|
1885
|
+
node: textNode
|
|
1886
|
+
})
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
return true
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/**
|
|
1893
|
+
* Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
|
|
1894
|
+
* Returns true if merge was performed
|
|
1895
|
+
*/
|
|
1896
|
+
private tryMergeAtomicAfterText(result: ContentUnitWithNode[], children: Node[], lastProcessedIndex: number, atomicContent: string, atomicType: 'erb' | 'inline', atomicNode: Node): boolean {
|
|
1897
|
+
if (result.length === 0) return false
|
|
1898
|
+
|
|
1899
|
+
const lastUnit = result[result.length - 1]
|
|
1900
|
+
|
|
1901
|
+
if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic) return false
|
|
1902
|
+
|
|
1903
|
+
const words = normalizeAndSplitWords(lastUnit.unit.content)
|
|
1904
|
+
const lastWord = words[words.length - 1]
|
|
1905
|
+
|
|
1906
|
+
if (!lastWord) return false
|
|
1907
|
+
|
|
1908
|
+
result.pop()
|
|
1909
|
+
|
|
1910
|
+
if (words.length > 1) {
|
|
1911
|
+
const remainingText = words.slice(0, -1).join(' ')
|
|
1912
|
+
|
|
1913
|
+
result.push({
|
|
1914
|
+
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
|
|
1915
|
+
node: children[lastProcessedIndex]
|
|
1916
|
+
})
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
result.push({
|
|
1920
|
+
unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
|
|
1921
|
+
node: atomicNode
|
|
1922
|
+
})
|
|
1923
|
+
|
|
1924
|
+
return true
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Check if there's whitespace between current node and last processed node
|
|
1929
|
+
*/
|
|
1930
|
+
private hasWhitespaceBeforeNode(children: Node[], lastProcessedIndex: number, currentIndex: number, currentNode: Node): boolean {
|
|
1931
|
+
if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
|
|
1932
|
+
return true
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
if (isNode(currentNode, HTMLTextNode) && /^\s/.test(currentNode.content)) {
|
|
1936
|
+
return true
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
return false
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Check if last unit in result ends with whitespace
|
|
1944
|
+
*/
|
|
1945
|
+
private lastUnitEndsWithWhitespace(result: ContentUnitWithNode[]): boolean {
|
|
1946
|
+
if (result.length === 0) return false
|
|
1947
|
+
|
|
1948
|
+
const lastUnit = result[result.length - 1]
|
|
1949
|
+
|
|
1950
|
+
return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content)
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
/**
|
|
1954
|
+
* Process a text node and add it to results (with potential merging)
|
|
1955
|
+
*/
|
|
1956
|
+
private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void {
|
|
1957
|
+
const isAtomic = child.content === ' '
|
|
1958
|
+
|
|
1959
|
+
if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
|
|
1960
|
+
const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child)
|
|
1961
|
+
const lastUnit = result[result.length - 1]
|
|
1962
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline')
|
|
1963
|
+
|
|
1964
|
+
if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) {
|
|
1965
|
+
return
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
result.push({
|
|
1970
|
+
unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
|
|
1971
|
+
node: child
|
|
1972
|
+
})
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/**
|
|
1976
|
+
* Process an inline element and add it to results (with potential merging)
|
|
1977
|
+
*/
|
|
1978
|
+
private processInlineElement(result: ContentUnitWithNode[], children: Node[], child: HTMLElementNode, index: number, lastProcessedIndex: number): boolean {
|
|
1979
|
+
const tagName = getTagName(child)
|
|
1980
|
+
const childrenToRender = this.getFilteredChildren(child.body)
|
|
1981
|
+
const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender)
|
|
1982
|
+
|
|
1983
|
+
if (inlineContent === null) {
|
|
1984
|
+
result.push({
|
|
1985
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
1986
|
+
node: child
|
|
1987
|
+
})
|
|
1988
|
+
|
|
1989
|
+
return false
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
if (lastProcessedIndex >= 0) {
|
|
1993
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
|
|
1994
|
+
|
|
1995
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
|
|
1996
|
+
return true
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
result.push({
|
|
2001
|
+
unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
|
|
2002
|
+
node: child
|
|
2003
|
+
})
|
|
2004
|
+
|
|
2005
|
+
return false
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Process an ERB content node and add it to results (with potential merging)
|
|
2010
|
+
*/
|
|
2011
|
+
private processERBContentNode(result: ContentUnitWithNode[], children: Node[], child: ERBContentNode, index: number, lastProcessedIndex: number): boolean {
|
|
2012
|
+
const erbContent = this.renderERBAsString(child)
|
|
2013
|
+
const isHerbDisable = isHerbDisableComment(child)
|
|
2014
|
+
|
|
2015
|
+
if (lastProcessedIndex >= 0) {
|
|
2016
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
|
|
2017
|
+
|
|
2018
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
|
|
2019
|
+
return true
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
if (hasWhitespace && result.length > 0) {
|
|
2023
|
+
const lastUnit = result[result.length - 1]
|
|
2024
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb')
|
|
2025
|
+
|
|
2026
|
+
if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
|
|
2027
|
+
result.push({
|
|
2028
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
2029
|
+
node: null
|
|
2030
|
+
})
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
result.push({
|
|
2036
|
+
unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
|
|
2037
|
+
node: child
|
|
2038
|
+
})
|
|
2039
|
+
|
|
2040
|
+
return false
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* Convert AST nodes to content units with node references
|
|
2045
|
+
*/
|
|
2046
|
+
private buildContentUnitsWithNodes(children: Node[]): ContentUnitWithNode[] {
|
|
2047
|
+
const result: ContentUnitWithNode[] = []
|
|
2048
|
+
let lastProcessedIndex = -1
|
|
2049
|
+
|
|
2050
|
+
for (let i = 0; i < children.length; i++) {
|
|
2051
|
+
const child = children[i]
|
|
2052
|
+
|
|
2053
|
+
if (isNode(child, WhitespaceNode)) continue
|
|
2054
|
+
|
|
2055
|
+
if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
|
|
2056
|
+
if (lastProcessedIndex >= 0) {
|
|
2057
|
+
const hasNonWhitespaceAfter = children.slice(i + 1).some(node =>
|
|
2058
|
+
!isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node)
|
|
2059
|
+
)
|
|
2060
|
+
|
|
2061
|
+
if (hasNonWhitespaceAfter) {
|
|
2062
|
+
const previousNode = children[lastProcessedIndex]
|
|
2063
|
+
|
|
2064
|
+
if (!isLineBreakingElement(previousNode)) {
|
|
2065
|
+
result.push({
|
|
2066
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
2067
|
+
node: child
|
|
2068
|
+
})
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
continue
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
if (isNode(child, HTMLTextNode)) {
|
|
2077
|
+
this.processTextNode(result, children, child, i, lastProcessedIndex)
|
|
2078
|
+
|
|
2079
|
+
lastProcessedIndex = i
|
|
2080
|
+
} else if (isNode(child, HTMLElementNode)) {
|
|
2081
|
+
const tagName = getTagName(child)
|
|
2082
|
+
|
|
2083
|
+
if (isInlineElement(tagName)) {
|
|
2084
|
+
const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex)
|
|
2085
|
+
|
|
2086
|
+
if (merged) {
|
|
2087
|
+
lastProcessedIndex = i
|
|
2088
|
+
|
|
2089
|
+
continue
|
|
2090
|
+
}
|
|
2091
|
+
} else {
|
|
2092
|
+
result.push({
|
|
2093
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
2094
|
+
node: child
|
|
2095
|
+
})
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
lastProcessedIndex = i
|
|
2099
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
2100
|
+
const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex)
|
|
2101
|
+
|
|
2102
|
+
if (merged) {
|
|
2103
|
+
lastProcessedIndex = i
|
|
2104
|
+
|
|
2105
|
+
continue
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
lastProcessedIndex = i
|
|
2109
|
+
} else {
|
|
2110
|
+
result.push({
|
|
2111
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
2112
|
+
node: child
|
|
2113
|
+
})
|
|
2114
|
+
|
|
2115
|
+
lastProcessedIndex = i
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
return result
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* Flush accumulated words to output with wrapping
|
|
2124
|
+
*/
|
|
2125
|
+
private flushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
|
|
2126
|
+
if (words.length > 0) {
|
|
2127
|
+
this.wrapAndPushWords(words)
|
|
2128
|
+
words.length = 0
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* Wrap words to fit within line length and push to output
|
|
2134
|
+
* Handles punctuation spacing intelligently
|
|
2135
|
+
* Excludes herb:disable comments from line length calculations
|
|
2136
|
+
*/
|
|
2137
|
+
private wrapAndPushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
|
|
2138
|
+
const wrapWidth = this.maxLineLength - this.indent.length
|
|
2139
|
+
const lines: string[] = []
|
|
2140
|
+
let currentLine = ""
|
|
2141
|
+
let effectiveLength = 0
|
|
2142
|
+
|
|
2143
|
+
for (const { word, isHerbDisable } of words) {
|
|
2144
|
+
const nextLine = buildLineWithWord(currentLine, word)
|
|
2145
|
+
|
|
2146
|
+
let nextEffectiveLength = effectiveLength
|
|
2147
|
+
|
|
2148
|
+
if (!isHerbDisable) {
|
|
2149
|
+
const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0
|
|
2150
|
+
nextEffectiveLength = effectiveLength + spaceBefore + word.length
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
|
|
2154
|
+
lines.push(this.indent + currentLine.trimEnd())
|
|
2155
|
+
|
|
2156
|
+
currentLine = word
|
|
2157
|
+
effectiveLength = isHerbDisable ? 0 : word.length
|
|
2158
|
+
} else {
|
|
2159
|
+
currentLine = nextLine
|
|
2160
|
+
effectiveLength = nextEffectiveLength
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (currentLine) {
|
|
2165
|
+
lines.push(this.indent + currentLine.trimEnd())
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
lines.forEach(line => this.push(line))
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean {
|
|
2172
|
+
const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "")
|
|
2173
|
+
const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode))
|
|
2174
|
+
|
|
2175
|
+
if (!hasTextContent) return false
|
|
2176
|
+
if (nonTextChildren.length === 0) return false
|
|
2177
|
+
|
|
2178
|
+
const allInline = nonTextChildren.every(child => {
|
|
2179
|
+
if (isNode(child, ERBContentNode)) return true
|
|
2180
|
+
|
|
2181
|
+
if (isNode(child, HTMLElementNode)) {
|
|
2182
|
+
return isInlineElement(getTagName(child))
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
return false
|
|
2186
|
+
})
|
|
2187
|
+
|
|
2188
|
+
if (!allInline) return false
|
|
2189
|
+
|
|
2190
|
+
return true
|
|
1498
2191
|
}
|
|
1499
2192
|
|
|
1500
2193
|
private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
|
|
@@ -1576,7 +2269,7 @@ export class FormatPrinter extends Printer {
|
|
|
1576
2269
|
} else {
|
|
1577
2270
|
const printed = IdentityPrinter.print(child)
|
|
1578
2271
|
|
|
1579
|
-
if (this.
|
|
2272
|
+
if (this.isInTokenListAttribute) {
|
|
1580
2273
|
return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%')
|
|
1581
2274
|
}
|
|
1582
2275
|
|
|
@@ -1617,7 +2310,7 @@ export class FormatPrinter extends Printer {
|
|
|
1617
2310
|
result += this.renderAttributesString(attributes)
|
|
1618
2311
|
result += ">"
|
|
1619
2312
|
|
|
1620
|
-
const childrenContent = this.tryRenderChildrenInline(children)
|
|
2313
|
+
const childrenContent = this.tryRenderChildrenInline(children, tagName)
|
|
1621
2314
|
|
|
1622
2315
|
if (!childrenContent) return null
|
|
1623
2316
|
|
|
@@ -1627,11 +2320,32 @@ export class FormatPrinter extends Printer {
|
|
|
1627
2320
|
return result
|
|
1628
2321
|
}
|
|
1629
2322
|
|
|
2323
|
+
/**
|
|
2324
|
+
* Check if children contain a leading herb:disable comment (after optional whitespace)
|
|
2325
|
+
*/
|
|
2326
|
+
private hasLeadingHerbDisable(children: Node[]): boolean {
|
|
2327
|
+
for (const child of children) {
|
|
2328
|
+
if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
|
|
2329
|
+
continue
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
return isNode(child, ERBContentNode) && isHerbDisableComment(child)
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
return false
|
|
2336
|
+
}
|
|
2337
|
+
|
|
1630
2338
|
/**
|
|
1631
2339
|
* Try to render just the children inline (without tags)
|
|
1632
2340
|
*/
|
|
1633
|
-
private tryRenderChildrenInline(children: Node[]): string | null {
|
|
2341
|
+
private tryRenderChildrenInline(children: Node[], tagName?: string): string | null {
|
|
1634
2342
|
let result = ""
|
|
2343
|
+
let hasInternalWhitespace = false
|
|
2344
|
+
let addedLeadingSpace = false
|
|
2345
|
+
|
|
2346
|
+
const hasHerbDisable = this.hasLeadingHerbDisable(children)
|
|
2347
|
+
const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
|
|
2348
|
+
const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName)
|
|
1635
2349
|
|
|
1636
2350
|
for (const child of children) {
|
|
1637
2351
|
if (isNode(child, HTMLTextNode)) {
|
|
@@ -1641,33 +2355,41 @@ export class FormatPrinter extends Printer {
|
|
|
1641
2355
|
const trimmedContent = normalizedContent.trim()
|
|
1642
2356
|
|
|
1643
2357
|
if (trimmedContent) {
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
if (hasLeadingSpace && result && !result.endsWith(' ')) {
|
|
1647
|
-
finalContent = ' ' + finalContent
|
|
2358
|
+
if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
|
|
2359
|
+
result += ' '
|
|
1648
2360
|
}
|
|
1649
2361
|
|
|
1650
|
-
|
|
1651
|
-
finalContent = finalContent + ' '
|
|
1652
|
-
}
|
|
2362
|
+
result += trimmedContent
|
|
1653
2363
|
|
|
1654
|
-
|
|
1655
|
-
} else if (hasLeadingSpace || hasTrailingSpace) {
|
|
1656
|
-
if (result && !result.endsWith(' ')) {
|
|
2364
|
+
if (hasTrailingSpace) {
|
|
1657
2365
|
result += ' '
|
|
1658
2366
|
}
|
|
2367
|
+
|
|
2368
|
+
continue
|
|
1659
2369
|
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")
|
|
1660
2373
|
|
|
2374
|
+
if (isWhitespace && !result.endsWith(' ')) {
|
|
2375
|
+
if (!result && hasHerbDisable && !addedLeadingSpace) {
|
|
2376
|
+
result += ' '
|
|
2377
|
+
addedLeadingSpace = true
|
|
2378
|
+
} else if (result) {
|
|
2379
|
+
result += ' '
|
|
2380
|
+
hasInternalWhitespace = true
|
|
2381
|
+
}
|
|
1661
2382
|
} else if (isNode(child, HTMLElementNode)) {
|
|
1662
2383
|
const tagName = getTagName(child)
|
|
1663
2384
|
|
|
1664
|
-
if (!
|
|
2385
|
+
if (!isInlineElement(tagName)) {
|
|
1665
2386
|
return null
|
|
1666
2387
|
}
|
|
1667
2388
|
|
|
2389
|
+
const childrenToRender = this.getFilteredChildren(child.body)
|
|
1668
2390
|
const childInline = this.tryRenderInlineFull(child, tagName,
|
|
1669
2391
|
filterNodes(child.open_tag?.children, HTMLAttributeNode),
|
|
1670
|
-
|
|
2392
|
+
childrenToRender
|
|
1671
2393
|
)
|
|
1672
2394
|
|
|
1673
2395
|
if (!childInline) {
|
|
@@ -1675,11 +2397,23 @@ export class FormatPrinter extends Printer {
|
|
|
1675
2397
|
}
|
|
1676
2398
|
|
|
1677
2399
|
result += childInline
|
|
1678
|
-
} else {
|
|
1679
|
-
|
|
2400
|
+
} else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
|
|
2401
|
+
const wasInlineMode = this.inlineMode
|
|
2402
|
+
this.inlineMode = true
|
|
2403
|
+
const captured = this.capture(() => this.visit(child)).join("")
|
|
2404
|
+
this.inlineMode = wasInlineMode
|
|
2405
|
+
result += captured
|
|
1680
2406
|
}
|
|
1681
2407
|
}
|
|
1682
2408
|
|
|
2409
|
+
if (shouldPreserveSpaces) {
|
|
2410
|
+
return result
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
|
|
2414
|
+
return result.trimEnd()
|
|
2415
|
+
}
|
|
2416
|
+
|
|
1683
2417
|
return result.trim()
|
|
1684
2418
|
}
|
|
1685
2419
|
|
|
@@ -1694,9 +2428,7 @@ export class FormatPrinter extends Printer {
|
|
|
1694
2428
|
return null
|
|
1695
2429
|
}
|
|
1696
2430
|
} else if (isNode(child, HTMLElementNode)) {
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
if (!isInlineElement) {
|
|
2431
|
+
if (!isInlineElement(getTagName(child))) {
|
|
1700
2432
|
return null
|
|
1701
2433
|
}
|
|
1702
2434
|
} else if (isNode(child, ERBContentNode)) {
|
|
@@ -1716,121 +2448,18 @@ export class FormatPrinter extends Printer {
|
|
|
1716
2448
|
}
|
|
1717
2449
|
|
|
1718
2450
|
/**
|
|
1719
|
-
*
|
|
1720
|
-
* or mixed ERB output and text (like "<%= value %> text")
|
|
1721
|
-
* This indicates content that should be formatted inline even with structural newlines
|
|
1722
|
-
*/
|
|
1723
|
-
private hasMixedTextAndInlineContent(children: Node[]): boolean {
|
|
1724
|
-
let hasText = false
|
|
1725
|
-
let hasInlineElements = false
|
|
1726
|
-
|
|
1727
|
-
for (const child of children) {
|
|
1728
|
-
if (isNode(child, HTMLTextNode)) {
|
|
1729
|
-
if (child.content.trim() !== "") {
|
|
1730
|
-
hasText = true
|
|
1731
|
-
}
|
|
1732
|
-
} else if (isNode(child, HTMLElementNode)) {
|
|
1733
|
-
if (this.isInlineElement(getTagName(child))) {
|
|
1734
|
-
hasInlineElements = true
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText)
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
/**
|
|
1743
|
-
* Check if children contain any text content with newlines
|
|
1744
|
-
*/
|
|
1745
|
-
private hasMultilineTextContent(children: Node[]): boolean {
|
|
1746
|
-
for (const child of children) {
|
|
1747
|
-
if (isNode(child, HTMLTextNode)) {
|
|
1748
|
-
return child.content.includes('\n')
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
if (isNode(child, HTMLElementNode)) {
|
|
1752
|
-
const nestedChildren = this.filterEmptyNodes(child.body)
|
|
1753
|
-
|
|
1754
|
-
if (this.hasMultilineTextContent(nestedChildren)) {
|
|
1755
|
-
return true
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
|
-
return false
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
/**
|
|
1764
|
-
* Check if all nested elements in the children are inline elements
|
|
1765
|
-
*/
|
|
1766
|
-
private areAllNestedElementsInline(children: Node[]): boolean {
|
|
1767
|
-
for (const child of children) {
|
|
1768
|
-
if (isNode(child, HTMLElementNode)) {
|
|
1769
|
-
if (!this.isInlineElement(getTagName(child))) {
|
|
1770
|
-
return false
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
const nestedChildren = this.filterEmptyNodes(child.body)
|
|
1774
|
-
|
|
1775
|
-
if (!this.areAllNestedElementsInline(nestedChildren)) {
|
|
1776
|
-
return false
|
|
1777
|
-
}
|
|
1778
|
-
} else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
|
|
1779
|
-
return false
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
return true
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/**
|
|
1787
|
-
* Check if element has complex ERB control flow
|
|
1788
|
-
*/
|
|
1789
|
-
private hasComplexERBControlFlow(inlineNodes: Node[]): boolean {
|
|
1790
|
-
return inlineNodes.some(node => {
|
|
1791
|
-
if (isNode(node, ERBIfNode)) {
|
|
1792
|
-
if (node.statements.length > 0 && node.location) {
|
|
1793
|
-
const startLine = node.location.start.line
|
|
1794
|
-
const endLine = node.location.end.line
|
|
1795
|
-
|
|
1796
|
-
return startLine !== endLine
|
|
1797
|
-
}
|
|
1798
|
-
|
|
1799
|
-
return false
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
return false
|
|
1803
|
-
})
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
/**
|
|
1807
|
-
* Filter children to remove insignificant whitespace
|
|
2451
|
+
* Get filtered children, using smart herb:disable filtering if needed
|
|
1808
2452
|
*/
|
|
1809
|
-
private
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
if (isNode(child, HTMLTextNode)) {
|
|
1814
|
-
if (hasTextFlow && child.content === " ") return true
|
|
1815
|
-
|
|
1816
|
-
return child.content.trim() !== ""
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
return true
|
|
1820
|
-
})
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
/**
|
|
1824
|
-
* Filter out empty text nodes and whitespace nodes
|
|
1825
|
-
*/
|
|
1826
|
-
private filterEmptyNodes(nodes: Node[]): Node[] {
|
|
1827
|
-
return nodes.filter(child =>
|
|
1828
|
-
!isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === "")
|
|
2453
|
+
private getFilteredChildren(body: Node[]): Node[] {
|
|
2454
|
+
const hasHerbDisable = body.some(child =>
|
|
2455
|
+
isNode(child, ERBContentNode) && isHerbDisableComment(child)
|
|
1829
2456
|
)
|
|
2457
|
+
|
|
2458
|
+
return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body
|
|
1830
2459
|
}
|
|
1831
2460
|
|
|
1832
2461
|
private renderElementInline(element: HTMLElementNode): string {
|
|
1833
|
-
const children = this.
|
|
2462
|
+
const children = this.getFilteredChildren(element.body)
|
|
1834
2463
|
|
|
1835
2464
|
return this.renderChildrenInline(children)
|
|
1836
2465
|
}
|
|
@@ -1855,10 +2484,4 @@ export class FormatPrinter extends Printer {
|
|
|
1855
2484
|
|
|
1856
2485
|
return content.replace(/\s+/g, ' ').trim()
|
|
1857
2486
|
}
|
|
1858
|
-
|
|
1859
|
-
private isContentPreserving(element: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): boolean {
|
|
1860
|
-
const tagName = getTagName(element)
|
|
1861
|
-
|
|
1862
|
-
return FormatPrinter.CONTENT_PRESERVING_ELEMENTS.has(tagName)
|
|
1863
|
-
}
|
|
1864
2487
|
}
|