@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/dist/index.esm.js
CHANGED
|
@@ -10,6 +10,7 @@ function createDedent(options) {
|
|
|
10
10
|
function dedent(strings, ...values) {
|
|
11
11
|
const raw = typeof strings === "string" ? [strings] : strings.raw;
|
|
12
12
|
const {
|
|
13
|
+
alignValues = false,
|
|
13
14
|
escapeSpecialCharacters = Array.isArray(strings),
|
|
14
15
|
trimWhitespace = true
|
|
15
16
|
} = options;
|
|
@@ -24,8 +25,10 @@ function createDedent(options) {
|
|
|
24
25
|
}
|
|
25
26
|
result += next;
|
|
26
27
|
if (i < values.length) {
|
|
28
|
+
const value = alignValues ? alignValue(values[i], result) : values[i];
|
|
29
|
+
|
|
27
30
|
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
|
28
|
-
result +=
|
|
31
|
+
result += value;
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -65,11 +68,35 @@ function createDedent(options) {
|
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Adjusts the indentation of a multi-line interpolated value to match the current line.
|
|
73
|
+
*/
|
|
74
|
+
function alignValue(value, precedingText) {
|
|
75
|
+
if (typeof value !== "string" || !value.includes("\n")) {
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
const currentLine = precedingText.slice(precedingText.lastIndexOf("\n") + 1);
|
|
79
|
+
const indentMatch = currentLine.match(/^(\s+)/);
|
|
80
|
+
if (indentMatch) {
|
|
81
|
+
const indent = indentMatch[1];
|
|
82
|
+
return value.replace(/\n/g, `\n${indent}`);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
class Position {
|
|
69
88
|
line;
|
|
70
89
|
column;
|
|
71
|
-
static from(
|
|
72
|
-
|
|
90
|
+
static from(positionOrLine, column) {
|
|
91
|
+
if (typeof positionOrLine === "number") {
|
|
92
|
+
return new Position(positionOrLine, column);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return new Position(positionOrLine.line, positionOrLine.column);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
static get zero() {
|
|
99
|
+
return new Position(0, 0);
|
|
73
100
|
}
|
|
74
101
|
constructor(line, column) {
|
|
75
102
|
this.line = line;
|
|
@@ -95,10 +122,20 @@ class Position {
|
|
|
95
122
|
class Location {
|
|
96
123
|
start;
|
|
97
124
|
end;
|
|
98
|
-
static from(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
125
|
+
static from(locationOrLine, column, endLine, endColumn) {
|
|
126
|
+
if (typeof locationOrLine === "number") {
|
|
127
|
+
const start = Position.from(locationOrLine, column);
|
|
128
|
+
const end = Position.from(endLine, endColumn);
|
|
129
|
+
return new Location(start, end);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const start = Position.from(locationOrLine.start);
|
|
133
|
+
const end = Position.from(locationOrLine.end);
|
|
134
|
+
return new Location(start, end);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
static get zero() {
|
|
138
|
+
return new Location(Position.zero, Position.zero);
|
|
102
139
|
}
|
|
103
140
|
constructor(start, end) {
|
|
104
141
|
this.start = start;
|
|
@@ -130,8 +167,16 @@ class Location {
|
|
|
130
167
|
class Range {
|
|
131
168
|
start;
|
|
132
169
|
end;
|
|
133
|
-
static from(
|
|
134
|
-
|
|
170
|
+
static from(rangeOrStart, end) {
|
|
171
|
+
if (typeof rangeOrStart === "number") {
|
|
172
|
+
return new Range(rangeOrStart, end);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
return new Range(rangeOrStart[0], rangeOrStart[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
static get zero() {
|
|
179
|
+
return new Range(0, 0);
|
|
135
180
|
}
|
|
136
181
|
constructor(start, end) {
|
|
137
182
|
this.start = start;
|
|
@@ -196,7 +241,7 @@ class Token {
|
|
|
196
241
|
}
|
|
197
242
|
|
|
198
243
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
199
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
244
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.0/templates/javascript/packages/core/src/errors.ts.erb
|
|
200
245
|
class HerbError {
|
|
201
246
|
type;
|
|
202
247
|
message;
|
|
@@ -621,6 +666,84 @@ class RubyParseError extends HerbError {
|
|
|
621
666
|
return output;
|
|
622
667
|
}
|
|
623
668
|
}
|
|
669
|
+
class ERBControlFlowScopeError extends HerbError {
|
|
670
|
+
keyword;
|
|
671
|
+
static from(data) {
|
|
672
|
+
return new ERBControlFlowScopeError({
|
|
673
|
+
type: data.type,
|
|
674
|
+
message: data.message,
|
|
675
|
+
location: Location.from(data.location),
|
|
676
|
+
keyword: data.keyword,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
constructor(props) {
|
|
680
|
+
super(props.type, props.message, props.location);
|
|
681
|
+
this.keyword = props.keyword;
|
|
682
|
+
}
|
|
683
|
+
toJSON() {
|
|
684
|
+
return {
|
|
685
|
+
...super.toJSON(),
|
|
686
|
+
type: "ERB_CONTROL_FLOW_SCOPE_ERROR",
|
|
687
|
+
keyword: this.keyword,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
toMonacoDiagnostic() {
|
|
691
|
+
return {
|
|
692
|
+
line: this.location.start.line,
|
|
693
|
+
column: this.location.start.column,
|
|
694
|
+
endLine: this.location.end.line,
|
|
695
|
+
endColumn: this.location.end.column,
|
|
696
|
+
message: this.message,
|
|
697
|
+
severity: 'error'
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
treeInspect() {
|
|
701
|
+
let output = "";
|
|
702
|
+
output += `@ ERBControlFlowScopeError ${this.location.treeInspectWithLabel()}\n`;
|
|
703
|
+
output += `├── message: "${this.message}"\n`;
|
|
704
|
+
output += `└── keyword: ${JSON.stringify(this.keyword)}\n`;
|
|
705
|
+
return output;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
class MissingERBEndTagError extends HerbError {
|
|
709
|
+
keyword;
|
|
710
|
+
static from(data) {
|
|
711
|
+
return new MissingERBEndTagError({
|
|
712
|
+
type: data.type,
|
|
713
|
+
message: data.message,
|
|
714
|
+
location: Location.from(data.location),
|
|
715
|
+
keyword: data.keyword,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
constructor(props) {
|
|
719
|
+
super(props.type, props.message, props.location);
|
|
720
|
+
this.keyword = props.keyword;
|
|
721
|
+
}
|
|
722
|
+
toJSON() {
|
|
723
|
+
return {
|
|
724
|
+
...super.toJSON(),
|
|
725
|
+
type: "MISSINGERB_END_TAG_ERROR",
|
|
726
|
+
keyword: this.keyword,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
toMonacoDiagnostic() {
|
|
730
|
+
return {
|
|
731
|
+
line: this.location.start.line,
|
|
732
|
+
column: this.location.start.column,
|
|
733
|
+
endLine: this.location.end.line,
|
|
734
|
+
endColumn: this.location.end.column,
|
|
735
|
+
message: this.message,
|
|
736
|
+
severity: 'error'
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
treeInspect() {
|
|
740
|
+
let output = "";
|
|
741
|
+
output += `@ MissingERBEndTagError ${this.location.treeInspectWithLabel()}\n`;
|
|
742
|
+
output += `├── message: "${this.message}"\n`;
|
|
743
|
+
output += `└── keyword: ${JSON.stringify(this.keyword)}\n`;
|
|
744
|
+
return output;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
624
747
|
function fromSerializedError(error) {
|
|
625
748
|
switch (error.type) {
|
|
626
749
|
case "UNEXPECTED_ERROR": return UnexpectedError.from(error);
|
|
@@ -632,6 +755,8 @@ function fromSerializedError(error) {
|
|
|
632
755
|
case "VOID_ELEMENT_CLOSING_TAG_ERROR": return VoidElementClosingTagError.from(error);
|
|
633
756
|
case "UNCLOSED_ELEMENT_ERROR": return UnclosedElementError.from(error);
|
|
634
757
|
case "RUBY_PARSE_ERROR": return RubyParseError.from(error);
|
|
758
|
+
case "ERB_CONTROL_FLOW_SCOPE_ERROR": return ERBControlFlowScopeError.from(error);
|
|
759
|
+
case "MISSINGERB_END_TAG_ERROR": return MissingERBEndTagError.from(error);
|
|
635
760
|
default:
|
|
636
761
|
throw new Error(`Unknown node type: ${error.type}`);
|
|
637
762
|
}
|
|
@@ -646,7 +771,7 @@ function convertToUTF8(string) {
|
|
|
646
771
|
}
|
|
647
772
|
|
|
648
773
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
649
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
774
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.0/templates/javascript/packages/core/src/nodes.ts.erb
|
|
650
775
|
class Node {
|
|
651
776
|
type;
|
|
652
777
|
location;
|
|
@@ -2880,7 +3005,7 @@ class ParseResult extends Result {
|
|
|
2880
3005
|
}
|
|
2881
3006
|
|
|
2882
3007
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
2883
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
3008
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.0/templates/javascript/packages/core/src/node-type-guards.ts.erb
|
|
2884
3009
|
/**
|
|
2885
3010
|
* Type guard functions for AST nodes.
|
|
2886
3011
|
* These functions provide type checking by combining both instanceof
|
|
@@ -3265,7 +3390,7 @@ function isParseResult(object) {
|
|
|
3265
3390
|
* Checks if a node is an ERB output node (generates content: <%= %> or <%== %>)
|
|
3266
3391
|
*/
|
|
3267
3392
|
function isERBOutputNode(node) {
|
|
3268
|
-
if (!
|
|
3393
|
+
if (!isERBNode(node))
|
|
3269
3394
|
return false;
|
|
3270
3395
|
if (!node.tag_opening?.value)
|
|
3271
3396
|
return false;
|
|
@@ -3367,7 +3492,7 @@ function getNodesAfterPosition(nodes, position, inclusive = true) {
|
|
|
3367
3492
|
}
|
|
3368
3493
|
|
|
3369
3494
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
3370
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
3495
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.0/templates/javascript/packages/core/src/visitor.ts.erb
|
|
3371
3496
|
class Visitor {
|
|
3372
3497
|
visit(node) {
|
|
3373
3498
|
if (!node)
|
|
@@ -3380,97 +3505,151 @@ class Visitor {
|
|
|
3380
3505
|
visitChildNodes(node) {
|
|
3381
3506
|
node.compactChildNodes().forEach(node => node.accept(this));
|
|
3382
3507
|
}
|
|
3508
|
+
visitNode(_node) {
|
|
3509
|
+
// Default implementation does nothing
|
|
3510
|
+
}
|
|
3511
|
+
visitERBNode(_node) {
|
|
3512
|
+
// Default implementation does nothing
|
|
3513
|
+
}
|
|
3383
3514
|
visitDocumentNode(node) {
|
|
3515
|
+
this.visitNode(node);
|
|
3384
3516
|
this.visitChildNodes(node);
|
|
3385
3517
|
}
|
|
3386
3518
|
visitLiteralNode(node) {
|
|
3519
|
+
this.visitNode(node);
|
|
3387
3520
|
this.visitChildNodes(node);
|
|
3388
3521
|
}
|
|
3389
3522
|
visitHTMLOpenTagNode(node) {
|
|
3523
|
+
this.visitNode(node);
|
|
3390
3524
|
this.visitChildNodes(node);
|
|
3391
3525
|
}
|
|
3392
3526
|
visitHTMLCloseTagNode(node) {
|
|
3527
|
+
this.visitNode(node);
|
|
3393
3528
|
this.visitChildNodes(node);
|
|
3394
3529
|
}
|
|
3395
3530
|
visitHTMLElementNode(node) {
|
|
3531
|
+
this.visitNode(node);
|
|
3396
3532
|
this.visitChildNodes(node);
|
|
3397
3533
|
}
|
|
3398
3534
|
visitHTMLAttributeValueNode(node) {
|
|
3535
|
+
this.visitNode(node);
|
|
3399
3536
|
this.visitChildNodes(node);
|
|
3400
3537
|
}
|
|
3401
3538
|
visitHTMLAttributeNameNode(node) {
|
|
3539
|
+
this.visitNode(node);
|
|
3402
3540
|
this.visitChildNodes(node);
|
|
3403
3541
|
}
|
|
3404
3542
|
visitHTMLAttributeNode(node) {
|
|
3543
|
+
this.visitNode(node);
|
|
3405
3544
|
this.visitChildNodes(node);
|
|
3406
3545
|
}
|
|
3407
3546
|
visitHTMLTextNode(node) {
|
|
3547
|
+
this.visitNode(node);
|
|
3408
3548
|
this.visitChildNodes(node);
|
|
3409
3549
|
}
|
|
3410
3550
|
visitHTMLCommentNode(node) {
|
|
3551
|
+
this.visitNode(node);
|
|
3411
3552
|
this.visitChildNodes(node);
|
|
3412
3553
|
}
|
|
3413
3554
|
visitHTMLDoctypeNode(node) {
|
|
3555
|
+
this.visitNode(node);
|
|
3414
3556
|
this.visitChildNodes(node);
|
|
3415
3557
|
}
|
|
3416
3558
|
visitXMLDeclarationNode(node) {
|
|
3559
|
+
this.visitNode(node);
|
|
3417
3560
|
this.visitChildNodes(node);
|
|
3418
3561
|
}
|
|
3419
3562
|
visitCDATANode(node) {
|
|
3563
|
+
this.visitNode(node);
|
|
3420
3564
|
this.visitChildNodes(node);
|
|
3421
3565
|
}
|
|
3422
3566
|
visitWhitespaceNode(node) {
|
|
3567
|
+
this.visitNode(node);
|
|
3423
3568
|
this.visitChildNodes(node);
|
|
3424
3569
|
}
|
|
3425
3570
|
visitERBContentNode(node) {
|
|
3571
|
+
this.visitNode(node);
|
|
3572
|
+
this.visitERBNode(node);
|
|
3426
3573
|
this.visitChildNodes(node);
|
|
3427
3574
|
}
|
|
3428
3575
|
visitERBEndNode(node) {
|
|
3576
|
+
this.visitNode(node);
|
|
3577
|
+
this.visitERBNode(node);
|
|
3429
3578
|
this.visitChildNodes(node);
|
|
3430
3579
|
}
|
|
3431
3580
|
visitERBElseNode(node) {
|
|
3581
|
+
this.visitNode(node);
|
|
3582
|
+
this.visitERBNode(node);
|
|
3432
3583
|
this.visitChildNodes(node);
|
|
3433
3584
|
}
|
|
3434
3585
|
visitERBIfNode(node) {
|
|
3586
|
+
this.visitNode(node);
|
|
3587
|
+
this.visitERBNode(node);
|
|
3435
3588
|
this.visitChildNodes(node);
|
|
3436
3589
|
}
|
|
3437
3590
|
visitERBBlockNode(node) {
|
|
3591
|
+
this.visitNode(node);
|
|
3592
|
+
this.visitERBNode(node);
|
|
3438
3593
|
this.visitChildNodes(node);
|
|
3439
3594
|
}
|
|
3440
3595
|
visitERBWhenNode(node) {
|
|
3596
|
+
this.visitNode(node);
|
|
3597
|
+
this.visitERBNode(node);
|
|
3441
3598
|
this.visitChildNodes(node);
|
|
3442
3599
|
}
|
|
3443
3600
|
visitERBCaseNode(node) {
|
|
3601
|
+
this.visitNode(node);
|
|
3602
|
+
this.visitERBNode(node);
|
|
3444
3603
|
this.visitChildNodes(node);
|
|
3445
3604
|
}
|
|
3446
3605
|
visitERBCaseMatchNode(node) {
|
|
3606
|
+
this.visitNode(node);
|
|
3607
|
+
this.visitERBNode(node);
|
|
3447
3608
|
this.visitChildNodes(node);
|
|
3448
3609
|
}
|
|
3449
3610
|
visitERBWhileNode(node) {
|
|
3611
|
+
this.visitNode(node);
|
|
3612
|
+
this.visitERBNode(node);
|
|
3450
3613
|
this.visitChildNodes(node);
|
|
3451
3614
|
}
|
|
3452
3615
|
visitERBUntilNode(node) {
|
|
3616
|
+
this.visitNode(node);
|
|
3617
|
+
this.visitERBNode(node);
|
|
3453
3618
|
this.visitChildNodes(node);
|
|
3454
3619
|
}
|
|
3455
3620
|
visitERBForNode(node) {
|
|
3621
|
+
this.visitNode(node);
|
|
3622
|
+
this.visitERBNode(node);
|
|
3456
3623
|
this.visitChildNodes(node);
|
|
3457
3624
|
}
|
|
3458
3625
|
visitERBRescueNode(node) {
|
|
3626
|
+
this.visitNode(node);
|
|
3627
|
+
this.visitERBNode(node);
|
|
3459
3628
|
this.visitChildNodes(node);
|
|
3460
3629
|
}
|
|
3461
3630
|
visitERBEnsureNode(node) {
|
|
3631
|
+
this.visitNode(node);
|
|
3632
|
+
this.visitERBNode(node);
|
|
3462
3633
|
this.visitChildNodes(node);
|
|
3463
3634
|
}
|
|
3464
3635
|
visitERBBeginNode(node) {
|
|
3636
|
+
this.visitNode(node);
|
|
3637
|
+
this.visitERBNode(node);
|
|
3465
3638
|
this.visitChildNodes(node);
|
|
3466
3639
|
}
|
|
3467
3640
|
visitERBUnlessNode(node) {
|
|
3641
|
+
this.visitNode(node);
|
|
3642
|
+
this.visitERBNode(node);
|
|
3468
3643
|
this.visitChildNodes(node);
|
|
3469
3644
|
}
|
|
3470
3645
|
visitERBYieldNode(node) {
|
|
3646
|
+
this.visitNode(node);
|
|
3647
|
+
this.visitERBNode(node);
|
|
3471
3648
|
this.visitChildNodes(node);
|
|
3472
3649
|
}
|
|
3473
3650
|
visitERBInNode(node) {
|
|
3651
|
+
this.visitNode(node);
|
|
3652
|
+
this.visitERBNode(node);
|
|
3474
3653
|
this.visitChildNodes(node);
|
|
3475
3654
|
}
|
|
3476
3655
|
}
|
|
@@ -3629,6 +3808,11 @@ class Printer extends Visitor {
|
|
|
3629
3808
|
* - Verifying AST round-trip fidelity
|
|
3630
3809
|
*/
|
|
3631
3810
|
class IdentityPrinter extends Printer {
|
|
3811
|
+
static printERBNode(node) {
|
|
3812
|
+
const printer = new IdentityPrinter();
|
|
3813
|
+
printer.printERBNode(node);
|
|
3814
|
+
return printer.context.getOutput();
|
|
3815
|
+
}
|
|
3632
3816
|
visitLiteralNode(node) {
|
|
3633
3817
|
this.write(node.content);
|
|
3634
3818
|
}
|
|
@@ -3916,11 +4100,396 @@ class IdentityPrinter extends Printer {
|
|
|
3916
4100
|
({
|
|
3917
4101
|
...DEFAULT_PRINT_OPTIONS});
|
|
3918
4102
|
|
|
4103
|
+
// --- Constants ---
|
|
3919
4104
|
// TODO: we can probably expand this list with more tags/attributes
|
|
3920
4105
|
const FORMATTABLE_ATTRIBUTES = {
|
|
3921
4106
|
'*': ['class'],
|
|
3922
4107
|
'img': ['srcset', 'sizes']
|
|
3923
4108
|
};
|
|
4109
|
+
const INLINE_ELEMENTS = new Set([
|
|
4110
|
+
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
|
|
4111
|
+
'dfn', 'em', 'hr', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
|
|
4112
|
+
'samp', 'small', 'span', 'strong', 'sub', 'sup',
|
|
4113
|
+
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
|
|
4114
|
+
]);
|
|
4115
|
+
const CONTENT_PRESERVING_ELEMENTS = new Set([
|
|
4116
|
+
'script', 'style', 'pre', 'textarea'
|
|
4117
|
+
]);
|
|
4118
|
+
const SPACEABLE_CONTAINERS = new Set([
|
|
4119
|
+
'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
|
|
4120
|
+
'figure', 'details', 'summary', 'dialog', 'fieldset'
|
|
4121
|
+
]);
|
|
4122
|
+
const TIGHT_GROUP_PARENTS = new Set([
|
|
4123
|
+
'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
|
|
4124
|
+
'tbody', 'tfoot'
|
|
4125
|
+
]);
|
|
4126
|
+
const TIGHT_GROUP_CHILDREN = new Set([
|
|
4127
|
+
'li', 'option', 'td', 'th', 'dt', 'dd'
|
|
4128
|
+
]);
|
|
4129
|
+
const SPACING_THRESHOLD = 3;
|
|
4130
|
+
/**
|
|
4131
|
+
* Token list attributes that contain space-separated values and benefit from
|
|
4132
|
+
* spacing around ERB content for readability
|
|
4133
|
+
*/
|
|
4134
|
+
const TOKEN_LIST_ATTRIBUTES = new Set([
|
|
4135
|
+
'class', 'data-controller', 'data-action'
|
|
4136
|
+
]);
|
|
4137
|
+
// --- Node Utility Functions ---
|
|
4138
|
+
/**
|
|
4139
|
+
* Check if a node is pure whitespace (empty text node with only whitespace)
|
|
4140
|
+
*/
|
|
4141
|
+
function isPureWhitespaceNode(node) {
|
|
4142
|
+
return isNode(node, HTMLTextNode) && node.content.trim() === "";
|
|
4143
|
+
}
|
|
4144
|
+
/**
|
|
4145
|
+
* Check if a node is non-whitespace (has meaningful content)
|
|
4146
|
+
*/
|
|
4147
|
+
function isNonWhitespaceNode(node) {
|
|
4148
|
+
if (isNode(node, WhitespaceNode))
|
|
4149
|
+
return false;
|
|
4150
|
+
if (isNode(node, HTMLTextNode))
|
|
4151
|
+
return node.content.trim() !== "";
|
|
4152
|
+
return true;
|
|
4153
|
+
}
|
|
4154
|
+
/**
|
|
4155
|
+
* Find the previous meaningful (non-whitespace) sibling
|
|
4156
|
+
* Returns -1 if no meaningful sibling is found
|
|
4157
|
+
*/
|
|
4158
|
+
function findPreviousMeaningfulSibling(siblings, currentIndex) {
|
|
4159
|
+
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
4160
|
+
if (isNonWhitespaceNode(siblings[i])) {
|
|
4161
|
+
return i;
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
return -1;
|
|
4165
|
+
}
|
|
4166
|
+
/**
|
|
4167
|
+
* Check if there's whitespace between two indices in children array
|
|
4168
|
+
*/
|
|
4169
|
+
function hasWhitespaceBetween(children, startIndex, endIndex) {
|
|
4170
|
+
for (let j = startIndex + 1; j < endIndex; j++) {
|
|
4171
|
+
if (isNode(children[j], WhitespaceNode) || isPureWhitespaceNode(children[j])) {
|
|
4172
|
+
return true;
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
return false;
|
|
4176
|
+
}
|
|
4177
|
+
/**
|
|
4178
|
+
* Filter children to remove insignificant whitespace
|
|
4179
|
+
*/
|
|
4180
|
+
function filterSignificantChildren(body) {
|
|
4181
|
+
return body.filter(child => {
|
|
4182
|
+
if (isNode(child, WhitespaceNode))
|
|
4183
|
+
return false;
|
|
4184
|
+
if (isNode(child, HTMLTextNode)) {
|
|
4185
|
+
if (child.content === " ")
|
|
4186
|
+
return true;
|
|
4187
|
+
return child.content.trim() !== "";
|
|
4188
|
+
}
|
|
4189
|
+
return true;
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
/**
|
|
4193
|
+
* Smart filter that preserves exactly ONE whitespace before herb:disable comments
|
|
4194
|
+
*/
|
|
4195
|
+
function filterEmptyNodesForHerbDisable(nodes) {
|
|
4196
|
+
const result = [];
|
|
4197
|
+
let pendingWhitespace = null;
|
|
4198
|
+
for (const node of nodes) {
|
|
4199
|
+
const isWhitespace = isNode(node, WhitespaceNode) || (isNode(node, HTMLTextNode) && node.content.trim() === "");
|
|
4200
|
+
const isHerbDisable = isNode(node, ERBContentNode) && isHerbDisableComment(node);
|
|
4201
|
+
if (isWhitespace) {
|
|
4202
|
+
if (!pendingWhitespace) {
|
|
4203
|
+
pendingWhitespace = node;
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
else {
|
|
4207
|
+
if (isHerbDisable && pendingWhitespace) {
|
|
4208
|
+
result.push(pendingWhitespace);
|
|
4209
|
+
}
|
|
4210
|
+
pendingWhitespace = null;
|
|
4211
|
+
result.push(node);
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
return result;
|
|
4215
|
+
}
|
|
4216
|
+
// --- Punctuation and Word Spacing Functions ---
|
|
4217
|
+
/**
|
|
4218
|
+
* Check if a word is standalone closing punctuation
|
|
4219
|
+
*/
|
|
4220
|
+
function isClosingPunctuation(word) {
|
|
4221
|
+
return /^[.,;:!?)\]]+$/.test(word);
|
|
4222
|
+
}
|
|
4223
|
+
/**
|
|
4224
|
+
* Check if a line ends with opening punctuation
|
|
4225
|
+
*/
|
|
4226
|
+
function lineEndsWithOpeningPunctuation(line) {
|
|
4227
|
+
return /[(\[]$/.test(line);
|
|
4228
|
+
}
|
|
4229
|
+
/**
|
|
4230
|
+
* Check if a string ends with an ERB tag
|
|
4231
|
+
*/
|
|
4232
|
+
function endsWithERBTag(text) {
|
|
4233
|
+
return /%>$/.test(text.trim());
|
|
4234
|
+
}
|
|
4235
|
+
/**
|
|
4236
|
+
* Check if a string starts with an ERB tag
|
|
4237
|
+
*/
|
|
4238
|
+
function startsWithERBTag(text) {
|
|
4239
|
+
return /^<%/.test(text.trim());
|
|
4240
|
+
}
|
|
4241
|
+
/**
|
|
4242
|
+
* Determine if space is needed between the current line and the next word
|
|
4243
|
+
*/
|
|
4244
|
+
function needsSpaceBetween(currentLine, word) {
|
|
4245
|
+
if (isClosingPunctuation(word))
|
|
4246
|
+
return false;
|
|
4247
|
+
if (lineEndsWithOpeningPunctuation(currentLine))
|
|
4248
|
+
return false;
|
|
4249
|
+
if (currentLine.endsWith(' '))
|
|
4250
|
+
return false;
|
|
4251
|
+
if (word.startsWith(' '))
|
|
4252
|
+
return false;
|
|
4253
|
+
if (endsWithERBTag(currentLine) && startsWithERBTag(word))
|
|
4254
|
+
return false;
|
|
4255
|
+
return true;
|
|
4256
|
+
}
|
|
4257
|
+
/**
|
|
4258
|
+
* Build a line by adding a word with appropriate spacing
|
|
4259
|
+
*/
|
|
4260
|
+
function buildLineWithWord(currentLine, word) {
|
|
4261
|
+
if (!currentLine)
|
|
4262
|
+
return word;
|
|
4263
|
+
if (word === ' ') {
|
|
4264
|
+
return currentLine.endsWith(' ') ? currentLine : `${currentLine} `;
|
|
4265
|
+
}
|
|
4266
|
+
if (isClosingPunctuation(word)) {
|
|
4267
|
+
currentLine = currentLine.trimEnd();
|
|
4268
|
+
return `${currentLine}${word}`;
|
|
4269
|
+
}
|
|
4270
|
+
return needsSpaceBetween(currentLine, word) ? `${currentLine} ${word}` : `${currentLine}${word}`;
|
|
4271
|
+
}
|
|
4272
|
+
/**
|
|
4273
|
+
* Check if a node is an inline element or ERB node
|
|
4274
|
+
*/
|
|
4275
|
+
function isInlineOrERBNode(node) {
|
|
4276
|
+
return isERBNode(node) || (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node)));
|
|
4277
|
+
}
|
|
4278
|
+
/**
|
|
4279
|
+
* Check if an element should be treated as inline based on its tag name
|
|
4280
|
+
*/
|
|
4281
|
+
function isInlineElement(tagName) {
|
|
4282
|
+
return INLINE_ELEMENTS.has(tagName.toLowerCase());
|
|
4283
|
+
}
|
|
4284
|
+
/**
|
|
4285
|
+
* Check if the current inline element is adjacent to a previous inline element (no whitespace between)
|
|
4286
|
+
*/
|
|
4287
|
+
function isAdjacentToPreviousInline(siblings, index) {
|
|
4288
|
+
const previousNode = siblings[index - 1];
|
|
4289
|
+
if (isInlineOrERBNode(previousNode)) {
|
|
4290
|
+
return true;
|
|
4291
|
+
}
|
|
4292
|
+
if (index > 1 && isNode(previousNode, HTMLTextNode) && !/^\s/.test(previousNode.content)) {
|
|
4293
|
+
const twoBack = siblings[index - 2];
|
|
4294
|
+
return isInlineOrERBNode(twoBack);
|
|
4295
|
+
}
|
|
4296
|
+
return false;
|
|
4297
|
+
}
|
|
4298
|
+
/**
|
|
4299
|
+
* Check if a node should be appended to the last line (for adjacent inline elements and punctuation)
|
|
4300
|
+
*/
|
|
4301
|
+
function shouldAppendToLastLine(child, siblings, index) {
|
|
4302
|
+
if (index === 0)
|
|
4303
|
+
return false;
|
|
4304
|
+
if (isNode(child, HTMLTextNode) && !/^\s/.test(child.content)) {
|
|
4305
|
+
const previousNode = siblings[index - 1];
|
|
4306
|
+
return isInlineOrERBNode(previousNode);
|
|
4307
|
+
}
|
|
4308
|
+
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
|
|
4309
|
+
return isAdjacentToPreviousInline(siblings, index);
|
|
4310
|
+
}
|
|
4311
|
+
if (isNode(child, ERBContentNode)) {
|
|
4312
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
4313
|
+
const previousSibling = siblings[i];
|
|
4314
|
+
if (isPureWhitespaceNode(previousSibling) || isNode(previousSibling, WhitespaceNode)) {
|
|
4315
|
+
continue;
|
|
4316
|
+
}
|
|
4317
|
+
if (previousSibling.location && child.location) {
|
|
4318
|
+
return previousSibling.location.end.line === child.location.start.line;
|
|
4319
|
+
}
|
|
4320
|
+
break;
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
return false;
|
|
4324
|
+
}
|
|
4325
|
+
/**
|
|
4326
|
+
* Check if user-intentional spacing should be preserved (double newlines between elements)
|
|
4327
|
+
*/
|
|
4328
|
+
function shouldPreserveUserSpacing(child, siblings, index) {
|
|
4329
|
+
if (!isPureWhitespaceNode(child))
|
|
4330
|
+
return false;
|
|
4331
|
+
const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(siblings[index - 1]);
|
|
4332
|
+
const hasNextNonWhitespace = index < siblings.length - 1 && isNonWhitespaceNode(siblings[index + 1]);
|
|
4333
|
+
const hasMultipleNewlines = isNode(child, HTMLTextNode) && child.content.includes('\n\n');
|
|
4334
|
+
return hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines;
|
|
4335
|
+
}
|
|
4336
|
+
/**
|
|
4337
|
+
* Check if children contain any text content with newlines
|
|
4338
|
+
*/
|
|
4339
|
+
function hasMultilineTextContent(children) {
|
|
4340
|
+
for (const child of children) {
|
|
4341
|
+
if (isNode(child, HTMLTextNode)) {
|
|
4342
|
+
return child.content.includes('\n');
|
|
4343
|
+
}
|
|
4344
|
+
if (isNode(child, HTMLElementNode) && hasMultilineTextContent(child.body)) {
|
|
4345
|
+
return true;
|
|
4346
|
+
}
|
|
4347
|
+
}
|
|
4348
|
+
return false;
|
|
4349
|
+
}
|
|
4350
|
+
/**
|
|
4351
|
+
* Check if all nested elements in the children are inline elements
|
|
4352
|
+
*/
|
|
4353
|
+
function areAllNestedElementsInline(children) {
|
|
4354
|
+
for (const child of children) {
|
|
4355
|
+
if (isNode(child, HTMLElementNode)) {
|
|
4356
|
+
if (!isInlineElement(getTagName(child))) {
|
|
4357
|
+
return false;
|
|
4358
|
+
}
|
|
4359
|
+
if (!areAllNestedElementsInline(child.body)) {
|
|
4360
|
+
return false;
|
|
4361
|
+
}
|
|
4362
|
+
}
|
|
4363
|
+
else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
|
|
4364
|
+
return false;
|
|
4365
|
+
}
|
|
4366
|
+
}
|
|
4367
|
+
return true;
|
|
4368
|
+
}
|
|
4369
|
+
/**
|
|
4370
|
+
* Check if element has complex ERB control flow
|
|
4371
|
+
*/
|
|
4372
|
+
function hasComplexERBControlFlow(inlineNodes) {
|
|
4373
|
+
return inlineNodes.some(node => {
|
|
4374
|
+
if (isNode(node, ERBIfNode)) {
|
|
4375
|
+
if (node.statements.length > 0 && node.location) {
|
|
4376
|
+
const startLine = node.location.start.line;
|
|
4377
|
+
const endLine = node.location.end.line;
|
|
4378
|
+
return startLine !== endLine;
|
|
4379
|
+
}
|
|
4380
|
+
return false;
|
|
4381
|
+
}
|
|
4382
|
+
return false;
|
|
4383
|
+
});
|
|
4384
|
+
}
|
|
4385
|
+
/**
|
|
4386
|
+
* Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
|
|
4387
|
+
* or mixed ERB output and text (like "<%= value %> text")
|
|
4388
|
+
* This indicates content that should be formatted inline even with structural newlines
|
|
4389
|
+
*/
|
|
4390
|
+
function hasMixedTextAndInlineContent(children) {
|
|
4391
|
+
let hasText = false;
|
|
4392
|
+
let hasInlineElements = false;
|
|
4393
|
+
for (const child of children) {
|
|
4394
|
+
if (isNode(child, HTMLTextNode)) {
|
|
4395
|
+
if (child.content.trim() !== "") {
|
|
4396
|
+
hasText = true;
|
|
4397
|
+
}
|
|
4398
|
+
}
|
|
4399
|
+
else if (isNode(child, HTMLElementNode)) {
|
|
4400
|
+
if (isInlineElement(getTagName(child))) {
|
|
4401
|
+
hasInlineElements = true;
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
|
|
4406
|
+
}
|
|
4407
|
+
function isContentPreserving(element) {
|
|
4408
|
+
const tagName = getTagName(element);
|
|
4409
|
+
return CONTENT_PRESERVING_ELEMENTS.has(tagName);
|
|
4410
|
+
}
|
|
4411
|
+
/**
|
|
4412
|
+
* Count consecutive inline elements/ERB at the start of children (with no whitespace between)
|
|
4413
|
+
*/
|
|
4414
|
+
function countAdjacentInlineElements(children) {
|
|
4415
|
+
let count = 0;
|
|
4416
|
+
let lastSignificantIndex = -1;
|
|
4417
|
+
for (let i = 0; i < children.length; i++) {
|
|
4418
|
+
const child = children[i];
|
|
4419
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
4420
|
+
continue;
|
|
4421
|
+
}
|
|
4422
|
+
const isInlineOrERB = (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) || isNode(child, ERBContentNode);
|
|
4423
|
+
if (!isInlineOrERB) {
|
|
4424
|
+
break;
|
|
4425
|
+
}
|
|
4426
|
+
if (lastSignificantIndex >= 0 && hasWhitespaceBetween(children, lastSignificantIndex, i)) {
|
|
4427
|
+
break;
|
|
4428
|
+
}
|
|
4429
|
+
count++;
|
|
4430
|
+
lastSignificantIndex = i;
|
|
4431
|
+
}
|
|
4432
|
+
return count;
|
|
4433
|
+
}
|
|
4434
|
+
/**
|
|
4435
|
+
* Check if a node represents a block-level element
|
|
4436
|
+
*/
|
|
4437
|
+
function isBlockLevelNode(node) {
|
|
4438
|
+
if (!isNode(node, HTMLElementNode)) {
|
|
4439
|
+
return false;
|
|
4440
|
+
}
|
|
4441
|
+
const tagName = getTagName(node);
|
|
4442
|
+
if (INLINE_ELEMENTS.has(tagName)) {
|
|
4443
|
+
return false;
|
|
4444
|
+
}
|
|
4445
|
+
return true;
|
|
4446
|
+
}
|
|
4447
|
+
/**
|
|
4448
|
+
* Check if an element is a line-breaking element (br or hr)
|
|
4449
|
+
*/
|
|
4450
|
+
function isLineBreakingElement(node) {
|
|
4451
|
+
if (!isNode(node, HTMLElementNode)) {
|
|
4452
|
+
return false;
|
|
4453
|
+
}
|
|
4454
|
+
const tagName = getTagName(node);
|
|
4455
|
+
return tagName === 'br' || tagName === 'hr';
|
|
4456
|
+
}
|
|
4457
|
+
/**
|
|
4458
|
+
* Normalize text by replacing multiple spaces with single space and trim
|
|
4459
|
+
* Then split into words
|
|
4460
|
+
*/
|
|
4461
|
+
function normalizeAndSplitWords(text) {
|
|
4462
|
+
const normalized = text.replace(/\s+/g, ' ');
|
|
4463
|
+
return normalized.trim().split(' ');
|
|
4464
|
+
}
|
|
4465
|
+
/**
|
|
4466
|
+
* Check if text ends with whitespace
|
|
4467
|
+
*/
|
|
4468
|
+
function endsWithWhitespace(text) {
|
|
4469
|
+
return /\s$/.test(text);
|
|
4470
|
+
}
|
|
4471
|
+
/**
|
|
4472
|
+
* Check if an ERB content node is a herb:disable comment
|
|
4473
|
+
*/
|
|
4474
|
+
function isHerbDisableComment(node) {
|
|
4475
|
+
if (!isNode(node, ERBContentNode))
|
|
4476
|
+
return false;
|
|
4477
|
+
if (node.tag_opening?.value !== "<%#")
|
|
4478
|
+
return false;
|
|
4479
|
+
const content = node?.content?.value || "";
|
|
4480
|
+
const trimmed = content.trim();
|
|
4481
|
+
return trimmed.startsWith("herb:disable");
|
|
4482
|
+
}
|
|
4483
|
+
/**
|
|
4484
|
+
* Check if a text node is YAML frontmatter (starts and ends with ---)
|
|
4485
|
+
*/
|
|
4486
|
+
function isFrontmatter(node) {
|
|
4487
|
+
if (!isNode(node, HTMLTextNode))
|
|
4488
|
+
return false;
|
|
4489
|
+
const content = node.content.trim();
|
|
4490
|
+
return content.startsWith("---") && /---\s*$/.test(content);
|
|
4491
|
+
}
|
|
4492
|
+
|
|
3924
4493
|
/**
|
|
3925
4494
|
* Printer traverses the Herb AST using the Visitor pattern
|
|
3926
4495
|
* and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
|
|
@@ -3944,28 +4513,6 @@ class FormatPrinter extends Printer {
|
|
|
3944
4513
|
elementStack = [];
|
|
3945
4514
|
elementFormattingAnalysis = new Map();
|
|
3946
4515
|
source;
|
|
3947
|
-
// TODO: extract
|
|
3948
|
-
static INLINE_ELEMENTS = new Set([
|
|
3949
|
-
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
|
|
3950
|
-
'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
|
|
3951
|
-
'samp', 'small', 'span', 'strong', 'sub', 'sup',
|
|
3952
|
-
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
|
|
3953
|
-
]);
|
|
3954
|
-
static CONTENT_PRESERVING_ELEMENTS = new Set([
|
|
3955
|
-
'script', 'style', 'pre', 'textarea'
|
|
3956
|
-
]);
|
|
3957
|
-
static SPACEABLE_CONTAINERS = new Set([
|
|
3958
|
-
'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
|
|
3959
|
-
'figure', 'details', 'summary', 'dialog', 'fieldset'
|
|
3960
|
-
]);
|
|
3961
|
-
static TIGHT_GROUP_PARENTS = new Set([
|
|
3962
|
-
'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
|
|
3963
|
-
'tbody', 'tfoot'
|
|
3964
|
-
]);
|
|
3965
|
-
static TIGHT_GROUP_CHILDREN = new Set([
|
|
3966
|
-
'li', 'option', 'td', 'th', 'dt', 'dd'
|
|
3967
|
-
]);
|
|
3968
|
-
static SPACING_THRESHOLD = 3;
|
|
3969
4516
|
constructor(source, options) {
|
|
3970
4517
|
super();
|
|
3971
4518
|
this.source = source;
|
|
@@ -4124,20 +4671,20 @@ class FormatPrinter extends Printer {
|
|
|
4124
4671
|
if (hasMixedContent) {
|
|
4125
4672
|
return false;
|
|
4126
4673
|
}
|
|
4127
|
-
const meaningfulSiblings = siblings.filter(child =>
|
|
4128
|
-
if (meaningfulSiblings.length <
|
|
4674
|
+
const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
|
|
4675
|
+
if (meaningfulSiblings.length < SPACING_THRESHOLD) {
|
|
4129
4676
|
return false;
|
|
4130
4677
|
}
|
|
4131
4678
|
const parentTagName = parentElement ? getTagName(parentElement) : null;
|
|
4132
|
-
if (parentTagName &&
|
|
4679
|
+
if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
|
|
4133
4680
|
return false;
|
|
4134
4681
|
}
|
|
4135
|
-
const isSpaceableContainer = !parentTagName || (parentTagName &&
|
|
4682
|
+
const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
|
|
4136
4683
|
if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
|
|
4137
4684
|
return false;
|
|
4138
4685
|
}
|
|
4139
4686
|
const currentNode = siblings[currentIndex];
|
|
4140
|
-
const previousMeaningfulIndex =
|
|
4687
|
+
const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
|
|
4141
4688
|
const isCurrentComment = isCommentNode(currentNode);
|
|
4142
4689
|
if (previousMeaningfulIndex !== -1) {
|
|
4143
4690
|
const previousNode = siblings[previousMeaningfulIndex];
|
|
@@ -4151,66 +4698,34 @@ class FormatPrinter extends Printer {
|
|
|
4151
4698
|
}
|
|
4152
4699
|
if (isNode(currentNode, HTMLElementNode)) {
|
|
4153
4700
|
const currentTagName = getTagName(currentNode);
|
|
4154
|
-
if (
|
|
4701
|
+
if (INLINE_ELEMENTS.has(currentTagName)) {
|
|
4155
4702
|
return false;
|
|
4156
4703
|
}
|
|
4157
|
-
if (
|
|
4704
|
+
if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
|
|
4158
4705
|
return false;
|
|
4159
4706
|
}
|
|
4160
4707
|
if (currentTagName === 'a' && parentTagName === 'nav') {
|
|
4161
4708
|
return false;
|
|
4162
4709
|
}
|
|
4163
4710
|
}
|
|
4164
|
-
const isBlockElement =
|
|
4711
|
+
const isBlockElement = isBlockLevelNode(currentNode);
|
|
4165
4712
|
const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode);
|
|
4166
4713
|
const isComment = isCommentNode(currentNode);
|
|
4167
4714
|
return isBlockElement || isERBBlock || isComment;
|
|
4168
4715
|
}
|
|
4169
|
-
/**
|
|
4170
|
-
* Token list attributes that contain space-separated values and benefit from
|
|
4171
|
-
* spacing around ERB content for readability
|
|
4172
|
-
*/
|
|
4173
|
-
static TOKEN_LIST_ATTRIBUTES = new Set([
|
|
4174
|
-
'class', 'data-controller', 'data-action'
|
|
4175
|
-
]);
|
|
4176
4716
|
/**
|
|
4177
4717
|
* Check if we're currently processing a token list attribute that needs spacing
|
|
4178
4718
|
*/
|
|
4179
|
-
isInTokenListAttribute() {
|
|
4180
|
-
return this.currentAttributeName !== null &&
|
|
4181
|
-
FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
|
|
4719
|
+
get isInTokenListAttribute() {
|
|
4720
|
+
return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
|
|
4182
4721
|
}
|
|
4183
4722
|
/**
|
|
4184
|
-
*
|
|
4723
|
+
* Render attributes as a space-separated string
|
|
4185
4724
|
*/
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
}
|
|
4191
|
-
}
|
|
4192
|
-
return -1;
|
|
4193
|
-
}
|
|
4194
|
-
/**
|
|
4195
|
-
* Check if a node represents a block-level element
|
|
4196
|
-
*/
|
|
4197
|
-
isBlockLevelNode(node) {
|
|
4198
|
-
if (!isNode(node, HTMLElementNode)) {
|
|
4199
|
-
return false;
|
|
4200
|
-
}
|
|
4201
|
-
const tagName = getTagName(node);
|
|
4202
|
-
if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
|
|
4203
|
-
return false;
|
|
4204
|
-
}
|
|
4205
|
-
return true;
|
|
4206
|
-
}
|
|
4207
|
-
/**
|
|
4208
|
-
* Render attributes as a space-separated string
|
|
4209
|
-
*/
|
|
4210
|
-
renderAttributesString(attributes) {
|
|
4211
|
-
if (attributes.length === 0)
|
|
4212
|
-
return "";
|
|
4213
|
-
return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
|
|
4725
|
+
renderAttributesString(attributes) {
|
|
4726
|
+
if (attributes.length === 0)
|
|
4727
|
+
return "";
|
|
4728
|
+
return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
|
|
4214
4729
|
}
|
|
4215
4730
|
/**
|
|
4216
4731
|
* Determine if a tag should be rendered inline based on attribute count and other factors
|
|
@@ -4240,9 +4755,6 @@ class FormatPrinter extends Printer {
|
|
|
4240
4755
|
}
|
|
4241
4756
|
return true;
|
|
4242
4757
|
}
|
|
4243
|
-
getAttributeName(attribute) {
|
|
4244
|
-
return attribute.name ? getCombinedAttributeName(attribute.name) : "";
|
|
4245
|
-
}
|
|
4246
4758
|
wouldClassAttributeBeMultiline(content, indentLength) {
|
|
4247
4759
|
const normalizedContent = content.replace(/\s+/g, ' ').trim();
|
|
4248
4760
|
const hasActualNewlines = /\r?\n/.test(content);
|
|
@@ -4264,6 +4776,11 @@ class FormatPrinter extends Printer {
|
|
|
4264
4776
|
}
|
|
4265
4777
|
return false;
|
|
4266
4778
|
}
|
|
4779
|
+
// TOOD: extract to core or reuse function from core
|
|
4780
|
+
getAttributeName(attribute) {
|
|
4781
|
+
return attribute.name ? getCombinedAttributeName(attribute.name) : "";
|
|
4782
|
+
}
|
|
4783
|
+
// TOOD: extract to core or reuse function from core
|
|
4267
4784
|
getAttributeValue(attribute) {
|
|
4268
4785
|
if (isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
4269
4786
|
return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('');
|
|
@@ -4399,28 +4916,38 @@ class FormatPrinter extends Printer {
|
|
|
4399
4916
|
}
|
|
4400
4917
|
// --- Visitor methods ---
|
|
4401
4918
|
visitDocumentNode(node) {
|
|
4919
|
+
const children = this.formatFrontmatter(node);
|
|
4920
|
+
const hasTextFlow = this.isInTextFlowContext(null, children);
|
|
4921
|
+
if (hasTextFlow) {
|
|
4922
|
+
const wasInlineMode = this.inlineMode;
|
|
4923
|
+
this.inlineMode = true;
|
|
4924
|
+
this.visitTextFlowChildren(children);
|
|
4925
|
+
this.inlineMode = wasInlineMode;
|
|
4926
|
+
return;
|
|
4927
|
+
}
|
|
4402
4928
|
let lastWasMeaningful = false;
|
|
4403
4929
|
let hasHandledSpacing = false;
|
|
4404
|
-
for (let i = 0; i <
|
|
4405
|
-
const child =
|
|
4406
|
-
if (
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
this.push("");
|
|
4414
|
-
hasHandledSpacing = true;
|
|
4415
|
-
}
|
|
4416
|
-
continue;
|
|
4417
|
-
}
|
|
4930
|
+
for (let i = 0; i < children.length; i++) {
|
|
4931
|
+
const child = children[i];
|
|
4932
|
+
if (shouldPreserveUserSpacing(child, children, i)) {
|
|
4933
|
+
this.push("");
|
|
4934
|
+
hasHandledSpacing = true;
|
|
4935
|
+
continue;
|
|
4936
|
+
}
|
|
4937
|
+
if (isPureWhitespaceNode(child)) {
|
|
4938
|
+
continue;
|
|
4418
4939
|
}
|
|
4419
|
-
if (
|
|
4940
|
+
if (shouldAppendToLastLine(child, children, i)) {
|
|
4941
|
+
this.appendChildToLastLine(child, children, i);
|
|
4942
|
+
lastWasMeaningful = true;
|
|
4943
|
+
hasHandledSpacing = false;
|
|
4944
|
+
continue;
|
|
4945
|
+
}
|
|
4946
|
+
if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
4420
4947
|
this.push("");
|
|
4421
4948
|
}
|
|
4422
4949
|
this.visit(child);
|
|
4423
|
-
if (
|
|
4950
|
+
if (isNonWhitespaceNode(child)) {
|
|
4424
4951
|
lastWasMeaningful = true;
|
|
4425
4952
|
hasHandledSpacing = false;
|
|
4426
4953
|
}
|
|
@@ -4429,6 +4956,12 @@ class FormatPrinter extends Printer {
|
|
|
4429
4956
|
visitHTMLElementNode(node) {
|
|
4430
4957
|
this.elementStack.push(node);
|
|
4431
4958
|
this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node));
|
|
4959
|
+
if (this.inlineMode && node.is_void && this.indentLevel === 0) {
|
|
4960
|
+
const openTag = this.capture(() => this.visit(node.open_tag)).join('');
|
|
4961
|
+
this.pushToLastLine(openTag);
|
|
4962
|
+
this.elementStack.pop();
|
|
4963
|
+
return;
|
|
4964
|
+
}
|
|
4432
4965
|
this.visit(node.open_tag);
|
|
4433
4966
|
if (node.body.length > 0) {
|
|
4434
4967
|
this.visitHTMLElementBody(node.body, node);
|
|
@@ -4439,7 +4972,8 @@ class FormatPrinter extends Printer {
|
|
|
4439
4972
|
this.elementStack.pop();
|
|
4440
4973
|
}
|
|
4441
4974
|
visitHTMLElementBody(body, element) {
|
|
4442
|
-
|
|
4975
|
+
const tagName = getTagName(element);
|
|
4976
|
+
if (isContentPreserving(element)) {
|
|
4443
4977
|
element.body.map(child => {
|
|
4444
4978
|
if (isNode(child, HTMLElementNode)) {
|
|
4445
4979
|
const wasInlineMode = this.inlineMode;
|
|
@@ -4456,12 +4990,14 @@ class FormatPrinter extends Printer {
|
|
|
4456
4990
|
}
|
|
4457
4991
|
const analysis = this.elementFormattingAnalysis.get(element);
|
|
4458
4992
|
const hasTextFlow = this.isInTextFlowContext(null, body);
|
|
4459
|
-
const children =
|
|
4993
|
+
const children = filterSignificantChildren(body);
|
|
4460
4994
|
if (analysis?.elementContentInline) {
|
|
4461
4995
|
if (children.length === 0)
|
|
4462
4996
|
return;
|
|
4463
4997
|
const oldInlineMode = this.inlineMode;
|
|
4464
4998
|
const nodesToRender = hasTextFlow ? body : children;
|
|
4999
|
+
const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
|
|
5000
|
+
const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName);
|
|
4465
5001
|
this.inlineMode = true;
|
|
4466
5002
|
const lines = this.capture(() => {
|
|
4467
5003
|
nodesToRender.forEach(child => {
|
|
@@ -4476,10 +5012,19 @@ class FormatPrinter extends Printer {
|
|
|
4476
5012
|
}
|
|
4477
5013
|
}
|
|
4478
5014
|
else {
|
|
4479
|
-
const normalizedContent = child.content.replace(/\s+/g, ' ')
|
|
4480
|
-
if (normalizedContent) {
|
|
5015
|
+
const normalizedContent = child.content.replace(/\s+/g, ' ');
|
|
5016
|
+
if (shouldPreserveSpaces && normalizedContent) {
|
|
4481
5017
|
this.push(normalizedContent);
|
|
4482
5018
|
}
|
|
5019
|
+
else {
|
|
5020
|
+
const trimmedContent = normalizedContent.trim();
|
|
5021
|
+
if (trimmedContent) {
|
|
5022
|
+
this.push(trimmedContent);
|
|
5023
|
+
}
|
|
5024
|
+
else if (normalizedContent === ' ') {
|
|
5025
|
+
this.push(' ');
|
|
5026
|
+
}
|
|
5027
|
+
}
|
|
4483
5028
|
}
|
|
4484
5029
|
}
|
|
4485
5030
|
else if (isNode(child, WhitespaceNode)) {
|
|
@@ -4491,7 +5036,9 @@ class FormatPrinter extends Printer {
|
|
|
4491
5036
|
});
|
|
4492
5037
|
});
|
|
4493
5038
|
const content = lines.join('');
|
|
4494
|
-
const inlineContent =
|
|
5039
|
+
const inlineContent = shouldPreserveSpaces
|
|
5040
|
+
? (hasTextFlow ? content.replace(/\s+/g, ' ') : content)
|
|
5041
|
+
: (hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim());
|
|
4495
5042
|
if (inlineContent) {
|
|
4496
5043
|
this.pushToLastLine(inlineContent);
|
|
4497
5044
|
}
|
|
@@ -4500,12 +5047,69 @@ class FormatPrinter extends Printer {
|
|
|
4500
5047
|
}
|
|
4501
5048
|
if (children.length === 0)
|
|
4502
5049
|
return;
|
|
5050
|
+
let leadingHerbDisableComment = null;
|
|
5051
|
+
let leadingHerbDisableIndex = -1;
|
|
5052
|
+
let firstWhitespaceIndex = -1;
|
|
5053
|
+
let remainingChildren = children;
|
|
5054
|
+
let remainingBodyUnfiltered = body;
|
|
5055
|
+
for (let i = 0; i < children.length; i++) {
|
|
5056
|
+
const child = children[i];
|
|
5057
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5058
|
+
if (firstWhitespaceIndex < 0) {
|
|
5059
|
+
firstWhitespaceIndex = i;
|
|
5060
|
+
}
|
|
5061
|
+
continue;
|
|
5062
|
+
}
|
|
5063
|
+
if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
|
|
5064
|
+
leadingHerbDisableComment = child;
|
|
5065
|
+
leadingHerbDisableIndex = i;
|
|
5066
|
+
}
|
|
5067
|
+
break;
|
|
5068
|
+
}
|
|
5069
|
+
if (leadingHerbDisableComment && leadingHerbDisableIndex >= 0) {
|
|
5070
|
+
remainingChildren = children.filter((_, index) => {
|
|
5071
|
+
if (index === leadingHerbDisableIndex)
|
|
5072
|
+
return false;
|
|
5073
|
+
if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
|
|
5074
|
+
const child = children[index];
|
|
5075
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5076
|
+
return false;
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
return true;
|
|
5080
|
+
});
|
|
5081
|
+
remainingBodyUnfiltered = body.filter((_, index) => {
|
|
5082
|
+
if (index === leadingHerbDisableIndex)
|
|
5083
|
+
return false;
|
|
5084
|
+
if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
|
|
5085
|
+
const child = body[index];
|
|
5086
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5087
|
+
return false;
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
return true;
|
|
5091
|
+
});
|
|
5092
|
+
}
|
|
5093
|
+
if (leadingHerbDisableComment) {
|
|
5094
|
+
const herbDisableString = this.capture(() => {
|
|
5095
|
+
const savedIndentLevel = this.indentLevel;
|
|
5096
|
+
this.indentLevel = 0;
|
|
5097
|
+
this.inlineMode = true;
|
|
5098
|
+
this.visit(leadingHerbDisableComment);
|
|
5099
|
+
this.inlineMode = false;
|
|
5100
|
+
this.indentLevel = savedIndentLevel;
|
|
5101
|
+
}).join("");
|
|
5102
|
+
const hasLeadingWhitespace = firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex;
|
|
5103
|
+
this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString);
|
|
5104
|
+
}
|
|
5105
|
+
if (remainingChildren.length === 0)
|
|
5106
|
+
return;
|
|
4503
5107
|
this.withIndent(() => {
|
|
4504
5108
|
if (hasTextFlow) {
|
|
4505
|
-
this.visitTextFlowChildren(
|
|
5109
|
+
this.visitTextFlowChildren(remainingBodyUnfiltered);
|
|
4506
5110
|
}
|
|
4507
5111
|
else {
|
|
4508
|
-
this.visitElementChildren(body, element);
|
|
5112
|
+
this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element);
|
|
4509
5113
|
}
|
|
4510
5114
|
});
|
|
4511
5115
|
}
|
|
@@ -4520,8 +5124,8 @@ class FormatPrinter extends Printer {
|
|
|
4520
5124
|
if (isNode(child, HTMLTextNode)) {
|
|
4521
5125
|
const isWhitespaceOnly = child.content.trim() === "";
|
|
4522
5126
|
if (isWhitespaceOnly) {
|
|
4523
|
-
const hasPreviousNonWhitespace = i > 0 &&
|
|
4524
|
-
const hasNextNonWhitespace = i < body.length - 1 &&
|
|
5127
|
+
const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1]);
|
|
5128
|
+
const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1]);
|
|
4525
5129
|
const hasMultipleNewlines = child.content.includes('\n\n');
|
|
4526
5130
|
if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
|
|
4527
5131
|
this.push("");
|
|
@@ -4530,7 +5134,7 @@ class FormatPrinter extends Printer {
|
|
|
4530
5134
|
continue;
|
|
4531
5135
|
}
|
|
4532
5136
|
}
|
|
4533
|
-
if (
|
|
5137
|
+
if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
4534
5138
|
const element = body[i - 1];
|
|
4535
5139
|
const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
|
|
4536
5140
|
const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
|
|
@@ -4538,8 +5142,35 @@ class FormatPrinter extends Printer {
|
|
|
4538
5142
|
this.push("");
|
|
4539
5143
|
}
|
|
4540
5144
|
}
|
|
4541
|
-
|
|
4542
|
-
if (
|
|
5145
|
+
let hasTrailingHerbDisable = false;
|
|
5146
|
+
if (isNode(child, HTMLElementNode) && child.close_tag) {
|
|
5147
|
+
for (let j = i + 1; j < body.length; j++) {
|
|
5148
|
+
const nextChild = body[j];
|
|
5149
|
+
if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
|
|
5150
|
+
continue;
|
|
5151
|
+
}
|
|
5152
|
+
if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
|
|
5153
|
+
hasTrailingHerbDisable = true;
|
|
5154
|
+
this.visit(child);
|
|
5155
|
+
const herbDisableString = this.capture(() => {
|
|
5156
|
+
const savedIndentLevel = this.indentLevel;
|
|
5157
|
+
this.indentLevel = 0;
|
|
5158
|
+
this.inlineMode = true;
|
|
5159
|
+
this.visit(nextChild);
|
|
5160
|
+
this.inlineMode = false;
|
|
5161
|
+
this.indentLevel = savedIndentLevel;
|
|
5162
|
+
}).join("");
|
|
5163
|
+
this.pushToLastLine(' ' + herbDisableString);
|
|
5164
|
+
i = j;
|
|
5165
|
+
break;
|
|
5166
|
+
}
|
|
5167
|
+
break;
|
|
5168
|
+
}
|
|
5169
|
+
}
|
|
5170
|
+
if (!hasTrailingHerbDisable) {
|
|
5171
|
+
this.visit(child);
|
|
5172
|
+
}
|
|
5173
|
+
if (isNonWhitespaceNode(child)) {
|
|
4543
5174
|
lastWasMeaningful = true;
|
|
4544
5175
|
hasHandledSpacing = false;
|
|
4545
5176
|
}
|
|
@@ -4629,8 +5260,8 @@ class FormatPrinter extends Printer {
|
|
|
4629
5260
|
if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
|
|
4630
5261
|
return child.content;
|
|
4631
5262
|
}
|
|
4632
|
-
else if (isERBNode(child)
|
|
4633
|
-
return
|
|
5263
|
+
else if (isERBNode(child)) {
|
|
5264
|
+
return IdentityPrinter.print(child);
|
|
4634
5265
|
}
|
|
4635
5266
|
else {
|
|
4636
5267
|
return "";
|
|
@@ -4683,11 +5314,21 @@ class FormatPrinter extends Printer {
|
|
|
4683
5314
|
if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
|
|
4684
5315
|
const startsWithSpace = content[0] === " ";
|
|
4685
5316
|
const before = startsWithSpace ? "" : " ";
|
|
4686
|
-
this.
|
|
5317
|
+
if (this.inlineMode) {
|
|
5318
|
+
this.push(open + before + content.trimEnd() + ' ' + close);
|
|
5319
|
+
}
|
|
5320
|
+
else {
|
|
5321
|
+
this.pushWithIndent(open + before + content.trimEnd() + ' ' + close);
|
|
5322
|
+
}
|
|
4687
5323
|
return;
|
|
4688
5324
|
}
|
|
4689
5325
|
if (contentTrimmedLines.length === 1) {
|
|
4690
|
-
this.
|
|
5326
|
+
if (this.inlineMode) {
|
|
5327
|
+
this.push(open + ' ' + content.trim() + ' ' + close);
|
|
5328
|
+
}
|
|
5329
|
+
else {
|
|
5330
|
+
this.pushWithIndent(open + ' ' + content.trim() + ' ' + close);
|
|
5331
|
+
}
|
|
4691
5332
|
return;
|
|
4692
5333
|
}
|
|
4693
5334
|
const firstLineEmpty = contentLines[0].trim() === "";
|
|
@@ -4728,6 +5369,7 @@ class FormatPrinter extends Printer {
|
|
|
4728
5369
|
}
|
|
4729
5370
|
visitERBCaseMatchNode(node) {
|
|
4730
5371
|
this.printERBNode(node);
|
|
5372
|
+
this.withIndent(() => this.visitAll(node.children));
|
|
4731
5373
|
this.visitAll(node.conditions);
|
|
4732
5374
|
if (node.else_clause)
|
|
4733
5375
|
this.visit(node.else_clause);
|
|
@@ -4736,7 +5378,15 @@ class FormatPrinter extends Printer {
|
|
|
4736
5378
|
}
|
|
4737
5379
|
visitERBBlockNode(node) {
|
|
4738
5380
|
this.printERBNode(node);
|
|
4739
|
-
this.withIndent(() =>
|
|
5381
|
+
this.withIndent(() => {
|
|
5382
|
+
const hasTextFlow = this.isInTextFlowContext(null, node.body);
|
|
5383
|
+
if (hasTextFlow) {
|
|
5384
|
+
this.visitTextFlowChildren(node.body);
|
|
5385
|
+
}
|
|
5386
|
+
else {
|
|
5387
|
+
this.visitElementChildren(node.body, null);
|
|
5388
|
+
}
|
|
5389
|
+
});
|
|
4740
5390
|
if (node.end_node)
|
|
4741
5391
|
this.visit(node.end_node);
|
|
4742
5392
|
}
|
|
@@ -4749,7 +5399,7 @@ class FormatPrinter extends Printer {
|
|
|
4749
5399
|
this.lines.push(this.renderAttribute(child));
|
|
4750
5400
|
}
|
|
4751
5401
|
else {
|
|
4752
|
-
const shouldAddSpaces = this.isInTokenListAttribute
|
|
5402
|
+
const shouldAddSpaces = this.isInTokenListAttribute;
|
|
4753
5403
|
if (shouldAddSpaces) {
|
|
4754
5404
|
this.lines.push(" ");
|
|
4755
5405
|
}
|
|
@@ -4760,12 +5410,12 @@ class FormatPrinter extends Printer {
|
|
|
4760
5410
|
}
|
|
4761
5411
|
});
|
|
4762
5412
|
const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
|
|
4763
|
-
const isTokenList = this.isInTokenListAttribute
|
|
5413
|
+
const isTokenList = this.isInTokenListAttribute;
|
|
4764
5414
|
if ((hasHTMLAttributes || isTokenList) && node.end_node) {
|
|
4765
5415
|
this.lines.push(" ");
|
|
4766
5416
|
}
|
|
4767
5417
|
if (node.subsequent)
|
|
4768
|
-
this.visit(node.
|
|
5418
|
+
this.visit(node.subsequent);
|
|
4769
5419
|
if (node.end_node)
|
|
4770
5420
|
this.visit(node.end_node);
|
|
4771
5421
|
}
|
|
@@ -4781,8 +5431,14 @@ class FormatPrinter extends Printer {
|
|
|
4781
5431
|
}
|
|
4782
5432
|
}
|
|
4783
5433
|
visitERBElseNode(node) {
|
|
4784
|
-
this.
|
|
4785
|
-
|
|
5434
|
+
if (this.inlineMode) {
|
|
5435
|
+
this.printERBNode(node);
|
|
5436
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
5437
|
+
}
|
|
5438
|
+
else {
|
|
5439
|
+
this.printERBNode(node);
|
|
5440
|
+
this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
|
|
5441
|
+
}
|
|
4786
5442
|
}
|
|
4787
5443
|
visitERBWhenNode(node) {
|
|
4788
5444
|
this.printERBNode(node);
|
|
@@ -4790,6 +5446,7 @@ class FormatPrinter extends Printer {
|
|
|
4790
5446
|
}
|
|
4791
5447
|
visitERBCaseNode(node) {
|
|
4792
5448
|
this.printERBNode(node);
|
|
5449
|
+
this.withIndent(() => this.visitAll(node.children));
|
|
4793
5450
|
this.visitAll(node.conditions);
|
|
4794
5451
|
if (node.else_clause)
|
|
4795
5452
|
this.visit(node.else_clause);
|
|
@@ -4864,7 +5521,7 @@ class FormatPrinter extends Printer {
|
|
|
4864
5521
|
const attributes = filterNodes(children, HTMLAttributeNode);
|
|
4865
5522
|
const inlineNodes = this.extractInlineNodes(children);
|
|
4866
5523
|
const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node));
|
|
4867
|
-
const hasComplexERB = hasERBControlFlow &&
|
|
5524
|
+
const hasComplexERB = hasERBControlFlow && hasComplexERBControlFlow(inlineNodes);
|
|
4868
5525
|
if (hasComplexERB)
|
|
4869
5526
|
return false;
|
|
4870
5527
|
const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
|
|
@@ -4879,26 +5536,38 @@ class FormatPrinter extends Printer {
|
|
|
4879
5536
|
*/
|
|
4880
5537
|
shouldRenderElementContentInline(node) {
|
|
4881
5538
|
const tagName = getTagName(node);
|
|
4882
|
-
const children =
|
|
4883
|
-
const isInlineElement = this.isInlineElement(tagName);
|
|
5539
|
+
const children = filterSignificantChildren(node.body);
|
|
4884
5540
|
const openTagInline = this.shouldRenderOpenTagInline(node);
|
|
4885
5541
|
if (!openTagInline)
|
|
4886
5542
|
return false;
|
|
4887
5543
|
if (children.length === 0)
|
|
4888
5544
|
return true;
|
|
4889
|
-
|
|
4890
|
-
|
|
5545
|
+
let hasLeadingHerbDisable = false;
|
|
5546
|
+
for (const child of node.body) {
|
|
5547
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5548
|
+
continue;
|
|
5549
|
+
}
|
|
5550
|
+
if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
|
|
5551
|
+
hasLeadingHerbDisable = true;
|
|
5552
|
+
}
|
|
5553
|
+
break;
|
|
5554
|
+
}
|
|
5555
|
+
if (hasLeadingHerbDisable && !isInlineElement(tagName)) {
|
|
5556
|
+
return false;
|
|
5557
|
+
}
|
|
5558
|
+
if (isInlineElement(tagName)) {
|
|
5559
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
|
|
4891
5560
|
if (fullInlineResult) {
|
|
4892
5561
|
const totalLength = this.indent.length + fullInlineResult.length;
|
|
4893
5562
|
return totalLength <= this.maxLineLength || totalLength <= 120;
|
|
4894
5563
|
}
|
|
4895
5564
|
return false;
|
|
4896
5565
|
}
|
|
4897
|
-
const allNestedAreInline =
|
|
4898
|
-
const hasMultilineText =
|
|
4899
|
-
const hasMixedContent =
|
|
5566
|
+
const allNestedAreInline = areAllNestedElementsInline(children);
|
|
5567
|
+
const hasMultilineText = hasMultilineTextContent(children);
|
|
5568
|
+
const hasMixedContent = hasMixedTextAndInlineContent(children);
|
|
4900
5569
|
if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
|
|
4901
|
-
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode),
|
|
5570
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
|
|
4902
5571
|
if (fullInlineResult) {
|
|
4903
5572
|
const totalLength = this.indent.length + fullInlineResult.length;
|
|
4904
5573
|
if (totalLength <= this.maxLineLength) {
|
|
@@ -4925,117 +5594,551 @@ class FormatPrinter extends Printer {
|
|
|
4925
5594
|
return true;
|
|
4926
5595
|
if (node.open_tag?.tag_closing?.value === "/>")
|
|
4927
5596
|
return true;
|
|
4928
|
-
if (
|
|
5597
|
+
if (isContentPreserving(node))
|
|
4929
5598
|
return true;
|
|
4930
|
-
const children =
|
|
5599
|
+
const children = filterSignificantChildren(node.body);
|
|
4931
5600
|
if (children.length === 0)
|
|
4932
5601
|
return true;
|
|
4933
5602
|
return elementContentInline;
|
|
4934
5603
|
}
|
|
4935
5604
|
// --- Utility methods ---
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
if (
|
|
4940
|
-
return node.
|
|
4941
|
-
|
|
5605
|
+
formatFrontmatter(node) {
|
|
5606
|
+
const firstChild = node.children[0];
|
|
5607
|
+
const hasFrontmatter = firstChild && isFrontmatter(firstChild);
|
|
5608
|
+
if (!hasFrontmatter)
|
|
5609
|
+
return node.children;
|
|
5610
|
+
this.push(firstChild.content.trimEnd());
|
|
5611
|
+
const remaining = node.children.slice(1);
|
|
5612
|
+
if (remaining.length > 0)
|
|
5613
|
+
this.push("");
|
|
5614
|
+
return remaining;
|
|
4942
5615
|
}
|
|
4943
5616
|
/**
|
|
4944
|
-
*
|
|
5617
|
+
* Append a child node to the last output line
|
|
4945
5618
|
*/
|
|
4946
|
-
|
|
4947
|
-
|
|
5619
|
+
appendChildToLastLine(child, siblings, index) {
|
|
5620
|
+
if (isNode(child, HTMLTextNode)) {
|
|
5621
|
+
this.pushToLastLine(child.content.trim());
|
|
5622
|
+
}
|
|
5623
|
+
else {
|
|
5624
|
+
let hasSpaceBefore = false;
|
|
5625
|
+
if (siblings && index !== undefined && index > 0) {
|
|
5626
|
+
const prevSibling = siblings[index - 1];
|
|
5627
|
+
if (isPureWhitespaceNode(prevSibling) || isNode(prevSibling, WhitespaceNode)) {
|
|
5628
|
+
hasSpaceBefore = true;
|
|
5629
|
+
}
|
|
5630
|
+
}
|
|
5631
|
+
const oldInlineMode = this.inlineMode;
|
|
5632
|
+
this.inlineMode = true;
|
|
5633
|
+
const inlineContent = this.capture(() => this.visit(child)).join("");
|
|
5634
|
+
this.inlineMode = oldInlineMode;
|
|
5635
|
+
this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent);
|
|
5636
|
+
}
|
|
4948
5637
|
}
|
|
4949
5638
|
/**
|
|
4950
|
-
*
|
|
5639
|
+
* Visit children in a text flow context (mixed text and inline elements)
|
|
5640
|
+
* Handles word wrapping and keeps adjacent inline elements together
|
|
4951
5641
|
*/
|
|
4952
5642
|
visitTextFlowChildren(children) {
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
5643
|
+
const adjacentInlineCount = countAdjacentInlineElements(children);
|
|
5644
|
+
if (adjacentInlineCount >= 2) {
|
|
5645
|
+
const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount);
|
|
5646
|
+
this.visitRemainingChildren(children, processedIndices);
|
|
5647
|
+
return;
|
|
5648
|
+
}
|
|
5649
|
+
this.buildAndWrapTextFlow(children);
|
|
5650
|
+
}
|
|
5651
|
+
/**
|
|
5652
|
+
* Wrap remaining words that don't fit on the current line
|
|
5653
|
+
* Returns the wrapped lines with proper indentation
|
|
5654
|
+
*/
|
|
5655
|
+
wrapRemainingWords(words, wrapWidth) {
|
|
5656
|
+
const lines = [];
|
|
5657
|
+
let line = "";
|
|
5658
|
+
for (const word of words) {
|
|
5659
|
+
const testLine = line + (line ? " " : "") + word;
|
|
5660
|
+
if (testLine.length > wrapWidth && line) {
|
|
5661
|
+
lines.push(this.indent + line);
|
|
5662
|
+
line = word;
|
|
5663
|
+
}
|
|
5664
|
+
else {
|
|
5665
|
+
line = testLine;
|
|
5666
|
+
}
|
|
5667
|
+
}
|
|
5668
|
+
if (line) {
|
|
5669
|
+
lines.push(this.indent + line);
|
|
5670
|
+
}
|
|
5671
|
+
return lines;
|
|
5672
|
+
}
|
|
5673
|
+
/**
|
|
5674
|
+
* Try to merge text starting with punctuation to inline content
|
|
5675
|
+
* Returns object with merged content and whether processing should stop
|
|
5676
|
+
*/
|
|
5677
|
+
tryMergePunctuationText(inlineContent, trimmedText, wrapWidth) {
|
|
5678
|
+
const combined = inlineContent + trimmedText;
|
|
5679
|
+
if (combined.length <= wrapWidth) {
|
|
5680
|
+
return {
|
|
5681
|
+
mergedContent: inlineContent + trimmedText,
|
|
5682
|
+
shouldStop: false,
|
|
5683
|
+
wrappedLines: []
|
|
5684
|
+
};
|
|
5685
|
+
}
|
|
5686
|
+
const match = trimmedText.match(/^[.!?:;]+/);
|
|
5687
|
+
if (!match) {
|
|
5688
|
+
return {
|
|
5689
|
+
mergedContent: inlineContent,
|
|
5690
|
+
shouldStop: false,
|
|
5691
|
+
wrappedLines: []
|
|
5692
|
+
};
|
|
5693
|
+
}
|
|
5694
|
+
const punctuation = match[0];
|
|
5695
|
+
const restText = trimmedText.substring(punctuation.length).trim();
|
|
5696
|
+
if (!restText) {
|
|
5697
|
+
return {
|
|
5698
|
+
mergedContent: inlineContent + punctuation,
|
|
5699
|
+
shouldStop: false,
|
|
5700
|
+
wrappedLines: []
|
|
5701
|
+
};
|
|
5702
|
+
}
|
|
5703
|
+
const words = restText.split(/\s+/);
|
|
5704
|
+
let toMerge = punctuation;
|
|
5705
|
+
let mergedWordCount = 0;
|
|
5706
|
+
for (const word of words) {
|
|
5707
|
+
const testMerge = toMerge + ' ' + word;
|
|
5708
|
+
if ((inlineContent + testMerge).length <= wrapWidth) {
|
|
5709
|
+
toMerge = testMerge;
|
|
5710
|
+
mergedWordCount++;
|
|
5711
|
+
}
|
|
5712
|
+
else {
|
|
5713
|
+
break;
|
|
5714
|
+
}
|
|
5715
|
+
}
|
|
5716
|
+
const mergedContent = inlineContent + toMerge;
|
|
5717
|
+
if (mergedWordCount >= words.length) {
|
|
5718
|
+
return {
|
|
5719
|
+
mergedContent,
|
|
5720
|
+
shouldStop: false,
|
|
5721
|
+
wrappedLines: []
|
|
5722
|
+
};
|
|
5723
|
+
}
|
|
5724
|
+
const remainingWords = words.slice(mergedWordCount);
|
|
5725
|
+
const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth);
|
|
5726
|
+
return {
|
|
5727
|
+
mergedContent,
|
|
5728
|
+
shouldStop: true,
|
|
5729
|
+
wrappedLines
|
|
5730
|
+
};
|
|
5731
|
+
}
|
|
5732
|
+
/**
|
|
5733
|
+
* Render adjacent inline elements together on one line
|
|
5734
|
+
*/
|
|
5735
|
+
renderAdjacentInlineElements(children, count) {
|
|
5736
|
+
let inlineContent = "";
|
|
5737
|
+
let processedCount = 0;
|
|
5738
|
+
let lastProcessedIndex = -1;
|
|
5739
|
+
const processedIndices = new Set();
|
|
5740
|
+
for (let index = 0; index < children.length && processedCount < count; index++) {
|
|
5741
|
+
const child = children[index];
|
|
5742
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
5743
|
+
continue;
|
|
5744
|
+
}
|
|
5745
|
+
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
|
|
5746
|
+
inlineContent += this.renderInlineElementAsString(child);
|
|
5747
|
+
processedCount++;
|
|
5748
|
+
lastProcessedIndex = index;
|
|
5749
|
+
processedIndices.add(index);
|
|
5750
|
+
if (inlineContent && isLineBreakingElement(child)) {
|
|
5751
|
+
this.pushWithIndent(inlineContent);
|
|
5752
|
+
inlineContent = "";
|
|
5753
|
+
}
|
|
5754
|
+
}
|
|
5755
|
+
else if (isNode(child, ERBContentNode)) {
|
|
5756
|
+
inlineContent += this.renderERBAsString(child);
|
|
5757
|
+
processedCount++;
|
|
5758
|
+
lastProcessedIndex = index;
|
|
5759
|
+
processedIndices.add(index);
|
|
5760
|
+
}
|
|
5761
|
+
}
|
|
5762
|
+
if (lastProcessedIndex >= 0) {
|
|
5763
|
+
for (let index = lastProcessedIndex + 1; index < children.length; index++) {
|
|
5764
|
+
const child = children[index];
|
|
5765
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
5766
|
+
continue;
|
|
5767
|
+
}
|
|
5768
|
+
if (isNode(child, ERBContentNode)) {
|
|
5769
|
+
inlineContent += this.renderERBAsString(child);
|
|
5770
|
+
processedIndices.add(index);
|
|
5771
|
+
continue;
|
|
5772
|
+
}
|
|
5773
|
+
if (isNode(child, HTMLTextNode)) {
|
|
5774
|
+
const trimmed = child.content.trim();
|
|
5775
|
+
if (trimmed && /^[.!?:;]/.test(trimmed)) {
|
|
5776
|
+
const wrapWidth = this.maxLineLength - this.indent.length;
|
|
5777
|
+
const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth);
|
|
5778
|
+
inlineContent = result.mergedContent;
|
|
5779
|
+
processedIndices.add(index);
|
|
5780
|
+
if (result.shouldStop) {
|
|
5781
|
+
if (inlineContent) {
|
|
5782
|
+
this.pushWithIndent(inlineContent);
|
|
5783
|
+
}
|
|
5784
|
+
result.wrappedLines.forEach(line => this.push(line));
|
|
5785
|
+
return { processedIndices };
|
|
5786
|
+
}
|
|
4971
5787
|
}
|
|
4972
5788
|
}
|
|
5789
|
+
break;
|
|
4973
5790
|
}
|
|
4974
|
-
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
5791
|
+
}
|
|
5792
|
+
if (inlineContent) {
|
|
5793
|
+
this.pushWithIndent(inlineContent);
|
|
5794
|
+
}
|
|
5795
|
+
return { processedIndices };
|
|
5796
|
+
}
|
|
5797
|
+
/**
|
|
5798
|
+
* Render an inline element as a string
|
|
5799
|
+
*/
|
|
5800
|
+
renderInlineElementAsString(element) {
|
|
5801
|
+
const tagName = getTagName(element);
|
|
5802
|
+
if (element.is_void || element.open_tag?.tag_closing?.value === "/>") {
|
|
5803
|
+
const attributes = filterNodes(element.open_tag?.children, HTMLAttributeNode);
|
|
5804
|
+
const attributesString = this.renderAttributesString(attributes);
|
|
5805
|
+
const isSelfClosing = element.open_tag?.tag_closing?.value === "/>";
|
|
5806
|
+
return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`;
|
|
5807
|
+
}
|
|
5808
|
+
const childrenToRender = this.getFilteredChildren(element.body);
|
|
5809
|
+
const childInline = this.tryRenderInlineFull(element, tagName, filterNodes(element.open_tag?.children, HTMLAttributeNode), childrenToRender);
|
|
5810
|
+
return childInline !== null ? childInline : "";
|
|
5811
|
+
}
|
|
5812
|
+
/**
|
|
5813
|
+
* Render an ERB node as a string
|
|
5814
|
+
*/
|
|
5815
|
+
renderERBAsString(node) {
|
|
5816
|
+
return this.capture(() => {
|
|
5817
|
+
this.inlineMode = true;
|
|
5818
|
+
this.visit(node);
|
|
5819
|
+
}).join("");
|
|
5820
|
+
}
|
|
5821
|
+
/**
|
|
5822
|
+
* Visit remaining children after processing adjacent inline elements
|
|
5823
|
+
*/
|
|
5824
|
+
visitRemainingChildren(children, processedIndices) {
|
|
5825
|
+
for (let index = 0; index < children.length; index++) {
|
|
5826
|
+
const child = children[index];
|
|
5827
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
5828
|
+
continue;
|
|
5829
|
+
}
|
|
5830
|
+
if (processedIndices.has(index)) {
|
|
5831
|
+
continue;
|
|
5832
|
+
}
|
|
5833
|
+
this.visit(child);
|
|
5834
|
+
}
|
|
5835
|
+
}
|
|
5836
|
+
/**
|
|
5837
|
+
* Build words array from text/inline/ERB and wrap them
|
|
5838
|
+
*/
|
|
5839
|
+
buildAndWrapTextFlow(children) {
|
|
5840
|
+
const unitsWithNodes = this.buildContentUnitsWithNodes(children);
|
|
5841
|
+
const words = [];
|
|
5842
|
+
for (const { unit, node } of unitsWithNodes) {
|
|
5843
|
+
if (unit.breaksFlow) {
|
|
5844
|
+
this.flushWords(words);
|
|
5845
|
+
if (node) {
|
|
5846
|
+
this.visit(node);
|
|
5847
|
+
}
|
|
5848
|
+
}
|
|
5849
|
+
else if (unit.isAtomic) {
|
|
5850
|
+
words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false });
|
|
5851
|
+
}
|
|
5852
|
+
else {
|
|
5853
|
+
const text = unit.content.replace(/\s+/g, ' ');
|
|
5854
|
+
const hasLeadingSpace = text.startsWith(' ');
|
|
5855
|
+
const hasTrailingSpace = text.endsWith(' ');
|
|
5856
|
+
const trimmedText = text.trim();
|
|
5857
|
+
if (trimmedText) {
|
|
5858
|
+
if (hasLeadingSpace && words.length > 0) {
|
|
5859
|
+
const lastWord = words[words.length - 1];
|
|
5860
|
+
if (!lastWord.word.endsWith(' ')) {
|
|
5861
|
+
lastWord.word += ' ';
|
|
4983
5862
|
}
|
|
4984
5863
|
}
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
5864
|
+
const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false }));
|
|
5865
|
+
words.push(...textWords);
|
|
5866
|
+
if (hasTrailingSpace && words.length > 0) {
|
|
5867
|
+
const lastWord = words[words.length - 1];
|
|
5868
|
+
if (!isClosingPunctuation(lastWord.word)) {
|
|
5869
|
+
lastWord.word += ' ';
|
|
4989
5870
|
}
|
|
4990
|
-
this.visit(child);
|
|
4991
5871
|
}
|
|
4992
5872
|
}
|
|
4993
|
-
else {
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
5873
|
+
else if (text === ' ' && words.length > 0) {
|
|
5874
|
+
const lastWord = words[words.length - 1];
|
|
5875
|
+
if (!lastWord.word.endsWith(' ')) {
|
|
5876
|
+
lastWord.word += ' ';
|
|
4997
5877
|
}
|
|
4998
|
-
this.visit(child);
|
|
4999
5878
|
}
|
|
5000
5879
|
}
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5880
|
+
}
|
|
5881
|
+
this.flushWords(words);
|
|
5882
|
+
}
|
|
5883
|
+
/**
|
|
5884
|
+
* Try to merge text that follows an atomic unit (ERB/inline) with no whitespace
|
|
5885
|
+
* Returns true if merge was performed
|
|
5886
|
+
*/
|
|
5887
|
+
tryMergeTextAfterAtomic(result, textNode) {
|
|
5888
|
+
if (result.length === 0)
|
|
5889
|
+
return false;
|
|
5890
|
+
const lastUnit = result[result.length - 1];
|
|
5891
|
+
if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) {
|
|
5892
|
+
return false;
|
|
5893
|
+
}
|
|
5894
|
+
const words = normalizeAndSplitWords(textNode.content);
|
|
5895
|
+
if (words.length === 0 || !words[0])
|
|
5896
|
+
return false;
|
|
5897
|
+
const firstWord = words[0];
|
|
5898
|
+
const firstChar = firstWord[0];
|
|
5899
|
+
if (!/[a-zA-Z0-9.!?:;]/.test(firstChar)) {
|
|
5900
|
+
return false;
|
|
5901
|
+
}
|
|
5902
|
+
lastUnit.unit.content += firstWord;
|
|
5903
|
+
if (words.length > 1) {
|
|
5904
|
+
let remainingText = words.slice(1).join(' ');
|
|
5905
|
+
if (endsWithWhitespace(textNode.content)) {
|
|
5906
|
+
remainingText += ' ';
|
|
5907
|
+
}
|
|
5908
|
+
result.push({
|
|
5909
|
+
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
|
|
5910
|
+
node: textNode
|
|
5911
|
+
});
|
|
5912
|
+
}
|
|
5913
|
+
return true;
|
|
5914
|
+
}
|
|
5915
|
+
/**
|
|
5916
|
+
* Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
|
|
5917
|
+
* Returns true if merge was performed
|
|
5918
|
+
*/
|
|
5919
|
+
tryMergeAtomicAfterText(result, children, lastProcessedIndex, atomicContent, atomicType, atomicNode) {
|
|
5920
|
+
if (result.length === 0)
|
|
5921
|
+
return false;
|
|
5922
|
+
const lastUnit = result[result.length - 1];
|
|
5923
|
+
if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic)
|
|
5924
|
+
return false;
|
|
5925
|
+
const words = normalizeAndSplitWords(lastUnit.unit.content);
|
|
5926
|
+
const lastWord = words[words.length - 1];
|
|
5927
|
+
if (!lastWord)
|
|
5928
|
+
return false;
|
|
5929
|
+
result.pop();
|
|
5930
|
+
if (words.length > 1) {
|
|
5931
|
+
const remainingText = words.slice(0, -1).join(' ');
|
|
5932
|
+
result.push({
|
|
5933
|
+
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
|
|
5934
|
+
node: children[lastProcessedIndex]
|
|
5935
|
+
});
|
|
5936
|
+
}
|
|
5937
|
+
result.push({
|
|
5938
|
+
unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
|
|
5939
|
+
node: atomicNode
|
|
5940
|
+
});
|
|
5941
|
+
return true;
|
|
5942
|
+
}
|
|
5943
|
+
/**
|
|
5944
|
+
* Check if there's whitespace between current node and last processed node
|
|
5945
|
+
*/
|
|
5946
|
+
hasWhitespaceBeforeNode(children, lastProcessedIndex, currentIndex, currentNode) {
|
|
5947
|
+
if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
|
|
5948
|
+
return true;
|
|
5949
|
+
}
|
|
5950
|
+
if (isNode(currentNode, HTMLTextNode) && /^\s/.test(currentNode.content)) {
|
|
5951
|
+
return true;
|
|
5952
|
+
}
|
|
5953
|
+
return false;
|
|
5954
|
+
}
|
|
5955
|
+
/**
|
|
5956
|
+
* Check if last unit in result ends with whitespace
|
|
5957
|
+
*/
|
|
5958
|
+
lastUnitEndsWithWhitespace(result) {
|
|
5959
|
+
if (result.length === 0)
|
|
5960
|
+
return false;
|
|
5961
|
+
const lastUnit = result[result.length - 1];
|
|
5962
|
+
return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content);
|
|
5963
|
+
}
|
|
5964
|
+
/**
|
|
5965
|
+
* Process a text node and add it to results (with potential merging)
|
|
5966
|
+
*/
|
|
5967
|
+
processTextNode(result, children, child, index, lastProcessedIndex) {
|
|
5968
|
+
const isAtomic = child.content === ' ';
|
|
5969
|
+
if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
|
|
5970
|
+
const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child);
|
|
5971
|
+
const lastUnit = result[result.length - 1];
|
|
5972
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline');
|
|
5973
|
+
const trimmed = child.content.trim();
|
|
5974
|
+
const startsWithClosingPunct = trimmed.length > 0 && /^[.!?:;]/.test(trimmed);
|
|
5975
|
+
if (lastIsAtomic && (!hasWhitespace || startsWithClosingPunct) && this.tryMergeTextAfterAtomic(result, child)) {
|
|
5976
|
+
return;
|
|
5977
|
+
}
|
|
5978
|
+
}
|
|
5979
|
+
result.push({
|
|
5980
|
+
unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
|
|
5981
|
+
node: child
|
|
5982
|
+
});
|
|
5983
|
+
}
|
|
5984
|
+
/**
|
|
5985
|
+
* Process an inline element and add it to results (with potential merging)
|
|
5986
|
+
*/
|
|
5987
|
+
processInlineElement(result, children, child, index, lastProcessedIndex) {
|
|
5988
|
+
const tagName = getTagName(child);
|
|
5989
|
+
const childrenToRender = this.getFilteredChildren(child.body);
|
|
5990
|
+
const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
|
|
5991
|
+
if (inlineContent === null) {
|
|
5992
|
+
result.push({
|
|
5993
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
5994
|
+
node: child
|
|
5995
|
+
});
|
|
5996
|
+
return false;
|
|
5997
|
+
}
|
|
5998
|
+
if (lastProcessedIndex >= 0) {
|
|
5999
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
|
|
6000
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
|
|
6001
|
+
return true;
|
|
6002
|
+
}
|
|
6003
|
+
}
|
|
6004
|
+
result.push({
|
|
6005
|
+
unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
|
|
6006
|
+
node: child
|
|
6007
|
+
});
|
|
6008
|
+
return false;
|
|
6009
|
+
}
|
|
6010
|
+
/**
|
|
6011
|
+
* Process an ERB content node and add it to results (with potential merging)
|
|
6012
|
+
*/
|
|
6013
|
+
processERBContentNode(result, children, child, index, lastProcessedIndex) {
|
|
6014
|
+
const erbContent = this.renderERBAsString(child);
|
|
6015
|
+
const isHerbDisable = isHerbDisableComment(child);
|
|
6016
|
+
if (lastProcessedIndex >= 0) {
|
|
6017
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
|
|
6018
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
|
|
6019
|
+
return true;
|
|
6020
|
+
}
|
|
6021
|
+
if (hasWhitespace && result.length > 0) {
|
|
6022
|
+
const lastUnit = result[result.length - 1];
|
|
6023
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb');
|
|
6024
|
+
if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
|
|
6025
|
+
result.push({
|
|
6026
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
6027
|
+
node: null
|
|
6028
|
+
});
|
|
6029
|
+
}
|
|
6030
|
+
}
|
|
6031
|
+
}
|
|
6032
|
+
result.push({
|
|
6033
|
+
unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
|
|
6034
|
+
node: child
|
|
6035
|
+
});
|
|
6036
|
+
return false;
|
|
6037
|
+
}
|
|
6038
|
+
/**
|
|
6039
|
+
* Convert AST nodes to content units with node references
|
|
6040
|
+
*/
|
|
6041
|
+
buildContentUnitsWithNodes(children) {
|
|
6042
|
+
const result = [];
|
|
6043
|
+
let lastProcessedIndex = -1;
|
|
6044
|
+
for (let i = 0; i < children.length; i++) {
|
|
6045
|
+
const child = children[i];
|
|
6046
|
+
if (isNode(child, WhitespaceNode))
|
|
6047
|
+
continue;
|
|
6048
|
+
if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
|
|
6049
|
+
if (lastProcessedIndex >= 0) {
|
|
6050
|
+
const hasNonWhitespaceAfter = children.slice(i + 1).some(node => !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node));
|
|
6051
|
+
if (hasNonWhitespaceAfter) {
|
|
6052
|
+
const previousNode = children[lastProcessedIndex];
|
|
6053
|
+
if (!isLineBreakingElement(previousNode)) {
|
|
6054
|
+
result.push({
|
|
6055
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
6056
|
+
node: child
|
|
6057
|
+
});
|
|
6058
|
+
}
|
|
6059
|
+
}
|
|
6060
|
+
}
|
|
6061
|
+
continue;
|
|
6062
|
+
}
|
|
6063
|
+
if (isNode(child, HTMLTextNode)) {
|
|
6064
|
+
this.processTextNode(result, children, child, i, lastProcessedIndex);
|
|
6065
|
+
lastProcessedIndex = i;
|
|
6066
|
+
}
|
|
6067
|
+
else if (isNode(child, HTMLElementNode)) {
|
|
6068
|
+
const tagName = getTagName(child);
|
|
6069
|
+
if (isInlineElement(tagName)) {
|
|
6070
|
+
const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex);
|
|
6071
|
+
if (merged) {
|
|
6072
|
+
lastProcessedIndex = i;
|
|
6073
|
+
continue;
|
|
5016
6074
|
}
|
|
5017
6075
|
}
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
6076
|
+
else {
|
|
6077
|
+
result.push({
|
|
6078
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
6079
|
+
node: child
|
|
6080
|
+
});
|
|
5021
6081
|
}
|
|
6082
|
+
lastProcessedIndex = i;
|
|
5022
6083
|
}
|
|
5023
|
-
else {
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
6084
|
+
else if (isNode(child, ERBContentNode)) {
|
|
6085
|
+
const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex);
|
|
6086
|
+
if (merged) {
|
|
6087
|
+
lastProcessedIndex = i;
|
|
6088
|
+
continue;
|
|
5027
6089
|
}
|
|
5028
|
-
|
|
6090
|
+
lastProcessedIndex = i;
|
|
6091
|
+
}
|
|
6092
|
+
else {
|
|
6093
|
+
result.push({
|
|
6094
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
6095
|
+
node: child
|
|
6096
|
+
});
|
|
6097
|
+
lastProcessedIndex = i;
|
|
5029
6098
|
}
|
|
5030
6099
|
}
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
6100
|
+
return result;
|
|
6101
|
+
}
|
|
6102
|
+
/**
|
|
6103
|
+
* Flush accumulated words to output with wrapping
|
|
6104
|
+
*/
|
|
6105
|
+
flushWords(words) {
|
|
6106
|
+
if (words.length > 0) {
|
|
6107
|
+
this.wrapAndPushWords(words);
|
|
6108
|
+
words.length = 0;
|
|
6109
|
+
}
|
|
6110
|
+
}
|
|
6111
|
+
/**
|
|
6112
|
+
* Wrap words to fit within line length and push to output
|
|
6113
|
+
* Handles punctuation spacing intelligently
|
|
6114
|
+
* Excludes herb:disable comments from line length calculations
|
|
6115
|
+
*/
|
|
6116
|
+
wrapAndPushWords(words) {
|
|
6117
|
+
const wrapWidth = this.maxLineLength - this.indent.length;
|
|
6118
|
+
const lines = [];
|
|
6119
|
+
let currentLine = "";
|
|
6120
|
+
let effectiveLength = 0;
|
|
6121
|
+
for (const { word, isHerbDisable } of words) {
|
|
6122
|
+
const nextLine = buildLineWithWord(currentLine, word);
|
|
6123
|
+
let nextEffectiveLength = effectiveLength;
|
|
6124
|
+
if (!isHerbDisable) {
|
|
6125
|
+
const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0;
|
|
6126
|
+
nextEffectiveLength = effectiveLength + spaceBefore + word.length;
|
|
6127
|
+
}
|
|
6128
|
+
if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
|
|
6129
|
+
lines.push(this.indent + currentLine.trimEnd());
|
|
6130
|
+
currentLine = word;
|
|
6131
|
+
effectiveLength = isHerbDisable ? 0 : word.length;
|
|
6132
|
+
}
|
|
6133
|
+
else {
|
|
6134
|
+
currentLine = nextLine;
|
|
6135
|
+
effectiveLength = nextEffectiveLength;
|
|
5036
6136
|
}
|
|
5037
|
-
this.push(finalLine);
|
|
5038
6137
|
}
|
|
6138
|
+
if (currentLine) {
|
|
6139
|
+
lines.push(this.indent + currentLine.trimEnd());
|
|
6140
|
+
}
|
|
6141
|
+
lines.forEach(line => this.push(line));
|
|
5039
6142
|
}
|
|
5040
6143
|
isInTextFlowContext(_parent, children) {
|
|
5041
6144
|
const hasTextContent = children.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
|
|
@@ -5048,7 +6151,7 @@ class FormatPrinter extends Printer {
|
|
|
5048
6151
|
if (isNode(child, ERBContentNode))
|
|
5049
6152
|
return true;
|
|
5050
6153
|
if (isNode(child, HTMLElementNode)) {
|
|
5051
|
-
return
|
|
6154
|
+
return isInlineElement(getTagName(child));
|
|
5052
6155
|
}
|
|
5053
6156
|
return false;
|
|
5054
6157
|
});
|
|
@@ -5118,7 +6221,7 @@ class FormatPrinter extends Printer {
|
|
|
5118
6221
|
}
|
|
5119
6222
|
else {
|
|
5120
6223
|
const printed = IdentityPrinter.print(child);
|
|
5121
|
-
if (this.
|
|
6224
|
+
if (this.isInTokenListAttribute) {
|
|
5122
6225
|
return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%');
|
|
5123
6226
|
}
|
|
5124
6227
|
return printed;
|
|
@@ -5154,18 +6257,35 @@ class FormatPrinter extends Printer {
|
|
|
5154
6257
|
let result = `<${tagName}`;
|
|
5155
6258
|
result += this.renderAttributesString(attributes);
|
|
5156
6259
|
result += ">";
|
|
5157
|
-
const childrenContent = this.tryRenderChildrenInline(children);
|
|
6260
|
+
const childrenContent = this.tryRenderChildrenInline(children, tagName);
|
|
5158
6261
|
if (!childrenContent)
|
|
5159
6262
|
return null;
|
|
5160
6263
|
result += childrenContent;
|
|
5161
6264
|
result += `</${tagName}>`;
|
|
5162
6265
|
return result;
|
|
5163
6266
|
}
|
|
6267
|
+
/**
|
|
6268
|
+
* Check if children contain a leading herb:disable comment (after optional whitespace)
|
|
6269
|
+
*/
|
|
6270
|
+
hasLeadingHerbDisable(children) {
|
|
6271
|
+
for (const child of children) {
|
|
6272
|
+
if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
|
|
6273
|
+
continue;
|
|
6274
|
+
}
|
|
6275
|
+
return isNode(child, ERBContentNode) && isHerbDisableComment(child);
|
|
6276
|
+
}
|
|
6277
|
+
return false;
|
|
6278
|
+
}
|
|
5164
6279
|
/**
|
|
5165
6280
|
* Try to render just the children inline (without tags)
|
|
5166
6281
|
*/
|
|
5167
|
-
tryRenderChildrenInline(children) {
|
|
6282
|
+
tryRenderChildrenInline(children, tagName) {
|
|
5168
6283
|
let result = "";
|
|
6284
|
+
let hasInternalWhitespace = false;
|
|
6285
|
+
let addedLeadingSpace = false;
|
|
6286
|
+
const hasHerbDisable = this.hasLeadingHerbDisable(children);
|
|
6287
|
+
const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
|
|
6288
|
+
const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName);
|
|
5169
6289
|
for (const child of children) {
|
|
5170
6290
|
if (isNode(child, HTMLTextNode)) {
|
|
5171
6291
|
const normalizedContent = child.content.replace(/\s+/g, ' ');
|
|
@@ -5173,36 +6293,53 @@ class FormatPrinter extends Printer {
|
|
|
5173
6293
|
const hasTrailingSpace = /\s$/.test(child.content);
|
|
5174
6294
|
const trimmedContent = normalizedContent.trim();
|
|
5175
6295
|
if (trimmedContent) {
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
finalContent = ' ' + finalContent;
|
|
6296
|
+
if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
|
|
6297
|
+
result += ' ';
|
|
5179
6298
|
}
|
|
6299
|
+
result += trimmedContent;
|
|
5180
6300
|
if (hasTrailingSpace) {
|
|
5181
|
-
finalContent = finalContent + ' ';
|
|
5182
|
-
}
|
|
5183
|
-
result += finalContent;
|
|
5184
|
-
}
|
|
5185
|
-
else if (hasLeadingSpace || hasTrailingSpace) {
|
|
5186
|
-
if (result && !result.endsWith(' ')) {
|
|
5187
6301
|
result += ' ';
|
|
5188
6302
|
}
|
|
6303
|
+
continue;
|
|
6304
|
+
}
|
|
6305
|
+
}
|
|
6306
|
+
const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "");
|
|
6307
|
+
if (isWhitespace && !result.endsWith(' ')) {
|
|
6308
|
+
if (!result && hasHerbDisable && !addedLeadingSpace) {
|
|
6309
|
+
result += ' ';
|
|
6310
|
+
addedLeadingSpace = true;
|
|
6311
|
+
}
|
|
6312
|
+
else if (result) {
|
|
6313
|
+
result += ' ';
|
|
6314
|
+
hasInternalWhitespace = true;
|
|
5189
6315
|
}
|
|
5190
6316
|
}
|
|
5191
6317
|
else if (isNode(child, HTMLElementNode)) {
|
|
5192
6318
|
const tagName = getTagName(child);
|
|
5193
|
-
if (!
|
|
6319
|
+
if (!isInlineElement(tagName)) {
|
|
5194
6320
|
return null;
|
|
5195
6321
|
}
|
|
5196
|
-
const
|
|
6322
|
+
const childrenToRender = this.getFilteredChildren(child.body);
|
|
6323
|
+
const childInline = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
|
|
5197
6324
|
if (!childInline) {
|
|
5198
6325
|
return null;
|
|
5199
6326
|
}
|
|
5200
6327
|
result += childInline;
|
|
5201
6328
|
}
|
|
5202
|
-
else {
|
|
5203
|
-
|
|
6329
|
+
else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
|
|
6330
|
+
const wasInlineMode = this.inlineMode;
|
|
6331
|
+
this.inlineMode = true;
|
|
6332
|
+
const captured = this.capture(() => this.visit(child)).join("");
|
|
6333
|
+
this.inlineMode = wasInlineMode;
|
|
6334
|
+
result += captured;
|
|
5204
6335
|
}
|
|
5205
6336
|
}
|
|
6337
|
+
if (shouldPreserveSpaces) {
|
|
6338
|
+
return result;
|
|
6339
|
+
}
|
|
6340
|
+
if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
|
|
6341
|
+
return result.trimEnd();
|
|
6342
|
+
}
|
|
5206
6343
|
return result.trim();
|
|
5207
6344
|
}
|
|
5208
6345
|
/**
|
|
@@ -5217,8 +6354,7 @@ class FormatPrinter extends Printer {
|
|
|
5217
6354
|
}
|
|
5218
6355
|
}
|
|
5219
6356
|
else if (isNode(child, HTMLElementNode)) {
|
|
5220
|
-
|
|
5221
|
-
if (!isInlineElement) {
|
|
6357
|
+
if (!isInlineElement(getTagName(child))) {
|
|
5222
6358
|
return null;
|
|
5223
6359
|
}
|
|
5224
6360
|
}
|
|
@@ -5234,103 +6370,14 @@ class FormatPrinter extends Printer {
|
|
|
5234
6370
|
return `<${tagName}>${content}</${tagName}>`;
|
|
5235
6371
|
}
|
|
5236
6372
|
/**
|
|
5237
|
-
*
|
|
5238
|
-
* or mixed ERB output and text (like "<%= value %> text")
|
|
5239
|
-
* This indicates content that should be formatted inline even with structural newlines
|
|
5240
|
-
*/
|
|
5241
|
-
hasMixedTextAndInlineContent(children) {
|
|
5242
|
-
let hasText = false;
|
|
5243
|
-
let hasInlineElements = false;
|
|
5244
|
-
for (const child of children) {
|
|
5245
|
-
if (isNode(child, HTMLTextNode)) {
|
|
5246
|
-
if (child.content.trim() !== "") {
|
|
5247
|
-
hasText = true;
|
|
5248
|
-
}
|
|
5249
|
-
}
|
|
5250
|
-
else if (isNode(child, HTMLElementNode)) {
|
|
5251
|
-
if (this.isInlineElement(getTagName(child))) {
|
|
5252
|
-
hasInlineElements = true;
|
|
5253
|
-
}
|
|
5254
|
-
}
|
|
5255
|
-
}
|
|
5256
|
-
return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
|
|
5257
|
-
}
|
|
5258
|
-
/**
|
|
5259
|
-
* Check if children contain any text content with newlines
|
|
6373
|
+
* Get filtered children, using smart herb:disable filtering if needed
|
|
5260
6374
|
*/
|
|
5261
|
-
|
|
5262
|
-
|
|
5263
|
-
|
|
5264
|
-
return child.content.includes('\n');
|
|
5265
|
-
}
|
|
5266
|
-
if (isNode(child, HTMLElementNode)) {
|
|
5267
|
-
const nestedChildren = this.filterEmptyNodes(child.body);
|
|
5268
|
-
if (this.hasMultilineTextContent(nestedChildren)) {
|
|
5269
|
-
return true;
|
|
5270
|
-
}
|
|
5271
|
-
}
|
|
5272
|
-
}
|
|
5273
|
-
return false;
|
|
5274
|
-
}
|
|
5275
|
-
/**
|
|
5276
|
-
* Check if all nested elements in the children are inline elements
|
|
5277
|
-
*/
|
|
5278
|
-
areAllNestedElementsInline(children) {
|
|
5279
|
-
for (const child of children) {
|
|
5280
|
-
if (isNode(child, HTMLElementNode)) {
|
|
5281
|
-
if (!this.isInlineElement(getTagName(child))) {
|
|
5282
|
-
return false;
|
|
5283
|
-
}
|
|
5284
|
-
const nestedChildren = this.filterEmptyNodes(child.body);
|
|
5285
|
-
if (!this.areAllNestedElementsInline(nestedChildren)) {
|
|
5286
|
-
return false;
|
|
5287
|
-
}
|
|
5288
|
-
}
|
|
5289
|
-
else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
|
|
5290
|
-
return false;
|
|
5291
|
-
}
|
|
5292
|
-
}
|
|
5293
|
-
return true;
|
|
5294
|
-
}
|
|
5295
|
-
/**
|
|
5296
|
-
* Check if element has complex ERB control flow
|
|
5297
|
-
*/
|
|
5298
|
-
hasComplexERBControlFlow(inlineNodes) {
|
|
5299
|
-
return inlineNodes.some(node => {
|
|
5300
|
-
if (isNode(node, ERBIfNode)) {
|
|
5301
|
-
if (node.statements.length > 0 && node.location) {
|
|
5302
|
-
const startLine = node.location.start.line;
|
|
5303
|
-
const endLine = node.location.end.line;
|
|
5304
|
-
return startLine !== endLine;
|
|
5305
|
-
}
|
|
5306
|
-
return false;
|
|
5307
|
-
}
|
|
5308
|
-
return false;
|
|
5309
|
-
});
|
|
5310
|
-
}
|
|
5311
|
-
/**
|
|
5312
|
-
* Filter children to remove insignificant whitespace
|
|
5313
|
-
*/
|
|
5314
|
-
filterSignificantChildren(body, hasTextFlow) {
|
|
5315
|
-
return body.filter(child => {
|
|
5316
|
-
if (isNode(child, WhitespaceNode))
|
|
5317
|
-
return false;
|
|
5318
|
-
if (isNode(child, HTMLTextNode)) {
|
|
5319
|
-
if (hasTextFlow && child.content === " ")
|
|
5320
|
-
return true;
|
|
5321
|
-
return child.content.trim() !== "";
|
|
5322
|
-
}
|
|
5323
|
-
return true;
|
|
5324
|
-
});
|
|
5325
|
-
}
|
|
5326
|
-
/**
|
|
5327
|
-
* Filter out empty text nodes and whitespace nodes
|
|
5328
|
-
*/
|
|
5329
|
-
filterEmptyNodes(nodes) {
|
|
5330
|
-
return nodes.filter(child => !isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === ""));
|
|
6375
|
+
getFilteredChildren(body) {
|
|
6376
|
+
const hasHerbDisable = body.some(child => isNode(child, ERBContentNode) && isHerbDisableComment(child));
|
|
6377
|
+
return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body;
|
|
5331
6378
|
}
|
|
5332
6379
|
renderElementInline(element) {
|
|
5333
|
-
const children = this.
|
|
6380
|
+
const children = this.getFilteredChildren(element.body);
|
|
5334
6381
|
return this.renderChildrenInline(children);
|
|
5335
6382
|
}
|
|
5336
6383
|
renderChildrenInline(children) {
|
|
@@ -5352,9 +6399,29 @@ class FormatPrinter extends Printer {
|
|
|
5352
6399
|
}
|
|
5353
6400
|
return content.replace(/\s+/g, ' ').trim();
|
|
5354
6401
|
}
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
6402
|
+
}
|
|
6403
|
+
|
|
6404
|
+
const isScaffoldTemplate = (result) => {
|
|
6405
|
+
const detector = new ScaffoldTemplateDetector();
|
|
6406
|
+
detector.visit(result.value);
|
|
6407
|
+
return detector.hasEscapedERB;
|
|
6408
|
+
};
|
|
6409
|
+
/**
|
|
6410
|
+
* Visitor that detects if the AST represents a Rails scaffold template.
|
|
6411
|
+
* Scaffold templates contain escaped ERB tags (<%%= or <%%)
|
|
6412
|
+
* and should not be formatted to preserve their exact structure.
|
|
6413
|
+
*/
|
|
6414
|
+
class ScaffoldTemplateDetector extends Visitor {
|
|
6415
|
+
hasEscapedERB = false;
|
|
6416
|
+
visitERBContentNode(node) {
|
|
6417
|
+
const opening = node.tag_opening?.value;
|
|
6418
|
+
if (opening && opening.startsWith("<%%")) {
|
|
6419
|
+
this.hasEscapedERB = true;
|
|
6420
|
+
return;
|
|
6421
|
+
}
|
|
6422
|
+
if (this.hasEscapedERB)
|
|
6423
|
+
return;
|
|
6424
|
+
this.visitChildNodes(node);
|
|
5358
6425
|
}
|
|
5359
6426
|
}
|
|
5360
6427
|
|
|
@@ -5364,6 +6431,8 @@ class FormatPrinter extends Printer {
|
|
|
5364
6431
|
const defaultFormatOptions = {
|
|
5365
6432
|
indentWidth: 2,
|
|
5366
6433
|
maxLineLength: 80,
|
|
6434
|
+
preRewriters: [],
|
|
6435
|
+
postRewriters: [],
|
|
5367
6436
|
};
|
|
5368
6437
|
/**
|
|
5369
6438
|
* Merge provided options with defaults for any missing values.
|
|
@@ -5374,6 +6443,8 @@ function resolveFormatOptions(options = {}) {
|
|
|
5374
6443
|
return {
|
|
5375
6444
|
indentWidth: options.indentWidth ?? defaultFormatOptions.indentWidth,
|
|
5376
6445
|
maxLineLength: options.maxLineLength ?? defaultFormatOptions.maxLineLength,
|
|
6446
|
+
preRewriters: options.preRewriters ?? defaultFormatOptions.preRewriters,
|
|
6447
|
+
postRewriters: options.postRewriters ?? defaultFormatOptions.postRewriters,
|
|
5377
6448
|
};
|
|
5378
6449
|
}
|
|
5379
6450
|
|
|
@@ -5384,6 +6455,30 @@ function resolveFormatOptions(options = {}) {
|
|
|
5384
6455
|
class Formatter {
|
|
5385
6456
|
herb;
|
|
5386
6457
|
options;
|
|
6458
|
+
/**
|
|
6459
|
+
* Creates a Formatter instance from a Config object (recommended).
|
|
6460
|
+
*
|
|
6461
|
+
* @param herb - The Herb backend instance for parsing
|
|
6462
|
+
* @param config - Optional Config instance for formatter options
|
|
6463
|
+
* @param options - Additional options to override config
|
|
6464
|
+
* @returns A configured Formatter instance
|
|
6465
|
+
*/
|
|
6466
|
+
static from(herb, config, options = {}) {
|
|
6467
|
+
const formatterConfig = config?.formatter || {};
|
|
6468
|
+
const mergedOptions = {
|
|
6469
|
+
indentWidth: options.indentWidth ?? formatterConfig.indentWidth,
|
|
6470
|
+
maxLineLength: options.maxLineLength ?? formatterConfig.maxLineLength,
|
|
6471
|
+
preRewriters: options.preRewriters,
|
|
6472
|
+
postRewriters: options.postRewriters,
|
|
6473
|
+
};
|
|
6474
|
+
return new Formatter(herb, mergedOptions);
|
|
6475
|
+
}
|
|
6476
|
+
/**
|
|
6477
|
+
* Creates a new Formatter instance.
|
|
6478
|
+
*
|
|
6479
|
+
* @param herb - The Herb backend instance for parsing
|
|
6480
|
+
* @param options - Format options (including rewriters)
|
|
6481
|
+
*/
|
|
5387
6482
|
constructor(herb, options = {}) {
|
|
5388
6483
|
this.herb = herb;
|
|
5389
6484
|
this.options = resolveFormatOptions(options);
|
|
@@ -5391,12 +6486,44 @@ class Formatter {
|
|
|
5391
6486
|
/**
|
|
5392
6487
|
* Format a source string, optionally overriding format options per call.
|
|
5393
6488
|
*/
|
|
5394
|
-
format(source, options = {}) {
|
|
5395
|
-
|
|
6489
|
+
format(source, options = {}, filePath) {
|
|
6490
|
+
let result = this.parse(source);
|
|
5396
6491
|
if (result.failed)
|
|
5397
6492
|
return source;
|
|
6493
|
+
if (isScaffoldTemplate(result))
|
|
6494
|
+
return source;
|
|
5398
6495
|
const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
|
|
5399
|
-
|
|
6496
|
+
let node = result.value;
|
|
6497
|
+
if (resolvedOptions.preRewriters.length > 0) {
|
|
6498
|
+
const context = {
|
|
6499
|
+
filePath,
|
|
6500
|
+
baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
|
|
6501
|
+
};
|
|
6502
|
+
for (const rewriter of resolvedOptions.preRewriters) {
|
|
6503
|
+
try {
|
|
6504
|
+
node = rewriter.rewrite(node, context);
|
|
6505
|
+
}
|
|
6506
|
+
catch (error) {
|
|
6507
|
+
console.error(`Pre-format rewriter "${rewriter.name}" failed:`, error);
|
|
6508
|
+
}
|
|
6509
|
+
}
|
|
6510
|
+
}
|
|
6511
|
+
let formatted = new FormatPrinter(source, resolvedOptions).print(node);
|
|
6512
|
+
if (resolvedOptions.postRewriters.length > 0) {
|
|
6513
|
+
const context = {
|
|
6514
|
+
filePath,
|
|
6515
|
+
baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
|
|
6516
|
+
};
|
|
6517
|
+
for (const rewriter of resolvedOptions.postRewriters) {
|
|
6518
|
+
try {
|
|
6519
|
+
formatted = rewriter.rewrite(formatted, context);
|
|
6520
|
+
}
|
|
6521
|
+
catch (error) {
|
|
6522
|
+
console.error(`Post-format rewriter "${rewriter.name}" failed:`, error);
|
|
6523
|
+
}
|
|
6524
|
+
}
|
|
6525
|
+
}
|
|
6526
|
+
return formatted;
|
|
5400
6527
|
}
|
|
5401
6528
|
parse(source) {
|
|
5402
6529
|
this.herb.ensureBackend();
|