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