@herb-tools/formatter 0.7.5 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -11
- package/dist/herb-format.js +23158 -2213
- package/dist/herb-format.js.map +1 -1
- package/dist/index.cjs +1409 -331
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +1409 -331
- package/dist/index.esm.js.map +1 -1
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/format-helpers.d.ts +160 -0
- package/dist/types/format-printer.d.ts +87 -50
- package/dist/types/formatter.d.ts +18 -2
- package/dist/types/options.d.ts +7 -0
- package/dist/types/scaffold-template-detector.d.ts +12 -0
- package/dist/types/types.d.ts +23 -0
- package/package.json +5 -6
- package/src/cli.ts +355 -109
- package/src/format-helpers.ts +510 -0
- package/src/format-printer.ts +1013 -390
- package/src/formatter.ts +76 -4
- package/src/options.ts +12 -0
- package/src/scaffold-template-detector.ts +33 -0
- package/src/types.ts +27 -0
package/dist/index.esm.js
CHANGED
|
@@ -10,6 +10,7 @@ function createDedent(options) {
|
|
|
10
10
|
function dedent(strings, ...values) {
|
|
11
11
|
const raw = typeof strings === "string" ? [strings] : strings.raw;
|
|
12
12
|
const {
|
|
13
|
+
alignValues = false,
|
|
13
14
|
escapeSpecialCharacters = Array.isArray(strings),
|
|
14
15
|
trimWhitespace = true
|
|
15
16
|
} = options;
|
|
@@ -24,8 +25,10 @@ function createDedent(options) {
|
|
|
24
25
|
}
|
|
25
26
|
result += next;
|
|
26
27
|
if (i < values.length) {
|
|
28
|
+
const value = alignValues ? alignValue(values[i], result) : values[i];
|
|
29
|
+
|
|
27
30
|
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
|
28
|
-
result +=
|
|
31
|
+
result += value;
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
@@ -65,11 +68,35 @@ function createDedent(options) {
|
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Adjusts the indentation of a multi-line interpolated value to match the current line.
|
|
73
|
+
*/
|
|
74
|
+
function alignValue(value, precedingText) {
|
|
75
|
+
if (typeof value !== "string" || !value.includes("\n")) {
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
const currentLine = precedingText.slice(precedingText.lastIndexOf("\n") + 1);
|
|
79
|
+
const indentMatch = currentLine.match(/^(\s+)/);
|
|
80
|
+
if (indentMatch) {
|
|
81
|
+
const indent = indentMatch[1];
|
|
82
|
+
return value.replace(/\n/g, `\n${indent}`);
|
|
83
|
+
}
|
|
84
|
+
return value;
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
class Position {
|
|
69
88
|
line;
|
|
70
89
|
column;
|
|
71
|
-
static from(
|
|
72
|
-
|
|
90
|
+
static from(positionOrLine, column) {
|
|
91
|
+
if (typeof positionOrLine === "number") {
|
|
92
|
+
return new Position(positionOrLine, column);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return new Position(positionOrLine.line, positionOrLine.column);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
static get zero() {
|
|
99
|
+
return new Position(0, 0);
|
|
73
100
|
}
|
|
74
101
|
constructor(line, column) {
|
|
75
102
|
this.line = line;
|
|
@@ -95,10 +122,20 @@ class Position {
|
|
|
95
122
|
class Location {
|
|
96
123
|
start;
|
|
97
124
|
end;
|
|
98
|
-
static from(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
125
|
+
static from(locationOrLine, column, endLine, endColumn) {
|
|
126
|
+
if (typeof locationOrLine === "number") {
|
|
127
|
+
const start = Position.from(locationOrLine, column);
|
|
128
|
+
const end = Position.from(endLine, endColumn);
|
|
129
|
+
return new Location(start, end);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
const start = Position.from(locationOrLine.start);
|
|
133
|
+
const end = Position.from(locationOrLine.end);
|
|
134
|
+
return new Location(start, end);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
static get zero() {
|
|
138
|
+
return new Location(Position.zero, Position.zero);
|
|
102
139
|
}
|
|
103
140
|
constructor(start, end) {
|
|
104
141
|
this.start = start;
|
|
@@ -130,8 +167,16 @@ class Location {
|
|
|
130
167
|
class Range {
|
|
131
168
|
start;
|
|
132
169
|
end;
|
|
133
|
-
static from(
|
|
134
|
-
|
|
170
|
+
static from(rangeOrStart, end) {
|
|
171
|
+
if (typeof rangeOrStart === "number") {
|
|
172
|
+
return new Range(rangeOrStart, end);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
return new Range(rangeOrStart[0], rangeOrStart[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
static get zero() {
|
|
179
|
+
return new Range(0, 0);
|
|
135
180
|
}
|
|
136
181
|
constructor(start, end) {
|
|
137
182
|
this.start = start;
|
|
@@ -196,7 +241,7 @@ class Token {
|
|
|
196
241
|
}
|
|
197
242
|
|
|
198
243
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
199
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
244
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/errors.ts.erb
|
|
200
245
|
class HerbError {
|
|
201
246
|
type;
|
|
202
247
|
message;
|
|
@@ -621,6 +666,84 @@ class RubyParseError extends HerbError {
|
|
|
621
666
|
return output;
|
|
622
667
|
}
|
|
623
668
|
}
|
|
669
|
+
class ERBControlFlowScopeError extends HerbError {
|
|
670
|
+
keyword;
|
|
671
|
+
static from(data) {
|
|
672
|
+
return new ERBControlFlowScopeError({
|
|
673
|
+
type: data.type,
|
|
674
|
+
message: data.message,
|
|
675
|
+
location: Location.from(data.location),
|
|
676
|
+
keyword: data.keyword,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
constructor(props) {
|
|
680
|
+
super(props.type, props.message, props.location);
|
|
681
|
+
this.keyword = props.keyword;
|
|
682
|
+
}
|
|
683
|
+
toJSON() {
|
|
684
|
+
return {
|
|
685
|
+
...super.toJSON(),
|
|
686
|
+
type: "ERB_CONTROL_FLOW_SCOPE_ERROR",
|
|
687
|
+
keyword: this.keyword,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
toMonacoDiagnostic() {
|
|
691
|
+
return {
|
|
692
|
+
line: this.location.start.line,
|
|
693
|
+
column: this.location.start.column,
|
|
694
|
+
endLine: this.location.end.line,
|
|
695
|
+
endColumn: this.location.end.column,
|
|
696
|
+
message: this.message,
|
|
697
|
+
severity: 'error'
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
treeInspect() {
|
|
701
|
+
let output = "";
|
|
702
|
+
output += `@ ERBControlFlowScopeError ${this.location.treeInspectWithLabel()}\n`;
|
|
703
|
+
output += `├── message: "${this.message}"\n`;
|
|
704
|
+
output += `└── keyword: ${JSON.stringify(this.keyword)}\n`;
|
|
705
|
+
return output;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
class MissingERBEndTagError extends HerbError {
|
|
709
|
+
keyword;
|
|
710
|
+
static from(data) {
|
|
711
|
+
return new MissingERBEndTagError({
|
|
712
|
+
type: data.type,
|
|
713
|
+
message: data.message,
|
|
714
|
+
location: Location.from(data.location),
|
|
715
|
+
keyword: data.keyword,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
constructor(props) {
|
|
719
|
+
super(props.type, props.message, props.location);
|
|
720
|
+
this.keyword = props.keyword;
|
|
721
|
+
}
|
|
722
|
+
toJSON() {
|
|
723
|
+
return {
|
|
724
|
+
...super.toJSON(),
|
|
725
|
+
type: "MISSINGERB_END_TAG_ERROR",
|
|
726
|
+
keyword: this.keyword,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
toMonacoDiagnostic() {
|
|
730
|
+
return {
|
|
731
|
+
line: this.location.start.line,
|
|
732
|
+
column: this.location.start.column,
|
|
733
|
+
endLine: this.location.end.line,
|
|
734
|
+
endColumn: this.location.end.column,
|
|
735
|
+
message: this.message,
|
|
736
|
+
severity: 'error'
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
treeInspect() {
|
|
740
|
+
let output = "";
|
|
741
|
+
output += `@ MissingERBEndTagError ${this.location.treeInspectWithLabel()}\n`;
|
|
742
|
+
output += `├── message: "${this.message}"\n`;
|
|
743
|
+
output += `└── keyword: ${JSON.stringify(this.keyword)}\n`;
|
|
744
|
+
return output;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
624
747
|
function fromSerializedError(error) {
|
|
625
748
|
switch (error.type) {
|
|
626
749
|
case "UNEXPECTED_ERROR": return UnexpectedError.from(error);
|
|
@@ -632,6 +755,8 @@ function fromSerializedError(error) {
|
|
|
632
755
|
case "VOID_ELEMENT_CLOSING_TAG_ERROR": return VoidElementClosingTagError.from(error);
|
|
633
756
|
case "UNCLOSED_ELEMENT_ERROR": return UnclosedElementError.from(error);
|
|
634
757
|
case "RUBY_PARSE_ERROR": return RubyParseError.from(error);
|
|
758
|
+
case "ERB_CONTROL_FLOW_SCOPE_ERROR": return ERBControlFlowScopeError.from(error);
|
|
759
|
+
case "MISSINGERB_END_TAG_ERROR": return MissingERBEndTagError.from(error);
|
|
635
760
|
default:
|
|
636
761
|
throw new Error(`Unknown node type: ${error.type}`);
|
|
637
762
|
}
|
|
@@ -646,7 +771,7 @@ function convertToUTF8(string) {
|
|
|
646
771
|
}
|
|
647
772
|
|
|
648
773
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
649
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
774
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/nodes.ts.erb
|
|
650
775
|
class Node {
|
|
651
776
|
type;
|
|
652
777
|
location;
|
|
@@ -2880,7 +3005,7 @@ class ParseResult extends Result {
|
|
|
2880
3005
|
}
|
|
2881
3006
|
|
|
2882
3007
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
2883
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
3008
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/node-type-guards.ts.erb
|
|
2884
3009
|
/**
|
|
2885
3010
|
* Type guard functions for AST nodes.
|
|
2886
3011
|
* These functions provide type checking by combining both instanceof
|
|
@@ -3265,7 +3390,7 @@ function isParseResult(object) {
|
|
|
3265
3390
|
* Checks if a node is an ERB output node (generates content: <%= %> or <%== %>)
|
|
3266
3391
|
*/
|
|
3267
3392
|
function isERBOutputNode(node) {
|
|
3268
|
-
if (!
|
|
3393
|
+
if (!isERBNode(node))
|
|
3269
3394
|
return false;
|
|
3270
3395
|
if (!node.tag_opening?.value)
|
|
3271
3396
|
return false;
|
|
@@ -3367,7 +3492,7 @@ function getNodesAfterPosition(nodes, position, inclusive = true) {
|
|
|
3367
3492
|
}
|
|
3368
3493
|
|
|
3369
3494
|
// NOTE: This file is generated by the templates/template.rb script and should not
|
|
3370
|
-
// be modified manually. See /Users/marcoroth/Development/herb-release-0.
|
|
3495
|
+
// be modified manually. See /Users/marcoroth/Development/herb-release-0.8.1/templates/javascript/packages/core/src/visitor.ts.erb
|
|
3371
3496
|
class Visitor {
|
|
3372
3497
|
visit(node) {
|
|
3373
3498
|
if (!node)
|
|
@@ -3683,6 +3808,11 @@ class Printer extends Visitor {
|
|
|
3683
3808
|
* - Verifying AST round-trip fidelity
|
|
3684
3809
|
*/
|
|
3685
3810
|
class IdentityPrinter extends Printer {
|
|
3811
|
+
static printERBNode(node) {
|
|
3812
|
+
const printer = new IdentityPrinter();
|
|
3813
|
+
printer.printERBNode(node);
|
|
3814
|
+
return printer.context.getOutput();
|
|
3815
|
+
}
|
|
3686
3816
|
visitLiteralNode(node) {
|
|
3687
3817
|
this.write(node.content);
|
|
3688
3818
|
}
|
|
@@ -3970,11 +4100,397 @@ class IdentityPrinter extends Printer {
|
|
|
3970
4100
|
({
|
|
3971
4101
|
...DEFAULT_PRINT_OPTIONS});
|
|
3972
4102
|
|
|
4103
|
+
// --- Constants ---
|
|
3973
4104
|
// TODO: we can probably expand this list with more tags/attributes
|
|
3974
4105
|
const FORMATTABLE_ATTRIBUTES = {
|
|
3975
4106
|
'*': ['class'],
|
|
3976
4107
|
'img': ['srcset', 'sizes']
|
|
3977
4108
|
};
|
|
4109
|
+
const INLINE_ELEMENTS = new Set([
|
|
4110
|
+
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
|
|
4111
|
+
'dfn', 'em', 'hr', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
|
|
4112
|
+
'samp', 'small', 'span', 'strong', 'sub', 'sup',
|
|
4113
|
+
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
|
|
4114
|
+
]);
|
|
4115
|
+
const CONTENT_PRESERVING_ELEMENTS = new Set([
|
|
4116
|
+
'script', 'style', 'pre', 'textarea'
|
|
4117
|
+
]);
|
|
4118
|
+
const SPACEABLE_CONTAINERS = new Set([
|
|
4119
|
+
'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
|
|
4120
|
+
'figure', 'details', 'summary', 'dialog', 'fieldset'
|
|
4121
|
+
]);
|
|
4122
|
+
const TIGHT_GROUP_PARENTS = new Set([
|
|
4123
|
+
'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
|
|
4124
|
+
'tbody', 'tfoot'
|
|
4125
|
+
]);
|
|
4126
|
+
const TIGHT_GROUP_CHILDREN = new Set([
|
|
4127
|
+
'li', 'option', 'td', 'th', 'dt', 'dd'
|
|
4128
|
+
]);
|
|
4129
|
+
const SPACING_THRESHOLD = 3;
|
|
4130
|
+
/**
|
|
4131
|
+
* Token list attributes that contain space-separated values and benefit from
|
|
4132
|
+
* spacing around ERB content for readability
|
|
4133
|
+
*/
|
|
4134
|
+
const TOKEN_LIST_ATTRIBUTES = new Set([
|
|
4135
|
+
'class', 'data-controller', 'data-action'
|
|
4136
|
+
]);
|
|
4137
|
+
// --- Node Utility Functions ---
|
|
4138
|
+
/**
|
|
4139
|
+
* Check if a node is pure whitespace (empty text node with only whitespace)
|
|
4140
|
+
*/
|
|
4141
|
+
function isPureWhitespaceNode(node) {
|
|
4142
|
+
return isNode(node, HTMLTextNode) && node.content.trim() === "";
|
|
4143
|
+
}
|
|
4144
|
+
/**
|
|
4145
|
+
* Check if a node is non-whitespace (has meaningful content)
|
|
4146
|
+
*/
|
|
4147
|
+
function isNonWhitespaceNode(node) {
|
|
4148
|
+
if (isNode(node, WhitespaceNode))
|
|
4149
|
+
return false;
|
|
4150
|
+
if (isNode(node, HTMLTextNode))
|
|
4151
|
+
return node.content.trim() !== "";
|
|
4152
|
+
return true;
|
|
4153
|
+
}
|
|
4154
|
+
/**
|
|
4155
|
+
* Find the previous meaningful (non-whitespace) sibling
|
|
4156
|
+
* Returns -1 if no meaningful sibling is found
|
|
4157
|
+
*/
|
|
4158
|
+
function findPreviousMeaningfulSibling(siblings, currentIndex) {
|
|
4159
|
+
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
4160
|
+
if (isNonWhitespaceNode(siblings[i])) {
|
|
4161
|
+
return i;
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
return -1;
|
|
4165
|
+
}
|
|
4166
|
+
/**
|
|
4167
|
+
* Check if there's whitespace between two indices in children array
|
|
4168
|
+
*/
|
|
4169
|
+
function hasWhitespaceBetween(children, startIndex, endIndex) {
|
|
4170
|
+
for (let j = startIndex + 1; j < endIndex; j++) {
|
|
4171
|
+
if (isNode(children[j], WhitespaceNode) || isPureWhitespaceNode(children[j])) {
|
|
4172
|
+
return true;
|
|
4173
|
+
}
|
|
4174
|
+
}
|
|
4175
|
+
return false;
|
|
4176
|
+
}
|
|
4177
|
+
/**
|
|
4178
|
+
* Filter children to remove insignificant whitespace
|
|
4179
|
+
*/
|
|
4180
|
+
function filterSignificantChildren(body) {
|
|
4181
|
+
return body.filter(child => {
|
|
4182
|
+
if (isNode(child, WhitespaceNode))
|
|
4183
|
+
return false;
|
|
4184
|
+
if (isNode(child, HTMLTextNode)) {
|
|
4185
|
+
if (child.content === " ")
|
|
4186
|
+
return true;
|
|
4187
|
+
return child.content.trim() !== "";
|
|
4188
|
+
}
|
|
4189
|
+
return true;
|
|
4190
|
+
});
|
|
4191
|
+
}
|
|
4192
|
+
/**
|
|
4193
|
+
* Smart filter that preserves exactly ONE whitespace before herb:disable comments
|
|
4194
|
+
*/
|
|
4195
|
+
function filterEmptyNodesForHerbDisable(nodes) {
|
|
4196
|
+
const result = [];
|
|
4197
|
+
let pendingWhitespace = null;
|
|
4198
|
+
for (const node of nodes) {
|
|
4199
|
+
const isWhitespace = isNode(node, WhitespaceNode) || (isNode(node, HTMLTextNode) && node.content.trim() === "");
|
|
4200
|
+
const isHerbDisable = isNode(node, ERBContentNode) && isHerbDisableComment(node);
|
|
4201
|
+
if (isWhitespace) {
|
|
4202
|
+
if (!pendingWhitespace) {
|
|
4203
|
+
pendingWhitespace = node;
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
else {
|
|
4207
|
+
if (isHerbDisable && pendingWhitespace) {
|
|
4208
|
+
result.push(pendingWhitespace);
|
|
4209
|
+
}
|
|
4210
|
+
pendingWhitespace = null;
|
|
4211
|
+
result.push(node);
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
return result;
|
|
4215
|
+
}
|
|
4216
|
+
// --- Punctuation and Word Spacing Functions ---
|
|
4217
|
+
/**
|
|
4218
|
+
* Check if a word is standalone closing punctuation
|
|
4219
|
+
*/
|
|
4220
|
+
function isClosingPunctuation(word) {
|
|
4221
|
+
return /^[.,;:!?)\]]+$/.test(word);
|
|
4222
|
+
}
|
|
4223
|
+
/**
|
|
4224
|
+
* Check if a line ends with opening punctuation
|
|
4225
|
+
*/
|
|
4226
|
+
function lineEndsWithOpeningPunctuation(line) {
|
|
4227
|
+
return /[(\[]$/.test(line);
|
|
4228
|
+
}
|
|
4229
|
+
/**
|
|
4230
|
+
* Check if a string ends with an ERB tag
|
|
4231
|
+
*/
|
|
4232
|
+
function endsWithERBTag(text) {
|
|
4233
|
+
const trimmed = text.trim();
|
|
4234
|
+
return /%>$/.test(trimmed) || /%>\S+$/.test(trimmed);
|
|
4235
|
+
}
|
|
4236
|
+
/**
|
|
4237
|
+
* Check if a string starts with an ERB tag
|
|
4238
|
+
*/
|
|
4239
|
+
function startsWithERBTag(text) {
|
|
4240
|
+
return /^<%/.test(text.trim());
|
|
4241
|
+
}
|
|
4242
|
+
/**
|
|
4243
|
+
* Determine if space is needed between the current line and the next word
|
|
4244
|
+
*/
|
|
4245
|
+
function needsSpaceBetween(currentLine, word) {
|
|
4246
|
+
if (isClosingPunctuation(word))
|
|
4247
|
+
return false;
|
|
4248
|
+
if (lineEndsWithOpeningPunctuation(currentLine))
|
|
4249
|
+
return false;
|
|
4250
|
+
if (currentLine.endsWith(' '))
|
|
4251
|
+
return false;
|
|
4252
|
+
if (word.startsWith(' '))
|
|
4253
|
+
return false;
|
|
4254
|
+
if (endsWithERBTag(currentLine) && startsWithERBTag(word))
|
|
4255
|
+
return false;
|
|
4256
|
+
return true;
|
|
4257
|
+
}
|
|
4258
|
+
/**
|
|
4259
|
+
* Build a line by adding a word with appropriate spacing
|
|
4260
|
+
*/
|
|
4261
|
+
function buildLineWithWord(currentLine, word) {
|
|
4262
|
+
if (!currentLine)
|
|
4263
|
+
return word;
|
|
4264
|
+
if (word === ' ') {
|
|
4265
|
+
return currentLine.endsWith(' ') ? currentLine : `${currentLine} `;
|
|
4266
|
+
}
|
|
4267
|
+
if (isClosingPunctuation(word)) {
|
|
4268
|
+
currentLine = currentLine.trimEnd();
|
|
4269
|
+
return `${currentLine}${word}`;
|
|
4270
|
+
}
|
|
4271
|
+
return needsSpaceBetween(currentLine, word) ? `${currentLine} ${word}` : `${currentLine}${word}`;
|
|
4272
|
+
}
|
|
4273
|
+
/**
|
|
4274
|
+
* Check if a node is an inline element or ERB node
|
|
4275
|
+
*/
|
|
4276
|
+
function isInlineOrERBNode(node) {
|
|
4277
|
+
return isERBNode(node) || (isNode(node, HTMLElementNode) && isInlineElement(getTagName(node)));
|
|
4278
|
+
}
|
|
4279
|
+
/**
|
|
4280
|
+
* Check if an element should be treated as inline based on its tag name
|
|
4281
|
+
*/
|
|
4282
|
+
function isInlineElement(tagName) {
|
|
4283
|
+
return INLINE_ELEMENTS.has(tagName.toLowerCase());
|
|
4284
|
+
}
|
|
4285
|
+
/**
|
|
4286
|
+
* Check if the current inline element is adjacent to a previous inline element (no whitespace between)
|
|
4287
|
+
*/
|
|
4288
|
+
function isAdjacentToPreviousInline(siblings, index) {
|
|
4289
|
+
const previousNode = siblings[index - 1];
|
|
4290
|
+
if (isInlineOrERBNode(previousNode)) {
|
|
4291
|
+
return true;
|
|
4292
|
+
}
|
|
4293
|
+
if (index > 1 && isNode(previousNode, HTMLTextNode) && !/^\s/.test(previousNode.content)) {
|
|
4294
|
+
const twoBack = siblings[index - 2];
|
|
4295
|
+
return isInlineOrERBNode(twoBack);
|
|
4296
|
+
}
|
|
4297
|
+
return false;
|
|
4298
|
+
}
|
|
4299
|
+
/**
|
|
4300
|
+
* Check if a node should be appended to the last line (for adjacent inline elements and punctuation)
|
|
4301
|
+
*/
|
|
4302
|
+
function shouldAppendToLastLine(child, siblings, index) {
|
|
4303
|
+
if (index === 0)
|
|
4304
|
+
return false;
|
|
4305
|
+
if (isNode(child, HTMLTextNode) && !/^\s/.test(child.content)) {
|
|
4306
|
+
const previousNode = siblings[index - 1];
|
|
4307
|
+
return isInlineOrERBNode(previousNode);
|
|
4308
|
+
}
|
|
4309
|
+
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
|
|
4310
|
+
return isAdjacentToPreviousInline(siblings, index);
|
|
4311
|
+
}
|
|
4312
|
+
if (isNode(child, ERBContentNode)) {
|
|
4313
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
4314
|
+
const previousSibling = siblings[i];
|
|
4315
|
+
if (isPureWhitespaceNode(previousSibling) || isNode(previousSibling, WhitespaceNode)) {
|
|
4316
|
+
continue;
|
|
4317
|
+
}
|
|
4318
|
+
if (previousSibling.location && child.location) {
|
|
4319
|
+
return previousSibling.location.end.line === child.location.start.line;
|
|
4320
|
+
}
|
|
4321
|
+
break;
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
return false;
|
|
4325
|
+
}
|
|
4326
|
+
/**
|
|
4327
|
+
* Check if user-intentional spacing should be preserved (double newlines between elements)
|
|
4328
|
+
*/
|
|
4329
|
+
function shouldPreserveUserSpacing(child, siblings, index) {
|
|
4330
|
+
if (!isPureWhitespaceNode(child))
|
|
4331
|
+
return false;
|
|
4332
|
+
const hasPreviousNonWhitespace = index > 0 && isNonWhitespaceNode(siblings[index - 1]);
|
|
4333
|
+
const hasNextNonWhitespace = index < siblings.length - 1 && isNonWhitespaceNode(siblings[index + 1]);
|
|
4334
|
+
const hasMultipleNewlines = isNode(child, HTMLTextNode) && child.content.includes('\n\n');
|
|
4335
|
+
return hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines;
|
|
4336
|
+
}
|
|
4337
|
+
/**
|
|
4338
|
+
* Check if children contain any text content with newlines
|
|
4339
|
+
*/
|
|
4340
|
+
function hasMultilineTextContent(children) {
|
|
4341
|
+
for (const child of children) {
|
|
4342
|
+
if (isNode(child, HTMLTextNode)) {
|
|
4343
|
+
return child.content.includes('\n');
|
|
4344
|
+
}
|
|
4345
|
+
if (isNode(child, HTMLElementNode) && hasMultilineTextContent(child.body)) {
|
|
4346
|
+
return true;
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
return false;
|
|
4350
|
+
}
|
|
4351
|
+
/**
|
|
4352
|
+
* Check if all nested elements in the children are inline elements
|
|
4353
|
+
*/
|
|
4354
|
+
function areAllNestedElementsInline(children) {
|
|
4355
|
+
for (const child of children) {
|
|
4356
|
+
if (isNode(child, HTMLElementNode)) {
|
|
4357
|
+
if (!isInlineElement(getTagName(child))) {
|
|
4358
|
+
return false;
|
|
4359
|
+
}
|
|
4360
|
+
if (!areAllNestedElementsInline(child.body)) {
|
|
4361
|
+
return false;
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
|
|
4365
|
+
return false;
|
|
4366
|
+
}
|
|
4367
|
+
}
|
|
4368
|
+
return true;
|
|
4369
|
+
}
|
|
4370
|
+
/**
|
|
4371
|
+
* Check if element has complex ERB control flow
|
|
4372
|
+
*/
|
|
4373
|
+
function hasComplexERBControlFlow(inlineNodes) {
|
|
4374
|
+
return inlineNodes.some(node => {
|
|
4375
|
+
if (isNode(node, ERBIfNode)) {
|
|
4376
|
+
if (node.statements.length > 0 && node.location) {
|
|
4377
|
+
const startLine = node.location.start.line;
|
|
4378
|
+
const endLine = node.location.end.line;
|
|
4379
|
+
return startLine !== endLine;
|
|
4380
|
+
}
|
|
4381
|
+
return false;
|
|
4382
|
+
}
|
|
4383
|
+
return false;
|
|
4384
|
+
});
|
|
4385
|
+
}
|
|
4386
|
+
/**
|
|
4387
|
+
* Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
|
|
4388
|
+
* or mixed ERB output and text (like "<%= value %> text")
|
|
4389
|
+
* This indicates content that should be formatted inline even with structural newlines
|
|
4390
|
+
*/
|
|
4391
|
+
function hasMixedTextAndInlineContent(children) {
|
|
4392
|
+
let hasText = false;
|
|
4393
|
+
let hasInlineElements = false;
|
|
4394
|
+
for (const child of children) {
|
|
4395
|
+
if (isNode(child, HTMLTextNode)) {
|
|
4396
|
+
if (child.content.trim() !== "") {
|
|
4397
|
+
hasText = true;
|
|
4398
|
+
}
|
|
4399
|
+
}
|
|
4400
|
+
else if (isNode(child, HTMLElementNode)) {
|
|
4401
|
+
if (isInlineElement(getTagName(child))) {
|
|
4402
|
+
hasInlineElements = true;
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
}
|
|
4406
|
+
return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
|
|
4407
|
+
}
|
|
4408
|
+
function isContentPreserving(element) {
|
|
4409
|
+
const tagName = getTagName(element);
|
|
4410
|
+
return CONTENT_PRESERVING_ELEMENTS.has(tagName);
|
|
4411
|
+
}
|
|
4412
|
+
/**
|
|
4413
|
+
* Count consecutive inline elements/ERB at the start of children (with no whitespace between)
|
|
4414
|
+
*/
|
|
4415
|
+
function countAdjacentInlineElements(children) {
|
|
4416
|
+
let count = 0;
|
|
4417
|
+
let lastSignificantIndex = -1;
|
|
4418
|
+
for (let i = 0; i < children.length; i++) {
|
|
4419
|
+
const child = children[i];
|
|
4420
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
4421
|
+
continue;
|
|
4422
|
+
}
|
|
4423
|
+
const isInlineOrERB = (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) || isNode(child, ERBContentNode);
|
|
4424
|
+
if (!isInlineOrERB) {
|
|
4425
|
+
break;
|
|
4426
|
+
}
|
|
4427
|
+
if (lastSignificantIndex >= 0 && hasWhitespaceBetween(children, lastSignificantIndex, i)) {
|
|
4428
|
+
break;
|
|
4429
|
+
}
|
|
4430
|
+
count++;
|
|
4431
|
+
lastSignificantIndex = i;
|
|
4432
|
+
}
|
|
4433
|
+
return count;
|
|
4434
|
+
}
|
|
4435
|
+
/**
|
|
4436
|
+
* Check if a node represents a block-level element
|
|
4437
|
+
*/
|
|
4438
|
+
function isBlockLevelNode(node) {
|
|
4439
|
+
if (!isNode(node, HTMLElementNode)) {
|
|
4440
|
+
return false;
|
|
4441
|
+
}
|
|
4442
|
+
const tagName = getTagName(node);
|
|
4443
|
+
if (INLINE_ELEMENTS.has(tagName)) {
|
|
4444
|
+
return false;
|
|
4445
|
+
}
|
|
4446
|
+
return true;
|
|
4447
|
+
}
|
|
4448
|
+
/**
|
|
4449
|
+
* Check if an element is a line-breaking element (br or hr)
|
|
4450
|
+
*/
|
|
4451
|
+
function isLineBreakingElement(node) {
|
|
4452
|
+
if (!isNode(node, HTMLElementNode)) {
|
|
4453
|
+
return false;
|
|
4454
|
+
}
|
|
4455
|
+
const tagName = getTagName(node);
|
|
4456
|
+
return tagName === 'br' || tagName === 'hr';
|
|
4457
|
+
}
|
|
4458
|
+
/**
|
|
4459
|
+
* Normalize text by replacing multiple spaces with single space and trim
|
|
4460
|
+
* Then split into words
|
|
4461
|
+
*/
|
|
4462
|
+
function normalizeAndSplitWords(text) {
|
|
4463
|
+
const normalized = text.replace(/\s+/g, ' ');
|
|
4464
|
+
return normalized.trim().split(' ');
|
|
4465
|
+
}
|
|
4466
|
+
/**
|
|
4467
|
+
* Check if text ends with whitespace
|
|
4468
|
+
*/
|
|
4469
|
+
function endsWithWhitespace(text) {
|
|
4470
|
+
return /\s$/.test(text);
|
|
4471
|
+
}
|
|
4472
|
+
/**
|
|
4473
|
+
* Check if an ERB content node is a herb:disable comment
|
|
4474
|
+
*/
|
|
4475
|
+
function isHerbDisableComment(node) {
|
|
4476
|
+
if (!isNode(node, ERBContentNode))
|
|
4477
|
+
return false;
|
|
4478
|
+
if (node.tag_opening?.value !== "<%#")
|
|
4479
|
+
return false;
|
|
4480
|
+
const content = node?.content?.value || "";
|
|
4481
|
+
const trimmed = content.trim();
|
|
4482
|
+
return trimmed.startsWith("herb:disable");
|
|
4483
|
+
}
|
|
4484
|
+
/**
|
|
4485
|
+
* Check if a text node is YAML frontmatter (starts and ends with ---)
|
|
4486
|
+
*/
|
|
4487
|
+
function isFrontmatter(node) {
|
|
4488
|
+
if (!isNode(node, HTMLTextNode))
|
|
4489
|
+
return false;
|
|
4490
|
+
const content = node.content.trim();
|
|
4491
|
+
return content.startsWith("---") && /---\s*$/.test(content);
|
|
4492
|
+
}
|
|
4493
|
+
|
|
3978
4494
|
/**
|
|
3979
4495
|
* Printer traverses the Herb AST using the Visitor pattern
|
|
3980
4496
|
* and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
|
|
@@ -3998,28 +4514,6 @@ class FormatPrinter extends Printer {
|
|
|
3998
4514
|
elementStack = [];
|
|
3999
4515
|
elementFormattingAnalysis = new Map();
|
|
4000
4516
|
source;
|
|
4001
|
-
// TODO: extract
|
|
4002
|
-
static INLINE_ELEMENTS = new Set([
|
|
4003
|
-
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
|
|
4004
|
-
'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
|
|
4005
|
-
'samp', 'small', 'span', 'strong', 'sub', 'sup',
|
|
4006
|
-
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
|
|
4007
|
-
]);
|
|
4008
|
-
static CONTENT_PRESERVING_ELEMENTS = new Set([
|
|
4009
|
-
'script', 'style', 'pre', 'textarea'
|
|
4010
|
-
]);
|
|
4011
|
-
static SPACEABLE_CONTAINERS = new Set([
|
|
4012
|
-
'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
|
|
4013
|
-
'figure', 'details', 'summary', 'dialog', 'fieldset'
|
|
4014
|
-
]);
|
|
4015
|
-
static TIGHT_GROUP_PARENTS = new Set([
|
|
4016
|
-
'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead',
|
|
4017
|
-
'tbody', 'tfoot'
|
|
4018
|
-
]);
|
|
4019
|
-
static TIGHT_GROUP_CHILDREN = new Set([
|
|
4020
|
-
'li', 'option', 'td', 'th', 'dt', 'dd'
|
|
4021
|
-
]);
|
|
4022
|
-
static SPACING_THRESHOLD = 3;
|
|
4023
4517
|
constructor(source, options) {
|
|
4024
4518
|
super();
|
|
4025
4519
|
this.source = source;
|
|
@@ -4178,20 +4672,20 @@ class FormatPrinter extends Printer {
|
|
|
4178
4672
|
if (hasMixedContent) {
|
|
4179
4673
|
return false;
|
|
4180
4674
|
}
|
|
4181
|
-
const meaningfulSiblings = siblings.filter(child =>
|
|
4182
|
-
if (meaningfulSiblings.length <
|
|
4675
|
+
const meaningfulSiblings = siblings.filter(child => isNonWhitespaceNode(child));
|
|
4676
|
+
if (meaningfulSiblings.length < SPACING_THRESHOLD) {
|
|
4183
4677
|
return false;
|
|
4184
4678
|
}
|
|
4185
4679
|
const parentTagName = parentElement ? getTagName(parentElement) : null;
|
|
4186
|
-
if (parentTagName &&
|
|
4680
|
+
if (parentTagName && TIGHT_GROUP_PARENTS.has(parentTagName)) {
|
|
4187
4681
|
return false;
|
|
4188
4682
|
}
|
|
4189
|
-
const isSpaceableContainer = !parentTagName || (parentTagName &&
|
|
4683
|
+
const isSpaceableContainer = !parentTagName || (parentTagName && SPACEABLE_CONTAINERS.has(parentTagName));
|
|
4190
4684
|
if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
|
|
4191
4685
|
return false;
|
|
4192
4686
|
}
|
|
4193
4687
|
const currentNode = siblings[currentIndex];
|
|
4194
|
-
const previousMeaningfulIndex =
|
|
4688
|
+
const previousMeaningfulIndex = findPreviousMeaningfulSibling(siblings, currentIndex);
|
|
4195
4689
|
const isCurrentComment = isCommentNode(currentNode);
|
|
4196
4690
|
if (previousMeaningfulIndex !== -1) {
|
|
4197
4691
|
const previousNode = siblings[previousMeaningfulIndex];
|
|
@@ -4205,69 +4699,37 @@ class FormatPrinter extends Printer {
|
|
|
4205
4699
|
}
|
|
4206
4700
|
if (isNode(currentNode, HTMLElementNode)) {
|
|
4207
4701
|
const currentTagName = getTagName(currentNode);
|
|
4208
|
-
if (
|
|
4702
|
+
if (INLINE_ELEMENTS.has(currentTagName)) {
|
|
4209
4703
|
return false;
|
|
4210
4704
|
}
|
|
4211
|
-
if (
|
|
4705
|
+
if (TIGHT_GROUP_CHILDREN.has(currentTagName)) {
|
|
4212
4706
|
return false;
|
|
4213
4707
|
}
|
|
4214
4708
|
if (currentTagName === 'a' && parentTagName === 'nav') {
|
|
4215
4709
|
return false;
|
|
4216
4710
|
}
|
|
4217
4711
|
}
|
|
4218
|
-
const isBlockElement =
|
|
4712
|
+
const isBlockElement = isBlockLevelNode(currentNode);
|
|
4219
4713
|
const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode);
|
|
4220
4714
|
const isComment = isCommentNode(currentNode);
|
|
4221
4715
|
return isBlockElement || isERBBlock || isComment;
|
|
4222
4716
|
}
|
|
4223
|
-
/**
|
|
4224
|
-
* Token list attributes that contain space-separated values and benefit from
|
|
4225
|
-
* spacing around ERB content for readability
|
|
4226
|
-
*/
|
|
4227
|
-
static TOKEN_LIST_ATTRIBUTES = new Set([
|
|
4228
|
-
'class', 'data-controller', 'data-action'
|
|
4229
|
-
]);
|
|
4230
4717
|
/**
|
|
4231
4718
|
* Check if we're currently processing a token list attribute that needs spacing
|
|
4232
4719
|
*/
|
|
4233
|
-
isInTokenListAttribute() {
|
|
4234
|
-
return this.currentAttributeName !== null &&
|
|
4235
|
-
FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
|
|
4720
|
+
get isInTokenListAttribute() {
|
|
4721
|
+
return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName);
|
|
4236
4722
|
}
|
|
4237
4723
|
/**
|
|
4238
|
-
*
|
|
4724
|
+
* Render attributes as a space-separated string
|
|
4239
4725
|
*/
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
}
|
|
4245
|
-
}
|
|
4246
|
-
return -1;
|
|
4726
|
+
renderAttributesString(attributes) {
|
|
4727
|
+
if (attributes.length === 0)
|
|
4728
|
+
return "";
|
|
4729
|
+
return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
|
|
4247
4730
|
}
|
|
4248
4731
|
/**
|
|
4249
|
-
*
|
|
4250
|
-
*/
|
|
4251
|
-
isBlockLevelNode(node) {
|
|
4252
|
-
if (!isNode(node, HTMLElementNode)) {
|
|
4253
|
-
return false;
|
|
4254
|
-
}
|
|
4255
|
-
const tagName = getTagName(node);
|
|
4256
|
-
if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
|
|
4257
|
-
return false;
|
|
4258
|
-
}
|
|
4259
|
-
return true;
|
|
4260
|
-
}
|
|
4261
|
-
/**
|
|
4262
|
-
* Render attributes as a space-separated string
|
|
4263
|
-
*/
|
|
4264
|
-
renderAttributesString(attributes) {
|
|
4265
|
-
if (attributes.length === 0)
|
|
4266
|
-
return "";
|
|
4267
|
-
return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`;
|
|
4268
|
-
}
|
|
4269
|
-
/**
|
|
4270
|
-
* Determine if a tag should be rendered inline based on attribute count and other factors
|
|
4732
|
+
* Determine if a tag should be rendered inline based on attribute count and other factors
|
|
4271
4733
|
*/
|
|
4272
4734
|
shouldRenderInline(totalAttributeCount, inlineLength, indentLength, maxLineLength = this.maxLineLength, hasComplexERB = false, hasMultilineAttributes = false, attributes = []) {
|
|
4273
4735
|
if (hasComplexERB || hasMultilineAttributes)
|
|
@@ -4294,9 +4756,6 @@ class FormatPrinter extends Printer {
|
|
|
4294
4756
|
}
|
|
4295
4757
|
return true;
|
|
4296
4758
|
}
|
|
4297
|
-
getAttributeName(attribute) {
|
|
4298
|
-
return attribute.name ? getCombinedAttributeName(attribute.name) : "";
|
|
4299
|
-
}
|
|
4300
4759
|
wouldClassAttributeBeMultiline(content, indentLength) {
|
|
4301
4760
|
const normalizedContent = content.replace(/\s+/g, ' ').trim();
|
|
4302
4761
|
const hasActualNewlines = /\r?\n/.test(content);
|
|
@@ -4318,6 +4777,11 @@ class FormatPrinter extends Printer {
|
|
|
4318
4777
|
}
|
|
4319
4778
|
return false;
|
|
4320
4779
|
}
|
|
4780
|
+
// TOOD: extract to core or reuse function from core
|
|
4781
|
+
getAttributeName(attribute) {
|
|
4782
|
+
return attribute.name ? getCombinedAttributeName(attribute.name) : "";
|
|
4783
|
+
}
|
|
4784
|
+
// TOOD: extract to core or reuse function from core
|
|
4321
4785
|
getAttributeValue(attribute) {
|
|
4322
4786
|
if (isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
4323
4787
|
return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('');
|
|
@@ -4453,28 +4917,38 @@ class FormatPrinter extends Printer {
|
|
|
4453
4917
|
}
|
|
4454
4918
|
// --- Visitor methods ---
|
|
4455
4919
|
visitDocumentNode(node) {
|
|
4920
|
+
const children = this.formatFrontmatter(node);
|
|
4921
|
+
const hasTextFlow = this.isInTextFlowContext(null, children);
|
|
4922
|
+
if (hasTextFlow) {
|
|
4923
|
+
const wasInlineMode = this.inlineMode;
|
|
4924
|
+
this.inlineMode = true;
|
|
4925
|
+
this.visitTextFlowChildren(children);
|
|
4926
|
+
this.inlineMode = wasInlineMode;
|
|
4927
|
+
return;
|
|
4928
|
+
}
|
|
4456
4929
|
let lastWasMeaningful = false;
|
|
4457
4930
|
let hasHandledSpacing = false;
|
|
4458
|
-
for (let i = 0; i <
|
|
4459
|
-
const child =
|
|
4460
|
-
if (
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
this.push("");
|
|
4468
|
-
hasHandledSpacing = true;
|
|
4469
|
-
}
|
|
4470
|
-
continue;
|
|
4471
|
-
}
|
|
4931
|
+
for (let i = 0; i < children.length; i++) {
|
|
4932
|
+
const child = children[i];
|
|
4933
|
+
if (shouldPreserveUserSpacing(child, children, i)) {
|
|
4934
|
+
this.push("");
|
|
4935
|
+
hasHandledSpacing = true;
|
|
4936
|
+
continue;
|
|
4937
|
+
}
|
|
4938
|
+
if (isPureWhitespaceNode(child)) {
|
|
4939
|
+
continue;
|
|
4472
4940
|
}
|
|
4473
|
-
if (
|
|
4941
|
+
if (shouldAppendToLastLine(child, children, i)) {
|
|
4942
|
+
this.appendChildToLastLine(child, children, i);
|
|
4943
|
+
lastWasMeaningful = true;
|
|
4944
|
+
hasHandledSpacing = false;
|
|
4945
|
+
continue;
|
|
4946
|
+
}
|
|
4947
|
+
if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
4474
4948
|
this.push("");
|
|
4475
4949
|
}
|
|
4476
4950
|
this.visit(child);
|
|
4477
|
-
if (
|
|
4951
|
+
if (isNonWhitespaceNode(child)) {
|
|
4478
4952
|
lastWasMeaningful = true;
|
|
4479
4953
|
hasHandledSpacing = false;
|
|
4480
4954
|
}
|
|
@@ -4483,6 +4957,12 @@ class FormatPrinter extends Printer {
|
|
|
4483
4957
|
visitHTMLElementNode(node) {
|
|
4484
4958
|
this.elementStack.push(node);
|
|
4485
4959
|
this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node));
|
|
4960
|
+
if (this.inlineMode && node.is_void && this.indentLevel === 0) {
|
|
4961
|
+
const openTag = this.capture(() => this.visit(node.open_tag)).join('');
|
|
4962
|
+
this.pushToLastLine(openTag);
|
|
4963
|
+
this.elementStack.pop();
|
|
4964
|
+
return;
|
|
4965
|
+
}
|
|
4486
4966
|
this.visit(node.open_tag);
|
|
4487
4967
|
if (node.body.length > 0) {
|
|
4488
4968
|
this.visitHTMLElementBody(node.body, node);
|
|
@@ -4493,7 +4973,8 @@ class FormatPrinter extends Printer {
|
|
|
4493
4973
|
this.elementStack.pop();
|
|
4494
4974
|
}
|
|
4495
4975
|
visitHTMLElementBody(body, element) {
|
|
4496
|
-
|
|
4976
|
+
const tagName = getTagName(element);
|
|
4977
|
+
if (isContentPreserving(element)) {
|
|
4497
4978
|
element.body.map(child => {
|
|
4498
4979
|
if (isNode(child, HTMLElementNode)) {
|
|
4499
4980
|
const wasInlineMode = this.inlineMode;
|
|
@@ -4510,12 +4991,14 @@ class FormatPrinter extends Printer {
|
|
|
4510
4991
|
}
|
|
4511
4992
|
const analysis = this.elementFormattingAnalysis.get(element);
|
|
4512
4993
|
const hasTextFlow = this.isInTextFlowContext(null, body);
|
|
4513
|
-
const children =
|
|
4994
|
+
const children = filterSignificantChildren(body);
|
|
4514
4995
|
if (analysis?.elementContentInline) {
|
|
4515
4996
|
if (children.length === 0)
|
|
4516
4997
|
return;
|
|
4517
4998
|
const oldInlineMode = this.inlineMode;
|
|
4518
4999
|
const nodesToRender = hasTextFlow ? body : children;
|
|
5000
|
+
const hasOnlyTextContent = nodesToRender.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
|
|
5001
|
+
const shouldPreserveSpaces = hasOnlyTextContent && isInlineElement(tagName);
|
|
4519
5002
|
this.inlineMode = true;
|
|
4520
5003
|
const lines = this.capture(() => {
|
|
4521
5004
|
nodesToRender.forEach(child => {
|
|
@@ -4530,10 +5013,19 @@ class FormatPrinter extends Printer {
|
|
|
4530
5013
|
}
|
|
4531
5014
|
}
|
|
4532
5015
|
else {
|
|
4533
|
-
const normalizedContent = child.content.replace(/\s+/g, ' ')
|
|
4534
|
-
if (normalizedContent) {
|
|
5016
|
+
const normalizedContent = child.content.replace(/\s+/g, ' ');
|
|
5017
|
+
if (shouldPreserveSpaces && normalizedContent) {
|
|
4535
5018
|
this.push(normalizedContent);
|
|
4536
5019
|
}
|
|
5020
|
+
else {
|
|
5021
|
+
const trimmedContent = normalizedContent.trim();
|
|
5022
|
+
if (trimmedContent) {
|
|
5023
|
+
this.push(trimmedContent);
|
|
5024
|
+
}
|
|
5025
|
+
else if (normalizedContent === ' ') {
|
|
5026
|
+
this.push(' ');
|
|
5027
|
+
}
|
|
5028
|
+
}
|
|
4537
5029
|
}
|
|
4538
5030
|
}
|
|
4539
5031
|
else if (isNode(child, WhitespaceNode)) {
|
|
@@ -4545,7 +5037,9 @@ class FormatPrinter extends Printer {
|
|
|
4545
5037
|
});
|
|
4546
5038
|
});
|
|
4547
5039
|
const content = lines.join('');
|
|
4548
|
-
const inlineContent =
|
|
5040
|
+
const inlineContent = shouldPreserveSpaces
|
|
5041
|
+
? (hasTextFlow ? content.replace(/\s+/g, ' ') : content)
|
|
5042
|
+
: (hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim());
|
|
4549
5043
|
if (inlineContent) {
|
|
4550
5044
|
this.pushToLastLine(inlineContent);
|
|
4551
5045
|
}
|
|
@@ -4554,12 +5048,69 @@ class FormatPrinter extends Printer {
|
|
|
4554
5048
|
}
|
|
4555
5049
|
if (children.length === 0)
|
|
4556
5050
|
return;
|
|
5051
|
+
let leadingHerbDisableComment = null;
|
|
5052
|
+
let leadingHerbDisableIndex = -1;
|
|
5053
|
+
let firstWhitespaceIndex = -1;
|
|
5054
|
+
let remainingChildren = children;
|
|
5055
|
+
let remainingBodyUnfiltered = body;
|
|
5056
|
+
for (let i = 0; i < children.length; i++) {
|
|
5057
|
+
const child = children[i];
|
|
5058
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5059
|
+
if (firstWhitespaceIndex < 0) {
|
|
5060
|
+
firstWhitespaceIndex = i;
|
|
5061
|
+
}
|
|
5062
|
+
continue;
|
|
5063
|
+
}
|
|
5064
|
+
if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
|
|
5065
|
+
leadingHerbDisableComment = child;
|
|
5066
|
+
leadingHerbDisableIndex = i;
|
|
5067
|
+
}
|
|
5068
|
+
break;
|
|
5069
|
+
}
|
|
5070
|
+
if (leadingHerbDisableComment && leadingHerbDisableIndex >= 0) {
|
|
5071
|
+
remainingChildren = children.filter((_, index) => {
|
|
5072
|
+
if (index === leadingHerbDisableIndex)
|
|
5073
|
+
return false;
|
|
5074
|
+
if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
|
|
5075
|
+
const child = children[index];
|
|
5076
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5077
|
+
return false;
|
|
5078
|
+
}
|
|
5079
|
+
}
|
|
5080
|
+
return true;
|
|
5081
|
+
});
|
|
5082
|
+
remainingBodyUnfiltered = body.filter((_, index) => {
|
|
5083
|
+
if (index === leadingHerbDisableIndex)
|
|
5084
|
+
return false;
|
|
5085
|
+
if (firstWhitespaceIndex >= 0 && index === leadingHerbDisableIndex - 1) {
|
|
5086
|
+
const child = body[index];
|
|
5087
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5088
|
+
return false;
|
|
5089
|
+
}
|
|
5090
|
+
}
|
|
5091
|
+
return true;
|
|
5092
|
+
});
|
|
5093
|
+
}
|
|
5094
|
+
if (leadingHerbDisableComment) {
|
|
5095
|
+
const herbDisableString = this.capture(() => {
|
|
5096
|
+
const savedIndentLevel = this.indentLevel;
|
|
5097
|
+
this.indentLevel = 0;
|
|
5098
|
+
this.inlineMode = true;
|
|
5099
|
+
this.visit(leadingHerbDisableComment);
|
|
5100
|
+
this.inlineMode = false;
|
|
5101
|
+
this.indentLevel = savedIndentLevel;
|
|
5102
|
+
}).join("");
|
|
5103
|
+
const hasLeadingWhitespace = firstWhitespaceIndex >= 0 && firstWhitespaceIndex < leadingHerbDisableIndex;
|
|
5104
|
+
this.pushToLastLine((hasLeadingWhitespace ? ' ' : '') + herbDisableString);
|
|
5105
|
+
}
|
|
5106
|
+
if (remainingChildren.length === 0)
|
|
5107
|
+
return;
|
|
4557
5108
|
this.withIndent(() => {
|
|
4558
5109
|
if (hasTextFlow) {
|
|
4559
|
-
this.visitTextFlowChildren(
|
|
5110
|
+
this.visitTextFlowChildren(remainingBodyUnfiltered);
|
|
4560
5111
|
}
|
|
4561
5112
|
else {
|
|
4562
|
-
this.visitElementChildren(body, element);
|
|
5113
|
+
this.visitElementChildren(leadingHerbDisableComment ? remainingChildren : body, element);
|
|
4563
5114
|
}
|
|
4564
5115
|
});
|
|
4565
5116
|
}
|
|
@@ -4574,8 +5125,8 @@ class FormatPrinter extends Printer {
|
|
|
4574
5125
|
if (isNode(child, HTMLTextNode)) {
|
|
4575
5126
|
const isWhitespaceOnly = child.content.trim() === "";
|
|
4576
5127
|
if (isWhitespaceOnly) {
|
|
4577
|
-
const hasPreviousNonWhitespace = i > 0 &&
|
|
4578
|
-
const hasNextNonWhitespace = i < body.length - 1 &&
|
|
5128
|
+
const hasPreviousNonWhitespace = i > 0 && isNonWhitespaceNode(body[i - 1]);
|
|
5129
|
+
const hasNextNonWhitespace = i < body.length - 1 && isNonWhitespaceNode(body[i + 1]);
|
|
4579
5130
|
const hasMultipleNewlines = child.content.includes('\n\n');
|
|
4580
5131
|
if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
|
|
4581
5132
|
this.push("");
|
|
@@ -4584,7 +5135,7 @@ class FormatPrinter extends Printer {
|
|
|
4584
5135
|
continue;
|
|
4585
5136
|
}
|
|
4586
5137
|
}
|
|
4587
|
-
if (
|
|
5138
|
+
if (isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
4588
5139
|
const element = body[i - 1];
|
|
4589
5140
|
const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2);
|
|
4590
5141
|
const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(parentElement, body, i, hasExistingSpacing);
|
|
@@ -4592,8 +5143,35 @@ class FormatPrinter extends Printer {
|
|
|
4592
5143
|
this.push("");
|
|
4593
5144
|
}
|
|
4594
5145
|
}
|
|
4595
|
-
|
|
4596
|
-
if (
|
|
5146
|
+
let hasTrailingHerbDisable = false;
|
|
5147
|
+
if (isNode(child, HTMLElementNode) && child.close_tag) {
|
|
5148
|
+
for (let j = i + 1; j < body.length; j++) {
|
|
5149
|
+
const nextChild = body[j];
|
|
5150
|
+
if (isNode(nextChild, WhitespaceNode) || isPureWhitespaceNode(nextChild)) {
|
|
5151
|
+
continue;
|
|
5152
|
+
}
|
|
5153
|
+
if (isNode(nextChild, ERBContentNode) && isHerbDisableComment(nextChild)) {
|
|
5154
|
+
hasTrailingHerbDisable = true;
|
|
5155
|
+
this.visit(child);
|
|
5156
|
+
const herbDisableString = this.capture(() => {
|
|
5157
|
+
const savedIndentLevel = this.indentLevel;
|
|
5158
|
+
this.indentLevel = 0;
|
|
5159
|
+
this.inlineMode = true;
|
|
5160
|
+
this.visit(nextChild);
|
|
5161
|
+
this.inlineMode = false;
|
|
5162
|
+
this.indentLevel = savedIndentLevel;
|
|
5163
|
+
}).join("");
|
|
5164
|
+
this.pushToLastLine(' ' + herbDisableString);
|
|
5165
|
+
i = j;
|
|
5166
|
+
break;
|
|
5167
|
+
}
|
|
5168
|
+
break;
|
|
5169
|
+
}
|
|
5170
|
+
}
|
|
5171
|
+
if (!hasTrailingHerbDisable) {
|
|
5172
|
+
this.visit(child);
|
|
5173
|
+
}
|
|
5174
|
+
if (isNonWhitespaceNode(child)) {
|
|
4597
5175
|
lastWasMeaningful = true;
|
|
4598
5176
|
hasHandledSpacing = false;
|
|
4599
5177
|
}
|
|
@@ -4683,8 +5261,8 @@ class FormatPrinter extends Printer {
|
|
|
4683
5261
|
if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
|
|
4684
5262
|
return child.content;
|
|
4685
5263
|
}
|
|
4686
|
-
else if (isERBNode(child)
|
|
4687
|
-
return
|
|
5264
|
+
else if (isERBNode(child)) {
|
|
5265
|
+
return IdentityPrinter.print(child);
|
|
4688
5266
|
}
|
|
4689
5267
|
else {
|
|
4690
5268
|
return "";
|
|
@@ -4737,11 +5315,21 @@ class FormatPrinter extends Printer {
|
|
|
4737
5315
|
if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
|
|
4738
5316
|
const startsWithSpace = content[0] === " ";
|
|
4739
5317
|
const before = startsWithSpace ? "" : " ";
|
|
4740
|
-
this.
|
|
5318
|
+
if (this.inlineMode) {
|
|
5319
|
+
this.push(open + before + content.trimEnd() + ' ' + close);
|
|
5320
|
+
}
|
|
5321
|
+
else {
|
|
5322
|
+
this.pushWithIndent(open + before + content.trimEnd() + ' ' + close);
|
|
5323
|
+
}
|
|
4741
5324
|
return;
|
|
4742
5325
|
}
|
|
4743
5326
|
if (contentTrimmedLines.length === 1) {
|
|
4744
|
-
this.
|
|
5327
|
+
if (this.inlineMode) {
|
|
5328
|
+
this.push(open + ' ' + content.trim() + ' ' + close);
|
|
5329
|
+
}
|
|
5330
|
+
else {
|
|
5331
|
+
this.pushWithIndent(open + ' ' + content.trim() + ' ' + close);
|
|
5332
|
+
}
|
|
4745
5333
|
return;
|
|
4746
5334
|
}
|
|
4747
5335
|
const firstLineEmpty = contentLines[0].trim() === "";
|
|
@@ -4782,6 +5370,7 @@ class FormatPrinter extends Printer {
|
|
|
4782
5370
|
}
|
|
4783
5371
|
visitERBCaseMatchNode(node) {
|
|
4784
5372
|
this.printERBNode(node);
|
|
5373
|
+
this.withIndent(() => this.visitAll(node.children));
|
|
4785
5374
|
this.visitAll(node.conditions);
|
|
4786
5375
|
if (node.else_clause)
|
|
4787
5376
|
this.visit(node.else_clause);
|
|
@@ -4790,7 +5379,15 @@ class FormatPrinter extends Printer {
|
|
|
4790
5379
|
}
|
|
4791
5380
|
visitERBBlockNode(node) {
|
|
4792
5381
|
this.printERBNode(node);
|
|
4793
|
-
this.withIndent(() =>
|
|
5382
|
+
this.withIndent(() => {
|
|
5383
|
+
const hasTextFlow = this.isInTextFlowContext(null, node.body);
|
|
5384
|
+
if (hasTextFlow) {
|
|
5385
|
+
this.visitTextFlowChildren(node.body);
|
|
5386
|
+
}
|
|
5387
|
+
else {
|
|
5388
|
+
this.visitElementChildren(node.body, null);
|
|
5389
|
+
}
|
|
5390
|
+
});
|
|
4794
5391
|
if (node.end_node)
|
|
4795
5392
|
this.visit(node.end_node);
|
|
4796
5393
|
}
|
|
@@ -4803,7 +5400,7 @@ class FormatPrinter extends Printer {
|
|
|
4803
5400
|
this.lines.push(this.renderAttribute(child));
|
|
4804
5401
|
}
|
|
4805
5402
|
else {
|
|
4806
|
-
const shouldAddSpaces = this.isInTokenListAttribute
|
|
5403
|
+
const shouldAddSpaces = this.isInTokenListAttribute;
|
|
4807
5404
|
if (shouldAddSpaces) {
|
|
4808
5405
|
this.lines.push(" ");
|
|
4809
5406
|
}
|
|
@@ -4814,12 +5411,12 @@ class FormatPrinter extends Printer {
|
|
|
4814
5411
|
}
|
|
4815
5412
|
});
|
|
4816
5413
|
const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode));
|
|
4817
|
-
const isTokenList = this.isInTokenListAttribute
|
|
5414
|
+
const isTokenList = this.isInTokenListAttribute;
|
|
4818
5415
|
if ((hasHTMLAttributes || isTokenList) && node.end_node) {
|
|
4819
5416
|
this.lines.push(" ");
|
|
4820
5417
|
}
|
|
4821
5418
|
if (node.subsequent)
|
|
4822
|
-
this.visit(node.
|
|
5419
|
+
this.visit(node.subsequent);
|
|
4823
5420
|
if (node.end_node)
|
|
4824
5421
|
this.visit(node.end_node);
|
|
4825
5422
|
}
|
|
@@ -4835,8 +5432,14 @@ class FormatPrinter extends Printer {
|
|
|
4835
5432
|
}
|
|
4836
5433
|
}
|
|
4837
5434
|
visitERBElseNode(node) {
|
|
4838
|
-
this.
|
|
4839
|
-
|
|
5435
|
+
if (this.inlineMode) {
|
|
5436
|
+
this.printERBNode(node);
|
|
5437
|
+
node.statements.forEach(statement => this.visit(statement));
|
|
5438
|
+
}
|
|
5439
|
+
else {
|
|
5440
|
+
this.printERBNode(node);
|
|
5441
|
+
this.withIndent(() => node.statements.forEach(statement => this.visit(statement)));
|
|
5442
|
+
}
|
|
4840
5443
|
}
|
|
4841
5444
|
visitERBWhenNode(node) {
|
|
4842
5445
|
this.printERBNode(node);
|
|
@@ -4844,6 +5447,7 @@ class FormatPrinter extends Printer {
|
|
|
4844
5447
|
}
|
|
4845
5448
|
visitERBCaseNode(node) {
|
|
4846
5449
|
this.printERBNode(node);
|
|
5450
|
+
this.withIndent(() => this.visitAll(node.children));
|
|
4847
5451
|
this.visitAll(node.conditions);
|
|
4848
5452
|
if (node.else_clause)
|
|
4849
5453
|
this.visit(node.else_clause);
|
|
@@ -4918,7 +5522,7 @@ class FormatPrinter extends Printer {
|
|
|
4918
5522
|
const attributes = filterNodes(children, HTMLAttributeNode);
|
|
4919
5523
|
const inlineNodes = this.extractInlineNodes(children);
|
|
4920
5524
|
const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node));
|
|
4921
|
-
const hasComplexERB = hasERBControlFlow &&
|
|
5525
|
+
const hasComplexERB = hasERBControlFlow && hasComplexERBControlFlow(inlineNodes);
|
|
4922
5526
|
if (hasComplexERB)
|
|
4923
5527
|
return false;
|
|
4924
5528
|
const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
|
|
@@ -4933,26 +5537,38 @@ class FormatPrinter extends Printer {
|
|
|
4933
5537
|
*/
|
|
4934
5538
|
shouldRenderElementContentInline(node) {
|
|
4935
5539
|
const tagName = getTagName(node);
|
|
4936
|
-
const children =
|
|
4937
|
-
const isInlineElement = this.isInlineElement(tagName);
|
|
5540
|
+
const children = filterSignificantChildren(node.body);
|
|
4938
5541
|
const openTagInline = this.shouldRenderOpenTagInline(node);
|
|
4939
5542
|
if (!openTagInline)
|
|
4940
5543
|
return false;
|
|
4941
5544
|
if (children.length === 0)
|
|
4942
5545
|
return true;
|
|
4943
|
-
|
|
4944
|
-
|
|
5546
|
+
let hasLeadingHerbDisable = false;
|
|
5547
|
+
for (const child of node.body) {
|
|
5548
|
+
if (isNode(child, WhitespaceNode) || isPureWhitespaceNode(child)) {
|
|
5549
|
+
continue;
|
|
5550
|
+
}
|
|
5551
|
+
if (isNode(child, ERBContentNode) && isHerbDisableComment(child)) {
|
|
5552
|
+
hasLeadingHerbDisable = true;
|
|
5553
|
+
}
|
|
5554
|
+
break;
|
|
5555
|
+
}
|
|
5556
|
+
if (hasLeadingHerbDisable && !isInlineElement(tagName)) {
|
|
5557
|
+
return false;
|
|
5558
|
+
}
|
|
5559
|
+
if (isInlineElement(tagName)) {
|
|
5560
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
|
|
4945
5561
|
if (fullInlineResult) {
|
|
4946
5562
|
const totalLength = this.indent.length + fullInlineResult.length;
|
|
4947
5563
|
return totalLength <= this.maxLineLength || totalLength <= 120;
|
|
4948
5564
|
}
|
|
4949
5565
|
return false;
|
|
4950
5566
|
}
|
|
4951
|
-
const allNestedAreInline =
|
|
4952
|
-
const hasMultilineText =
|
|
4953
|
-
const hasMixedContent =
|
|
5567
|
+
const allNestedAreInline = areAllNestedElementsInline(children);
|
|
5568
|
+
const hasMultilineText = hasMultilineTextContent(children);
|
|
5569
|
+
const hasMixedContent = hasMixedTextAndInlineContent(children);
|
|
4954
5570
|
if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
|
|
4955
|
-
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode),
|
|
5571
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), node.body);
|
|
4956
5572
|
if (fullInlineResult) {
|
|
4957
5573
|
const totalLength = this.indent.length + fullInlineResult.length;
|
|
4958
5574
|
if (totalLength <= this.maxLineLength) {
|
|
@@ -4979,117 +5595,555 @@ class FormatPrinter extends Printer {
|
|
|
4979
5595
|
return true;
|
|
4980
5596
|
if (node.open_tag?.tag_closing?.value === "/>")
|
|
4981
5597
|
return true;
|
|
4982
|
-
if (
|
|
5598
|
+
if (isContentPreserving(node))
|
|
4983
5599
|
return true;
|
|
4984
|
-
const children =
|
|
5600
|
+
const children = filterSignificantChildren(node.body);
|
|
4985
5601
|
if (children.length === 0)
|
|
4986
5602
|
return true;
|
|
4987
5603
|
return elementContentInline;
|
|
4988
5604
|
}
|
|
4989
5605
|
// --- Utility methods ---
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
if (
|
|
4994
|
-
return node.
|
|
4995
|
-
|
|
5606
|
+
formatFrontmatter(node) {
|
|
5607
|
+
const firstChild = node.children[0];
|
|
5608
|
+
const hasFrontmatter = firstChild && isFrontmatter(firstChild);
|
|
5609
|
+
if (!hasFrontmatter)
|
|
5610
|
+
return node.children;
|
|
5611
|
+
this.push(firstChild.content.trimEnd());
|
|
5612
|
+
const remaining = node.children.slice(1);
|
|
5613
|
+
if (remaining.length > 0)
|
|
5614
|
+
this.push("");
|
|
5615
|
+
return remaining;
|
|
4996
5616
|
}
|
|
4997
5617
|
/**
|
|
4998
|
-
*
|
|
5618
|
+
* Append a child node to the last output line
|
|
4999
5619
|
*/
|
|
5000
|
-
|
|
5001
|
-
|
|
5620
|
+
appendChildToLastLine(child, siblings, index) {
|
|
5621
|
+
if (isNode(child, HTMLTextNode)) {
|
|
5622
|
+
this.pushToLastLine(child.content.trim());
|
|
5623
|
+
}
|
|
5624
|
+
else {
|
|
5625
|
+
let hasSpaceBefore = false;
|
|
5626
|
+
if (siblings && index !== undefined && index > 0) {
|
|
5627
|
+
const prevSibling = siblings[index - 1];
|
|
5628
|
+
if (isPureWhitespaceNode(prevSibling) || isNode(prevSibling, WhitespaceNode)) {
|
|
5629
|
+
hasSpaceBefore = true;
|
|
5630
|
+
}
|
|
5631
|
+
}
|
|
5632
|
+
const oldInlineMode = this.inlineMode;
|
|
5633
|
+
this.inlineMode = true;
|
|
5634
|
+
const inlineContent = this.capture(() => this.visit(child)).join("");
|
|
5635
|
+
this.inlineMode = oldInlineMode;
|
|
5636
|
+
this.pushToLastLine((hasSpaceBefore ? " " : "") + inlineContent);
|
|
5637
|
+
}
|
|
5002
5638
|
}
|
|
5003
5639
|
/**
|
|
5004
|
-
*
|
|
5640
|
+
* Visit children in a text flow context (mixed text and inline elements)
|
|
5641
|
+
* Handles word wrapping and keeps adjacent inline elements together
|
|
5005
5642
|
*/
|
|
5006
5643
|
visitTextFlowChildren(children) {
|
|
5007
|
-
|
|
5008
|
-
|
|
5009
|
-
|
|
5010
|
-
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5644
|
+
const adjacentInlineCount = countAdjacentInlineElements(children);
|
|
5645
|
+
if (adjacentInlineCount >= 2) {
|
|
5646
|
+
const { processedIndices } = this.renderAdjacentInlineElements(children, adjacentInlineCount);
|
|
5647
|
+
this.visitRemainingChildren(children, processedIndices);
|
|
5648
|
+
return;
|
|
5649
|
+
}
|
|
5650
|
+
this.buildAndWrapTextFlow(children);
|
|
5651
|
+
}
|
|
5652
|
+
/**
|
|
5653
|
+
* Wrap remaining words that don't fit on the current line
|
|
5654
|
+
* Returns the wrapped lines with proper indentation
|
|
5655
|
+
*/
|
|
5656
|
+
wrapRemainingWords(words, wrapWidth) {
|
|
5657
|
+
const lines = [];
|
|
5658
|
+
let line = "";
|
|
5659
|
+
for (const word of words) {
|
|
5660
|
+
const testLine = line + (line ? " " : "") + word;
|
|
5661
|
+
if (testLine.length > wrapWidth && line) {
|
|
5662
|
+
lines.push(this.indent + line);
|
|
5663
|
+
line = word;
|
|
5664
|
+
}
|
|
5665
|
+
else {
|
|
5666
|
+
line = testLine;
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
if (line) {
|
|
5670
|
+
lines.push(this.indent + line);
|
|
5671
|
+
}
|
|
5672
|
+
return lines;
|
|
5673
|
+
}
|
|
5674
|
+
/**
|
|
5675
|
+
* Try to merge text starting with punctuation to inline content
|
|
5676
|
+
* Returns object with merged content and whether processing should stop
|
|
5677
|
+
*/
|
|
5678
|
+
tryMergePunctuationText(inlineContent, trimmedText, wrapWidth) {
|
|
5679
|
+
const combined = inlineContent + trimmedText;
|
|
5680
|
+
if (combined.length <= wrapWidth) {
|
|
5681
|
+
return {
|
|
5682
|
+
mergedContent: inlineContent + trimmedText,
|
|
5683
|
+
shouldStop: false,
|
|
5684
|
+
wrappedLines: []
|
|
5685
|
+
};
|
|
5686
|
+
}
|
|
5687
|
+
const match = trimmedText.match(/^[.!?:;]+/);
|
|
5688
|
+
if (!match) {
|
|
5689
|
+
return {
|
|
5690
|
+
mergedContent: inlineContent,
|
|
5691
|
+
shouldStop: false,
|
|
5692
|
+
wrappedLines: []
|
|
5693
|
+
};
|
|
5694
|
+
}
|
|
5695
|
+
const punctuation = match[0];
|
|
5696
|
+
const restText = trimmedText.substring(punctuation.length).trim();
|
|
5697
|
+
if (!restText) {
|
|
5698
|
+
return {
|
|
5699
|
+
mergedContent: inlineContent + punctuation,
|
|
5700
|
+
shouldStop: false,
|
|
5701
|
+
wrappedLines: []
|
|
5702
|
+
};
|
|
5703
|
+
}
|
|
5704
|
+
const words = restText.split(/\s+/);
|
|
5705
|
+
let toMerge = punctuation;
|
|
5706
|
+
let mergedWordCount = 0;
|
|
5707
|
+
for (const word of words) {
|
|
5708
|
+
const testMerge = toMerge + ' ' + word;
|
|
5709
|
+
if ((inlineContent + testMerge).length <= wrapWidth) {
|
|
5710
|
+
toMerge = testMerge;
|
|
5711
|
+
mergedWordCount++;
|
|
5712
|
+
}
|
|
5713
|
+
else {
|
|
5714
|
+
break;
|
|
5715
|
+
}
|
|
5716
|
+
}
|
|
5717
|
+
const mergedContent = inlineContent + toMerge;
|
|
5718
|
+
if (mergedWordCount >= words.length) {
|
|
5719
|
+
return {
|
|
5720
|
+
mergedContent,
|
|
5721
|
+
shouldStop: false,
|
|
5722
|
+
wrappedLines: []
|
|
5723
|
+
};
|
|
5724
|
+
}
|
|
5725
|
+
const remainingWords = words.slice(mergedWordCount);
|
|
5726
|
+
const wrappedLines = this.wrapRemainingWords(remainingWords, wrapWidth);
|
|
5727
|
+
return {
|
|
5728
|
+
mergedContent,
|
|
5729
|
+
shouldStop: true,
|
|
5730
|
+
wrappedLines
|
|
5731
|
+
};
|
|
5732
|
+
}
|
|
5733
|
+
/**
|
|
5734
|
+
* Render adjacent inline elements together on one line
|
|
5735
|
+
*/
|
|
5736
|
+
renderAdjacentInlineElements(children, count) {
|
|
5737
|
+
let inlineContent = "";
|
|
5738
|
+
let processedCount = 0;
|
|
5739
|
+
let lastProcessedIndex = -1;
|
|
5740
|
+
const processedIndices = new Set();
|
|
5741
|
+
for (let index = 0; index < children.length && processedCount < count; index++) {
|
|
5742
|
+
const child = children[index];
|
|
5743
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
5744
|
+
continue;
|
|
5745
|
+
}
|
|
5746
|
+
if (isNode(child, HTMLElementNode) && isInlineElement(getTagName(child))) {
|
|
5747
|
+
inlineContent += this.renderInlineElementAsString(child);
|
|
5748
|
+
processedCount++;
|
|
5749
|
+
lastProcessedIndex = index;
|
|
5750
|
+
processedIndices.add(index);
|
|
5751
|
+
if (inlineContent && isLineBreakingElement(child)) {
|
|
5752
|
+
this.pushWithIndent(inlineContent);
|
|
5753
|
+
inlineContent = "";
|
|
5754
|
+
}
|
|
5755
|
+
}
|
|
5756
|
+
else if (isNode(child, ERBContentNode)) {
|
|
5757
|
+
inlineContent += this.renderERBAsString(child);
|
|
5758
|
+
processedCount++;
|
|
5759
|
+
lastProcessedIndex = index;
|
|
5760
|
+
processedIndices.add(index);
|
|
5761
|
+
}
|
|
5762
|
+
}
|
|
5763
|
+
if (lastProcessedIndex >= 0) {
|
|
5764
|
+
for (let index = lastProcessedIndex + 1; index < children.length; index++) {
|
|
5765
|
+
const child = children[index];
|
|
5766
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
5767
|
+
continue;
|
|
5768
|
+
}
|
|
5769
|
+
if (isNode(child, ERBContentNode)) {
|
|
5770
|
+
inlineContent += this.renderERBAsString(child);
|
|
5771
|
+
processedIndices.add(index);
|
|
5772
|
+
continue;
|
|
5773
|
+
}
|
|
5774
|
+
if (isNode(child, HTMLTextNode)) {
|
|
5775
|
+
const trimmed = child.content.trim();
|
|
5776
|
+
if (trimmed && /^[.!?:;]/.test(trimmed)) {
|
|
5777
|
+
const wrapWidth = this.maxLineLength - this.indent.length;
|
|
5778
|
+
const result = this.tryMergePunctuationText(inlineContent, trimmed, wrapWidth);
|
|
5779
|
+
inlineContent = result.mergedContent;
|
|
5780
|
+
processedIndices.add(index);
|
|
5781
|
+
if (result.shouldStop) {
|
|
5782
|
+
if (inlineContent) {
|
|
5783
|
+
this.pushWithIndent(inlineContent);
|
|
5784
|
+
}
|
|
5785
|
+
result.wrappedLines.forEach(line => this.push(line));
|
|
5786
|
+
return { processedIndices };
|
|
5787
|
+
}
|
|
5025
5788
|
}
|
|
5026
5789
|
}
|
|
5790
|
+
break;
|
|
5027
5791
|
}
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5792
|
+
}
|
|
5793
|
+
if (inlineContent) {
|
|
5794
|
+
this.pushWithIndent(inlineContent);
|
|
5795
|
+
}
|
|
5796
|
+
return { processedIndices };
|
|
5797
|
+
}
|
|
5798
|
+
/**
|
|
5799
|
+
* Render an inline element as a string
|
|
5800
|
+
*/
|
|
5801
|
+
renderInlineElementAsString(element) {
|
|
5802
|
+
const tagName = getTagName(element);
|
|
5803
|
+
if (element.is_void || element.open_tag?.tag_closing?.value === "/>") {
|
|
5804
|
+
const attributes = filterNodes(element.open_tag?.children, HTMLAttributeNode);
|
|
5805
|
+
const attributesString = this.renderAttributesString(attributes);
|
|
5806
|
+
const isSelfClosing = element.open_tag?.tag_closing?.value === "/>";
|
|
5807
|
+
return `<${tagName}${attributesString}${isSelfClosing ? " />" : ">"}`;
|
|
5808
|
+
}
|
|
5809
|
+
const childrenToRender = this.getFilteredChildren(element.body);
|
|
5810
|
+
const childInline = this.tryRenderInlineFull(element, tagName, filterNodes(element.open_tag?.children, HTMLAttributeNode), childrenToRender);
|
|
5811
|
+
return childInline !== null ? childInline : "";
|
|
5812
|
+
}
|
|
5813
|
+
/**
|
|
5814
|
+
* Render an ERB node as a string
|
|
5815
|
+
*/
|
|
5816
|
+
renderERBAsString(node) {
|
|
5817
|
+
return this.capture(() => {
|
|
5818
|
+
this.inlineMode = true;
|
|
5819
|
+
this.visit(node);
|
|
5820
|
+
}).join("");
|
|
5821
|
+
}
|
|
5822
|
+
/**
|
|
5823
|
+
* Visit remaining children after processing adjacent inline elements
|
|
5824
|
+
*/
|
|
5825
|
+
visitRemainingChildren(children, processedIndices) {
|
|
5826
|
+
for (let index = 0; index < children.length; index++) {
|
|
5827
|
+
const child = children[index];
|
|
5828
|
+
if (isPureWhitespaceNode(child) || isNode(child, WhitespaceNode)) {
|
|
5829
|
+
continue;
|
|
5830
|
+
}
|
|
5831
|
+
if (processedIndices.has(index)) {
|
|
5832
|
+
continue;
|
|
5833
|
+
}
|
|
5834
|
+
this.visit(child);
|
|
5835
|
+
}
|
|
5836
|
+
}
|
|
5837
|
+
/**
|
|
5838
|
+
* Build words array from text/inline/ERB and wrap them
|
|
5839
|
+
*/
|
|
5840
|
+
buildAndWrapTextFlow(children) {
|
|
5841
|
+
const unitsWithNodes = this.buildContentUnitsWithNodes(children);
|
|
5842
|
+
const words = [];
|
|
5843
|
+
for (const { unit, node } of unitsWithNodes) {
|
|
5844
|
+
if (unit.breaksFlow) {
|
|
5845
|
+
this.flushWords(words);
|
|
5846
|
+
if (node) {
|
|
5847
|
+
this.visit(node);
|
|
5848
|
+
}
|
|
5849
|
+
}
|
|
5850
|
+
else if (unit.isAtomic) {
|
|
5851
|
+
words.push({ word: unit.content, isHerbDisable: unit.isHerbDisable || false });
|
|
5852
|
+
}
|
|
5853
|
+
else {
|
|
5854
|
+
const text = unit.content.replace(/\s+/g, ' ');
|
|
5855
|
+
const hasLeadingSpace = text.startsWith(' ');
|
|
5856
|
+
const hasTrailingSpace = text.endsWith(' ');
|
|
5857
|
+
const trimmedText = text.trim();
|
|
5858
|
+
if (trimmedText) {
|
|
5859
|
+
if (hasLeadingSpace && words.length > 0) {
|
|
5860
|
+
const lastWord = words[words.length - 1];
|
|
5861
|
+
if (!lastWord.word.endsWith(' ')) {
|
|
5862
|
+
lastWord.word += ' ';
|
|
5037
5863
|
}
|
|
5038
5864
|
}
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5865
|
+
const textWords = trimmedText.split(' ').map(w => ({ word: w, isHerbDisable: false }));
|
|
5866
|
+
words.push(...textWords);
|
|
5867
|
+
if (hasTrailingSpace && words.length > 0) {
|
|
5868
|
+
const lastWord = words[words.length - 1];
|
|
5869
|
+
if (!isClosingPunctuation(lastWord.word)) {
|
|
5870
|
+
lastWord.word += ' ';
|
|
5043
5871
|
}
|
|
5044
|
-
this.visit(child);
|
|
5045
5872
|
}
|
|
5046
5873
|
}
|
|
5047
|
-
else {
|
|
5048
|
-
|
|
5049
|
-
|
|
5050
|
-
|
|
5874
|
+
else if (text === ' ' && words.length > 0) {
|
|
5875
|
+
const lastWord = words[words.length - 1];
|
|
5876
|
+
if (!lastWord.word.endsWith(' ')) {
|
|
5877
|
+
lastWord.word += ' ';
|
|
5051
5878
|
}
|
|
5052
|
-
this.visit(child);
|
|
5053
5879
|
}
|
|
5054
5880
|
}
|
|
5055
|
-
|
|
5056
|
-
|
|
5057
|
-
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5881
|
+
}
|
|
5882
|
+
this.flushWords(words);
|
|
5883
|
+
}
|
|
5884
|
+
/**
|
|
5885
|
+
* Try to merge text that follows an atomic unit (ERB/inline) with no whitespace
|
|
5886
|
+
* Returns true if merge was performed
|
|
5887
|
+
*/
|
|
5888
|
+
tryMergeTextAfterAtomic(result, textNode) {
|
|
5889
|
+
if (result.length === 0)
|
|
5890
|
+
return false;
|
|
5891
|
+
const lastUnit = result[result.length - 1];
|
|
5892
|
+
if (!lastUnit.unit.isAtomic || (lastUnit.unit.type !== 'erb' && lastUnit.unit.type !== 'inline')) {
|
|
5893
|
+
return false;
|
|
5894
|
+
}
|
|
5895
|
+
const words = normalizeAndSplitWords(textNode.content);
|
|
5896
|
+
if (words.length === 0 || !words[0])
|
|
5897
|
+
return false;
|
|
5898
|
+
const firstWord = words[0];
|
|
5899
|
+
const firstChar = firstWord[0];
|
|
5900
|
+
if (/\s/.test(firstChar)) {
|
|
5901
|
+
return false;
|
|
5902
|
+
}
|
|
5903
|
+
lastUnit.unit.content += firstWord;
|
|
5904
|
+
if (words.length > 1) {
|
|
5905
|
+
let remainingText = words.slice(1).join(' ');
|
|
5906
|
+
if (endsWithWhitespace(textNode.content)) {
|
|
5907
|
+
remainingText += ' ';
|
|
5908
|
+
}
|
|
5909
|
+
result.push({
|
|
5910
|
+
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
|
|
5911
|
+
node: textNode
|
|
5912
|
+
});
|
|
5913
|
+
}
|
|
5914
|
+
else if (endsWithWhitespace(textNode.content)) {
|
|
5915
|
+
result.push({
|
|
5916
|
+
unit: { content: ' ', type: 'text', isAtomic: false, breaksFlow: false },
|
|
5917
|
+
node: textNode
|
|
5918
|
+
});
|
|
5919
|
+
}
|
|
5920
|
+
return true;
|
|
5921
|
+
}
|
|
5922
|
+
/**
|
|
5923
|
+
* Try to merge an atomic unit (ERB/inline) with preceding text that has no whitespace
|
|
5924
|
+
* Returns true if merge was performed
|
|
5925
|
+
*/
|
|
5926
|
+
tryMergeAtomicAfterText(result, children, lastProcessedIndex, atomicContent, atomicType, atomicNode) {
|
|
5927
|
+
if (result.length === 0)
|
|
5928
|
+
return false;
|
|
5929
|
+
const lastUnit = result[result.length - 1];
|
|
5930
|
+
if (lastUnit.unit.type !== 'text' || lastUnit.unit.isAtomic)
|
|
5931
|
+
return false;
|
|
5932
|
+
const words = normalizeAndSplitWords(lastUnit.unit.content);
|
|
5933
|
+
const lastWord = words[words.length - 1];
|
|
5934
|
+
if (!lastWord)
|
|
5935
|
+
return false;
|
|
5936
|
+
result.pop();
|
|
5937
|
+
if (words.length > 1) {
|
|
5938
|
+
const remainingText = words.slice(0, -1).join(' ');
|
|
5939
|
+
result.push({
|
|
5940
|
+
unit: { content: remainingText, type: 'text', isAtomic: false, breaksFlow: false },
|
|
5941
|
+
node: children[lastProcessedIndex]
|
|
5942
|
+
});
|
|
5943
|
+
}
|
|
5944
|
+
result.push({
|
|
5945
|
+
unit: { content: lastWord + atomicContent, type: atomicType, isAtomic: true, breaksFlow: false },
|
|
5946
|
+
node: atomicNode
|
|
5947
|
+
});
|
|
5948
|
+
return true;
|
|
5949
|
+
}
|
|
5950
|
+
/**
|
|
5951
|
+
* Check if there's whitespace between current node and last processed node
|
|
5952
|
+
*/
|
|
5953
|
+
hasWhitespaceBeforeNode(children, lastProcessedIndex, currentIndex, currentNode) {
|
|
5954
|
+
if (hasWhitespaceBetween(children, lastProcessedIndex, currentIndex)) {
|
|
5955
|
+
return true;
|
|
5956
|
+
}
|
|
5957
|
+
if (isNode(currentNode, HTMLTextNode) && /^\s/.test(currentNode.content)) {
|
|
5958
|
+
return true;
|
|
5959
|
+
}
|
|
5960
|
+
return false;
|
|
5961
|
+
}
|
|
5962
|
+
/**
|
|
5963
|
+
* Check if last unit in result ends with whitespace
|
|
5964
|
+
*/
|
|
5965
|
+
lastUnitEndsWithWhitespace(result) {
|
|
5966
|
+
if (result.length === 0)
|
|
5967
|
+
return false;
|
|
5968
|
+
const lastUnit = result[result.length - 1];
|
|
5969
|
+
return lastUnit.unit.type === 'text' && endsWithWhitespace(lastUnit.unit.content);
|
|
5970
|
+
}
|
|
5971
|
+
/**
|
|
5972
|
+
* Process a text node and add it to results (with potential merging)
|
|
5973
|
+
*/
|
|
5974
|
+
processTextNode(result, children, child, index, lastProcessedIndex) {
|
|
5975
|
+
const isAtomic = child.content === ' ';
|
|
5976
|
+
if (!isAtomic && lastProcessedIndex >= 0 && result.length > 0) {
|
|
5977
|
+
const hasWhitespace = this.hasWhitespaceBeforeNode(children, lastProcessedIndex, index, child);
|
|
5978
|
+
const lastUnit = result[result.length - 1];
|
|
5979
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'erb' || lastUnit.unit.type === 'inline');
|
|
5980
|
+
if (lastIsAtomic && !hasWhitespace && this.tryMergeTextAfterAtomic(result, child)) {
|
|
5981
|
+
return;
|
|
5982
|
+
}
|
|
5983
|
+
}
|
|
5984
|
+
result.push({
|
|
5985
|
+
unit: { content: child.content, type: 'text', isAtomic, breaksFlow: false },
|
|
5986
|
+
node: child
|
|
5987
|
+
});
|
|
5988
|
+
}
|
|
5989
|
+
/**
|
|
5990
|
+
* Process an inline element and add it to results (with potential merging)
|
|
5991
|
+
*/
|
|
5992
|
+
processInlineElement(result, children, child, index, lastProcessedIndex) {
|
|
5993
|
+
const tagName = getTagName(child);
|
|
5994
|
+
const childrenToRender = this.getFilteredChildren(child.body);
|
|
5995
|
+
const inlineContent = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
|
|
5996
|
+
if (inlineContent === null) {
|
|
5997
|
+
result.push({
|
|
5998
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
5999
|
+
node: child
|
|
6000
|
+
});
|
|
6001
|
+
return false;
|
|
6002
|
+
}
|
|
6003
|
+
if (lastProcessedIndex >= 0) {
|
|
6004
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
|
|
6005
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, inlineContent, 'inline', child)) {
|
|
6006
|
+
return true;
|
|
6007
|
+
}
|
|
6008
|
+
}
|
|
6009
|
+
result.push({
|
|
6010
|
+
unit: { content: inlineContent, type: 'inline', isAtomic: true, breaksFlow: false },
|
|
6011
|
+
node: child
|
|
6012
|
+
});
|
|
6013
|
+
return false;
|
|
6014
|
+
}
|
|
6015
|
+
/**
|
|
6016
|
+
* Process an ERB content node and add it to results (with potential merging)
|
|
6017
|
+
*/
|
|
6018
|
+
processERBContentNode(result, children, child, index, lastProcessedIndex) {
|
|
6019
|
+
const erbContent = this.renderERBAsString(child);
|
|
6020
|
+
const isHerbDisable = isHerbDisableComment(child);
|
|
6021
|
+
if (lastProcessedIndex >= 0) {
|
|
6022
|
+
const hasWhitespace = hasWhitespaceBetween(children, lastProcessedIndex, index) || this.lastUnitEndsWithWhitespace(result);
|
|
6023
|
+
if (!hasWhitespace && this.tryMergeAtomicAfterText(result, children, lastProcessedIndex, erbContent, 'erb', child)) {
|
|
6024
|
+
return true;
|
|
6025
|
+
}
|
|
6026
|
+
if (hasWhitespace && result.length > 0) {
|
|
6027
|
+
const lastUnit = result[result.length - 1];
|
|
6028
|
+
const lastIsAtomic = lastUnit.unit.isAtomic && (lastUnit.unit.type === 'inline' || lastUnit.unit.type === 'erb');
|
|
6029
|
+
if (lastIsAtomic && !this.lastUnitEndsWithWhitespace(result)) {
|
|
6030
|
+
result.push({
|
|
6031
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
6032
|
+
node: null
|
|
6033
|
+
});
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
}
|
|
6037
|
+
result.push({
|
|
6038
|
+
unit: { content: erbContent, type: 'erb', isAtomic: true, breaksFlow: false, isHerbDisable },
|
|
6039
|
+
node: child
|
|
6040
|
+
});
|
|
6041
|
+
return false;
|
|
6042
|
+
}
|
|
6043
|
+
/**
|
|
6044
|
+
* Convert AST nodes to content units with node references
|
|
6045
|
+
*/
|
|
6046
|
+
buildContentUnitsWithNodes(children) {
|
|
6047
|
+
const result = [];
|
|
6048
|
+
let lastProcessedIndex = -1;
|
|
6049
|
+
for (let i = 0; i < children.length; i++) {
|
|
6050
|
+
const child = children[i];
|
|
6051
|
+
if (isNode(child, WhitespaceNode))
|
|
6052
|
+
continue;
|
|
6053
|
+
if (isPureWhitespaceNode(child) && !(isNode(child, HTMLTextNode) && child.content === ' ')) {
|
|
6054
|
+
if (lastProcessedIndex >= 0) {
|
|
6055
|
+
const hasNonWhitespaceAfter = children.slice(i + 1).some(node => !isNode(node, WhitespaceNode) && !isPureWhitespaceNode(node));
|
|
6056
|
+
if (hasNonWhitespaceAfter) {
|
|
6057
|
+
const previousNode = children[lastProcessedIndex];
|
|
6058
|
+
if (!isLineBreakingElement(previousNode)) {
|
|
6059
|
+
result.push({
|
|
6060
|
+
unit: { content: ' ', type: 'text', isAtomic: true, breaksFlow: false },
|
|
6061
|
+
node: child
|
|
6062
|
+
});
|
|
6063
|
+
}
|
|
6064
|
+
}
|
|
6065
|
+
}
|
|
6066
|
+
continue;
|
|
6067
|
+
}
|
|
6068
|
+
if (isNode(child, HTMLTextNode)) {
|
|
6069
|
+
this.processTextNode(result, children, child, i, lastProcessedIndex);
|
|
6070
|
+
lastProcessedIndex = i;
|
|
6071
|
+
}
|
|
6072
|
+
else if (isNode(child, HTMLElementNode)) {
|
|
6073
|
+
const tagName = getTagName(child);
|
|
6074
|
+
if (isInlineElement(tagName)) {
|
|
6075
|
+
const merged = this.processInlineElement(result, children, child, i, lastProcessedIndex);
|
|
6076
|
+
if (merged) {
|
|
6077
|
+
lastProcessedIndex = i;
|
|
6078
|
+
continue;
|
|
5070
6079
|
}
|
|
5071
6080
|
}
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
6081
|
+
else {
|
|
6082
|
+
result.push({
|
|
6083
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
6084
|
+
node: child
|
|
6085
|
+
});
|
|
5075
6086
|
}
|
|
6087
|
+
lastProcessedIndex = i;
|
|
5076
6088
|
}
|
|
5077
|
-
else {
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
6089
|
+
else if (isNode(child, ERBContentNode)) {
|
|
6090
|
+
const merged = this.processERBContentNode(result, children, child, i, lastProcessedIndex);
|
|
6091
|
+
if (merged) {
|
|
6092
|
+
lastProcessedIndex = i;
|
|
6093
|
+
continue;
|
|
5081
6094
|
}
|
|
5082
|
-
|
|
6095
|
+
lastProcessedIndex = i;
|
|
6096
|
+
}
|
|
6097
|
+
else {
|
|
6098
|
+
result.push({
|
|
6099
|
+
unit: { content: '', type: 'block', isAtomic: false, breaksFlow: true },
|
|
6100
|
+
node: child
|
|
6101
|
+
});
|
|
6102
|
+
lastProcessedIndex = i;
|
|
5083
6103
|
}
|
|
5084
6104
|
}
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
6105
|
+
return result;
|
|
6106
|
+
}
|
|
6107
|
+
/**
|
|
6108
|
+
* Flush accumulated words to output with wrapping
|
|
6109
|
+
*/
|
|
6110
|
+
flushWords(words) {
|
|
6111
|
+
if (words.length > 0) {
|
|
6112
|
+
this.wrapAndPushWords(words);
|
|
6113
|
+
words.length = 0;
|
|
6114
|
+
}
|
|
6115
|
+
}
|
|
6116
|
+
/**
|
|
6117
|
+
* Wrap words to fit within line length and push to output
|
|
6118
|
+
* Handles punctuation spacing intelligently
|
|
6119
|
+
* Excludes herb:disable comments from line length calculations
|
|
6120
|
+
*/
|
|
6121
|
+
wrapAndPushWords(words) {
|
|
6122
|
+
const wrapWidth = this.maxLineLength - this.indent.length;
|
|
6123
|
+
const lines = [];
|
|
6124
|
+
let currentLine = "";
|
|
6125
|
+
let effectiveLength = 0;
|
|
6126
|
+
for (const { word, isHerbDisable } of words) {
|
|
6127
|
+
const nextLine = buildLineWithWord(currentLine, word);
|
|
6128
|
+
let nextEffectiveLength = effectiveLength;
|
|
6129
|
+
if (!isHerbDisable) {
|
|
6130
|
+
const spaceBefore = currentLine && needsSpaceBetween(currentLine, word) ? 1 : 0;
|
|
6131
|
+
nextEffectiveLength = effectiveLength + spaceBefore + word.length;
|
|
6132
|
+
}
|
|
6133
|
+
if (currentLine && !isClosingPunctuation(word) && nextEffectiveLength >= wrapWidth) {
|
|
6134
|
+
lines.push(this.indent + currentLine.trimEnd());
|
|
6135
|
+
currentLine = word;
|
|
6136
|
+
effectiveLength = isHerbDisable ? 0 : word.length;
|
|
6137
|
+
}
|
|
6138
|
+
else {
|
|
6139
|
+
currentLine = nextLine;
|
|
6140
|
+
effectiveLength = nextEffectiveLength;
|
|
5090
6141
|
}
|
|
5091
|
-
this.push(finalLine);
|
|
5092
6142
|
}
|
|
6143
|
+
if (currentLine) {
|
|
6144
|
+
lines.push(this.indent + currentLine.trimEnd());
|
|
6145
|
+
}
|
|
6146
|
+
lines.forEach(line => this.push(line));
|
|
5093
6147
|
}
|
|
5094
6148
|
isInTextFlowContext(_parent, children) {
|
|
5095
6149
|
const hasTextContent = children.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "");
|
|
@@ -5102,7 +6156,7 @@ class FormatPrinter extends Printer {
|
|
|
5102
6156
|
if (isNode(child, ERBContentNode))
|
|
5103
6157
|
return true;
|
|
5104
6158
|
if (isNode(child, HTMLElementNode)) {
|
|
5105
|
-
return
|
|
6159
|
+
return isInlineElement(getTagName(child));
|
|
5106
6160
|
}
|
|
5107
6161
|
return false;
|
|
5108
6162
|
});
|
|
@@ -5172,7 +6226,7 @@ class FormatPrinter extends Printer {
|
|
|
5172
6226
|
}
|
|
5173
6227
|
else {
|
|
5174
6228
|
const printed = IdentityPrinter.print(child);
|
|
5175
|
-
if (this.
|
|
6229
|
+
if (this.isInTokenListAttribute) {
|
|
5176
6230
|
return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%');
|
|
5177
6231
|
}
|
|
5178
6232
|
return printed;
|
|
@@ -5208,18 +6262,35 @@ class FormatPrinter extends Printer {
|
|
|
5208
6262
|
let result = `<${tagName}`;
|
|
5209
6263
|
result += this.renderAttributesString(attributes);
|
|
5210
6264
|
result += ">";
|
|
5211
|
-
const childrenContent = this.tryRenderChildrenInline(children);
|
|
6265
|
+
const childrenContent = this.tryRenderChildrenInline(children, tagName);
|
|
5212
6266
|
if (!childrenContent)
|
|
5213
6267
|
return null;
|
|
5214
6268
|
result += childrenContent;
|
|
5215
6269
|
result += `</${tagName}>`;
|
|
5216
6270
|
return result;
|
|
5217
6271
|
}
|
|
6272
|
+
/**
|
|
6273
|
+
* Check if children contain a leading herb:disable comment (after optional whitespace)
|
|
6274
|
+
*/
|
|
6275
|
+
hasLeadingHerbDisable(children) {
|
|
6276
|
+
for (const child of children) {
|
|
6277
|
+
if (isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "")) {
|
|
6278
|
+
continue;
|
|
6279
|
+
}
|
|
6280
|
+
return isNode(child, ERBContentNode) && isHerbDisableComment(child);
|
|
6281
|
+
}
|
|
6282
|
+
return false;
|
|
6283
|
+
}
|
|
5218
6284
|
/**
|
|
5219
6285
|
* Try to render just the children inline (without tags)
|
|
5220
6286
|
*/
|
|
5221
|
-
tryRenderChildrenInline(children) {
|
|
6287
|
+
tryRenderChildrenInline(children, tagName) {
|
|
5222
6288
|
let result = "";
|
|
6289
|
+
let hasInternalWhitespace = false;
|
|
6290
|
+
let addedLeadingSpace = false;
|
|
6291
|
+
const hasHerbDisable = this.hasLeadingHerbDisable(children);
|
|
6292
|
+
const hasOnlyTextContent = children.every(child => isNode(child, HTMLTextNode) || isNode(child, WhitespaceNode));
|
|
6293
|
+
const shouldPreserveSpaces = hasOnlyTextContent && tagName && isInlineElement(tagName);
|
|
5223
6294
|
for (const child of children) {
|
|
5224
6295
|
if (isNode(child, HTMLTextNode)) {
|
|
5225
6296
|
const normalizedContent = child.content.replace(/\s+/g, ' ');
|
|
@@ -5227,36 +6298,53 @@ class FormatPrinter extends Printer {
|
|
|
5227
6298
|
const hasTrailingSpace = /\s$/.test(child.content);
|
|
5228
6299
|
const trimmedContent = normalizedContent.trim();
|
|
5229
6300
|
if (trimmedContent) {
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
finalContent = ' ' + finalContent;
|
|
6301
|
+
if (hasLeadingSpace && (result || shouldPreserveSpaces) && !result.endsWith(' ')) {
|
|
6302
|
+
result += ' ';
|
|
5233
6303
|
}
|
|
6304
|
+
result += trimmedContent;
|
|
5234
6305
|
if (hasTrailingSpace) {
|
|
5235
|
-
finalContent = finalContent + ' ';
|
|
5236
|
-
}
|
|
5237
|
-
result += finalContent;
|
|
5238
|
-
}
|
|
5239
|
-
else if (hasLeadingSpace || hasTrailingSpace) {
|
|
5240
|
-
if (result && !result.endsWith(' ')) {
|
|
5241
6306
|
result += ' ';
|
|
5242
6307
|
}
|
|
6308
|
+
continue;
|
|
6309
|
+
}
|
|
6310
|
+
}
|
|
6311
|
+
const isWhitespace = isNode(child, WhitespaceNode) || (isNode(child, HTMLTextNode) && child.content.trim() === "");
|
|
6312
|
+
if (isWhitespace && !result.endsWith(' ')) {
|
|
6313
|
+
if (!result && hasHerbDisable && !addedLeadingSpace) {
|
|
6314
|
+
result += ' ';
|
|
6315
|
+
addedLeadingSpace = true;
|
|
6316
|
+
}
|
|
6317
|
+
else if (result) {
|
|
6318
|
+
result += ' ';
|
|
6319
|
+
hasInternalWhitespace = true;
|
|
5243
6320
|
}
|
|
5244
6321
|
}
|
|
5245
6322
|
else if (isNode(child, HTMLElementNode)) {
|
|
5246
6323
|
const tagName = getTagName(child);
|
|
5247
|
-
if (!
|
|
6324
|
+
if (!isInlineElement(tagName)) {
|
|
5248
6325
|
return null;
|
|
5249
6326
|
}
|
|
5250
|
-
const
|
|
6327
|
+
const childrenToRender = this.getFilteredChildren(child.body);
|
|
6328
|
+
const childInline = this.tryRenderInlineFull(child, tagName, filterNodes(child.open_tag?.children, HTMLAttributeNode), childrenToRender);
|
|
5251
6329
|
if (!childInline) {
|
|
5252
6330
|
return null;
|
|
5253
6331
|
}
|
|
5254
6332
|
result += childInline;
|
|
5255
6333
|
}
|
|
5256
|
-
else {
|
|
5257
|
-
|
|
6334
|
+
else if (!isNode(child, HTMLTextNode) && !isWhitespace) {
|
|
6335
|
+
const wasInlineMode = this.inlineMode;
|
|
6336
|
+
this.inlineMode = true;
|
|
6337
|
+
const captured = this.capture(() => this.visit(child)).join("");
|
|
6338
|
+
this.inlineMode = wasInlineMode;
|
|
6339
|
+
result += captured;
|
|
5258
6340
|
}
|
|
5259
6341
|
}
|
|
6342
|
+
if (shouldPreserveSpaces) {
|
|
6343
|
+
return result;
|
|
6344
|
+
}
|
|
6345
|
+
if (hasHerbDisable && result.startsWith(' ') || hasInternalWhitespace) {
|
|
6346
|
+
return result.trimEnd();
|
|
6347
|
+
}
|
|
5260
6348
|
return result.trim();
|
|
5261
6349
|
}
|
|
5262
6350
|
/**
|
|
@@ -5271,8 +6359,7 @@ class FormatPrinter extends Printer {
|
|
|
5271
6359
|
}
|
|
5272
6360
|
}
|
|
5273
6361
|
else if (isNode(child, HTMLElementNode)) {
|
|
5274
|
-
|
|
5275
|
-
if (!isInlineElement) {
|
|
6362
|
+
if (!isInlineElement(getTagName(child))) {
|
|
5276
6363
|
return null;
|
|
5277
6364
|
}
|
|
5278
6365
|
}
|
|
@@ -5288,103 +6375,14 @@ class FormatPrinter extends Printer {
|
|
|
5288
6375
|
return `<${tagName}>${content}</${tagName}>`;
|
|
5289
6376
|
}
|
|
5290
6377
|
/**
|
|
5291
|
-
*
|
|
5292
|
-
* or mixed ERB output and text (like "<%= value %> text")
|
|
5293
|
-
* This indicates content that should be formatted inline even with structural newlines
|
|
5294
|
-
*/
|
|
5295
|
-
hasMixedTextAndInlineContent(children) {
|
|
5296
|
-
let hasText = false;
|
|
5297
|
-
let hasInlineElements = false;
|
|
5298
|
-
for (const child of children) {
|
|
5299
|
-
if (isNode(child, HTMLTextNode)) {
|
|
5300
|
-
if (child.content.trim() !== "") {
|
|
5301
|
-
hasText = true;
|
|
5302
|
-
}
|
|
5303
|
-
}
|
|
5304
|
-
else if (isNode(child, HTMLElementNode)) {
|
|
5305
|
-
if (this.isInlineElement(getTagName(child))) {
|
|
5306
|
-
hasInlineElements = true;
|
|
5307
|
-
}
|
|
5308
|
-
}
|
|
5309
|
-
}
|
|
5310
|
-
return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText);
|
|
5311
|
-
}
|
|
5312
|
-
/**
|
|
5313
|
-
* Check if children contain any text content with newlines
|
|
5314
|
-
*/
|
|
5315
|
-
hasMultilineTextContent(children) {
|
|
5316
|
-
for (const child of children) {
|
|
5317
|
-
if (isNode(child, HTMLTextNode)) {
|
|
5318
|
-
return child.content.includes('\n');
|
|
5319
|
-
}
|
|
5320
|
-
if (isNode(child, HTMLElementNode)) {
|
|
5321
|
-
const nestedChildren = this.filterEmptyNodes(child.body);
|
|
5322
|
-
if (this.hasMultilineTextContent(nestedChildren)) {
|
|
5323
|
-
return true;
|
|
5324
|
-
}
|
|
5325
|
-
}
|
|
5326
|
-
}
|
|
5327
|
-
return false;
|
|
5328
|
-
}
|
|
5329
|
-
/**
|
|
5330
|
-
* Check if all nested elements in the children are inline elements
|
|
5331
|
-
*/
|
|
5332
|
-
areAllNestedElementsInline(children) {
|
|
5333
|
-
for (const child of children) {
|
|
5334
|
-
if (isNode(child, HTMLElementNode)) {
|
|
5335
|
-
if (!this.isInlineElement(getTagName(child))) {
|
|
5336
|
-
return false;
|
|
5337
|
-
}
|
|
5338
|
-
const nestedChildren = this.filterEmptyNodes(child.body);
|
|
5339
|
-
if (!this.areAllNestedElementsInline(nestedChildren)) {
|
|
5340
|
-
return false;
|
|
5341
|
-
}
|
|
5342
|
-
}
|
|
5343
|
-
else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
|
|
5344
|
-
return false;
|
|
5345
|
-
}
|
|
5346
|
-
}
|
|
5347
|
-
return true;
|
|
5348
|
-
}
|
|
5349
|
-
/**
|
|
5350
|
-
* Check if element has complex ERB control flow
|
|
6378
|
+
* Get filtered children, using smart herb:disable filtering if needed
|
|
5351
6379
|
*/
|
|
5352
|
-
|
|
5353
|
-
|
|
5354
|
-
|
|
5355
|
-
if (node.statements.length > 0 && node.location) {
|
|
5356
|
-
const startLine = node.location.start.line;
|
|
5357
|
-
const endLine = node.location.end.line;
|
|
5358
|
-
return startLine !== endLine;
|
|
5359
|
-
}
|
|
5360
|
-
return false;
|
|
5361
|
-
}
|
|
5362
|
-
return false;
|
|
5363
|
-
});
|
|
5364
|
-
}
|
|
5365
|
-
/**
|
|
5366
|
-
* Filter children to remove insignificant whitespace
|
|
5367
|
-
*/
|
|
5368
|
-
filterSignificantChildren(body, hasTextFlow) {
|
|
5369
|
-
return body.filter(child => {
|
|
5370
|
-
if (isNode(child, WhitespaceNode))
|
|
5371
|
-
return false;
|
|
5372
|
-
if (isNode(child, HTMLTextNode)) {
|
|
5373
|
-
if (hasTextFlow && child.content === " ")
|
|
5374
|
-
return true;
|
|
5375
|
-
return child.content.trim() !== "";
|
|
5376
|
-
}
|
|
5377
|
-
return true;
|
|
5378
|
-
});
|
|
5379
|
-
}
|
|
5380
|
-
/**
|
|
5381
|
-
* Filter out empty text nodes and whitespace nodes
|
|
5382
|
-
*/
|
|
5383
|
-
filterEmptyNodes(nodes) {
|
|
5384
|
-
return nodes.filter(child => !isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === ""));
|
|
6380
|
+
getFilteredChildren(body) {
|
|
6381
|
+
const hasHerbDisable = body.some(child => isNode(child, ERBContentNode) && isHerbDisableComment(child));
|
|
6382
|
+
return hasHerbDisable ? filterEmptyNodesForHerbDisable(body) : body;
|
|
5385
6383
|
}
|
|
5386
6384
|
renderElementInline(element) {
|
|
5387
|
-
const children = this.
|
|
6385
|
+
const children = this.getFilteredChildren(element.body);
|
|
5388
6386
|
return this.renderChildrenInline(children);
|
|
5389
6387
|
}
|
|
5390
6388
|
renderChildrenInline(children) {
|
|
@@ -5406,9 +6404,29 @@ class FormatPrinter extends Printer {
|
|
|
5406
6404
|
}
|
|
5407
6405
|
return content.replace(/\s+/g, ' ').trim();
|
|
5408
6406
|
}
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
6407
|
+
}
|
|
6408
|
+
|
|
6409
|
+
const isScaffoldTemplate = (result) => {
|
|
6410
|
+
const detector = new ScaffoldTemplateDetector();
|
|
6411
|
+
detector.visit(result.value);
|
|
6412
|
+
return detector.hasEscapedERB;
|
|
6413
|
+
};
|
|
6414
|
+
/**
|
|
6415
|
+
* Visitor that detects if the AST represents a Rails scaffold template.
|
|
6416
|
+
* Scaffold templates contain escaped ERB tags (<%%= or <%%)
|
|
6417
|
+
* and should not be formatted to preserve their exact structure.
|
|
6418
|
+
*/
|
|
6419
|
+
class ScaffoldTemplateDetector extends Visitor {
|
|
6420
|
+
hasEscapedERB = false;
|
|
6421
|
+
visitERBContentNode(node) {
|
|
6422
|
+
const opening = node.tag_opening?.value;
|
|
6423
|
+
if (opening && opening.startsWith("<%%")) {
|
|
6424
|
+
this.hasEscapedERB = true;
|
|
6425
|
+
return;
|
|
6426
|
+
}
|
|
6427
|
+
if (this.hasEscapedERB)
|
|
6428
|
+
return;
|
|
6429
|
+
this.visitChildNodes(node);
|
|
5412
6430
|
}
|
|
5413
6431
|
}
|
|
5414
6432
|
|
|
@@ -5418,6 +6436,8 @@ class FormatPrinter extends Printer {
|
|
|
5418
6436
|
const defaultFormatOptions = {
|
|
5419
6437
|
indentWidth: 2,
|
|
5420
6438
|
maxLineLength: 80,
|
|
6439
|
+
preRewriters: [],
|
|
6440
|
+
postRewriters: [],
|
|
5421
6441
|
};
|
|
5422
6442
|
/**
|
|
5423
6443
|
* Merge provided options with defaults for any missing values.
|
|
@@ -5428,6 +6448,8 @@ function resolveFormatOptions(options = {}) {
|
|
|
5428
6448
|
return {
|
|
5429
6449
|
indentWidth: options.indentWidth ?? defaultFormatOptions.indentWidth,
|
|
5430
6450
|
maxLineLength: options.maxLineLength ?? defaultFormatOptions.maxLineLength,
|
|
6451
|
+
preRewriters: options.preRewriters ?? defaultFormatOptions.preRewriters,
|
|
6452
|
+
postRewriters: options.postRewriters ?? defaultFormatOptions.postRewriters,
|
|
5431
6453
|
};
|
|
5432
6454
|
}
|
|
5433
6455
|
|
|
@@ -5438,6 +6460,30 @@ function resolveFormatOptions(options = {}) {
|
|
|
5438
6460
|
class Formatter {
|
|
5439
6461
|
herb;
|
|
5440
6462
|
options;
|
|
6463
|
+
/**
|
|
6464
|
+
* Creates a Formatter instance from a Config object (recommended).
|
|
6465
|
+
*
|
|
6466
|
+
* @param herb - The Herb backend instance for parsing
|
|
6467
|
+
* @param config - Optional Config instance for formatter options
|
|
6468
|
+
* @param options - Additional options to override config
|
|
6469
|
+
* @returns A configured Formatter instance
|
|
6470
|
+
*/
|
|
6471
|
+
static from(herb, config, options = {}) {
|
|
6472
|
+
const formatterConfig = config?.formatter || {};
|
|
6473
|
+
const mergedOptions = {
|
|
6474
|
+
indentWidth: options.indentWidth ?? formatterConfig.indentWidth,
|
|
6475
|
+
maxLineLength: options.maxLineLength ?? formatterConfig.maxLineLength,
|
|
6476
|
+
preRewriters: options.preRewriters,
|
|
6477
|
+
postRewriters: options.postRewriters,
|
|
6478
|
+
};
|
|
6479
|
+
return new Formatter(herb, mergedOptions);
|
|
6480
|
+
}
|
|
6481
|
+
/**
|
|
6482
|
+
* Creates a new Formatter instance.
|
|
6483
|
+
*
|
|
6484
|
+
* @param herb - The Herb backend instance for parsing
|
|
6485
|
+
* @param options - Format options (including rewriters)
|
|
6486
|
+
*/
|
|
5441
6487
|
constructor(herb, options = {}) {
|
|
5442
6488
|
this.herb = herb;
|
|
5443
6489
|
this.options = resolveFormatOptions(options);
|
|
@@ -5445,12 +6491,44 @@ class Formatter {
|
|
|
5445
6491
|
/**
|
|
5446
6492
|
* Format a source string, optionally overriding format options per call.
|
|
5447
6493
|
*/
|
|
5448
|
-
format(source, options = {}) {
|
|
5449
|
-
|
|
6494
|
+
format(source, options = {}, filePath) {
|
|
6495
|
+
let result = this.parse(source);
|
|
5450
6496
|
if (result.failed)
|
|
5451
6497
|
return source;
|
|
6498
|
+
if (isScaffoldTemplate(result))
|
|
6499
|
+
return source;
|
|
5452
6500
|
const resolvedOptions = resolveFormatOptions({ ...this.options, ...options });
|
|
5453
|
-
|
|
6501
|
+
let node = result.value;
|
|
6502
|
+
if (resolvedOptions.preRewriters.length > 0) {
|
|
6503
|
+
const context = {
|
|
6504
|
+
filePath,
|
|
6505
|
+
baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
|
|
6506
|
+
};
|
|
6507
|
+
for (const rewriter of resolvedOptions.preRewriters) {
|
|
6508
|
+
try {
|
|
6509
|
+
node = rewriter.rewrite(node, context);
|
|
6510
|
+
}
|
|
6511
|
+
catch (error) {
|
|
6512
|
+
console.error(`Pre-format rewriter "${rewriter.name}" failed:`, error);
|
|
6513
|
+
}
|
|
6514
|
+
}
|
|
6515
|
+
}
|
|
6516
|
+
let formatted = new FormatPrinter(source, resolvedOptions).print(node);
|
|
6517
|
+
if (resolvedOptions.postRewriters.length > 0) {
|
|
6518
|
+
const context = {
|
|
6519
|
+
filePath,
|
|
6520
|
+
baseDir: process.cwd() // TODO: format() shouldn't depend on node internals
|
|
6521
|
+
};
|
|
6522
|
+
for (const rewriter of resolvedOptions.postRewriters) {
|
|
6523
|
+
try {
|
|
6524
|
+
formatted = rewriter.rewrite(formatted, context);
|
|
6525
|
+
}
|
|
6526
|
+
catch (error) {
|
|
6527
|
+
console.error(`Post-format rewriter "${rewriter.name}" failed:`, error);
|
|
6528
|
+
}
|
|
6529
|
+
}
|
|
6530
|
+
}
|
|
6531
|
+
return formatted;
|
|
5454
6532
|
}
|
|
5455
6533
|
parse(source) {
|
|
5456
6534
|
this.herb.ensureBackend();
|