@herb-tools/formatter 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -11
- package/dist/herb-format.js +23209 -2215
- package/dist/herb-format.js.map +1 -1
- package/dist/index.cjs +1457 -330
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +1457 -330
- 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 +357 -111
- package/src/format-helpers.ts +508 -0
- package/src/format-printer.ts +1010 -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,690 @@ 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 (!/[a-zA-Z0-9.!?:;]/.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
|
+
}
|
|
1883
|
+
|
|
1884
|
+
return true
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/**
|
|
1888
|
+
* Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
|
|
1889
|
+
* Returns true if merge was performed
|
|
1890
|
+
*/
|
|
1891
|
+
private tryMergeAtomicAfterText(result: ContentUnitWithNode[], children: Node[], lastProcessedIndex: number, atomicContent: string, atomicType: 'erb' | 'inline', atomicNode: Node): boolean {
|
|
1892
|
+
if (result.length === 0) return false
|
|
1893
|
+
|
|
1894
|
+
const lastUnit = result[result.length - 1]
|
|
1895
|
+
|
|
1896
|
+
if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic) return false
|
|
1897
|
+
|
|
1898
|
+
const words = normalizeAndSplitWords(lastUnit.unit.content)
|
|
1899
|
+
const lastWord = words[words.length - 1]
|
|
1900
|
+
|
|
1901
|
+
if (!lastWord) return false
|
|
1902
|
+
|
|
1903
|
+
result.pop()
|
|
1904
|
+
|
|
1905
|
+
if (words.length > 1) {
|
|
1906
|
+
const remainingText = words.slice(0, -1).join(' ')
|
|
1907
|
+
|
|
1908
|
+
result.push({
|
|
1909
|
+
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
|
|
1910
|
+
node: children[lastProcessedIndex]
|
|
1911
|
+
})
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
result.push({
|
|
1915
|
+
unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
|
|
1916
|
+
node: atomicNode
|
|
1917
|
+
})
|
|
1918
|
+
|
|
1919
|
+
return true
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Check if there's whitespace between current node and last processed node
|
|
1924
|
+
*/
|
|
1925
|
+
private hasWhitespaceBeforeNode(children: Node[], lastProcessedIndex: number, currentIndex: number, currentNode: Node): boolean {
|
|
1926
|
+
if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
|
|
1927
|
+
return true
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
if (isNode(currentNode, HTMLTextNode) && /^\s/.test(currentNode.content)) {
|
|
1931
|
+
return true
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
return false
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Check if last unit in result ends with whitespace
|
|
1939
|
+
*/
|
|
1940
|
+
private lastUnitEndsWithWhitespace(result: ContentUnitWithNode[]): boolean {
|
|
1941
|
+
if (result.length === 0) return false
|
|
1942
|
+
|
|
1943
|
+
const lastUnit = result[result.length - 1]
|
|
1944
|
+
|
|
1945
|
+
return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content)
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* Process a text node and add it to results (with potential merging)
|
|
1950
|
+
*/
|
|
1951
|
+
private processTextNode(result: ContentUnitWithNode[], children: Node[], child: HTMLTextNode, index: number, lastProcessedIndex: number): void {
|
|
1952
|
+
const isAtomic = child.content === ' '
|
|
1953
|
+
|
|
1954
|
+
if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
|
|
1955
|
+
const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child)
|
|
1956
|
+
const lastUnit = result[result.length - 1]
|
|
1957
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline')
|
|
1958
|
+
const trimmed = child.content.trim()
|
|
1959
|
+
const startsWithClosingPunct = trimmed.length > 0 && /^[.!?:;]/.test(trimmed)
|
|
1960
|
+
|
|
1961
|
+
if (lastIsAtomic && (!hasWhitespace || startsWithClosingPunct) && this.tryMergeTextAfterAtomic(result, child)) {
|
|
1962
|
+
return
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
result.push({
|
|
1967
|
+
unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
|
|
1968
|
+
node: child
|
|
1969
|
+
})
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* Process an inline element and add it to results (with potential merging)
|
|
1974
|
+
*/
|
|
1975
|
+
private processInlineElement(result: ContentUnitWithNode[], children: Node[], child: HTMLElementNode, index: number, lastProcessedIndex: number): boolean {
|
|
1976
|
+
const tagName = getTagName(child)
|
|
1977
|
+
const childrenToRender = this.getFilteredChildren(child.body)
|
|
1978
|
+
const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender)
|
|
1979
|
+
|
|
1980
|
+
if (inlineContent === null) {
|
|
1981
|
+
result.push({
|
|
1982
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
1983
|
+
node: child
|
|
1984
|
+
})
|
|
1985
|
+
|
|
1986
|
+
return false
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
if (lastProcessedIndex >= 0) {
|
|
1990
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
|
|
1991
|
+
|
|
1992
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
|
|
1993
|
+
return true
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
result.push({
|
|
1998
|
+
unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
|
|
1999
|
+
node: child
|
|
2000
|
+
})
|
|
2001
|
+
|
|
2002
|
+
return false
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
/**
|
|
2006
|
+
* Process an ERB content node and add it to results (with potential merging)
|
|
2007
|
+
*/
|
|
2008
|
+
private processERBContentNode(result: ContentUnitWithNode[], children: Node[], child: ERBContentNode, index: number, lastProcessedIndex: number): boolean {
|
|
2009
|
+
const erbContent = this.renderERBAsString(child)
|
|
2010
|
+
const isHerbDisable = isHerbDisableComment(child)
|
|
2011
|
+
|
|
2012
|
+
if (lastProcessedIndex >= 0) {
|
|
2013
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result)
|
|
2014
|
+
|
|
2015
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
|
|
2016
|
+
return true
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
if (hasWhitespace && result.length > 0) {
|
|
2020
|
+
const lastUnit = result[result.length - 1]
|
|
2021
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb')
|
|
2022
|
+
|
|
2023
|
+
if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
|
|
2024
|
+
result.push({
|
|
2025
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
2026
|
+
node: null
|
|
2027
|
+
})
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
result.push({
|
|
2033
|
+
unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
|
|
2034
|
+
node: child
|
|
2035
|
+
})
|
|
2036
|
+
|
|
2037
|
+
return false
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Convert AST nodes to content units with node references
|
|
2042
|
+
*/
|
|
2043
|
+
private buildContentUnitsWithNodes(children: Node[]): ContentUnitWithNode[] {
|
|
2044
|
+
const result: ContentUnitWithNode[] = []
|
|
2045
|
+
let lastProcessedIndex = -1
|
|
2046
|
+
|
|
2047
|
+
for (let i = 0; i < children.length; i++) {
|
|
2048
|
+
const child = children[i]
|
|
2049
|
+
|
|
2050
|
+
if (isNode(child, WhitespaceNode)) continue
|
|
2051
|
+
|
|
2052
|
+
if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
|
|
2053
|
+
if (lastProcessedIndex >= 0) {
|
|
2054
|
+
const hasNonWhitespaceAfter = children.slice(i + 1).some(node =>
|
|
2055
|
+
!isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node)
|
|
2056
|
+
)
|
|
2057
|
+
|
|
2058
|
+
if (hasNonWhitespaceAfter) {
|
|
2059
|
+
const previousNode = children[lastProcessedIndex]
|
|
2060
|
+
|
|
2061
|
+
if (!isLineBreakingElement(previousNode)) {
|
|
2062
|
+
result.push({
|
|
2063
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
2064
|
+
node: child
|
|
2065
|
+
})
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
continue
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
if (isNode(child, HTMLTextNode)) {
|
|
2074
|
+
this.processTextNode(result, children, child, i, lastProcessedIndex)
|
|
2075
|
+
|
|
2076
|
+
lastProcessedIndex = i
|
|
2077
|
+
} else if (isNode(child, HTMLElementNode)) {
|
|
2078
|
+
const tagName = getTagName(child)
|
|
2079
|
+
|
|
2080
|
+
if (isInlineElement(tagName)) {
|
|
2081
|
+
const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex)
|
|
2082
|
+
|
|
2083
|
+
if (merged) {
|
|
2084
|
+
lastProcessedIndex = i
|
|
2085
|
+
|
|
2086
|
+
continue
|
|
2087
|
+
}
|
|
2088
|
+
} else {
|
|
2089
|
+
result.push({
|
|
2090
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
2091
|
+
node: child
|
|
2092
|
+
})
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
lastProcessedIndex = i
|
|
2096
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
2097
|
+
const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex)
|
|
2098
|
+
|
|
2099
|
+
if (merged) {
|
|
2100
|
+
lastProcessedIndex = i
|
|
2101
|
+
|
|
2102
|
+
continue
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
lastProcessedIndex = i
|
|
2106
|
+
} else {
|
|
2107
|
+
result.push({
|
|
2108
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
2109
|
+
node: child
|
|
2110
|
+
})
|
|
2111
|
+
|
|
2112
|
+
lastProcessedIndex = i
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
return result
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
/**
|
|
2120
|
+
* Flush accumulated words to output with wrapping
|
|
2121
|
+
*/
|
|
2122
|
+
private flushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
|
|
2123
|
+
if (words.length > 0) {
|
|
2124
|
+
this.wrapAndPushWords(words)
|
|
2125
|
+
words.length = 0
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/**
|
|
2130
|
+
* Wrap words to fit within line length and push to output
|
|
2131
|
+
* Handles punctuation spacing intelligently
|
|
2132
|
+
* Excludes herb:disable comments from line length calculations
|
|
2133
|
+
*/
|
|
2134
|
+
private wrapAndPushWords(words: Array<{ word: string, isHerbDisable: boolean }>): void {
|
|
2135
|
+
const wrapWidth = this.maxLineLength - this.indent.length
|
|
2136
|
+
const lines: string[] = []
|
|
2137
|
+
let currentLine = ""
|
|
2138
|
+
let effectiveLength = 0
|
|
2139
|
+
|
|
2140
|
+
for (const { word, isHerbDisable } of words) {
|
|
2141
|
+
const nextLine = buildLineWithWord(currentLine, word)
|
|
2142
|
+
|
|
2143
|
+
let nextEffectiveLength = effectiveLength
|
|
2144
|
+
|
|
2145
|
+
if (!isHerbDisable) {
|
|
2146
|
+
const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0
|
|
2147
|
+
nextEffectiveLength = effectiveLength + spaceBefore + word.length
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
|
|
2151
|
+
lines.push(this.indent + currentLine.trimEnd())
|
|
2152
|
+
|
|
2153
|
+
currentLine = word
|
|
2154
|
+
effectiveLength = isHerbDisable ? 0 : word.length
|
|
2155
|
+
} else {
|
|
2156
|
+
currentLine = nextLine
|
|
2157
|
+
effectiveLength = nextEffectiveLength
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
if (currentLine) {
|
|
2162
|
+
lines.push(this.indent + currentLine.trimEnd())
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
lines.forEach(line => this.push(line))
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean {
|
|
2169
|
+
const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "")
|
|
2170
|
+
const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode))
|
|
2171
|
+
|
|
2172
|
+
if (!hasTextContent) return false
|
|
2173
|
+
if (nonTextChildren.length === 0) return false
|
|
2174
|
+
|
|
2175
|
+
const allInline = nonTextChildren.every(child => {
|
|
2176
|
+
if (isNode(child, ERBContentNode)) return true
|
|
2177
|
+
|
|
2178
|
+
if (isNode(child, HTMLElementNode)) {
|
|
2179
|
+
return isInlineElement(getTagName(child))
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
return false
|
|
2183
|
+
})
|
|
2184
|
+
|
|
2185
|
+
if (!allInline) return false
|
|
2186
|
+
|
|
2187
|
+
return true
|
|
1498
2188
|
}
|
|
1499
2189
|
|
|
1500
2190
|
private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
|
|
@@ -1576,7 +2266,7 @@ export class FormatPrinter extends Printer {
|
|
|
1576
2266
|
} else {
|
|
1577
2267
|
const printed = IdentityPrinter.print(child)
|
|
1578
2268
|
|
|
1579
|
-
if (this.
|
|
2269
|
+
if (this.isInTokenListAttribute) {
|
|
1580
2270
|
return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%')
|
|
1581
2271
|
}
|
|
1582
2272
|
|
|
@@ -1617,7 +2307,7 @@ export class FormatPrinter extends Printer {
|
|
|
1617
2307
|
result += this.renderAttributesString(attributes)
|
|
1618
2308
|
result += ">"
|
|
1619
2309
|
|
|
1620
|
-
const childrenContent = this.tryRenderChildrenInline(children)
|
|
2310
|
+
const childrenContent = this.tryRenderChildrenInline(children, tagName)
|
|
1621
2311
|
|
|
1622
2312
|
if (!childrenContent) return null
|
|
1623
2313
|
|
|
@@ -1627,11 +2317,32 @@ export class FormatPrinter extends Printer {
|
|
|
1627
2317
|
return result
|
|
1628
2318
|
}
|
|
1629
2319
|
|
|
2320
|
+
/**
|
|
2321
|
+
* Check if children contain a leading herb:disable comment (after optional whitespace)
|
|
2322
|
+
*/
|
|
2323
|
+
private hasLeadingHerbDisable(children: Node[]): boolean {
|
|
2324
|
+
for (const child of children) {
|
|
2325
|
+
if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
|
|
2326
|
+
continue
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
return isNode(child, ERBContentNode) && isHerbDisableComment(child)
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
return false
|
|
2333
|
+
}
|
|
2334
|
+
|
|
1630
2335
|
/**
|
|
1631
2336
|
* Try to render just the children inline (without tags)
|
|
1632
2337
|
*/
|
|
1633
|
-
private tryRenderChildrenInline(children: Node[]): string | null {
|
|
2338
|
+
private tryRenderChildrenInline(children: Node[], tagName?: string): string | null {
|
|
1634
2339
|
let result = ""
|
|
2340
|
+
let hasInternalWhitespace = false
|
|
2341
|
+
let addedLeadingSpace = false
|
|
2342
|
+
|
|
2343
|
+
const hasHerbDisable = this.hasLeadingHerbDisable(children)
|
|
2344
|
+
const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode))
|
|
2345
|
+
const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName)
|
|
1635
2346
|
|
|
1636
2347
|
for (const child of children) {
|
|
1637
2348
|
if (isNode(child, HTMLTextNode)) {
|
|
@@ -1641,33 +2352,41 @@ export class FormatPrinter extends Printer {
|
|
|
1641
2352
|
const trimmedContent = normalizedContent.trim()
|
|
1642
2353
|
|
|
1643
2354
|
if (trimmedContent) {
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
if (hasLeadingSpace && result && !result.endsWith(' ')) {
|
|
1647
|
-
finalContent = ' ' + finalContent
|
|
2355
|
+
if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
|
|
2356
|
+
result += ' '
|
|
1648
2357
|
}
|
|
1649
2358
|
|
|
1650
|
-
|
|
1651
|
-
finalContent = finalContent + ' '
|
|
1652
|
-
}
|
|
2359
|
+
result += trimmedContent
|
|
1653
2360
|
|
|
1654
|
-
|
|
1655
|
-
} else if (hasLeadingSpace || hasTrailingSpace) {
|
|
1656
|
-
if (result && !result.endsWith(' ')) {
|
|
2361
|
+
if (hasTrailingSpace) {
|
|
1657
2362
|
result += ' '
|
|
1658
2363
|
}
|
|
2364
|
+
|
|
2365
|
+
continue
|
|
1659
2366
|
}
|
|
2367
|
+
}
|
|
1660
2368
|
|
|
2369
|
+
const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")
|
|
2370
|
+
|
|
2371
|
+
if (isWhitespace && !result.endsWith(' ')) {
|
|
2372
|
+
if (!result && hasHerbDisable && !addedLeadingSpace) {
|
|
2373
|
+
result += ' '
|
|
2374
|
+
addedLeadingSpace = true
|
|
2375
|
+
} else if (result) {
|
|
2376
|
+
result += ' '
|
|
2377
|
+
hasInternalWhitespace = true
|
|
2378
|
+
}
|
|
1661
2379
|
} else if (isNode(child, HTMLElementNode)) {
|
|
1662
2380
|
const tagName = getTagName(child)
|
|
1663
2381
|
|
|
1664
|
-
if (!
|
|
2382
|
+
if (!isInlineElement(tagName)) {
|
|
1665
2383
|
return null
|
|
1666
2384
|
}
|
|
1667
2385
|
|
|
2386
|
+
const childrenToRender = this.getFilteredChildren(child.body)
|
|
1668
2387
|
const childInline = this.tryRenderInlineFull(child, tagName,
|
|
1669
2388
|
filterNodes(child.open_tag?.children, HTMLAttributeNode),
|
|
1670
|
-
|
|
2389
|
+
childrenToRender
|
|
1671
2390
|
)
|
|
1672
2391
|
|
|
1673
2392
|
if (!childInline) {
|
|
@@ -1675,11 +2394,23 @@ export class FormatPrinter extends Printer {
|
|
|
1675
2394
|
}
|
|
1676
2395
|
|
|
1677
2396
|
result += childInline
|
|
1678
|
-
} else {
|
|
1679
|
-
|
|
2397
|
+
} else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
|
|
2398
|
+
const wasInlineMode = this.inlineMode
|
|
2399
|
+
this.inlineMode = true
|
|
2400
|
+
const captured = this.capture(() => this.visit(child)).join("")
|
|
2401
|
+
this.inlineMode = wasInlineMode
|
|
2402
|
+
result += captured
|
|
1680
2403
|
}
|
|
1681
2404
|
}
|
|
1682
2405
|
|
|
2406
|
+
if (shouldPreserveSpaces) {
|
|
2407
|
+
return result
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
|
|
2411
|
+
return result.trimEnd()
|
|
2412
|
+
}
|
|
2413
|
+
|
|
1683
2414
|
return result.trim()
|
|
1684
2415
|
}
|
|
1685
2416
|
|
|
@@ -1694,9 +2425,7 @@ export class FormatPrinter extends Printer {
|
|
|
1694
2425
|
return null
|
|
1695
2426
|
}
|
|
1696
2427
|
} else if (isNode(child, HTMLElementNode)) {
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
if (!isInlineElement) {
|
|
2428
|
+
if (!isInlineElement(getTagName(child))) {
|
|
1700
2429
|
return null
|
|
1701
2430
|
}
|
|
1702
2431
|
} else if (isNode(child, ERBContentNode)) {
|
|
@@ -1716,121 +2445,18 @@ export class FormatPrinter extends Printer {
|
|
|
1716
2445
|
}
|
|
1717
2446
|
|
|
1718
2447
|
/**
|
|
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
|
|
2448
|
+
* Get filtered children, using smart herb:disable filtering if needed
|
|
1744
2449
|
*/
|
|
1745
|
-
private
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
|
1808
|
-
*/
|
|
1809
|
-
private filterSignificantChildren(body: Node[], hasTextFlow: boolean): Node[] {
|
|
1810
|
-
return body.filter(child => {
|
|
1811
|
-
if (isNode(child, WhitespaceNode)) return false
|
|
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() === "")
|
|
2450
|
+
private getFilteredChildren(body: Node[]): Node[] {
|
|
2451
|
+
const hasHerbDisable = body.some(child =>
|
|
2452
|
+
isNode(child, ERBContentNode) && isHerbDisableComment(child)
|
|
1829
2453
|
)
|
|
2454
|
+
|
|
2455
|
+
return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body
|
|
1830
2456
|
}
|
|
1831
2457
|
|
|
1832
2458
|
private renderElementInline(element: HTMLElementNode): string {
|
|
1833
|
-
const children = this.
|
|
2459
|
+
const children = this.getFilteredChildren(element.body)
|
|
1834
2460
|
|
|
1835
2461
|
return this.renderChildrenInline(children)
|
|
1836
2462
|
}
|
|
@@ -1855,10 +2481,4 @@ export class FormatPrinter extends Printer {
|
|
|
1855
2481
|
|
|
1856
2482
|
return content.replace(/\s+/g, ' ').trim()
|
|
1857
2483
|
}
|
|
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
2484
|
}
|