@herb-tools/formatter 0.4.1 → 0.4.3
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 +773 -105
- package/dist/herb-format.js.map +1 -1
- package/dist/index.cjs +758 -93
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +758 -93
- package/dist/index.esm.js.map +1 -1
- package/dist/types/printer.d.ts +71 -0
- package/package.json +3 -2
- package/src/cli.ts +11 -6
- package/src/printer.ts +986 -103
package/src/printer.ts
CHANGED
|
@@ -68,9 +68,18 @@ export class Printer extends Visitor {
|
|
|
68
68
|
private lines: string[] = []
|
|
69
69
|
private indentLevel: number = 0
|
|
70
70
|
private inlineMode: boolean = false
|
|
71
|
+
private isInComplexNesting: boolean = false
|
|
72
|
+
|
|
73
|
+
private static readonly INLINE_ELEMENTS = new Set([
|
|
74
|
+
'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
|
|
75
|
+
'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
|
|
76
|
+
'samp', 'small', 'span', 'strong', 'sub', 'sup',
|
|
77
|
+
'tt', 'var', 'del', 'ins', 'mark', 's', 'u', 'time', 'wbr'
|
|
78
|
+
])
|
|
71
79
|
|
|
72
80
|
constructor(source: string, options: Required<FormatOptions>) {
|
|
73
81
|
super()
|
|
82
|
+
|
|
74
83
|
this.source = source
|
|
75
84
|
this.indentWidth = options.indentWidth
|
|
76
85
|
this.maxLineLength = options.maxLineLength
|
|
@@ -85,6 +94,7 @@ export class Printer extends Visitor {
|
|
|
85
94
|
|
|
86
95
|
this.lines = []
|
|
87
96
|
this.indentLevel = indentLevel
|
|
97
|
+
this.isInComplexNesting = false // Reset for each top-level element
|
|
88
98
|
|
|
89
99
|
if (typeof (node as any).accept === 'function') {
|
|
90
100
|
node.accept(this)
|
|
@@ -92,7 +102,7 @@ export class Printer extends Visitor {
|
|
|
92
102
|
this.visit(node)
|
|
93
103
|
}
|
|
94
104
|
|
|
95
|
-
return this.lines.
|
|
105
|
+
return this.lines.join("\n")
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
private push(line: string) {
|
|
@@ -103,6 +113,7 @@ export class Printer extends Visitor {
|
|
|
103
113
|
this.indentLevel++
|
|
104
114
|
const result = callback()
|
|
105
115
|
this.indentLevel--
|
|
116
|
+
|
|
106
117
|
return result
|
|
107
118
|
}
|
|
108
119
|
|
|
@@ -110,30 +121,188 @@ export class Printer extends Visitor {
|
|
|
110
121
|
return " ".repeat(this.indentLevel * this.indentWidth)
|
|
111
122
|
}
|
|
112
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Format ERB content with proper spacing around the inner content.
|
|
126
|
+
* Returns empty string if content is empty, otherwise wraps content with single spaces.
|
|
127
|
+
*/
|
|
128
|
+
private formatERBContent(content: string): string {
|
|
129
|
+
return content.trim() ? ` ${content.trim()} ` : ""
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if a node is an ERB control flow node (if, unless, block, case, while, for)
|
|
134
|
+
*/
|
|
135
|
+
private isERBControlFlow(node: Node): boolean {
|
|
136
|
+
return node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE' ||
|
|
137
|
+
node instanceof ERBUnlessNode || (node as any).type === 'AST_ERB_UNLESS_NODE' ||
|
|
138
|
+
node instanceof ERBBlockNode || (node as any).type === 'AST_ERB_BLOCK_NODE' ||
|
|
139
|
+
node instanceof ERBCaseNode || (node as any).type === 'AST_ERB_CASE_NODE' ||
|
|
140
|
+
node instanceof ERBCaseMatchNode || (node as any).type === 'AST_ERB_CASE_MATCH_NODE' ||
|
|
141
|
+
node instanceof ERBWhileNode || (node as any).type === 'AST_ERB_WHILE_NODE' ||
|
|
142
|
+
node instanceof ERBForNode || (node as any).type === 'AST_ERB_FOR_NODE'
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Count total attributes including those inside ERB conditionals
|
|
147
|
+
*/
|
|
148
|
+
private getTotalAttributeCount(attributes: HTMLAttributeNode[], inlineNodes: Node[] = []): number {
|
|
149
|
+
let totalAttributeCount = attributes.length
|
|
150
|
+
|
|
151
|
+
inlineNodes.forEach(node => {
|
|
152
|
+
if (this.isERBControlFlow(node)) {
|
|
153
|
+
const erbNode = node as any
|
|
154
|
+
if (erbNode.statements) {
|
|
155
|
+
totalAttributeCount += erbNode.statements.length
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
return totalAttributeCount
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract HTML attributes from a list of nodes
|
|
165
|
+
*/
|
|
166
|
+
private extractAttributes(nodes: Node[]): HTMLAttributeNode[] {
|
|
167
|
+
return nodes.filter((child): child is HTMLAttributeNode =>
|
|
168
|
+
child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Extract inline nodes (non-attribute, non-whitespace) from a list of nodes
|
|
174
|
+
*/
|
|
175
|
+
private extractInlineNodes(nodes: Node[]): Node[] {
|
|
176
|
+
return nodes.filter(child =>
|
|
177
|
+
!(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
|
|
178
|
+
!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Render attributes as a space-separated string
|
|
184
|
+
*/
|
|
185
|
+
private renderAttributesString(attributes: HTMLAttributeNode[]): string {
|
|
186
|
+
if (attributes.length === 0) return ""
|
|
187
|
+
return ` ${attributes.map(attr => this.renderAttribute(attr)).join(" ")}`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Determine if a tag should be rendered inline based on attribute count and other factors
|
|
192
|
+
*/
|
|
193
|
+
private shouldRenderInline(
|
|
194
|
+
totalAttributeCount: number,
|
|
195
|
+
inlineLength: number,
|
|
196
|
+
indentLength: number,
|
|
197
|
+
maxLineLength: number = this.maxLineLength,
|
|
198
|
+
hasComplexERB: boolean = false,
|
|
199
|
+
nestingDepth: number = 0,
|
|
200
|
+
inlineNodesLength: number = 0
|
|
201
|
+
): boolean {
|
|
202
|
+
if (hasComplexERB) return false
|
|
203
|
+
|
|
204
|
+
// Special case: no attributes at all, always inline if it fits
|
|
205
|
+
if (totalAttributeCount === 0) {
|
|
206
|
+
return inlineLength + indentLength <= maxLineLength
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const basicInlineCondition = totalAttributeCount <= 3 &&
|
|
210
|
+
inlineLength + indentLength <= maxLineLength
|
|
211
|
+
|
|
212
|
+
const erbInlineCondition = inlineNodesLength > 0 && totalAttributeCount <= 3
|
|
213
|
+
|
|
214
|
+
return basicInlineCondition || erbInlineCondition
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Render multiline attributes for a tag
|
|
219
|
+
*/
|
|
220
|
+
private renderMultilineAttributes(
|
|
221
|
+
tagName: string,
|
|
222
|
+
attributes: HTMLAttributeNode[],
|
|
223
|
+
inlineNodes: Node[] = [],
|
|
224
|
+
allChildren: Node[] = [],
|
|
225
|
+
isSelfClosing: boolean = false,
|
|
226
|
+
isVoid: boolean = false,
|
|
227
|
+
hasBodyContent: boolean = false
|
|
228
|
+
): void {
|
|
229
|
+
const indent = this.indent()
|
|
230
|
+
this.push(indent + `<${tagName}`)
|
|
231
|
+
|
|
232
|
+
this.withIndent(() => {
|
|
233
|
+
// Render children in order, handling both attributes and ERB nodes
|
|
234
|
+
allChildren.forEach(child => {
|
|
235
|
+
if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
|
|
236
|
+
this.push(this.indent() + this.renderAttribute(child as HTMLAttributeNode))
|
|
237
|
+
} else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
|
|
238
|
+
this.visit(child)
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
if (isSelfClosing) {
|
|
244
|
+
this.push(indent + "/>")
|
|
245
|
+
} else if (isVoid) {
|
|
246
|
+
this.push(indent + ">")
|
|
247
|
+
} else if (!hasBodyContent) {
|
|
248
|
+
this.push(indent + `></${tagName}>`)
|
|
249
|
+
} else {
|
|
250
|
+
this.push(indent + ">")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
113
254
|
/**
|
|
114
255
|
* Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
|
|
115
256
|
*/
|
|
116
257
|
private printERBNode(node: ERBNode): void {
|
|
117
|
-
const indent = this.indent()
|
|
258
|
+
const indent = this.inlineMode ? "" : this.indent()
|
|
118
259
|
const open = node.tag_opening?.value ?? ""
|
|
119
260
|
const close = node.tag_closing?.value ?? ""
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const [closingStart] = node.tag_closing.range.toArray()
|
|
124
|
-
const rawInner = this.source.slice(openingEnd, closingStart)
|
|
125
|
-
inner = ` ${rawInner.trim()} `
|
|
126
|
-
} else {
|
|
127
|
-
const txt = node.content?.value ?? ""
|
|
128
|
-
inner = txt.trim() ? ` ${txt.trim()} ` : ""
|
|
129
|
-
}
|
|
261
|
+
const content = node.content?.value ?? ""
|
|
262
|
+
const inner = this.formatERBContent(content)
|
|
263
|
+
|
|
130
264
|
this.push(indent + open + inner + close)
|
|
131
265
|
}
|
|
132
266
|
|
|
133
267
|
// --- Visitor methods ---
|
|
134
268
|
|
|
135
269
|
visitDocumentNode(node: DocumentNode): void {
|
|
136
|
-
|
|
270
|
+
let lastWasMeaningful = false
|
|
271
|
+
let hasHandledSpacing = false
|
|
272
|
+
|
|
273
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
274
|
+
const child = node.children[i]
|
|
275
|
+
|
|
276
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
277
|
+
const textNode = child as HTMLTextNode
|
|
278
|
+
const isWhitespaceOnly = textNode.content.trim() === ""
|
|
279
|
+
|
|
280
|
+
if (isWhitespaceOnly) {
|
|
281
|
+
const hasPrevNonWhitespace = i > 0 && this.isNonWhitespaceNode(node.children[i - 1])
|
|
282
|
+
const hasNextNonWhitespace = i < node.children.length - 1 && this.isNonWhitespaceNode(node.children[i + 1])
|
|
283
|
+
|
|
284
|
+
const hasMultipleNewlines = textNode.content.includes('\n\n')
|
|
285
|
+
|
|
286
|
+
if (hasPrevNonWhitespace && hasNextNonWhitespace && hasMultipleNewlines) {
|
|
287
|
+
this.push("")
|
|
288
|
+
hasHandledSpacing = true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (this.isNonWhitespaceNode(child) && lastWasMeaningful && !hasHandledSpacing) {
|
|
296
|
+
this.push("")
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.visit(child)
|
|
300
|
+
|
|
301
|
+
if (this.isNonWhitespaceNode(child)) {
|
|
302
|
+
lastWasMeaningful = true
|
|
303
|
+
hasHandledSpacing = false
|
|
304
|
+
}
|
|
305
|
+
}
|
|
137
306
|
}
|
|
138
307
|
|
|
139
308
|
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
@@ -141,20 +310,31 @@ export class Printer extends Visitor {
|
|
|
141
310
|
const tagName = open.tag_name?.value ?? ""
|
|
142
311
|
const indent = this.indent()
|
|
143
312
|
|
|
144
|
-
const attributes = open.children.filter((child): child is HTMLAttributeNode =>
|
|
145
|
-
child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
146
|
-
)
|
|
147
|
-
const inlineNodes = open.children.filter(child =>
|
|
148
|
-
!(child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') &&
|
|
149
|
-
!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
|
|
150
|
-
)
|
|
151
313
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
314
|
+
const attributes = this.extractAttributes(open.children)
|
|
315
|
+
const inlineNodes = this.extractInlineNodes(open.children)
|
|
316
|
+
|
|
317
|
+
const hasTextFlow = this.isInTextFlowContext(null, node.body)
|
|
318
|
+
|
|
319
|
+
const children = node.body.filter(child => {
|
|
320
|
+
if (child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') {
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
325
|
+
const content = (child as HTMLTextNode).content
|
|
326
|
+
|
|
327
|
+
if (hasTextFlow && content === " ") {
|
|
328
|
+
return true
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return content.trim() !== ""
|
|
332
|
+
}
|
|
157
333
|
|
|
334
|
+
return true
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
const isInlineElement = this.isInlineElement(tagName)
|
|
158
338
|
const hasClosing = open.tag_closing?.value === ">" || open.tag_closing?.value === "/>"
|
|
159
339
|
const isSelfClosing = open.tag_closing?.value === "/>"
|
|
160
340
|
|
|
@@ -177,10 +357,83 @@ export class Printer extends Visitor {
|
|
|
177
357
|
return
|
|
178
358
|
}
|
|
179
359
|
|
|
360
|
+
if (children.length >= 1) {
|
|
361
|
+
if (this.isInComplexNesting) {
|
|
362
|
+
if (children.length === 1) {
|
|
363
|
+
const child = children[0]
|
|
364
|
+
|
|
365
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
366
|
+
const textContent = (child as HTMLTextNode).content.trim()
|
|
367
|
+
const singleLine = `<${tagName}>${textContent}</${tagName}>`
|
|
368
|
+
|
|
369
|
+
if (!textContent.includes('\n') && (indent.length + singleLine.length) <= this.maxLineLength) {
|
|
370
|
+
this.push(indent + singleLine)
|
|
371
|
+
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
const inlineResult = this.tryRenderInline(children, tagName, 0, false, hasTextFlow)
|
|
378
|
+
|
|
379
|
+
if (inlineResult && (indent.length + inlineResult.length) <= this.maxLineLength) {
|
|
380
|
+
this.push(indent + inlineResult)
|
|
381
|
+
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (hasTextFlow) {
|
|
386
|
+
const hasAnyNewlines = children.some(child => {
|
|
387
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
388
|
+
return (child as HTMLTextNode).content.includes('\n')
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return false
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
if (!hasAnyNewlines) {
|
|
395
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
|
|
396
|
+
|
|
397
|
+
if (fullInlineResult) {
|
|
398
|
+
const totalLength = indent.length + fullInlineResult.length
|
|
399
|
+
const maxNesting = this.getMaxNestingDepth(children, 0)
|
|
400
|
+
const maxInlineLength = maxNesting <= 1 ? this.maxLineLength : 60
|
|
401
|
+
|
|
402
|
+
if (totalLength <= maxInlineLength) {
|
|
403
|
+
this.push(indent + fullInlineResult)
|
|
404
|
+
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (hasTextFlow) {
|
|
414
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, [], children)
|
|
415
|
+
|
|
416
|
+
if (fullInlineResult) {
|
|
417
|
+
const totalLength = indent.length + fullInlineResult.length
|
|
418
|
+
const maxNesting = this.getMaxNestingDepth(children, 0)
|
|
419
|
+
const maxInlineLength = maxNesting <= 1 ? this.maxLineLength : 60
|
|
420
|
+
|
|
421
|
+
if (totalLength <= maxInlineLength) {
|
|
422
|
+
this.push(indent + fullInlineResult)
|
|
423
|
+
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
180
429
|
this.push(indent + `<${tagName}>`)
|
|
181
430
|
|
|
182
431
|
this.withIndent(() => {
|
|
183
|
-
|
|
432
|
+
if (hasTextFlow) {
|
|
433
|
+
this.visitTextFlowChildren(children)
|
|
434
|
+
} else {
|
|
435
|
+
children.forEach(child => this.visit(child))
|
|
436
|
+
}
|
|
184
437
|
})
|
|
185
438
|
|
|
186
439
|
if (!node.is_void && !isSelfClosing) {
|
|
@@ -214,17 +467,41 @@ export class Printer extends Visitor {
|
|
|
214
467
|
return
|
|
215
468
|
}
|
|
216
469
|
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
(
|
|
222
|
-
|
|
470
|
+
const hasERBControlFlow = inlineNodes.some(node => this.isERBControlFlow(node)) ||
|
|
471
|
+
open.children.some(node => this.isERBControlFlow(node))
|
|
472
|
+
|
|
473
|
+
const hasComplexERB = hasERBControlFlow && inlineNodes.some(node => {
|
|
474
|
+
if (node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE') {
|
|
475
|
+
const erbNode = node as ERBIfNode
|
|
476
|
+
|
|
477
|
+
if (erbNode.statements.length > 0 && erbNode.location) {
|
|
478
|
+
const startLine = erbNode.location.start.line
|
|
479
|
+
const endLine = erbNode.location.end.line
|
|
480
|
+
|
|
481
|
+
return startLine !== endLine
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return false
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return false
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
const inline = hasComplexERB ? "" : this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children)
|
|
491
|
+
const nestingDepth = this.getMaxNestingDepth(children, 0)
|
|
492
|
+
const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
|
|
493
|
+
|
|
494
|
+
const shouldKeepInline = this.shouldRenderInline(
|
|
495
|
+
totalAttributeCount,
|
|
496
|
+
inline.length,
|
|
497
|
+
indent.length,
|
|
498
|
+
this.maxLineLength,
|
|
499
|
+
hasComplexERB,
|
|
500
|
+
nestingDepth,
|
|
501
|
+
inlineNodes.length
|
|
502
|
+
)
|
|
503
|
+
|
|
223
504
|
|
|
224
|
-
const shouldKeepInline = (attributes.length <= 3 &&
|
|
225
|
-
!hasEmptyValue &&
|
|
226
|
-
inline.length + indent.length <= this.maxLineLength) ||
|
|
227
|
-
inlineNodes.length > 0
|
|
228
505
|
|
|
229
506
|
if (shouldKeepInline) {
|
|
230
507
|
if (children.length === 0) {
|
|
@@ -233,8 +510,71 @@ export class Printer extends Visitor {
|
|
|
233
510
|
} else if (node.is_void) {
|
|
234
511
|
this.push(indent + inline)
|
|
235
512
|
} else {
|
|
236
|
-
|
|
513
|
+
let result = `<${tagName}`
|
|
514
|
+
|
|
515
|
+
result += this.renderAttributesString(attributes)
|
|
516
|
+
|
|
517
|
+
if (inlineNodes.length > 0) {
|
|
518
|
+
const currentIndentLevel = this.indentLevel
|
|
519
|
+
this.indentLevel = 0
|
|
520
|
+
const tempLines = this.lines
|
|
521
|
+
this.lines = []
|
|
522
|
+
|
|
523
|
+
inlineNodes.forEach(node => {
|
|
524
|
+
const wasInlineMode = this.inlineMode
|
|
525
|
+
|
|
526
|
+
if (!this.isERBControlFlow(node)) {
|
|
527
|
+
this.inlineMode = true
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
this.visit(node)
|
|
531
|
+
this.inlineMode = wasInlineMode
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const inlineContent = this.lines.join("")
|
|
535
|
+
|
|
536
|
+
this.lines = tempLines
|
|
537
|
+
this.indentLevel = currentIndentLevel
|
|
538
|
+
|
|
539
|
+
result += inlineContent
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
result += `></${tagName}>`
|
|
543
|
+
this.push(indent + result)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (isInlineElement && children.length > 0 && !hasERBControlFlow) {
|
|
550
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
|
|
551
|
+
|
|
552
|
+
if (fullInlineResult) {
|
|
553
|
+
const totalLength = indent.length + fullInlineResult.length
|
|
554
|
+
|
|
555
|
+
if (totalLength <= this.maxLineLength || totalLength <= 120) {
|
|
556
|
+
this.push(indent + fullInlineResult)
|
|
557
|
+
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (!isInlineElement && children.length > 0 && !hasERBControlFlow) {
|
|
564
|
+
this.push(indent + inline)
|
|
565
|
+
|
|
566
|
+
this.withIndent(() => {
|
|
567
|
+
if (hasTextFlow) {
|
|
568
|
+
this.visitTextFlowChildren(children)
|
|
569
|
+
} else {
|
|
570
|
+
children.forEach(child => this.visit(child))
|
|
571
|
+
}
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
if (!node.is_void && !isSelfClosing) {
|
|
575
|
+
this.push(indent + `</${tagName}>`)
|
|
237
576
|
}
|
|
577
|
+
|
|
238
578
|
return
|
|
239
579
|
}
|
|
240
580
|
|
|
@@ -245,7 +585,11 @@ export class Printer extends Visitor {
|
|
|
245
585
|
}
|
|
246
586
|
|
|
247
587
|
this.withIndent(() => {
|
|
248
|
-
|
|
588
|
+
if (hasTextFlow) {
|
|
589
|
+
this.visitTextFlowChildren(children)
|
|
590
|
+
} else {
|
|
591
|
+
children.forEach(child => this.visit(child))
|
|
592
|
+
}
|
|
249
593
|
})
|
|
250
594
|
|
|
251
595
|
if (!node.is_void && !isSelfClosing) {
|
|
@@ -255,7 +599,16 @@ export class Printer extends Visitor {
|
|
|
255
599
|
return
|
|
256
600
|
}
|
|
257
601
|
|
|
258
|
-
if (inlineNodes.length > 0) {
|
|
602
|
+
if (inlineNodes.length > 0 && hasERBControlFlow) {
|
|
603
|
+
this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
|
|
604
|
+
|
|
605
|
+
if (!isSelfClosing && !node.is_void && children.length > 0) {
|
|
606
|
+
this.withIndent(() => {
|
|
607
|
+
children.forEach(child => this.visit(child))
|
|
608
|
+
})
|
|
609
|
+
this.push(indent + `</${tagName}>`)
|
|
610
|
+
}
|
|
611
|
+
} else if (inlineNodes.length > 0) {
|
|
259
612
|
this.push(indent + this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children))
|
|
260
613
|
|
|
261
614
|
if (!isSelfClosing && !node.is_void && children.length > 0) {
|
|
@@ -265,24 +618,42 @@ export class Printer extends Visitor {
|
|
|
265
618
|
this.push(indent + `</${tagName}>`)
|
|
266
619
|
}
|
|
267
620
|
} else {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
attributes.forEach(attribute => {
|
|
271
|
-
this.push(this.indent() + this.renderAttribute(attribute))
|
|
272
|
-
})
|
|
273
|
-
})
|
|
621
|
+
if (isInlineElement && children.length > 0) {
|
|
622
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
|
|
274
623
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
624
|
+
if (fullInlineResult) {
|
|
625
|
+
const totalLength = indent.length + fullInlineResult.length
|
|
626
|
+
|
|
627
|
+
if (totalLength <= this.maxLineLength || totalLength <= 120) {
|
|
628
|
+
this.push(indent + fullInlineResult)
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
283
633
|
|
|
634
|
+
if (isInlineElement && children.length === 0) {
|
|
635
|
+
let result = `<${tagName}`
|
|
636
|
+
result += this.renderAttributesString(attributes)
|
|
637
|
+
if (isSelfClosing) {
|
|
638
|
+
result += " />"
|
|
639
|
+
} else if (node.is_void) {
|
|
640
|
+
result += ">"
|
|
641
|
+
} else {
|
|
642
|
+
result += `></${tagName}>`
|
|
643
|
+
}
|
|
644
|
+
this.push(indent + result)
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
|
|
649
|
+
|
|
650
|
+
if (!isSelfClosing && !node.is_void && children.length > 0) {
|
|
284
651
|
this.withIndent(() => {
|
|
285
|
-
|
|
652
|
+
if (hasTextFlow) {
|
|
653
|
+
this.visitTextFlowChildren(children)
|
|
654
|
+
} else {
|
|
655
|
+
children.forEach(child => this.visit(child))
|
|
656
|
+
}
|
|
286
657
|
})
|
|
287
658
|
|
|
288
659
|
this.push(indent + `</${tagName}>`)
|
|
@@ -293,9 +664,8 @@ export class Printer extends Visitor {
|
|
|
293
664
|
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
294
665
|
const tagName = node.tag_name?.value ?? ""
|
|
295
666
|
const indent = this.indent()
|
|
296
|
-
const attributes = node.children
|
|
297
|
-
|
|
298
|
-
)
|
|
667
|
+
const attributes = this.extractAttributes(node.children)
|
|
668
|
+
const inlineNodes = this.extractInlineNodes(node.children)
|
|
299
669
|
|
|
300
670
|
const hasClosing = node.tag_closing?.value === ">"
|
|
301
671
|
|
|
@@ -304,53 +674,53 @@ export class Printer extends Visitor {
|
|
|
304
674
|
return
|
|
305
675
|
}
|
|
306
676
|
|
|
307
|
-
const inline = this.renderInlineOpen(tagName, attributes, node.is_void)
|
|
677
|
+
const inline = this.renderInlineOpen(tagName, attributes, node.is_void, inlineNodes, node.children)
|
|
678
|
+
const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
|
|
679
|
+
const shouldKeepInline = this.shouldRenderInline(
|
|
680
|
+
totalAttributeCount,
|
|
681
|
+
inline.length,
|
|
682
|
+
indent.length,
|
|
683
|
+
this.maxLineLength,
|
|
684
|
+
false,
|
|
685
|
+
0,
|
|
686
|
+
inlineNodes.length
|
|
687
|
+
)
|
|
308
688
|
|
|
309
|
-
if (
|
|
689
|
+
if (shouldKeepInline) {
|
|
310
690
|
this.push(indent + inline)
|
|
311
691
|
|
|
312
692
|
return
|
|
313
693
|
}
|
|
314
694
|
|
|
315
|
-
this.
|
|
316
|
-
this.withIndent(() => {
|
|
317
|
-
attributes.forEach(attribute => {
|
|
318
|
-
this.push(this.indent() + this.renderAttribute(attribute))
|
|
319
|
-
})
|
|
320
|
-
})
|
|
321
|
-
this.push(indent + (node.is_void ? "/>" : ">"))
|
|
695
|
+
this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.children, false, node.is_void, false)
|
|
322
696
|
}
|
|
323
697
|
|
|
324
698
|
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
325
699
|
const tagName = node.tag_name?.value ?? ""
|
|
326
700
|
const indent = this.indent()
|
|
327
|
-
const attributes = node.attributes.filter((attribute): attribute is HTMLAttributeNode =>
|
|
328
|
-
attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
329
|
-
)
|
|
330
|
-
const inline = this.renderInlineOpen(tagName, attributes, true)
|
|
331
|
-
|
|
332
|
-
const singleAttribute = attributes[0]
|
|
333
|
-
const hasEmptyValue =
|
|
334
|
-
singleAttribute &&
|
|
335
|
-
(singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
|
|
336
|
-
(singleAttribute.value as any)?.children.length === 0
|
|
337
701
|
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
702
|
+
const attributes = this.extractAttributes(node.attributes)
|
|
703
|
+
const inlineNodes = this.extractInlineNodes(node.attributes)
|
|
704
|
+
|
|
705
|
+
const inline = this.renderInlineOpen(tagName, attributes, true, inlineNodes, node.attributes)
|
|
706
|
+
const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes)
|
|
707
|
+
const shouldKeepInline = this.shouldRenderInline(
|
|
708
|
+
totalAttributeCount,
|
|
709
|
+
inline.length,
|
|
710
|
+
indent.length,
|
|
711
|
+
this.maxLineLength,
|
|
712
|
+
false,
|
|
713
|
+
0,
|
|
714
|
+
inlineNodes.length
|
|
715
|
+
)
|
|
341
716
|
|
|
342
717
|
if (shouldKeepInline) {
|
|
343
718
|
this.push(indent + inline)
|
|
719
|
+
|
|
344
720
|
return
|
|
345
721
|
}
|
|
346
722
|
|
|
347
|
-
this.
|
|
348
|
-
this.withIndent(() => {
|
|
349
|
-
attributes.forEach(attribute => {
|
|
350
|
-
this.push(this.indent() + this.renderAttribute(attribute))
|
|
351
|
-
})
|
|
352
|
-
})
|
|
353
|
-
this.push(indent + "/>")
|
|
723
|
+
this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.attributes, true, false, false)
|
|
354
724
|
}
|
|
355
725
|
|
|
356
726
|
visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
|
|
@@ -363,6 +733,16 @@ export class Printer extends Visitor {
|
|
|
363
733
|
}
|
|
364
734
|
|
|
365
735
|
visitHTMLTextNode(node: HTMLTextNode): void {
|
|
736
|
+
if (this.inlineMode) {
|
|
737
|
+
const normalizedContent = node.content.replace(/\s+/g, ' ').trim()
|
|
738
|
+
|
|
739
|
+
if (normalizedContent) {
|
|
740
|
+
this.push(normalizedContent)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
|
|
366
746
|
const indent = this.indent()
|
|
367
747
|
let text = node.content.trim()
|
|
368
748
|
|
|
@@ -403,16 +783,21 @@ export class Printer extends Visitor {
|
|
|
403
783
|
const indent = this.indent()
|
|
404
784
|
const open_quote = node.open_quote?.value ?? ""
|
|
405
785
|
const close_quote = node.close_quote?.value ?? ""
|
|
786
|
+
|
|
406
787
|
const attribute_value = node.children.map(child => {
|
|
407
788
|
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' ||
|
|
408
789
|
child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
|
|
790
|
+
|
|
409
791
|
return (child as HTMLTextNode | LiteralNode).content
|
|
410
792
|
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
411
793
|
const erbChild = child as ERBContentNode
|
|
794
|
+
|
|
412
795
|
return (erbChild.tag_opening!.value + erbChild.content!.value + erbChild.tag_closing!.value)
|
|
413
796
|
}
|
|
797
|
+
|
|
414
798
|
return ""
|
|
415
799
|
}).join("")
|
|
800
|
+
|
|
416
801
|
this.push(indent + open_quote + attribute_value + close_quote)
|
|
417
802
|
}
|
|
418
803
|
|
|
@@ -511,10 +896,19 @@ export class Printer extends Visitor {
|
|
|
511
896
|
|
|
512
897
|
visitERBInNode(node: ERBInNode): void {
|
|
513
898
|
this.printERBNode(node)
|
|
899
|
+
|
|
900
|
+
this.withIndent(() => {
|
|
901
|
+
node.statements.forEach(stmt => this.visit(stmt))
|
|
902
|
+
})
|
|
514
903
|
}
|
|
515
904
|
|
|
516
905
|
visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
|
|
517
906
|
this.printERBNode(node)
|
|
907
|
+
|
|
908
|
+
node.conditions.forEach(condition => this.visit(condition))
|
|
909
|
+
|
|
910
|
+
if (node.else_clause) this.visit(node.else_clause)
|
|
911
|
+
if (node.end_node) this.visit(node.end_node)
|
|
518
912
|
}
|
|
519
913
|
|
|
520
914
|
visitERBBlockNode(node: ERBBlockNode): void {
|
|
@@ -539,22 +933,36 @@ export class Printer extends Visitor {
|
|
|
539
933
|
const open = node.tag_opening?.value ?? ""
|
|
540
934
|
const content = node.content?.value ?? ""
|
|
541
935
|
const close = node.tag_closing?.value ?? ""
|
|
542
|
-
this.
|
|
936
|
+
const inner = this.formatERBContent(content)
|
|
937
|
+
|
|
938
|
+
this.lines.push(open + inner + close)
|
|
939
|
+
|
|
940
|
+
node.statements.forEach((child, _index) => {
|
|
941
|
+
this.lines.push(" ")
|
|
543
942
|
|
|
544
|
-
node.statements.forEach(child => {
|
|
545
943
|
if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
|
|
546
|
-
this.lines.push(
|
|
944
|
+
this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
|
|
547
945
|
} else {
|
|
548
946
|
this.visit(child)
|
|
549
947
|
}
|
|
550
948
|
})
|
|
551
949
|
|
|
950
|
+
if (node.statements.length > 0 && node.end_node) {
|
|
951
|
+
this.lines.push(" ")
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (node.subsequent) {
|
|
955
|
+
this.visit(node.subsequent)
|
|
956
|
+
}
|
|
957
|
+
|
|
552
958
|
if (node.end_node) {
|
|
553
959
|
const endNode = node.end_node as any
|
|
554
960
|
const endOpen = endNode.tag_opening?.value ?? ""
|
|
555
961
|
const endContent = endNode.content?.value ?? ""
|
|
556
962
|
const endClose = endNode.tag_closing?.value ?? ""
|
|
557
|
-
this.
|
|
963
|
+
const endInner = this.formatERBContent(endContent)
|
|
964
|
+
|
|
965
|
+
this.lines.push(endOpen + endInner + endClose)
|
|
558
966
|
}
|
|
559
967
|
} else {
|
|
560
968
|
this.printERBNode(node)
|
|
@@ -590,11 +998,11 @@ export class Printer extends Visitor {
|
|
|
590
998
|
}
|
|
591
999
|
|
|
592
1000
|
visitERBCaseNode(node: ERBCaseNode): void {
|
|
593
|
-
const baseLevel = this.indentLevel
|
|
594
1001
|
const indent = this.indent()
|
|
595
1002
|
const open = node.tag_opening?.value ?? ""
|
|
596
1003
|
const content = node.content?.value ?? ""
|
|
597
1004
|
const close = node.tag_closing?.value ?? ""
|
|
1005
|
+
|
|
598
1006
|
this.push(indent + open + content + close)
|
|
599
1007
|
|
|
600
1008
|
node.conditions.forEach(condition => this.visit(condition))
|
|
@@ -667,6 +1075,186 @@ export class Printer extends Visitor {
|
|
|
667
1075
|
|
|
668
1076
|
// --- Utility methods ---
|
|
669
1077
|
|
|
1078
|
+
private isNonWhitespaceNode(node: Node): boolean {
|
|
1079
|
+
if (node instanceof HTMLTextNode || (node as any).type === 'AST_HTML_TEXT_NODE') {
|
|
1080
|
+
return (node as HTMLTextNode).content.trim() !== ""
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (node instanceof WhitespaceNode || (node as any).type === 'AST_WHITESPACE_NODE') {
|
|
1084
|
+
return false
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
return true
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* Check if an element should be treated as inline based on its tag name
|
|
1092
|
+
*/
|
|
1093
|
+
private isInlineElement(tagName: string): boolean {
|
|
1094
|
+
return Printer.INLINE_ELEMENTS.has(tagName.toLowerCase())
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Check if we're in a text flow context (parent contains mixed text and inline elements)
|
|
1099
|
+
*/
|
|
1100
|
+
private visitTextFlowChildren(children: Node[]): void {
|
|
1101
|
+
const indent = this.indent()
|
|
1102
|
+
let currentLineContent = ""
|
|
1103
|
+
|
|
1104
|
+
for (const child of children) {
|
|
1105
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
1106
|
+
const content = (child as HTMLTextNode).content
|
|
1107
|
+
|
|
1108
|
+
let processedContent = content.replace(/\s+/g, ' ').trim()
|
|
1109
|
+
|
|
1110
|
+
if (processedContent) {
|
|
1111
|
+
const hasLeadingSpace = /^\s/.test(content)
|
|
1112
|
+
|
|
1113
|
+
if (currentLineContent && hasLeadingSpace && !currentLineContent.endsWith(' ')) {
|
|
1114
|
+
currentLineContent += ' '
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
currentLineContent += processedContent
|
|
1118
|
+
|
|
1119
|
+
const hasTrailingSpace = /\s$/.test(content)
|
|
1120
|
+
|
|
1121
|
+
if (hasTrailingSpace && !currentLineContent.endsWith(' ')) {
|
|
1122
|
+
currentLineContent += ' '
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
|
|
1126
|
+
this.visitTextFlowChildrenMultiline(children)
|
|
1127
|
+
|
|
1128
|
+
return
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
} else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1132
|
+
const element = child as HTMLElementNode
|
|
1133
|
+
const openTag = element.open_tag as HTMLOpenTagNode
|
|
1134
|
+
const childTagName = openTag?.tag_name?.value || ''
|
|
1135
|
+
|
|
1136
|
+
if (this.isInlineElement(childTagName)) {
|
|
1137
|
+
const childInline = this.tryRenderInlineFull(element, childTagName,
|
|
1138
|
+
this.extractAttributes(openTag.children),
|
|
1139
|
+
element.body.filter(c => !(c instanceof WhitespaceNode || (c as any).type === 'AST_WHITESPACE_NODE') &&
|
|
1140
|
+
!((c instanceof HTMLTextNode || (c as any).type === 'AST_HTML_TEXT_NODE') && (c as any)?.content.trim() === "")))
|
|
1141
|
+
|
|
1142
|
+
if (childInline) {
|
|
1143
|
+
currentLineContent += childInline
|
|
1144
|
+
|
|
1145
|
+
if ((indent.length + currentLineContent.length) > this.maxLineLength) {
|
|
1146
|
+
this.visitTextFlowChildrenMultiline(children)
|
|
1147
|
+
|
|
1148
|
+
return
|
|
1149
|
+
}
|
|
1150
|
+
} else {
|
|
1151
|
+
if (currentLineContent.trim()) {
|
|
1152
|
+
this.push(indent + currentLineContent.trim())
|
|
1153
|
+
currentLineContent = ""
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
this.visit(child)
|
|
1157
|
+
}
|
|
1158
|
+
} else {
|
|
1159
|
+
if (currentLineContent.trim()) {
|
|
1160
|
+
this.push(indent + currentLineContent.trim())
|
|
1161
|
+
currentLineContent = ""
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
this.visit(child)
|
|
1165
|
+
}
|
|
1166
|
+
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1167
|
+
const oldLines = this.lines
|
|
1168
|
+
const oldInlineMode = this.inlineMode
|
|
1169
|
+
|
|
1170
|
+
try {
|
|
1171
|
+
this.lines = []
|
|
1172
|
+
this.inlineMode = true
|
|
1173
|
+
this.visit(child)
|
|
1174
|
+
const erbContent = this.lines.join("")
|
|
1175
|
+
currentLineContent += erbContent
|
|
1176
|
+
|
|
1177
|
+
if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
|
|
1178
|
+
this.visitTextFlowChildrenMultiline(children)
|
|
1179
|
+
|
|
1180
|
+
return
|
|
1181
|
+
}
|
|
1182
|
+
} finally {
|
|
1183
|
+
this.lines = oldLines
|
|
1184
|
+
this.inlineMode = oldInlineMode
|
|
1185
|
+
}
|
|
1186
|
+
} else {
|
|
1187
|
+
if (currentLineContent.trim()) {
|
|
1188
|
+
this.push(indent + currentLineContent.trim())
|
|
1189
|
+
currentLineContent = ""
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
this.visit(child)
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (currentLineContent.trim()) {
|
|
1197
|
+
const finalLine = indent + currentLineContent.trim()
|
|
1198
|
+
if (finalLine.length > Math.max(this.maxLineLength, 120)) {
|
|
1199
|
+
this.visitTextFlowChildrenMultiline(children)
|
|
1200
|
+
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
this.push(finalLine)
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
private visitTextFlowChildrenMultiline(children: Node[]): void {
|
|
1208
|
+
children.forEach(child => this.visit(child))
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
private isInTextFlowContext(parent: Node | null, children: Node[]): boolean {
|
|
1212
|
+
const hasTextContent = children.some(child =>
|
|
1213
|
+
(child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') &&
|
|
1214
|
+
(child as HTMLTextNode).content.trim() !== ""
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
if (!hasTextContent) {
|
|
1218
|
+
return false
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const nonTextChildren = children.filter(child =>
|
|
1222
|
+
!(child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE')
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
if (nonTextChildren.length === 0) {
|
|
1226
|
+
return false
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const allInline = nonTextChildren.every(child => {
|
|
1230
|
+
if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1231
|
+
return true
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1235
|
+
const element = child as HTMLElementNode
|
|
1236
|
+
const openTag = element.open_tag as HTMLOpenTagNode
|
|
1237
|
+
const tagName = openTag?.tag_name?.value || ''
|
|
1238
|
+
|
|
1239
|
+
return this.isInlineElement(tagName)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return false
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
if (!allInline) {
|
|
1246
|
+
return false
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const maxNestingDepth = this.getMaxNestingDepth(children, 0)
|
|
1250
|
+
|
|
1251
|
+
if (maxNestingDepth > 2) {
|
|
1252
|
+
return false
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
return true
|
|
1256
|
+
}
|
|
1257
|
+
|
|
670
1258
|
private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
|
|
671
1259
|
const parts = attributes.map(attribute => this.renderAttribute(attribute))
|
|
672
1260
|
|
|
@@ -684,10 +1272,10 @@ export class Printer extends Visitor {
|
|
|
684
1272
|
this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
|
|
685
1273
|
} else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
|
|
686
1274
|
const wasInlineMode = this.inlineMode
|
|
1275
|
+
|
|
687
1276
|
this.inlineMode = true
|
|
688
1277
|
|
|
689
1278
|
this.lines.push(" ")
|
|
690
|
-
|
|
691
1279
|
this.visit(child)
|
|
692
1280
|
this.inlineMode = wasInlineMode
|
|
693
1281
|
}
|
|
@@ -710,8 +1298,13 @@ export class Printer extends Visitor {
|
|
|
710
1298
|
|
|
711
1299
|
inlineNodes.forEach(node => {
|
|
712
1300
|
const wasInlineMode = this.inlineMode
|
|
713
|
-
|
|
1301
|
+
|
|
1302
|
+
if (!this.isERBControlFlow(node)) {
|
|
1303
|
+
this.inlineMode = true
|
|
1304
|
+
}
|
|
1305
|
+
|
|
714
1306
|
this.visit(node)
|
|
1307
|
+
|
|
715
1308
|
this.inlineMode = wasInlineMode
|
|
716
1309
|
})
|
|
717
1310
|
|
|
@@ -737,25 +1330,315 @@ export class Printer extends Visitor {
|
|
|
737
1330
|
let value = ""
|
|
738
1331
|
|
|
739
1332
|
if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
|
|
740
|
-
const
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1333
|
+
const attributeValue = attribute.value as HTMLAttributeValueNode
|
|
1334
|
+
|
|
1335
|
+
let open_quote = attributeValue.open_quote?.value ?? ""
|
|
1336
|
+
let close_quote = attributeValue.close_quote?.value ?? ""
|
|
1337
|
+
let htmlTextContent = ""
|
|
1338
|
+
|
|
1339
|
+
const content = attributeValue.children.map((child: Node) => {
|
|
1340
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' || child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
|
|
1341
|
+
const textContent = (child as HTMLTextNode | LiteralNode).content
|
|
1342
|
+
htmlTextContent += textContent
|
|
745
1343
|
|
|
746
|
-
return
|
|
747
|
-
} else if (
|
|
748
|
-
const
|
|
1344
|
+
return textContent
|
|
1345
|
+
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1346
|
+
const erbAttribute = child as ERBContentNode
|
|
749
1347
|
|
|
750
|
-
return
|
|
1348
|
+
return erbAttribute.tag_opening!.value + erbAttribute.content!.value + erbAttribute.tag_closing!.value
|
|
751
1349
|
}
|
|
752
1350
|
|
|
753
1351
|
return ""
|
|
754
1352
|
}).join("")
|
|
755
1353
|
|
|
756
|
-
|
|
1354
|
+
if (open_quote === "" && close_quote === "") {
|
|
1355
|
+
open_quote = '"'
|
|
1356
|
+
close_quote = '"'
|
|
1357
|
+
} else if (open_quote === "'" && close_quote === "'" && !htmlTextContent.includes('"')) {
|
|
1358
|
+
open_quote = '"'
|
|
1359
|
+
close_quote = '"'
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
value = open_quote + content + close_quote
|
|
757
1363
|
}
|
|
758
1364
|
|
|
759
1365
|
return name + equals + value
|
|
760
1366
|
}
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* Try to render a complete element inline including opening tag, children, and closing tag
|
|
1370
|
+
*/
|
|
1371
|
+
private tryRenderInlineFull(node: HTMLElementNode, tagName: string, attributes: HTMLAttributeNode[], children: Node[]): string | null {
|
|
1372
|
+
let result = `<${tagName}`
|
|
1373
|
+
|
|
1374
|
+
result += this.renderAttributesString(attributes)
|
|
1375
|
+
|
|
1376
|
+
result += ">"
|
|
1377
|
+
|
|
1378
|
+
const childrenContent = this.tryRenderChildrenInline(children)
|
|
1379
|
+
if (!childrenContent) {
|
|
1380
|
+
return null
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
result += childrenContent
|
|
1384
|
+
result += `</${tagName}>`
|
|
1385
|
+
|
|
1386
|
+
return result
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Try to render just the children inline (without tags)
|
|
1391
|
+
*/
|
|
1392
|
+
private tryRenderChildrenInline(children: Node[]): string | null {
|
|
1393
|
+
let result = ""
|
|
1394
|
+
|
|
1395
|
+
for (const child of children) {
|
|
1396
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
1397
|
+
const content = (child as HTMLTextNode).content
|
|
1398
|
+
const normalizedContent = content.replace(/\s+/g, ' ')
|
|
1399
|
+
const hasLeadingSpace = /^\s/.test(content)
|
|
1400
|
+
const hasTrailingSpace = /\s$/.test(content)
|
|
1401
|
+
const trimmedContent = normalizedContent.trim()
|
|
1402
|
+
|
|
1403
|
+
if (trimmedContent) {
|
|
1404
|
+
let finalContent = trimmedContent
|
|
1405
|
+
|
|
1406
|
+
if (hasLeadingSpace && result && !result.endsWith(' ')) {
|
|
1407
|
+
finalContent = ' ' + finalContent
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
if (hasTrailingSpace) {
|
|
1411
|
+
finalContent = finalContent + ' '
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
result += finalContent
|
|
1415
|
+
} else if (hasLeadingSpace || hasTrailingSpace) {
|
|
1416
|
+
if (result && !result.endsWith(' ')) {
|
|
1417
|
+
result += ' '
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
} else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1422
|
+
const element = child as HTMLElementNode
|
|
1423
|
+
const openTag = element.open_tag as HTMLOpenTagNode
|
|
1424
|
+
const childTagName = openTag?.tag_name?.value || ''
|
|
1425
|
+
|
|
1426
|
+
if (!this.isInlineElement(childTagName)) {
|
|
1427
|
+
return null
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const childInline = this.tryRenderInlineFull(element, childTagName,
|
|
1431
|
+
this.extractAttributes(openTag.children),
|
|
1432
|
+
element.body.filter(c => !(c instanceof WhitespaceNode || (c as any).type === 'AST_WHITESPACE_NODE') &&
|
|
1433
|
+
!((c instanceof HTMLTextNode || (c as any).type === 'AST_HTML_TEXT_NODE') && (c as any)?.content.trim() === ""))
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
if (!childInline) {
|
|
1437
|
+
return null
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
result += childInline
|
|
1441
|
+
} else {
|
|
1442
|
+
const oldLines = this.lines
|
|
1443
|
+
const oldInlineMode = this.inlineMode
|
|
1444
|
+
const oldIndentLevel = this.indentLevel
|
|
1445
|
+
|
|
1446
|
+
try {
|
|
1447
|
+
this.lines = []
|
|
1448
|
+
this.inlineMode = true
|
|
1449
|
+
this.indentLevel = 0
|
|
1450
|
+
this.visit(child)
|
|
1451
|
+
|
|
1452
|
+
result += this.lines.join("")
|
|
1453
|
+
} finally {
|
|
1454
|
+
this.lines = oldLines
|
|
1455
|
+
this.inlineMode = oldInlineMode
|
|
1456
|
+
this.indentLevel = oldIndentLevel
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
return result.trim()
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Try to render children inline if they are simple enough.
|
|
1466
|
+
* Returns the inline string if possible, null otherwise.
|
|
1467
|
+
*/
|
|
1468
|
+
private tryRenderInline(children: Node[], tagName: string, depth: number = 0, forceInline: boolean = false, hasTextFlow: boolean = false): string | null {
|
|
1469
|
+
if (!forceInline && children.length > 10) {
|
|
1470
|
+
return null
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const maxNestingDepth = this.getMaxNestingDepth(children, 0)
|
|
1474
|
+
|
|
1475
|
+
let maxAllowedDepth = forceInline ? 5 : (tagName && ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div'].includes(tagName) ? 1 : 2)
|
|
1476
|
+
|
|
1477
|
+
if (hasTextFlow && maxNestingDepth >= 2) {
|
|
1478
|
+
const roughContentLength = this.estimateContentLength(children)
|
|
1479
|
+
|
|
1480
|
+
if (roughContentLength > 47) {
|
|
1481
|
+
maxAllowedDepth = 1
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (!forceInline && maxNestingDepth > maxAllowedDepth) {
|
|
1486
|
+
this.isInComplexNesting = true
|
|
1487
|
+
|
|
1488
|
+
return null
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
for (const child of children) {
|
|
1492
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
1493
|
+
const textContent = (child as HTMLTextNode).content
|
|
1494
|
+
|
|
1495
|
+
if (textContent.includes('\n')) {
|
|
1496
|
+
return null
|
|
1497
|
+
}
|
|
1498
|
+
} else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1499
|
+
const element = child as HTMLElementNode
|
|
1500
|
+
const openTag = element.open_tag as HTMLOpenTagNode
|
|
1501
|
+
const elementTagName = openTag?.tag_name?.value || ''
|
|
1502
|
+
const isInlineElement = this.isInlineElement(elementTagName)
|
|
1503
|
+
|
|
1504
|
+
if (!isInlineElement) {
|
|
1505
|
+
return null
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1509
|
+
// ERB content nodes are allowed in inline rendering
|
|
1510
|
+
} else {
|
|
1511
|
+
return null
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const oldLines = this.lines
|
|
1516
|
+
const oldInlineMode = this.inlineMode
|
|
1517
|
+
|
|
1518
|
+
try {
|
|
1519
|
+
this.lines = []
|
|
1520
|
+
this.inlineMode = true
|
|
1521
|
+
|
|
1522
|
+
let content = ''
|
|
1523
|
+
|
|
1524
|
+
for (const child of children) {
|
|
1525
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
1526
|
+
content += (child as HTMLTextNode).content
|
|
1527
|
+
} else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1528
|
+
const element = child as HTMLElementNode
|
|
1529
|
+
const openTag = element.open_tag as HTMLOpenTagNode
|
|
1530
|
+
const childTagName = openTag?.tag_name?.value || ''
|
|
1531
|
+
|
|
1532
|
+
const attributes = this.extractAttributes(openTag.children)
|
|
1533
|
+
|
|
1534
|
+
const attributesString = this.renderAttributesString(attributes)
|
|
1535
|
+
|
|
1536
|
+
const elementContent = this.renderElementInline(element)
|
|
1537
|
+
|
|
1538
|
+
content += `<${childTagName}${attributesString}>${elementContent}</${childTagName}>`
|
|
1539
|
+
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1540
|
+
const erbNode = child as ERBContentNode
|
|
1541
|
+
const open = erbNode.tag_opening?.value ?? ""
|
|
1542
|
+
const erbContent = erbNode.content?.value ?? ""
|
|
1543
|
+
const close = erbNode.tag_closing?.value ?? ""
|
|
1544
|
+
|
|
1545
|
+
content += `${open}${this.formatERBContent(erbContent)}${close}`
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
content = content.replace(/\s+/g, ' ').trim()
|
|
1550
|
+
|
|
1551
|
+
return `<${tagName}>${content}</${tagName}>`
|
|
1552
|
+
|
|
1553
|
+
} finally {
|
|
1554
|
+
this.lines = oldLines
|
|
1555
|
+
this.inlineMode = oldInlineMode
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Estimate the total content length of children nodes for decision making.
|
|
1561
|
+
*/
|
|
1562
|
+
private estimateContentLength(children: Node[]): number {
|
|
1563
|
+
let length = 0
|
|
1564
|
+
|
|
1565
|
+
for (const child of children) {
|
|
1566
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
1567
|
+
length += (child as HTMLTextNode).content.length
|
|
1568
|
+
} else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1569
|
+
const element = child as HTMLElementNode
|
|
1570
|
+
const openTag = element.open_tag as HTMLOpenTagNode
|
|
1571
|
+
const tagName = openTag?.tag_name?.value || ''
|
|
1572
|
+
|
|
1573
|
+
length += tagName.length + 5 // Rough estimate for tag overhead
|
|
1574
|
+
length += this.estimateContentLength(element.body)
|
|
1575
|
+
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1576
|
+
length += (child as ERBContentNode).content?.value.length || 0
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
return length
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/**
|
|
1583
|
+
* Calculate the maximum nesting depth in a subtree of nodes.
|
|
1584
|
+
*/
|
|
1585
|
+
private getMaxNestingDepth(children: Node[], currentDepth: number): number {
|
|
1586
|
+
let maxDepth = currentDepth
|
|
1587
|
+
|
|
1588
|
+
for (const child of children) {
|
|
1589
|
+
if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1590
|
+
const element = child as HTMLElementNode
|
|
1591
|
+
const elementChildren = element.body.filter(
|
|
1592
|
+
child =>
|
|
1593
|
+
!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
|
|
1594
|
+
!((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
const childDepth = this.getMaxNestingDepth(elementChildren, currentDepth + 1)
|
|
1598
|
+
maxDepth = Math.max(maxDepth, childDepth)
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
return maxDepth
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
/**
|
|
1606
|
+
* Render an HTML element's content inline (without the wrapping tags).
|
|
1607
|
+
*/
|
|
1608
|
+
private renderElementInline(element: HTMLElementNode): string {
|
|
1609
|
+
const children = element.body.filter(
|
|
1610
|
+
child =>
|
|
1611
|
+
!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE') &&
|
|
1612
|
+
!((child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') && (child as any)?.content.trim() === ""),
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
let content = ''
|
|
1616
|
+
|
|
1617
|
+
for (const child of children) {
|
|
1618
|
+
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
1619
|
+
content += (child as HTMLTextNode).content
|
|
1620
|
+
} else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
1621
|
+
const childElement = child as HTMLElementNode
|
|
1622
|
+
const openTag = childElement.open_tag as HTMLOpenTagNode
|
|
1623
|
+
const childTagName = openTag?.tag_name?.value || ''
|
|
1624
|
+
|
|
1625
|
+
const attributes = this.extractAttributes(openTag.children)
|
|
1626
|
+
|
|
1627
|
+
const attributesString = this.renderAttributesString(attributes)
|
|
1628
|
+
|
|
1629
|
+
const childContent = this.renderElementInline(childElement)
|
|
1630
|
+
|
|
1631
|
+
content += `<${childTagName}${attributesString}>${childContent}</${childTagName}>`
|
|
1632
|
+
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1633
|
+
const erbNode = child as ERBContentNode
|
|
1634
|
+
const open = erbNode.tag_opening?.value ?? ""
|
|
1635
|
+
const erbContent = erbNode.content?.value ?? ""
|
|
1636
|
+
const close = erbNode.tag_closing?.value ?? ""
|
|
1637
|
+
|
|
1638
|
+
content += `${open}${this.formatERBContent(erbContent)}${close}`
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
return content.replace(/\s+/g, ' ').trim()
|
|
1643
|
+
}
|
|
761
1644
|
}
|