@herb-tools/formatter 0.4.3 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -15
- package/dist/herb-format.js +3379 -2212
- package/dist/herb-format.js.map +1 -1
- package/dist/index.cjs +2069 -901
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +2069 -902
- package/dist/index.esm.js.map +1 -1
- package/dist/types/format-printer.d.ts +243 -0
- package/dist/types/index.d.ts +2 -1
- package/package.json +5 -2
- package/src/cli.ts +57 -29
- package/src/format-printer.ts +1822 -0
- package/src/formatter.ts +2 -2
- package/src/index.ts +2 -3
- package/dist/types/printer.d.ts +0 -127
- package/src/printer.ts +0 -1644
|
@@ -0,0 +1,1822 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getTagName,
|
|
3
|
+
getCombinedAttributeName,
|
|
4
|
+
getCombinedStringFromNodes,
|
|
5
|
+
isNode,
|
|
6
|
+
isToken,
|
|
7
|
+
isParseResult,
|
|
8
|
+
isAnyOf,
|
|
9
|
+
isNoneOf,
|
|
10
|
+
isERBNode,
|
|
11
|
+
isCommentNode,
|
|
12
|
+
isERBControlFlowNode,
|
|
13
|
+
filterNodes,
|
|
14
|
+
hasERBOutput,
|
|
15
|
+
} from "@herb-tools/core"
|
|
16
|
+
import { Printer, IdentityPrinter } from "@herb-tools/printer"
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ParseResult,
|
|
20
|
+
Node,
|
|
21
|
+
DocumentNode,
|
|
22
|
+
HTMLOpenTagNode,
|
|
23
|
+
HTMLCloseTagNode,
|
|
24
|
+
HTMLElementNode,
|
|
25
|
+
HTMLAttributeNode,
|
|
26
|
+
HTMLAttributeValueNode,
|
|
27
|
+
HTMLAttributeNameNode,
|
|
28
|
+
HTMLTextNode,
|
|
29
|
+
HTMLCommentNode,
|
|
30
|
+
HTMLDoctypeNode,
|
|
31
|
+
LiteralNode,
|
|
32
|
+
WhitespaceNode,
|
|
33
|
+
ERBContentNode,
|
|
34
|
+
ERBBlockNode,
|
|
35
|
+
ERBEndNode,
|
|
36
|
+
ERBElseNode,
|
|
37
|
+
ERBIfNode,
|
|
38
|
+
ERBWhenNode,
|
|
39
|
+
ERBCaseNode,
|
|
40
|
+
ERBCaseMatchNode,
|
|
41
|
+
ERBWhileNode,
|
|
42
|
+
ERBUntilNode,
|
|
43
|
+
ERBForNode,
|
|
44
|
+
ERBRescueNode,
|
|
45
|
+
ERBEnsureNode,
|
|
46
|
+
ERBBeginNode,
|
|
47
|
+
ERBUnlessNode,
|
|
48
|
+
ERBYieldNode,
|
|
49
|
+
ERBInNode,
|
|
50
|
+
XMLDeclarationNode,
|
|
51
|
+
CDATANode,
|
|
52
|
+
Token
|
|
53
|
+
} from "@herb-tools/core"
|
|
54
|
+
|
|
55
|
+
import type { ERBNode } from "@herb-tools/core"
|
|
56
|
+
import type { FormatOptions } from "./options.js"
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Analysis result for HTMLElementNode formatting decisions
|
|
60
|
+
*/
|
|
61
|
+
interface ElementFormattingAnalysis {
|
|
62
|
+
openTagInline: boolean
|
|
63
|
+
elementContentInline: boolean
|
|
64
|
+
closeTagInline: boolean
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// TODO: we can probably expand this list with more tags/attributes
|
|
68
|
+
const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
|
|
69
|
+
'*': ['class'],
|
|
70
|
+
'img': ['srcset', 'sizes']
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Printer traverses the Herb AST using the Visitor pattern
|
|
75
|
+
* and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
|
|
76
|
+
*/
|
|
77
|
+
export class FormatPrinter extends Printer {
|
|
78
|
+
/**
|
|
79
|
+
* @deprecated integrate indentWidth into this.options and update FormatOptions to extend from @herb-tools/printer options
|
|
80
|
+
*/
|
|
81
|
+
private indentWidth: number
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @deprecated integrate maxLineLength into this.options and update FormatOptions to extend from @herb-tools/printer options
|
|
85
|
+
*/
|
|
86
|
+
private maxLineLength: number
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
|
|
90
|
+
*/
|
|
91
|
+
private lines: string[] = []
|
|
92
|
+
private indentLevel: number = 0
|
|
93
|
+
private inlineMode: boolean = false
|
|
94
|
+
private currentAttributeName: string | null = null
|
|
95
|
+
private elementStack: HTMLElementNode[] = []
|
|
96
|
+
private elementFormattingAnalysis = new Map<HTMLElementNode, ElementFormattingAnalysis>()
|
|
97
|
+
|
|
98
|
+
public source: string
|
|
99
|
+
|
|
100
|
+
// TODO: extract
|
|
101
|
+
private static readonly INLINE_ELEMENTS = new Set([
|
|
102
|
+
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
|
|
103
|
+
'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
|
|
104
|
+
'samp', 'small', 'span', 'strong', 'sub', 'sup',
|
|
105
|
+
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
|
|
106
|
+
])
|
|
107
|
+
|
|
108
|
+
private static readonly SPACEABLE_CONTAINERS = new Set([
|
|
109
|
+
'div', 'section', 'article', 'main', 'header', 'footer', 'aside',
|
|
110
|
+
'figure', 'details', 'summary', 'dialog', 'fieldset'
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
private static readonly TIGHT_GROUP_PARENTS = new Set([
|
|
114
|
+
'ul', 'ol', 'nav', 'select', 'datalist', 'optgroup', 'tr', 'thead', 'tbody', 'tfoot'
|
|
115
|
+
])
|
|
116
|
+
|
|
117
|
+
private static readonly TIGHT_GROUP_CHILDREN = new Set([
|
|
118
|
+
'li', 'option', 'td', 'th', 'dt', 'dd'
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
private static readonly SPACING_THRESHOLD = 3
|
|
122
|
+
|
|
123
|
+
constructor(source: string, options: Required<FormatOptions>) {
|
|
124
|
+
super()
|
|
125
|
+
|
|
126
|
+
this.source = source
|
|
127
|
+
this.indentWidth = options.indentWidth
|
|
128
|
+
this.maxLineLength = options.maxLineLength
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
print(input: Node | ParseResult | Token): string {
|
|
132
|
+
if (isToken(input)) return input.value
|
|
133
|
+
|
|
134
|
+
const node: Node = isParseResult(input) ? input.value : input
|
|
135
|
+
|
|
136
|
+
// TODO: refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
|
|
137
|
+
this.lines = []
|
|
138
|
+
this.indentLevel = 0
|
|
139
|
+
|
|
140
|
+
this.visit(node)
|
|
141
|
+
|
|
142
|
+
return this.lines.join("\n")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get the current element (top of stack)
|
|
147
|
+
*/
|
|
148
|
+
private get currentElement(): HTMLElementNode | null {
|
|
149
|
+
return this.elementStack.length > 0 ? this.elementStack[this.elementStack.length - 1] : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get the current tag name from the current element context
|
|
154
|
+
*/
|
|
155
|
+
private get currentTagName(): string {
|
|
156
|
+
return this.currentElement?.open_tag?.tag_name?.value ?? ""
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Append text to the last line instead of creating a new line
|
|
161
|
+
*/
|
|
162
|
+
private pushToLastLine(text: string): void {
|
|
163
|
+
if (this.lines.length > 0) {
|
|
164
|
+
this.lines[this.lines.length - 1] += text
|
|
165
|
+
} else {
|
|
166
|
+
this.lines.push(text)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Capture output from a callback into a separate lines array
|
|
172
|
+
* Useful for testing what output would be generated without affecting the main output
|
|
173
|
+
*/
|
|
174
|
+
private capture(callback: () => void): string[] {
|
|
175
|
+
const previousLines = this.lines
|
|
176
|
+
const previousInlineMode = this.inlineMode
|
|
177
|
+
|
|
178
|
+
this.lines = []
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
callback()
|
|
182
|
+
|
|
183
|
+
return this.lines
|
|
184
|
+
} finally {
|
|
185
|
+
this.lines = previousLines
|
|
186
|
+
this.inlineMode = previousInlineMode
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Capture all nodes that would be visited during a callback
|
|
192
|
+
* Returns a flat list of all nodes without generating any output
|
|
193
|
+
*/
|
|
194
|
+
private captureNodes(callback: () => void): Node[] {
|
|
195
|
+
const capturedNodes: Node[] = []
|
|
196
|
+
const previousLines = this.lines
|
|
197
|
+
const previousInlineMode = this.inlineMode
|
|
198
|
+
|
|
199
|
+
const originalPush = this.push.bind(this)
|
|
200
|
+
const originalPushToLastLine = this.pushToLastLine.bind(this)
|
|
201
|
+
const originalVisit = this.visit.bind(this)
|
|
202
|
+
|
|
203
|
+
this.lines = []
|
|
204
|
+
this.push = () => {}
|
|
205
|
+
this.pushToLastLine = () => {}
|
|
206
|
+
|
|
207
|
+
this.visit = (node: Node) => {
|
|
208
|
+
capturedNodes.push(node)
|
|
209
|
+
originalVisit(node)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
callback()
|
|
214
|
+
|
|
215
|
+
return capturedNodes
|
|
216
|
+
} finally {
|
|
217
|
+
this.lines = previousLines
|
|
218
|
+
this.inlineMode = previousInlineMode
|
|
219
|
+
this.push = originalPush
|
|
220
|
+
this.pushToLastLine = originalPushToLastLine
|
|
221
|
+
this.visit = originalVisit
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* @deprecated refactor to use @herb-tools/printer infrastructre (or rework printer use push and this.lines)
|
|
227
|
+
*/
|
|
228
|
+
private push(line: string) {
|
|
229
|
+
this.lines.push(line)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private withIndent<T>(callback: () => T): T {
|
|
233
|
+
this.indentLevel++
|
|
234
|
+
const result = callback()
|
|
235
|
+
this.indentLevel--
|
|
236
|
+
|
|
237
|
+
return result
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private get indent(): string {
|
|
241
|
+
return " ".repeat(this.indentLevel * this.indentWidth)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Format ERB content with proper spacing around the inner content.
|
|
246
|
+
* Returns empty string if content is empty, otherwise wraps content with single spaces.
|
|
247
|
+
*/
|
|
248
|
+
private formatERBContent(content: string): string {
|
|
249
|
+
return content.trim() ? ` ${content.trim()} ` : ""
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Count total attributes including those inside ERB conditionals
|
|
254
|
+
*/
|
|
255
|
+
private getTotalAttributeCount(attributes: HTMLAttributeNode[], inlineNodes: Node[] = []): number {
|
|
256
|
+
let totalAttributeCount = attributes.length
|
|
257
|
+
|
|
258
|
+
inlineNodes.forEach(node => {
|
|
259
|
+
if (isERBControlFlowNode(node)) {
|
|
260
|
+
const capturedNodes = this.captureNodes(() => this.visit(node))
|
|
261
|
+
const attributeNodes = filterNodes(capturedNodes, HTMLAttributeNode)
|
|
262
|
+
|
|
263
|
+
totalAttributeCount += attributeNodes.length
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
return totalAttributeCount
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Extract inline nodes (non-attribute, non-whitespace) from a list of nodes
|
|
272
|
+
*/
|
|
273
|
+
private extractInlineNodes(nodes: Node[]): Node[] {
|
|
274
|
+
return nodes.filter(child => isNoneOf(child, HTMLAttributeNode, WhitespaceNode))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Determine if spacing should be added between sibling elements
|
|
279
|
+
*
|
|
280
|
+
* This implements the "rule of three" intelligent spacing system:
|
|
281
|
+
* - Adds spacing between 3 or more meaningful siblings
|
|
282
|
+
* - Respects semantic groupings (e.g., ul/li, nav/a stay tight)
|
|
283
|
+
* - Groups comments with following elements
|
|
284
|
+
* - Preserves user-added spacing
|
|
285
|
+
*
|
|
286
|
+
* @param parentElement - The parent element containing the siblings
|
|
287
|
+
* @param siblings - Array of all sibling nodes
|
|
288
|
+
* @param currentIndex - Index of the current node being evaluated
|
|
289
|
+
* @param hasExistingSpacing - Whether user-added spacing already exists
|
|
290
|
+
* @returns true if spacing should be added before the current element
|
|
291
|
+
*/
|
|
292
|
+
private shouldAddSpacingBetweenSiblings(
|
|
293
|
+
parentElement: HTMLElementNode | null,
|
|
294
|
+
siblings: Node[],
|
|
295
|
+
currentIndex: number,
|
|
296
|
+
hasExistingSpacing: boolean
|
|
297
|
+
): boolean {
|
|
298
|
+
if (hasExistingSpacing) {
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const hasMixedContent = siblings.some(child => isNode(child, HTMLTextNode) && child.content.trim() !== "")
|
|
303
|
+
|
|
304
|
+
if (hasMixedContent) {
|
|
305
|
+
return false
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const meaningfulSiblings = siblings.filter(child => this.isNonWhitespaceNode(child))
|
|
309
|
+
|
|
310
|
+
if (meaningfulSiblings.length < FormatPrinter.SPACING_THRESHOLD) {
|
|
311
|
+
return false
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const parentTagName = parentElement ? getTagName(parentElement) : null
|
|
315
|
+
|
|
316
|
+
if (parentTagName && FormatPrinter.TIGHT_GROUP_PARENTS.has(parentTagName)) {
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const isSpaceableContainer = !parentTagName || (parentTagName && FormatPrinter.SPACEABLE_CONTAINERS.has(parentTagName))
|
|
321
|
+
|
|
322
|
+
if (!isSpaceableContainer && meaningfulSiblings.length < 5) {
|
|
323
|
+
return false
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const currentNode = siblings[currentIndex]
|
|
327
|
+
const previousMeaningfulIndex = this.findPreviousMeaningfulSibling(siblings, currentIndex)
|
|
328
|
+
const isCurrentComment = isCommentNode(currentNode)
|
|
329
|
+
|
|
330
|
+
if (previousMeaningfulIndex !== -1) {
|
|
331
|
+
const previousNode = siblings[previousMeaningfulIndex]
|
|
332
|
+
const isPreviousComment = isCommentNode(previousNode)
|
|
333
|
+
|
|
334
|
+
if (isPreviousComment && !isCurrentComment && (isNode(currentNode, HTMLElementNode) || isERBNode(currentNode))) {
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (isPreviousComment && isCurrentComment) {
|
|
339
|
+
return false
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (isNode(currentNode, HTMLElementNode)) {
|
|
344
|
+
const currentTagName = getTagName(currentNode)
|
|
345
|
+
|
|
346
|
+
if (FormatPrinter.INLINE_ELEMENTS.has(currentTagName)) {
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (FormatPrinter.TIGHT_GROUP_CHILDREN.has(currentTagName)) {
|
|
351
|
+
return false
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (currentTagName === 'a' && parentTagName === 'nav') {
|
|
355
|
+
return false
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const isBlockElement = this.isBlockLevelNode(currentNode)
|
|
360
|
+
const isERBBlock = isERBNode(currentNode) && isERBControlFlowNode(currentNode)
|
|
361
|
+
const isComment = isCommentNode(currentNode)
|
|
362
|
+
|
|
363
|
+
return isBlockElement || isERBBlock || isComment
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Token list attributes that contain space-separated values and benefit from
|
|
368
|
+
* spacing around ERB content for readability
|
|
369
|
+
*/
|
|
370
|
+
private static readonly TOKEN_LIST_ATTRIBUTES = new Set([
|
|
371
|
+
'class', 'data-controller', 'data-action'
|
|
372
|
+
])
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Check if we're currently processing a token list attribute that needs spacing
|
|
376
|
+
*/
|
|
377
|
+
private isInTokenListAttribute(): boolean {
|
|
378
|
+
return this.currentAttributeName !== null &&
|
|
379
|
+
FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Find the previous meaningful (non-whitespace) sibling
|
|
384
|
+
*/
|
|
385
|
+
private findPreviousMeaningfulSibling(siblings: Node[], currentIndex: number): number {
|
|
386
|
+
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
387
|
+
if (this.isNonWhitespaceNode(siblings[i])) {
|
|
388
|
+
return i
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return -1
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Check if a node represents a block-level element
|
|
396
|
+
*/
|
|
397
|
+
private isBlockLevelNode(node: Node): boolean {
|
|
398
|
+
if (!isNode(node, HTMLElementNode)) {
|
|
399
|
+
return false
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const tagName = getTagName(node)
|
|
403
|
+
|
|
404
|
+
if (FormatPrinter.INLINE_ELEMENTS.has(tagName)) {
|
|
405
|
+
return false
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return true
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Render attributes as a space-separated string
|
|
413
|
+
*/
|
|
414
|
+
private renderAttributesString(attributes: HTMLAttributeNode[]): string {
|
|
415
|
+
if (attributes.length === 0) return ""
|
|
416
|
+
|
|
417
|
+
return ` ${attributes.map(attribute => this.renderAttribute(attribute)).join(" ")}`
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Determine if a tag should be rendered inline based on attribute count and other factors
|
|
422
|
+
*/
|
|
423
|
+
private shouldRenderInline(
|
|
424
|
+
totalAttributeCount: number,
|
|
425
|
+
inlineLength: number,
|
|
426
|
+
indentLength: number,
|
|
427
|
+
maxLineLength: number = this.maxLineLength,
|
|
428
|
+
hasComplexERB: boolean = false,
|
|
429
|
+
hasMultilineAttributes: boolean = false,
|
|
430
|
+
attributes: HTMLAttributeNode[] = []
|
|
431
|
+
): boolean {
|
|
432
|
+
if (hasComplexERB || hasMultilineAttributes) return false
|
|
433
|
+
|
|
434
|
+
if (totalAttributeCount === 0) {
|
|
435
|
+
return inlineLength + indentLength <= maxLineLength
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (totalAttributeCount === 1 && attributes.length === 1) {
|
|
439
|
+
const attribute = attributes[0]
|
|
440
|
+
const attributeName = this.getAttributeName(attribute)
|
|
441
|
+
|
|
442
|
+
if (attributeName === 'class') {
|
|
443
|
+
const attributeValue = this.getAttributeValue(attribute)
|
|
444
|
+
const wouldBeMultiline = this.wouldClassAttributeBeMultiline(attributeValue, indentLength)
|
|
445
|
+
|
|
446
|
+
if (!wouldBeMultiline) {
|
|
447
|
+
return true
|
|
448
|
+
} else {
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (totalAttributeCount > 3 || inlineLength + indentLength > maxLineLength) {
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return true
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private getAttributeName(attribute: HTMLAttributeNode): string {
|
|
462
|
+
return attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
private wouldClassAttributeBeMultiline(content: string, indentLength: number): boolean {
|
|
466
|
+
const normalizedContent = content.replace(/\s+/g, ' ').trim()
|
|
467
|
+
const hasActualNewlines = /\r?\n/.test(content)
|
|
468
|
+
|
|
469
|
+
if (hasActualNewlines && normalizedContent.length > 80) {
|
|
470
|
+
const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
|
|
471
|
+
if (lines.length > 1) {
|
|
472
|
+
return true
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const attributeLine = `class="${normalizedContent}"`
|
|
477
|
+
const currentIndent = indentLength
|
|
478
|
+
|
|
479
|
+
if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
|
|
480
|
+
if (/<%[^%]*%>/.test(normalizedContent)) {
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const classes = normalizedContent.split(' ')
|
|
485
|
+
const lines = this.breakTokensIntoLines(classes, currentIndent)
|
|
486
|
+
return lines.length > 1
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
return false
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private getAttributeValue(attribute: HTMLAttributeNode): string {
|
|
493
|
+
if (attribute.value && isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
494
|
+
const content = attribute.value.children.map(child => {
|
|
495
|
+
if (isNode(child, HTMLTextNode)) {
|
|
496
|
+
return child.content
|
|
497
|
+
}
|
|
498
|
+
return IdentityPrinter.print(child)
|
|
499
|
+
}).join('')
|
|
500
|
+
return content
|
|
501
|
+
}
|
|
502
|
+
return ''
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private hasMultilineAttributes(attributes: HTMLAttributeNode[]): boolean {
|
|
506
|
+
return attributes.some(attribute => {
|
|
507
|
+
if (attribute.value && isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
508
|
+
const content = getCombinedStringFromNodes(attribute.value.children)
|
|
509
|
+
|
|
510
|
+
if (/\r?\n/.test(content)) {
|
|
511
|
+
const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
512
|
+
|
|
513
|
+
if (name === "class") {
|
|
514
|
+
const normalizedContent = content.replace(/\s+/g, ' ').trim()
|
|
515
|
+
|
|
516
|
+
return normalizedContent.length > 80
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const lines = content.split(/\r?\n/)
|
|
520
|
+
|
|
521
|
+
if (lines.length > 1) {
|
|
522
|
+
return lines.slice(1).some(line => /^\s+/.test(line))
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return false
|
|
528
|
+
})
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private formatClassAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
|
|
532
|
+
const normalizedContent = content.replace(/\s+/g, ' ').trim()
|
|
533
|
+
const hasActualNewlines = /\r?\n/.test(content)
|
|
534
|
+
|
|
535
|
+
if (hasActualNewlines && normalizedContent.length > 80) {
|
|
536
|
+
const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
|
|
537
|
+
|
|
538
|
+
if (lines.length > 1) {
|
|
539
|
+
return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const currentIndent = this.indentLevel * this.indentWidth
|
|
544
|
+
const attributeLine = `${name}${equals}${open_quote}${normalizedContent}${close_quote}`
|
|
545
|
+
|
|
546
|
+
if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
|
|
547
|
+
if (/<%[^%]*%>/.test(normalizedContent)) {
|
|
548
|
+
return open_quote + normalizedContent + close_quote
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const classes = normalizedContent.split(' ')
|
|
552
|
+
const lines = this.breakTokensIntoLines(classes, currentIndent)
|
|
553
|
+
|
|
554
|
+
if (lines.length > 1) {
|
|
555
|
+
return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return open_quote + normalizedContent + close_quote
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private isFormattableAttribute(attributeName: string, tagName: string): boolean {
|
|
563
|
+
const globalFormattable = FORMATTABLE_ATTRIBUTES['*'] || []
|
|
564
|
+
const tagSpecificFormattable = FORMATTABLE_ATTRIBUTES[tagName.toLowerCase()] || []
|
|
565
|
+
|
|
566
|
+
return globalFormattable.includes(attributeName) || tagSpecificFormattable.includes(attributeName)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private formatMultilineAttribute(content: string, name: string, open_quote: string, close_quote: string): string {
|
|
570
|
+
if (name === 'srcset' || name === 'sizes') {
|
|
571
|
+
const normalizedContent = content.replace(/\s+/g, ' ').trim()
|
|
572
|
+
|
|
573
|
+
return open_quote + normalizedContent + close_quote
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const lines = content.split('\n')
|
|
577
|
+
|
|
578
|
+
if (lines.length <= 1) {
|
|
579
|
+
return open_quote + content + close_quote
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const formattedContent = this.formatMultilineAttributeValue(lines)
|
|
583
|
+
|
|
584
|
+
return open_quote + formattedContent + close_quote
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private formatMultilineAttributeValue(lines: string[]): string {
|
|
588
|
+
const indent = " ".repeat((this.indentLevel + 1) * this.indentWidth)
|
|
589
|
+
const closeIndent = " ".repeat(this.indentLevel * this.indentWidth)
|
|
590
|
+
|
|
591
|
+
return "\n" + lines.map(line => indent + line).join("\n") + "\n" + closeIndent
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private breakTokensIntoLines(tokens: string[], currentIndent: number, separator: string = ' '): string[] {
|
|
595
|
+
const lines: string[] = []
|
|
596
|
+
let currentLine = ''
|
|
597
|
+
|
|
598
|
+
for (const token of tokens) {
|
|
599
|
+
const testLine = currentLine ? currentLine + separator + token : token
|
|
600
|
+
|
|
601
|
+
if (testLine.length > (this.maxLineLength - currentIndent - 6)) {
|
|
602
|
+
if (currentLine) {
|
|
603
|
+
lines.push(currentLine)
|
|
604
|
+
currentLine = token
|
|
605
|
+
} else {
|
|
606
|
+
lines.push(token)
|
|
607
|
+
}
|
|
608
|
+
} else {
|
|
609
|
+
currentLine = testLine
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (currentLine) lines.push(currentLine)
|
|
614
|
+
|
|
615
|
+
return lines
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Render multiline attributes for a tag
|
|
620
|
+
*/
|
|
621
|
+
private renderMultilineAttributes(tagName: string, allChildren: Node[] = [], isSelfClosing: boolean = false,) {
|
|
622
|
+
this.push(this.indent + `<${tagName}`)
|
|
623
|
+
|
|
624
|
+
this.withIndent(() => {
|
|
625
|
+
allChildren.forEach(child => {
|
|
626
|
+
if (isNode(child, HTMLAttributeNode)) {
|
|
627
|
+
this.push(this.indent + this.renderAttribute(child))
|
|
628
|
+
} else if (!isNode(child, WhitespaceNode)) {
|
|
629
|
+
this.visit(child)
|
|
630
|
+
}
|
|
631
|
+
})
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
if (isSelfClosing) {
|
|
635
|
+
this.push(this.indent + "/>")
|
|
636
|
+
} else {
|
|
637
|
+
this.push(this.indent + ">")
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Reconstruct the text representation of an ERB node
|
|
643
|
+
* @param withFormatting - if true, format the content; if false, preserve original
|
|
644
|
+
*/
|
|
645
|
+
private reconstructERBNode(node: ERBNode, withFormatting: boolean = true): string {
|
|
646
|
+
const open = node.tag_opening?.value ?? ""
|
|
647
|
+
const close = node.tag_closing?.value ?? ""
|
|
648
|
+
const content = node.content?.value ?? ""
|
|
649
|
+
const inner = withFormatting ? this.formatERBContent(content) : content
|
|
650
|
+
|
|
651
|
+
return open + inner + close
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
|
|
656
|
+
*/
|
|
657
|
+
printERBNode(node: ERBNode) {
|
|
658
|
+
const indent = this.inlineMode ? "" : this.indent
|
|
659
|
+
const erbText = this.reconstructERBNode(node, true)
|
|
660
|
+
|
|
661
|
+
this.push(indent + erbText)
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// --- Visitor methods ---
|
|
665
|
+
|
|
666
|
+
visitDocumentNode(node: DocumentNode) {
|
|
667
|
+
let lastWasMeaningful = false
|
|
668
|
+
let hasHandledSpacing = false
|
|
669
|
+
|
|
670
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
671
|
+
const child = node.children[i]
|
|
672
|
+
|
|
673
|
+
if (isNode(child, HTMLTextNode)) {
|
|
674
|
+
const isWhitespaceOnly = child.content.trim() === ""
|
|
675
|
+
|
|
676
|
+
if (isWhitespaceOnly) {
|
|
677
|
+
const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1])
|
|
678
|
+
const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1])
|
|
679
|
+
|
|
680
|
+
const hasMultipleNewlines = child.content.includes('\n\n')
|
|
681
|
+
|
|
682
|
+
if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
|
|
683
|
+
this.push("")
|
|
684
|
+
hasHandledSpacing = true
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
continue
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
692
|
+
this.push("")
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
this.visit(child)
|
|
696
|
+
|
|
697
|
+
if (this.isNonWhitespaceNode(child)) {
|
|
698
|
+
lastWasMeaningful = true
|
|
699
|
+
hasHandledSpacing = false
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
visitHTMLElementNode(node: HTMLElementNode) {
|
|
705
|
+
this.elementStack.push(node)
|
|
706
|
+
this.elementFormattingAnalysis.set(node, this.analyzeElementFormatting(node))
|
|
707
|
+
|
|
708
|
+
this.visit(node.open_tag)
|
|
709
|
+
|
|
710
|
+
if (node.body.length > 0) {
|
|
711
|
+
this.visitHTMLElementBody(node.body, node)
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (node.close_tag) {
|
|
715
|
+
this.visit(node.close_tag)
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this.elementStack.pop()
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
visitHTMLElementBody(body: Node[], element: HTMLElementNode) {
|
|
722
|
+
const analysis = this.elementFormattingAnalysis.get(element)
|
|
723
|
+
|
|
724
|
+
const hasTextFlow = this.isInTextFlowContext(null, body)
|
|
725
|
+
const children = this.filterSignificantChildren(body, hasTextFlow)
|
|
726
|
+
|
|
727
|
+
if (analysis?.elementContentInline) {
|
|
728
|
+
if (children.length === 0) return
|
|
729
|
+
|
|
730
|
+
const oldInlineMode = this.inlineMode
|
|
731
|
+
const nodesToRender = hasTextFlow ? body : children
|
|
732
|
+
|
|
733
|
+
this.inlineMode = true
|
|
734
|
+
|
|
735
|
+
const lines = this.capture(() => {
|
|
736
|
+
nodesToRender.forEach(child => {
|
|
737
|
+
if (isNode(child, HTMLTextNode)) {
|
|
738
|
+
if (hasTextFlow) {
|
|
739
|
+
const normalizedContent = child.content.replace(/\s+/g, ' ')
|
|
740
|
+
|
|
741
|
+
if (normalizedContent && normalizedContent !== ' ') {
|
|
742
|
+
this.push(normalizedContent)
|
|
743
|
+
} else if (normalizedContent === ' ') {
|
|
744
|
+
this.push(' ')
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
const normalizedContent = child.content.replace(/\s+/g, ' ').trim()
|
|
748
|
+
|
|
749
|
+
if (normalizedContent) {
|
|
750
|
+
this.push(normalizedContent)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
} else if (isNode(child, WhitespaceNode)) {
|
|
754
|
+
return
|
|
755
|
+
} else {
|
|
756
|
+
this.visit(child)
|
|
757
|
+
}
|
|
758
|
+
})
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
const content = lines.join('')
|
|
762
|
+
const inlineContent = hasTextFlow ? content.replace(/\s+/g, ' ').trim() : content.trim()
|
|
763
|
+
|
|
764
|
+
if (inlineContent) {
|
|
765
|
+
this.pushToLastLine(inlineContent)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
this.inlineMode = oldInlineMode
|
|
769
|
+
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (children.length === 0) return
|
|
774
|
+
|
|
775
|
+
this.withIndent(() => {
|
|
776
|
+
if (hasTextFlow) {
|
|
777
|
+
this.visitTextFlowChildren(children)
|
|
778
|
+
} else {
|
|
779
|
+
this.visitElementChildren(body, element)
|
|
780
|
+
}
|
|
781
|
+
})
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Visit element children with intelligent spacing logic
|
|
786
|
+
*/
|
|
787
|
+
private visitElementChildren(body: Node[], parentElement: HTMLElementNode | null) {
|
|
788
|
+
let lastWasMeaningful = false
|
|
789
|
+
let hasHandledSpacing = false
|
|
790
|
+
|
|
791
|
+
for (let i = 0; i < body.length; i++) {
|
|
792
|
+
const child = body[i]
|
|
793
|
+
|
|
794
|
+
if (isNode(child, HTMLTextNode)) {
|
|
795
|
+
const isWhitespaceOnly = child.content.trim() === ""
|
|
796
|
+
|
|
797
|
+
if (isWhitespaceOnly) {
|
|
798
|
+
const hasPreviousNonWhitespace = i > 0 && this.isNonWhitespaceNode(body[i - 1])
|
|
799
|
+
const hasNextNonWhitespace = i < body.length - 1 && this.isNonWhitespaceNode(body[i + 1])
|
|
800
|
+
|
|
801
|
+
const hasMultipleNewlines = child.content.includes('\n\n')
|
|
802
|
+
|
|
803
|
+
if (hasPreviousNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
|
|
804
|
+
this.push("")
|
|
805
|
+
hasHandledSpacing = true
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
continue
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
813
|
+
const element = body[i - 1]
|
|
814
|
+
const hasExistingSpacing = i > 0 && isNode(element, HTMLTextNode) && element.content.trim() === "" && (element.content.includes('\n\n') || element.content.split('\n').length > 2)
|
|
815
|
+
|
|
816
|
+
const shouldAddSpacing = this.shouldAddSpacingBetweenSiblings(
|
|
817
|
+
parentElement,
|
|
818
|
+
body,
|
|
819
|
+
i,
|
|
820
|
+
hasExistingSpacing
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
if (shouldAddSpacing) {
|
|
824
|
+
this.push("")
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
this.visit(child)
|
|
829
|
+
|
|
830
|
+
if (this.isNonWhitespaceNode(child)) {
|
|
831
|
+
lastWasMeaningful = true
|
|
832
|
+
hasHandledSpacing = false
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
visitHTMLOpenTagNode(node: HTMLOpenTagNode) {
|
|
838
|
+
const attributes = filterNodes(node.children, HTMLAttributeNode)
|
|
839
|
+
const inlineNodes = this.extractInlineNodes(node.children)
|
|
840
|
+
const isSelfClosing = node.tag_closing?.value === "/>"
|
|
841
|
+
|
|
842
|
+
if (this.currentElement && this.elementFormattingAnalysis.has(this.currentElement)) {
|
|
843
|
+
const analysis = this.elementFormattingAnalysis.get(this.currentElement)!
|
|
844
|
+
|
|
845
|
+
if (analysis.openTagInline) {
|
|
846
|
+
const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
|
|
847
|
+
|
|
848
|
+
this.push(this.inlineMode ? inline : this.indent + inline)
|
|
849
|
+
return
|
|
850
|
+
} else {
|
|
851
|
+
this.renderMultilineAttributes(getTagName(node), node.children, isSelfClosing)
|
|
852
|
+
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const inline = this.renderInlineOpen(getTagName(node), attributes, isSelfClosing, inlineNodes, node.children)
|
|
858
|
+
const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
|
|
859
|
+
const shouldKeepInline = this.shouldRenderInline(
|
|
860
|
+
totalAttributeCount,
|
|
861
|
+
inline.length,
|
|
862
|
+
this.indent.length,
|
|
863
|
+
this.maxLineLength,
|
|
864
|
+
false,
|
|
865
|
+
this.hasMultilineAttributes(attributes),
|
|
866
|
+
attributes
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
if (shouldKeepInline) {
|
|
870
|
+
this.push(this.inlineMode ? inline : this.indent + inline)
|
|
871
|
+
} else {
|
|
872
|
+
this.renderMultilineAttributes(getTagName(node), node.children, isSelfClosing)
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
visitHTMLCloseTagNode(node: HTMLCloseTagNode) {
|
|
877
|
+
const closingTag = IdentityPrinter.print(node)
|
|
878
|
+
const analysis = this.currentElement && this.elementFormattingAnalysis.get(this.currentElement)
|
|
879
|
+
const closeTagInline = analysis?.closeTagInline
|
|
880
|
+
|
|
881
|
+
if (this.currentElement && closeTagInline) {
|
|
882
|
+
this.pushToLastLine(closingTag)
|
|
883
|
+
} else {
|
|
884
|
+
this.push(this.indent + closingTag)
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
visitHTMLTextNode(node: HTMLTextNode) {
|
|
889
|
+
if (this.inlineMode) {
|
|
890
|
+
const normalizedContent = node.content.replace(/\s+/g, ' ').trim()
|
|
891
|
+
|
|
892
|
+
if (normalizedContent) {
|
|
893
|
+
this.push(normalizedContent)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
let text = node.content.trim()
|
|
900
|
+
|
|
901
|
+
if (!text) return
|
|
902
|
+
|
|
903
|
+
const wrapWidth = this.maxLineLength - this.indent.length
|
|
904
|
+
const words = text.split(/\s+/)
|
|
905
|
+
const lines: string[] = []
|
|
906
|
+
|
|
907
|
+
let line = ""
|
|
908
|
+
|
|
909
|
+
for (const word of words) {
|
|
910
|
+
if ((line + (line ? " " : "") + word).length > wrapWidth && line) {
|
|
911
|
+
lines.push(this.indent + line)
|
|
912
|
+
line = word
|
|
913
|
+
} else {
|
|
914
|
+
line += (line ? " " : "") + word
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (line) lines.push(this.indent + line)
|
|
919
|
+
|
|
920
|
+
lines.forEach(line => this.push(line))
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
visitHTMLAttributeNode(node: HTMLAttributeNode) {
|
|
924
|
+
this.push(this.indent + this.renderAttribute(node))
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
visitHTMLAttributeNameNode(node: HTMLAttributeNameNode) {
|
|
928
|
+
this.push(this.indent + getCombinedAttributeName(node))
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
visitHTMLAttributeValueNode(node: HTMLAttributeValueNode) {
|
|
932
|
+
this.push(this.indent + IdentityPrinter.print(node))
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// TODO: rework
|
|
936
|
+
visitHTMLCommentNode(node: HTMLCommentNode) {
|
|
937
|
+
const open = node.comment_start?.value ?? ""
|
|
938
|
+
const close = node.comment_end?.value ?? ""
|
|
939
|
+
|
|
940
|
+
let inner: string
|
|
941
|
+
|
|
942
|
+
if (node.children && node.children.length > 0) {
|
|
943
|
+
inner = node.children.map(child => {
|
|
944
|
+
if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
|
|
945
|
+
return child.content
|
|
946
|
+
} else if (isERBNode(child) || isNode(child, ERBContentNode)) {
|
|
947
|
+
return this.reconstructERBNode(child, false)
|
|
948
|
+
} else {
|
|
949
|
+
return ""
|
|
950
|
+
}
|
|
951
|
+
}).join("")
|
|
952
|
+
|
|
953
|
+
const hasNewlines = inner.includes('\n')
|
|
954
|
+
|
|
955
|
+
if (hasNewlines) {
|
|
956
|
+
const lines = inner.split('\n')
|
|
957
|
+
const childIndent = " ".repeat(this.indentWidth)
|
|
958
|
+
const firstLineHasContent = lines[0].trim() !== ''
|
|
959
|
+
|
|
960
|
+
if (firstLineHasContent && lines.length > 1) {
|
|
961
|
+
const contentLines = lines.map(line => line.trim()).filter(line => line !== '')
|
|
962
|
+
inner = '\n' + contentLines.map(line => childIndent + line).join('\n') + '\n'
|
|
963
|
+
} else {
|
|
964
|
+
const contentLines = lines.filter((line, index) => {
|
|
965
|
+
return line.trim() !== '' && !(index === 0 || index === lines.length - 1)
|
|
966
|
+
})
|
|
967
|
+
|
|
968
|
+
const minIndent = contentLines.length > 0 ? Math.min(...contentLines.map(line => line.length - line.trimStart().length)) : 0
|
|
969
|
+
|
|
970
|
+
const processedLines = lines.map((line, index) => {
|
|
971
|
+
const trimmedLine = line.trim()
|
|
972
|
+
|
|
973
|
+
if ((index === 0 || index === lines.length - 1) && trimmedLine === '') {
|
|
974
|
+
return line
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (trimmedLine !== '') {
|
|
978
|
+
const currentIndent = line.length - line.trimStart().length
|
|
979
|
+
const relativeIndent = Math.max(0, currentIndent - minIndent)
|
|
980
|
+
|
|
981
|
+
return childIndent + " ".repeat(relativeIndent) + trimmedLine
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return line
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
inner = processedLines.join('\n')
|
|
988
|
+
}
|
|
989
|
+
} else {
|
|
990
|
+
inner = ` ${inner.trim()} `
|
|
991
|
+
}
|
|
992
|
+
} else {
|
|
993
|
+
inner = ""
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
this.push(this.indent + open + inner + close)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
visitERBCommentNode(node: ERBContentNode) {
|
|
1000
|
+
const open = node.tag_opening?.value ?? ""
|
|
1001
|
+
const close = node.tag_closing?.value ?? ""
|
|
1002
|
+
|
|
1003
|
+
let inner: string
|
|
1004
|
+
|
|
1005
|
+
if (node.content && node.content.value) {
|
|
1006
|
+
const rawInner = node.content.value
|
|
1007
|
+
const lines = rawInner.split("\n")
|
|
1008
|
+
|
|
1009
|
+
if (lines.length > 2) {
|
|
1010
|
+
const childIndent = this.indent + " ".repeat(this.indentWidth)
|
|
1011
|
+
const innerLines = lines.slice(1, -1).map(line => childIndent + line.trim())
|
|
1012
|
+
|
|
1013
|
+
inner = "\n" + innerLines.join("\n") + "\n"
|
|
1014
|
+
} else {
|
|
1015
|
+
inner = ` ${rawInner.trim()} `
|
|
1016
|
+
}
|
|
1017
|
+
} else {
|
|
1018
|
+
inner = ""
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
this.push(this.indent + open + inner + close)
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
visitHTMLDoctypeNode(node: HTMLDoctypeNode) {
|
|
1025
|
+
this.push(this.indent + IdentityPrinter.print(node))
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
visitXMLDeclarationNode(node: XMLDeclarationNode) {
|
|
1029
|
+
this.push(this.indent + IdentityPrinter.print(node))
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
visitCDATANode(node: CDATANode) {
|
|
1033
|
+
this.push(this.indent + IdentityPrinter.print(node))
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
visitERBContentNode(node: ERBContentNode) {
|
|
1037
|
+
// TODO: this feels hacky
|
|
1038
|
+
if (node.tag_opening?.value === "<%#") {
|
|
1039
|
+
this.visitERBCommentNode(node)
|
|
1040
|
+
} else {
|
|
1041
|
+
this.printERBNode(node)
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
visitERBEndNode(node: ERBEndNode) {
|
|
1046
|
+
this.printERBNode(node)
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
visitERBYieldNode(node: ERBYieldNode) {
|
|
1050
|
+
this.printERBNode(node)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
visitERBInNode(node: ERBInNode) {
|
|
1054
|
+
this.printERBNode(node)
|
|
1055
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
visitERBCaseMatchNode(node: ERBCaseMatchNode) {
|
|
1059
|
+
this.printERBNode(node)
|
|
1060
|
+
this.visitAll(node.conditions)
|
|
1061
|
+
|
|
1062
|
+
if (node.else_clause) this.visit(node.else_clause)
|
|
1063
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
visitERBBlockNode(node: ERBBlockNode) {
|
|
1067
|
+
this.printERBNode(node)
|
|
1068
|
+
this.withIndent(() => this.visitElementChildren(node.body, null))
|
|
1069
|
+
|
|
1070
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
visitERBIfNode(node: ERBIfNode) {
|
|
1074
|
+
if (this.inlineMode) {
|
|
1075
|
+
this.printERBNode(node)
|
|
1076
|
+
|
|
1077
|
+
node.statements.forEach(child => {
|
|
1078
|
+
if (isNode(child, HTMLAttributeNode)) {
|
|
1079
|
+
this.lines.push(" ")
|
|
1080
|
+
this.lines.push(this.renderAttribute(child))
|
|
1081
|
+
} else {
|
|
1082
|
+
const shouldAddSpaces = this.isInTokenListAttribute()
|
|
1083
|
+
|
|
1084
|
+
if (shouldAddSpaces) {
|
|
1085
|
+
this.lines.push(" ")
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
this.visit(child)
|
|
1089
|
+
|
|
1090
|
+
if (shouldAddSpaces) {
|
|
1091
|
+
this.lines.push(" ")
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
})
|
|
1095
|
+
|
|
1096
|
+
const hasHTMLAttributes = node.statements.some(child => isNode(child, HTMLAttributeNode))
|
|
1097
|
+
const isTokenList = this.isInTokenListAttribute()
|
|
1098
|
+
|
|
1099
|
+
if ((hasHTMLAttributes || isTokenList) && node.end_node) {
|
|
1100
|
+
this.lines.push(" ")
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (node.subsequent) this.visit(node.end_node)
|
|
1104
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1105
|
+
} else {
|
|
1106
|
+
this.printERBNode(node)
|
|
1107
|
+
|
|
1108
|
+
this.withIndent(() => {
|
|
1109
|
+
node.statements.forEach(child => this.visit(child))
|
|
1110
|
+
})
|
|
1111
|
+
|
|
1112
|
+
if (node.subsequent) this.visit(node.subsequent)
|
|
1113
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
visitERBElseNode(node: ERBElseNode) {
|
|
1118
|
+
this.printERBNode(node)
|
|
1119
|
+
this.withIndent(() => node.statements.forEach(statement => this.visit(statement)))
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
visitERBWhenNode(node: ERBWhenNode) {
|
|
1123
|
+
this.printERBNode(node)
|
|
1124
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
visitERBCaseNode(node: ERBCaseNode) {
|
|
1128
|
+
this.printERBNode(node)
|
|
1129
|
+
this.visitAll(node.conditions)
|
|
1130
|
+
|
|
1131
|
+
if (node.else_clause) this.visit(node.else_clause)
|
|
1132
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
visitERBBeginNode(node: ERBBeginNode) {
|
|
1136
|
+
this.printERBNode(node)
|
|
1137
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1138
|
+
|
|
1139
|
+
if (node.rescue_clause) this.visit(node.rescue_clause)
|
|
1140
|
+
if (node.else_clause) this.visit(node.else_clause)
|
|
1141
|
+
if (node.ensure_clause) this.visit(node.ensure_clause)
|
|
1142
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
visitERBWhileNode(node: ERBWhileNode) {
|
|
1146
|
+
this.printERBNode(node)
|
|
1147
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1148
|
+
|
|
1149
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
visitERBUntilNode(node: ERBUntilNode) {
|
|
1153
|
+
this.printERBNode(node)
|
|
1154
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1155
|
+
|
|
1156
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
visitERBForNode(node: ERBForNode) {
|
|
1160
|
+
this.printERBNode(node)
|
|
1161
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1162
|
+
|
|
1163
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
visitERBRescueNode(node: ERBRescueNode) {
|
|
1167
|
+
this.printERBNode(node)
|
|
1168
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
visitERBEnsureNode(node: ERBEnsureNode) {
|
|
1172
|
+
this.printERBNode(node)
|
|
1173
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
visitERBUnlessNode(node: ERBUnlessNode) {
|
|
1177
|
+
this.printERBNode(node)
|
|
1178
|
+
this.withIndent(() => this.visitAll(node.statements))
|
|
1179
|
+
|
|
1180
|
+
if (node.else_clause) this.visit(node.else_clause)
|
|
1181
|
+
if (node.end_node) this.visit(node.end_node)
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// --- Element Formatting Analysis Helpers ---
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Analyzes an HTMLElementNode and returns formatting decisions for all parts
|
|
1188
|
+
*/
|
|
1189
|
+
private analyzeElementFormatting(node: HTMLElementNode): ElementFormattingAnalysis {
|
|
1190
|
+
const openTagInline = this.shouldRenderOpenTagInline(node)
|
|
1191
|
+
const elementContentInline = this.shouldRenderElementContentInline(node)
|
|
1192
|
+
const closeTagInline = this.shouldRenderCloseTagInline(node, elementContentInline)
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
openTagInline,
|
|
1196
|
+
elementContentInline,
|
|
1197
|
+
closeTagInline
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Determines if the open tag should be rendered inline
|
|
1203
|
+
*/
|
|
1204
|
+
private shouldRenderOpenTagInline(node: HTMLElementNode): boolean {
|
|
1205
|
+
const children = node.open_tag?.children || []
|
|
1206
|
+
const attributes = filterNodes(children, HTMLAttributeNode)
|
|
1207
|
+
const inlineNodes = this.extractInlineNodes(children)
|
|
1208
|
+
const hasERBControlFlow = inlineNodes.some(node => isERBControlFlowNode(node)) || children.some(node => isERBControlFlowNode(node))
|
|
1209
|
+
const hasComplexERB = hasERBControlFlow && this.hasComplexERBControlFlow(inlineNodes)
|
|
1210
|
+
|
|
1211
|
+
if (hasComplexERB) return false
|
|
1212
|
+
|
|
1213
|
+
const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
|
|
1214
|
+
const hasMultilineAttrs = this.hasMultilineAttributes(attributes)
|
|
1215
|
+
|
|
1216
|
+
if (hasMultilineAttrs) return false
|
|
1217
|
+
|
|
1218
|
+
const inline = this.renderInlineOpen(
|
|
1219
|
+
getTagName(node),
|
|
1220
|
+
attributes,
|
|
1221
|
+
node.open_tag?.tag_closing?.value === "/>",
|
|
1222
|
+
inlineNodes,
|
|
1223
|
+
children
|
|
1224
|
+
)
|
|
1225
|
+
|
|
1226
|
+
return this.shouldRenderInline(
|
|
1227
|
+
totalAttributeCount,
|
|
1228
|
+
inline.length,
|
|
1229
|
+
this.indent.length,
|
|
1230
|
+
this.maxLineLength,
|
|
1231
|
+
hasComplexERB,
|
|
1232
|
+
hasMultilineAttrs,
|
|
1233
|
+
attributes
|
|
1234
|
+
)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Determines if the element content should be rendered inline
|
|
1239
|
+
*/
|
|
1240
|
+
private shouldRenderElementContentInline(node: HTMLElementNode): boolean {
|
|
1241
|
+
const tagName = getTagName(node)
|
|
1242
|
+
const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body))
|
|
1243
|
+
const isInlineElement = this.isInlineElement(tagName)
|
|
1244
|
+
const openTagInline = this.shouldRenderOpenTagInline(node)
|
|
1245
|
+
|
|
1246
|
+
if (!openTagInline) return false
|
|
1247
|
+
if (children.length === 0) return true
|
|
1248
|
+
|
|
1249
|
+
if (isInlineElement) {
|
|
1250
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children)
|
|
1251
|
+
|
|
1252
|
+
if (fullInlineResult) {
|
|
1253
|
+
const totalLength = this.indent.length + fullInlineResult.length
|
|
1254
|
+
return totalLength <= this.maxLineLength || totalLength <= 120
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
return false
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const allNestedAreInline = this.areAllNestedElementsInline(children)
|
|
1261
|
+
const hasMultilineText = this.hasMultilineTextContent(children)
|
|
1262
|
+
const hasMixedContent = this.hasMixedTextAndInlineContent(children)
|
|
1263
|
+
|
|
1264
|
+
if (allNestedAreInline && (!hasMultilineText || hasMixedContent)) {
|
|
1265
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, filterNodes(node.open_tag?.children, HTMLAttributeNode), children)
|
|
1266
|
+
|
|
1267
|
+
if (fullInlineResult) {
|
|
1268
|
+
const totalLength = this.indent.length + fullInlineResult.length
|
|
1269
|
+
|
|
1270
|
+
if (totalLength <= this.maxLineLength) {
|
|
1271
|
+
return true
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
const inlineResult = this.tryRenderInline(children, tagName)
|
|
1277
|
+
|
|
1278
|
+
if (inlineResult) {
|
|
1279
|
+
const openTagResult = this.renderInlineOpen(
|
|
1280
|
+
tagName,
|
|
1281
|
+
filterNodes(node.open_tag?.children, HTMLAttributeNode),
|
|
1282
|
+
false,
|
|
1283
|
+
[],
|
|
1284
|
+
node.open_tag?.children || []
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
const childrenContent = this.renderChildrenInline(children)
|
|
1288
|
+
const fullLine = openTagResult + childrenContent + `</${tagName}>`
|
|
1289
|
+
|
|
1290
|
+
if ((this.indent.length + fullLine.length) <= this.maxLineLength) {
|
|
1291
|
+
return true
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return false
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/**
|
|
1299
|
+
* Determines if the close tag should be rendered inline (usually follows content decision)
|
|
1300
|
+
*/
|
|
1301
|
+
private shouldRenderCloseTagInline(node: HTMLElementNode, elementContentInline: boolean): boolean {
|
|
1302
|
+
const isSelfClosing = node.open_tag?.tag_closing?.value === "/>"
|
|
1303
|
+
|
|
1304
|
+
if (isSelfClosing || node.is_void) return true
|
|
1305
|
+
|
|
1306
|
+
const children = this.filterSignificantChildren(node.body, this.isInTextFlowContext(null, node.body))
|
|
1307
|
+
|
|
1308
|
+
if (children.length === 0) return true
|
|
1309
|
+
|
|
1310
|
+
return elementContentInline
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
// --- Utility methods ---
|
|
1315
|
+
|
|
1316
|
+
private isNonWhitespaceNode(node: Node): boolean {
|
|
1317
|
+
if (isNode(node, WhitespaceNode)) return false
|
|
1318
|
+
if (isNode(node, HTMLTextNode)) return node.content.trim() !== ""
|
|
1319
|
+
|
|
1320
|
+
return true
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Check if an element should be treated as inline based on its tag name
|
|
1325
|
+
*/
|
|
1326
|
+
private isInlineElement(tagName: string): boolean {
|
|
1327
|
+
return FormatPrinter.INLINE_ELEMENTS.has(tagName.toLowerCase())
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Check if we're in a text flow context (parent contains mixed text and inline elements)
|
|
1332
|
+
*/
|
|
1333
|
+
private visitTextFlowChildren(children: Node[]) {
|
|
1334
|
+
let currentLineContent = ""
|
|
1335
|
+
|
|
1336
|
+
for (const child of children) {
|
|
1337
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1338
|
+
const content = child.content
|
|
1339
|
+
|
|
1340
|
+
let processedContent = content.replace(/\s+/g, ' ').trim()
|
|
1341
|
+
|
|
1342
|
+
if (processedContent) {
|
|
1343
|
+
const hasLeadingSpace = /^\s/.test(content)
|
|
1344
|
+
|
|
1345
|
+
if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
|
|
1346
|
+
currentLineContent += ' '
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
currentLineContent += processedContent
|
|
1350
|
+
|
|
1351
|
+
const hasTrailingSpace = /\s$/.test(content)
|
|
1352
|
+
|
|
1353
|
+
if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
|
|
1354
|
+
currentLineContent += ' '
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
|
|
1358
|
+
children.forEach(child => this.visit(child))
|
|
1359
|
+
|
|
1360
|
+
return
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
} else if (isNode(child, HTMLElementNode)) {
|
|
1364
|
+
const childTagName = getTagName(child)
|
|
1365
|
+
|
|
1366
|
+
if (this.isInlineElement(childTagName)) {
|
|
1367
|
+
const childInline = this.tryRenderInlineFull(child, childTagName,
|
|
1368
|
+
filterNodes(child.open_tag?.children, HTMLAttributeNode),
|
|
1369
|
+
this.filterEmptyNodes(child.body)
|
|
1370
|
+
)
|
|
1371
|
+
|
|
1372
|
+
if (childInline) {
|
|
1373
|
+
currentLineContent += childInline
|
|
1374
|
+
|
|
1375
|
+
if ((this.indent.length + currentLineContent.length) > this.maxLineLength) {
|
|
1376
|
+
children.forEach(child => this.visit(child))
|
|
1377
|
+
|
|
1378
|
+
return
|
|
1379
|
+
}
|
|
1380
|
+
} else {
|
|
1381
|
+
if (currentLineContent.trim()) {
|
|
1382
|
+
this.push(this.indent + currentLineContent.trim())
|
|
1383
|
+
currentLineContent = ""
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
this.visit(child)
|
|
1387
|
+
}
|
|
1388
|
+
} else {
|
|
1389
|
+
if (currentLineContent.trim()) {
|
|
1390
|
+
this.push(this.indent + currentLineContent.trim())
|
|
1391
|
+
currentLineContent = ""
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
this.visit(child)
|
|
1395
|
+
}
|
|
1396
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
1397
|
+
const oldLines = this.lines
|
|
1398
|
+
const oldInlineMode = this.inlineMode
|
|
1399
|
+
|
|
1400
|
+
// TODO: use this.capture
|
|
1401
|
+
try {
|
|
1402
|
+
this.lines = []
|
|
1403
|
+
this.inlineMode = true
|
|
1404
|
+
this.visit(child)
|
|
1405
|
+
const erbContent = this.lines.join("")
|
|
1406
|
+
currentLineContent += erbContent
|
|
1407
|
+
|
|
1408
|
+
if ((this.indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
|
|
1409
|
+
this.lines = oldLines
|
|
1410
|
+
this.inlineMode = oldInlineMode
|
|
1411
|
+
children.forEach(child => this.visit(child))
|
|
1412
|
+
|
|
1413
|
+
return
|
|
1414
|
+
}
|
|
1415
|
+
} finally {
|
|
1416
|
+
this.lines = oldLines
|
|
1417
|
+
this.inlineMode = oldInlineMode
|
|
1418
|
+
}
|
|
1419
|
+
} else {
|
|
1420
|
+
if (currentLineContent.trim()) {
|
|
1421
|
+
this.push(this.indent + currentLineContent.trim())
|
|
1422
|
+
currentLineContent = ""
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
this.visit(child)
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
if (currentLineContent.trim()) {
|
|
1430
|
+
const finalLine = this.indent + currentLineContent.trim()
|
|
1431
|
+
|
|
1432
|
+
if (finalLine.length > Math.max(this.maxLineLength, 120)) {
|
|
1433
|
+
this.visitAll(children)
|
|
1434
|
+
|
|
1435
|
+
return
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
this.push(finalLine)
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
private isInTextFlowContext(_parent: Node | null, children: Node[]): boolean {
|
|
1443
|
+
const hasTextContent = children.some(child => isNode(child, HTMLTextNode) &&child.content.trim() !== "")
|
|
1444
|
+
const nonTextChildren = children.filter(child => !isNode(child, HTMLTextNode))
|
|
1445
|
+
|
|
1446
|
+
if (!hasTextContent) return false
|
|
1447
|
+
if (nonTextChildren.length === 0) return false
|
|
1448
|
+
|
|
1449
|
+
const allInline = nonTextChildren.every(child => {
|
|
1450
|
+
if (isNode(child, ERBContentNode)) return true
|
|
1451
|
+
|
|
1452
|
+
if (isNode(child, HTMLElementNode)) {
|
|
1453
|
+
return this.isInlineElement(getTagName(child))
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
return false
|
|
1457
|
+
})
|
|
1458
|
+
|
|
1459
|
+
if (!allInline) return false
|
|
1460
|
+
|
|
1461
|
+
return true
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
|
|
1465
|
+
const parts = attributes.map(attribute => this.renderAttribute(attribute))
|
|
1466
|
+
|
|
1467
|
+
if (inlineNodes.length > 0) {
|
|
1468
|
+
let result = `<${name}`
|
|
1469
|
+
|
|
1470
|
+
if (allChildren.length > 0) {
|
|
1471
|
+
const lines = this.capture(() => {
|
|
1472
|
+
allChildren.forEach(child => {
|
|
1473
|
+
if (isNode(child, HTMLAttributeNode)) {
|
|
1474
|
+
this.lines.push(" " + this.renderAttribute(child))
|
|
1475
|
+
} else if (!(isNode(child, WhitespaceNode))) {
|
|
1476
|
+
const wasInlineMode = this.inlineMode
|
|
1477
|
+
|
|
1478
|
+
this.inlineMode = true
|
|
1479
|
+
|
|
1480
|
+
this.lines.push(" ")
|
|
1481
|
+
this.visit(child)
|
|
1482
|
+
this.inlineMode = wasInlineMode
|
|
1483
|
+
}
|
|
1484
|
+
})
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
result += lines.join("")
|
|
1488
|
+
} else {
|
|
1489
|
+
if (parts.length > 0) {
|
|
1490
|
+
result += ` ${parts.join(" ")}`
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
const lines = this.capture(() => {
|
|
1494
|
+
inlineNodes.forEach(node => {
|
|
1495
|
+
const wasInlineMode = this.inlineMode
|
|
1496
|
+
|
|
1497
|
+
if (!isERBControlFlowNode(node)) {
|
|
1498
|
+
this.inlineMode = true
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
this.visit(node)
|
|
1502
|
+
|
|
1503
|
+
this.inlineMode = wasInlineMode
|
|
1504
|
+
})
|
|
1505
|
+
})
|
|
1506
|
+
|
|
1507
|
+
result += lines.join("")
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
result += selfClose ? " />" : ">"
|
|
1511
|
+
|
|
1512
|
+
return result
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
return `<${name}${parts.length ? " " + parts.join(" ") : ""}${selfClose ? " />" : ">"}`
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
renderAttribute(attribute: HTMLAttributeNode): string {
|
|
1519
|
+
const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
1520
|
+
const equals = attribute.equals?.value ?? ""
|
|
1521
|
+
|
|
1522
|
+
this.currentAttributeName = name
|
|
1523
|
+
|
|
1524
|
+
let value = ""
|
|
1525
|
+
|
|
1526
|
+
if (attribute.value && isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
1527
|
+
const attributeValue = attribute.value
|
|
1528
|
+
|
|
1529
|
+
let open_quote = attributeValue.open_quote?.value ?? ""
|
|
1530
|
+
let close_quote = attributeValue.close_quote?.value ?? ""
|
|
1531
|
+
let htmlTextContent = ""
|
|
1532
|
+
|
|
1533
|
+
const content = attributeValue.children.map((child: Node) => {
|
|
1534
|
+
if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
|
|
1535
|
+
htmlTextContent += child.content
|
|
1536
|
+
|
|
1537
|
+
return child.content
|
|
1538
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
1539
|
+
return IdentityPrinter.print(child)
|
|
1540
|
+
} else {
|
|
1541
|
+
const printed = IdentityPrinter.print(child)
|
|
1542
|
+
|
|
1543
|
+
if (this.currentAttributeName && FormatPrinter.TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)) {
|
|
1544
|
+
return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%')
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
return printed
|
|
1548
|
+
}
|
|
1549
|
+
}).join("")
|
|
1550
|
+
|
|
1551
|
+
if (open_quote === "" && close_quote === "") {
|
|
1552
|
+
open_quote = '"'
|
|
1553
|
+
close_quote = '"'
|
|
1554
|
+
} else if (open_quote === "'" && close_quote === "'" && !htmlTextContent.includes('"')) {
|
|
1555
|
+
open_quote = '"'
|
|
1556
|
+
close_quote = '"'
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (this.isFormattableAttribute(name, this.currentTagName)) {
|
|
1560
|
+
if (name === 'class') {
|
|
1561
|
+
value = this.formatClassAttribute(content, name, equals, open_quote, close_quote)
|
|
1562
|
+
} else {
|
|
1563
|
+
value = this.formatMultilineAttribute(content, name, open_quote, close_quote)
|
|
1564
|
+
}
|
|
1565
|
+
} else {
|
|
1566
|
+
value = open_quote + content + close_quote
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
this.currentAttributeName = null
|
|
1571
|
+
|
|
1572
|
+
return name + equals + value
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Try to render a complete element inline including opening tag, children, and closing tag
|
|
1577
|
+
*/
|
|
1578
|
+
private tryRenderInlineFull(_node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
|
|
1579
|
+
let result = `<${tagName}`
|
|
1580
|
+
|
|
1581
|
+
result += this.renderAttributesString(attributes)
|
|
1582
|
+
result += ">"
|
|
1583
|
+
|
|
1584
|
+
const childrenContent = this.tryRenderChildrenInline(children)
|
|
1585
|
+
|
|
1586
|
+
if (!childrenContent) return null
|
|
1587
|
+
|
|
1588
|
+
result += childrenContent
|
|
1589
|
+
result += `</${tagName}>`
|
|
1590
|
+
|
|
1591
|
+
return result
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
/**
|
|
1595
|
+
* Try to render just the children inline (without tags)
|
|
1596
|
+
*/
|
|
1597
|
+
private tryRenderChildrenInline(children: Node[]): string | null {
|
|
1598
|
+
let result = ""
|
|
1599
|
+
|
|
1600
|
+
for (const child of children) {
|
|
1601
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1602
|
+
const normalizedContent = child.content.replace(/\s+/g, ' ')
|
|
1603
|
+
const hasLeadingSpace = /^\s/.test(child.content)
|
|
1604
|
+
const hasTrailingSpace = /\s$/.test(child.content)
|
|
1605
|
+
const trimmedContent = normalizedContent.trim()
|
|
1606
|
+
|
|
1607
|
+
if (trimmedContent) {
|
|
1608
|
+
let finalContent = trimmedContent
|
|
1609
|
+
|
|
1610
|
+
if (hasLeadingSpace && result && !result.endsWith(' ')) {
|
|
1611
|
+
finalContent = ' ' + finalContent
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
if (hasTrailingSpace) {
|
|
1615
|
+
finalContent = finalContent + ' '
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
result += finalContent
|
|
1619
|
+
} else if (hasLeadingSpace || hasTrailingSpace) {
|
|
1620
|
+
if (result && !result.endsWith(' ')) {
|
|
1621
|
+
result += ' '
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
} else if (isNode(child, HTMLElementNode)) {
|
|
1626
|
+
const tagName = getTagName(child)
|
|
1627
|
+
|
|
1628
|
+
if (!this.isInlineElement(tagName)) {
|
|
1629
|
+
return null
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
const childInline = this.tryRenderInlineFull(child, tagName,
|
|
1633
|
+
filterNodes(child.open_tag?.children, HTMLAttributeNode),
|
|
1634
|
+
this.filterEmptyNodes(child.body)
|
|
1635
|
+
)
|
|
1636
|
+
|
|
1637
|
+
if (!childInline) {
|
|
1638
|
+
return null
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
result += childInline
|
|
1642
|
+
} else {
|
|
1643
|
+
result += this.capture(() => this.visit(child)).join("")
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
return result.trim()
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* Try to render children inline if they are simple enough.
|
|
1652
|
+
* Returns the inline string if possible, null otherwise.
|
|
1653
|
+
*/
|
|
1654
|
+
private tryRenderInline(children: Node[], tagName: string): string | null {
|
|
1655
|
+
for (const child of children) {
|
|
1656
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1657
|
+
if (child.content.includes('\n')) {
|
|
1658
|
+
return null
|
|
1659
|
+
}
|
|
1660
|
+
} else if (isNode(child, HTMLElementNode)) {
|
|
1661
|
+
const isInlineElement = this.isInlineElement(getTagName(child))
|
|
1662
|
+
|
|
1663
|
+
if (!isInlineElement) {
|
|
1664
|
+
return null
|
|
1665
|
+
}
|
|
1666
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
1667
|
+
// ERB content nodes are allowed in inline rendering
|
|
1668
|
+
} else {
|
|
1669
|
+
return null
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
let content = ""
|
|
1674
|
+
|
|
1675
|
+
this.capture(() => {
|
|
1676
|
+
content = this.renderChildrenInline(children)
|
|
1677
|
+
})
|
|
1678
|
+
|
|
1679
|
+
return `<${tagName}>${content}</${tagName}>`
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Check if children contain mixed text and inline elements (like "text<em>inline</em>text")
|
|
1684
|
+
* or mixed ERB output and text (like "<%= value %> text")
|
|
1685
|
+
* This indicates content that should be formatted inline even with structural newlines
|
|
1686
|
+
*/
|
|
1687
|
+
private hasMixedTextAndInlineContent(children: Node[]): boolean {
|
|
1688
|
+
let hasText = false
|
|
1689
|
+
let hasInlineElements = false
|
|
1690
|
+
|
|
1691
|
+
for (const child of children) {
|
|
1692
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1693
|
+
if (child.content.trim() !== "") {
|
|
1694
|
+
hasText = true
|
|
1695
|
+
}
|
|
1696
|
+
} else if (isNode(child, HTMLElementNode)) {
|
|
1697
|
+
if (this.isInlineElement(getTagName(child))) {
|
|
1698
|
+
hasInlineElements = true
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
return (hasText && hasInlineElements) || (hasERBOutput(children) && hasText)
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Check if children contain any text content with newlines
|
|
1708
|
+
*/
|
|
1709
|
+
private hasMultilineTextContent(children: Node[]): boolean {
|
|
1710
|
+
for (const child of children) {
|
|
1711
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1712
|
+
return child.content.includes('\n')
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
if (isNode(child, HTMLElementNode)) {
|
|
1716
|
+
const nestedChildren = this.filterEmptyNodes(child.body)
|
|
1717
|
+
|
|
1718
|
+
if (this.hasMultilineTextContent(nestedChildren)) {
|
|
1719
|
+
return true
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
return false
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/**
|
|
1728
|
+
* Check if all nested elements in the children are inline elements
|
|
1729
|
+
*/
|
|
1730
|
+
private areAllNestedElementsInline(children: Node[]): boolean {
|
|
1731
|
+
for (const child of children) {
|
|
1732
|
+
if (isNode(child, HTMLElementNode)) {
|
|
1733
|
+
if (!this.isInlineElement(getTagName(child))) {
|
|
1734
|
+
return false
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const nestedChildren = this.filterEmptyNodes(child.body)
|
|
1738
|
+
|
|
1739
|
+
if (!this.areAllNestedElementsInline(nestedChildren)) {
|
|
1740
|
+
return false
|
|
1741
|
+
}
|
|
1742
|
+
} else if (isAnyOf(child, HTMLDoctypeNode, HTMLCommentNode, isERBControlFlowNode)) {
|
|
1743
|
+
return false
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
return true
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Check if element has complex ERB control flow
|
|
1752
|
+
*/
|
|
1753
|
+
private hasComplexERBControlFlow(inlineNodes: Node[]): boolean {
|
|
1754
|
+
return inlineNodes.some(node => {
|
|
1755
|
+
if (isNode(node, ERBIfNode)) {
|
|
1756
|
+
if (node.statements.length > 0 && node.location) {
|
|
1757
|
+
const startLine = node.location.start.line
|
|
1758
|
+
const endLine = node.location.end.line
|
|
1759
|
+
|
|
1760
|
+
return startLine !== endLine
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
return false
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
return false
|
|
1767
|
+
})
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
/**
|
|
1771
|
+
* Filter children to remove insignificant whitespace
|
|
1772
|
+
*/
|
|
1773
|
+
private filterSignificantChildren(body: Node[], hasTextFlow: boolean): Node[] {
|
|
1774
|
+
return body.filter(child => {
|
|
1775
|
+
if (isNode(child, WhitespaceNode)) return false
|
|
1776
|
+
|
|
1777
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1778
|
+
if (hasTextFlow && child.content === " ") return true
|
|
1779
|
+
|
|
1780
|
+
return child.content.trim() !== ""
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
return true
|
|
1784
|
+
})
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
/**
|
|
1788
|
+
* Filter out empty text nodes and whitespace nodes
|
|
1789
|
+
*/
|
|
1790
|
+
private filterEmptyNodes(nodes: Node[]): Node[] {
|
|
1791
|
+
return nodes.filter(child =>
|
|
1792
|
+
!isNode(child, WhitespaceNode) && !(isNode(child, HTMLTextNode) && child.content.trim() === "")
|
|
1793
|
+
)
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
private renderElementInline(element: HTMLElementNode): string {
|
|
1797
|
+
const children = this.filterEmptyNodes(element.body)
|
|
1798
|
+
|
|
1799
|
+
return this.renderChildrenInline(children)
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
private renderChildrenInline(children: Node[]) {
|
|
1803
|
+
let content = ''
|
|
1804
|
+
|
|
1805
|
+
for (const child of children) {
|
|
1806
|
+
if (isNode(child, HTMLTextNode)) {
|
|
1807
|
+
content += child.content
|
|
1808
|
+
} else if (isNode(child, HTMLElementNode) ) {
|
|
1809
|
+
const tagName = getTagName(child)
|
|
1810
|
+
const attributes = filterNodes(child.open_tag?.children, HTMLAttributeNode)
|
|
1811
|
+
const attributesString = this.renderAttributesString(attributes)
|
|
1812
|
+
const childContent = this.renderElementInline(child)
|
|
1813
|
+
|
|
1814
|
+
content += `<${tagName}${attributesString}>${childContent}</${tagName}>`
|
|
1815
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
1816
|
+
content += this.reconstructERBNode(child, true)
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
return content.replace(/\s+/g, ' ').trim()
|
|
1821
|
+
}
|
|
1822
|
+
}
|