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