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