@herb-tools/formatter 0.4.2 → 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 +623 -145
- package/dist/herb-format.js.map +1 -1
- package/dist/index.cjs +617 -139
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +617 -139
- package/dist/index.esm.js.map +1 -1
- package/dist/types/printer.d.ts +57 -0
- package/package.json +3 -2
- package/src/printer.ts +803 -154
package/src/printer.ts
CHANGED
|
@@ -70,8 +70,16 @@ export class Printer extends Visitor {
|
|
|
70
70
|
private inlineMode: boolean = false
|
|
71
71
|
private isInComplexNesting: boolean = false
|
|
72
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
|
+
])
|
|
79
|
+
|
|
73
80
|
constructor(source: string, options: Required<FormatOptions>) {
|
|
74
81
|
super()
|
|
82
|
+
|
|
75
83
|
this.source = source
|
|
76
84
|
this.indentWidth = options.indentWidth
|
|
77
85
|
this.maxLineLength = options.maxLineLength
|
|
@@ -94,7 +102,7 @@ export class Printer extends Visitor {
|
|
|
94
102
|
this.visit(node)
|
|
95
103
|
}
|
|
96
104
|
|
|
97
|
-
return this.lines.
|
|
105
|
+
return this.lines.join("\n")
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
private push(line: string) {
|
|
@@ -105,6 +113,7 @@ export class Printer extends Visitor {
|
|
|
105
113
|
this.indentLevel++
|
|
106
114
|
const result = callback()
|
|
107
115
|
this.indentLevel--
|
|
116
|
+
|
|
108
117
|
return result
|
|
109
118
|
}
|
|
110
119
|
|
|
@@ -112,30 +121,188 @@ export class Printer extends Visitor {
|
|
|
112
121
|
return " ".repeat(this.indentLevel * this.indentWidth)
|
|
113
122
|
}
|
|
114
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
|
+
|
|
115
254
|
/**
|
|
116
255
|
* Print an ERB tag (<% %> or <%= %>) with single spaces around inner content.
|
|
117
256
|
*/
|
|
118
257
|
private printERBNode(node: ERBNode): void {
|
|
119
|
-
const indent = this.indent()
|
|
258
|
+
const indent = this.inlineMode ? "" : this.indent()
|
|
120
259
|
const open = node.tag_opening?.value ?? ""
|
|
121
260
|
const close = node.tag_closing?.value ?? ""
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const [closingStart] = node.tag_closing.range.toArray()
|
|
126
|
-
const rawInner = this.source.slice(openingEnd, closingStart)
|
|
127
|
-
inner = ` ${rawInner.trim()} `
|
|
128
|
-
} else {
|
|
129
|
-
const txt = node.content?.value ?? ""
|
|
130
|
-
inner = txt.trim() ? ` ${txt.trim()} ` : ""
|
|
131
|
-
}
|
|
261
|
+
const content = node.content?.value ?? ""
|
|
262
|
+
const inner = this.formatERBContent(content)
|
|
263
|
+
|
|
132
264
|
this.push(indent + open + inner + close)
|
|
133
265
|
}
|
|
134
266
|
|
|
135
267
|
// --- Visitor methods ---
|
|
136
268
|
|
|
137
269
|
visitDocumentNode(node: DocumentNode): void {
|
|
138
|
-
|
|
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
|
+
}
|
|
139
306
|
}
|
|
140
307
|
|
|
141
308
|
visitHTMLElementNode(node: HTMLElementNode): void {
|
|
@@ -143,21 +310,31 @@ export class Printer extends Visitor {
|
|
|
143
310
|
const tagName = open.tag_name?.value ?? ""
|
|
144
311
|
const indent = this.indent()
|
|
145
312
|
|
|
146
|
-
const attributes = open.children.filter((child): child is HTMLAttributeNode =>
|
|
147
|
-
child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
148
|
-
)
|
|
149
313
|
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')
|
|
153
|
-
)
|
|
314
|
+
const attributes = this.extractAttributes(open.children)
|
|
315
|
+
const inlineNodes = this.extractInlineNodes(open.children)
|
|
154
316
|
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
}
|
|
333
|
+
|
|
334
|
+
return true
|
|
335
|
+
})
|
|
160
336
|
|
|
337
|
+
const isInlineElement = this.isInlineElement(tagName)
|
|
161
338
|
const hasClosing = open.tag_closing?.value === ">" || open.tag_closing?.value === "/>"
|
|
162
339
|
const isSelfClosing = open.tag_closing?.value === "/>"
|
|
163
340
|
|
|
@@ -191,15 +368,59 @@ export class Printer extends Visitor {
|
|
|
191
368
|
|
|
192
369
|
if (!textContent.includes('\n') && (indent.length + singleLine.length) <= this.maxLineLength) {
|
|
193
370
|
this.push(indent + singleLine)
|
|
371
|
+
|
|
194
372
|
return
|
|
195
373
|
}
|
|
196
374
|
}
|
|
197
375
|
}
|
|
198
376
|
} else {
|
|
199
|
-
const inlineResult = this.tryRenderInline(children, tagName)
|
|
377
|
+
const inlineResult = this.tryRenderInline(children, tagName, 0, false, hasTextFlow)
|
|
200
378
|
|
|
201
379
|
if (inlineResult && (indent.length + inlineResult.length) <= this.maxLineLength) {
|
|
202
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
|
+
|
|
203
424
|
return
|
|
204
425
|
}
|
|
205
426
|
}
|
|
@@ -208,7 +429,11 @@ export class Printer extends Visitor {
|
|
|
208
429
|
this.push(indent + `<${tagName}>`)
|
|
209
430
|
|
|
210
431
|
this.withIndent(() => {
|
|
211
|
-
|
|
432
|
+
if (hasTextFlow) {
|
|
433
|
+
this.visitTextFlowChildren(children)
|
|
434
|
+
} else {
|
|
435
|
+
children.forEach(child => this.visit(child))
|
|
436
|
+
}
|
|
212
437
|
})
|
|
213
438
|
|
|
214
439
|
if (!node.is_void && !isSelfClosing) {
|
|
@@ -242,25 +467,41 @@ export class Printer extends Visitor {
|
|
|
242
467
|
return
|
|
243
468
|
}
|
|
244
469
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
const hasEmptyValue =
|
|
248
|
-
singleAttribute &&
|
|
249
|
-
(singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
|
|
250
|
-
(singleAttribute.value as any)?.children.length === 0
|
|
470
|
+
const hasERBControlFlow = inlineNodes.some(node => this.isERBControlFlow(node)) ||
|
|
471
|
+
open.children.some(node => this.isERBControlFlow(node))
|
|
251
472
|
|
|
252
|
-
const
|
|
253
|
-
node instanceof ERBIfNode || (node as any).type === 'AST_ERB_IF_NODE'
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
259
502
|
)
|
|
260
503
|
|
|
261
|
-
|
|
262
|
-
inline.length + indent.length <= this.maxLineLength) ||
|
|
263
|
-
(inlineNodes.length > 0 && !hasERBControlFlow)
|
|
504
|
+
|
|
264
505
|
|
|
265
506
|
if (shouldKeepInline) {
|
|
266
507
|
if (children.length === 0) {
|
|
@@ -269,7 +510,69 @@ export class Printer extends Visitor {
|
|
|
269
510
|
} else if (node.is_void) {
|
|
270
511
|
this.push(indent + inline)
|
|
271
512
|
} else {
|
|
272
|
-
|
|
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}>`)
|
|
273
576
|
}
|
|
274
577
|
|
|
275
578
|
return
|
|
@@ -282,7 +585,11 @@ export class Printer extends Visitor {
|
|
|
282
585
|
}
|
|
283
586
|
|
|
284
587
|
this.withIndent(() => {
|
|
285
|
-
|
|
588
|
+
if (hasTextFlow) {
|
|
589
|
+
this.visitTextFlowChildren(children)
|
|
590
|
+
} else {
|
|
591
|
+
children.forEach(child => this.visit(child))
|
|
592
|
+
}
|
|
286
593
|
})
|
|
287
594
|
|
|
288
595
|
if (!node.is_void && !isSelfClosing) {
|
|
@@ -293,25 +600,9 @@ export class Printer extends Visitor {
|
|
|
293
600
|
}
|
|
294
601
|
|
|
295
602
|
if (inlineNodes.length > 0 && hasERBControlFlow) {
|
|
296
|
-
this.
|
|
297
|
-
this.withIndent(() => {
|
|
298
|
-
open.children.forEach(child => {
|
|
299
|
-
if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
|
|
300
|
-
this.push(this.indent() + this.renderAttribute(child as HTMLAttributeNode))
|
|
301
|
-
} else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
|
|
302
|
-
this.visit(child)
|
|
303
|
-
}
|
|
304
|
-
})
|
|
305
|
-
})
|
|
603
|
+
this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0)
|
|
306
604
|
|
|
307
|
-
if (isSelfClosing) {
|
|
308
|
-
this.push(indent + "/>")
|
|
309
|
-
} else if (node.is_void) {
|
|
310
|
-
this.push(indent + ">")
|
|
311
|
-
} else if (children.length === 0) {
|
|
312
|
-
this.push(indent + ">" + `</${tagName}>`)
|
|
313
|
-
} else {
|
|
314
|
-
this.push(indent + ">")
|
|
605
|
+
if (!isSelfClosing && !node.is_void && children.length > 0) {
|
|
315
606
|
this.withIndent(() => {
|
|
316
607
|
children.forEach(child => this.visit(child))
|
|
317
608
|
})
|
|
@@ -327,24 +618,42 @@ export class Printer extends Visitor {
|
|
|
327
618
|
this.push(indent + `</${tagName}>`)
|
|
328
619
|
}
|
|
329
620
|
} else {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
attributes.forEach(attribute => {
|
|
333
|
-
this.push(this.indent() + this.renderAttribute(attribute))
|
|
334
|
-
})
|
|
335
|
-
})
|
|
621
|
+
if (isInlineElement && children.length > 0) {
|
|
622
|
+
const fullInlineResult = this.tryRenderInlineFull(node, tagName, attributes, children)
|
|
336
623
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
+
}
|
|
345
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) {
|
|
346
651
|
this.withIndent(() => {
|
|
347
|
-
|
|
652
|
+
if (hasTextFlow) {
|
|
653
|
+
this.visitTextFlowChildren(children)
|
|
654
|
+
} else {
|
|
655
|
+
children.forEach(child => this.visit(child))
|
|
656
|
+
}
|
|
348
657
|
})
|
|
349
658
|
|
|
350
659
|
this.push(indent + `</${tagName}>`)
|
|
@@ -355,9 +664,8 @@ export class Printer extends Visitor {
|
|
|
355
664
|
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
|
|
356
665
|
const tagName = node.tag_name?.value ?? ""
|
|
357
666
|
const indent = this.indent()
|
|
358
|
-
const attributes = node.children
|
|
359
|
-
|
|
360
|
-
)
|
|
667
|
+
const attributes = this.extractAttributes(node.children)
|
|
668
|
+
const inlineNodes = this.extractInlineNodes(node.children)
|
|
361
669
|
|
|
362
670
|
const hasClosing = node.tag_closing?.value === ">"
|
|
363
671
|
|
|
@@ -366,52 +674,53 @@ export class Printer extends Visitor {
|
|
|
366
674
|
return
|
|
367
675
|
}
|
|
368
676
|
|
|
369
|
-
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
|
+
)
|
|
370
688
|
|
|
371
|
-
if (
|
|
689
|
+
if (shouldKeepInline) {
|
|
372
690
|
this.push(indent + inline)
|
|
373
691
|
|
|
374
692
|
return
|
|
375
693
|
}
|
|
376
694
|
|
|
377
|
-
this.
|
|
378
|
-
this.withIndent(() => {
|
|
379
|
-
attributes.forEach(attribute => {
|
|
380
|
-
this.push(this.indent() + this.renderAttribute(attribute))
|
|
381
|
-
})
|
|
382
|
-
})
|
|
383
|
-
this.push(indent + (node.is_void ? "/>" : ">"))
|
|
695
|
+
this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.children, false, node.is_void, false)
|
|
384
696
|
}
|
|
385
697
|
|
|
386
698
|
visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void {
|
|
387
699
|
const tagName = node.tag_name?.value ?? ""
|
|
388
700
|
const indent = this.indent()
|
|
389
|
-
const attributes = node.attributes.filter((attribute): attribute is HTMLAttributeNode =>
|
|
390
|
-
attribute instanceof HTMLAttributeNode || (attribute as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
391
|
-
)
|
|
392
|
-
const inline = this.renderInlineOpen(tagName, attributes, true)
|
|
393
|
-
|
|
394
|
-
const singleAttribute = attributes[0]
|
|
395
|
-
const hasEmptyValue =
|
|
396
|
-
singleAttribute &&
|
|
397
|
-
(singleAttribute.value instanceof HTMLAttributeValueNode || (singleAttribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE') &&
|
|
398
|
-
(singleAttribute.value as any)?.children.length === 0
|
|
399
701
|
|
|
400
|
-
const
|
|
401
|
-
|
|
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
|
+
)
|
|
402
716
|
|
|
403
717
|
if (shouldKeepInline) {
|
|
404
718
|
this.push(indent + inline)
|
|
719
|
+
|
|
405
720
|
return
|
|
406
721
|
}
|
|
407
722
|
|
|
408
|
-
this.
|
|
409
|
-
this.withIndent(() => {
|
|
410
|
-
attributes.forEach(attribute => {
|
|
411
|
-
this.push(this.indent() + this.renderAttribute(attribute))
|
|
412
|
-
})
|
|
413
|
-
})
|
|
414
|
-
this.push(indent + "/>")
|
|
723
|
+
this.renderMultilineAttributes(tagName, attributes, inlineNodes, node.attributes, true, false, false)
|
|
415
724
|
}
|
|
416
725
|
|
|
417
726
|
visitHTMLCloseTagNode(node: HTMLCloseTagNode): void {
|
|
@@ -424,6 +733,16 @@ export class Printer extends Visitor {
|
|
|
424
733
|
}
|
|
425
734
|
|
|
426
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
|
+
|
|
427
746
|
const indent = this.indent()
|
|
428
747
|
let text = node.content.trim()
|
|
429
748
|
|
|
@@ -464,16 +783,21 @@ export class Printer extends Visitor {
|
|
|
464
783
|
const indent = this.indent()
|
|
465
784
|
const open_quote = node.open_quote?.value ?? ""
|
|
466
785
|
const close_quote = node.close_quote?.value ?? ""
|
|
786
|
+
|
|
467
787
|
const attribute_value = node.children.map(child => {
|
|
468
788
|
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE' ||
|
|
469
789
|
child instanceof LiteralNode || (child as any).type === 'AST_LITERAL_NODE') {
|
|
790
|
+
|
|
470
791
|
return (child as HTMLTextNode | LiteralNode).content
|
|
471
792
|
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
472
793
|
const erbChild = child as ERBContentNode
|
|
794
|
+
|
|
473
795
|
return (erbChild.tag_opening!.value + erbChild.content!.value + erbChild.tag_closing!.value)
|
|
474
796
|
}
|
|
797
|
+
|
|
475
798
|
return ""
|
|
476
799
|
}).join("")
|
|
800
|
+
|
|
477
801
|
this.push(indent + open_quote + attribute_value + close_quote)
|
|
478
802
|
}
|
|
479
803
|
|
|
@@ -572,10 +896,19 @@ export class Printer extends Visitor {
|
|
|
572
896
|
|
|
573
897
|
visitERBInNode(node: ERBInNode): void {
|
|
574
898
|
this.printERBNode(node)
|
|
899
|
+
|
|
900
|
+
this.withIndent(() => {
|
|
901
|
+
node.statements.forEach(stmt => this.visit(stmt))
|
|
902
|
+
})
|
|
575
903
|
}
|
|
576
904
|
|
|
577
905
|
visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
|
|
578
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)
|
|
579
912
|
}
|
|
580
913
|
|
|
581
914
|
visitERBBlockNode(node: ERBBlockNode): void {
|
|
@@ -600,26 +933,21 @@ export class Printer extends Visitor {
|
|
|
600
933
|
const open = node.tag_opening?.value ?? ""
|
|
601
934
|
const content = node.content?.value ?? ""
|
|
602
935
|
const close = node.tag_closing?.value ?? ""
|
|
936
|
+
const inner = this.formatERBContent(content)
|
|
603
937
|
|
|
604
|
-
this.lines.push(open +
|
|
938
|
+
this.lines.push(open + inner + close)
|
|
605
939
|
|
|
606
|
-
|
|
940
|
+
node.statements.forEach((child, _index) => {
|
|
607
941
|
this.lines.push(" ")
|
|
608
|
-
}
|
|
609
942
|
|
|
610
|
-
node.statements.forEach((child, index) => {
|
|
611
943
|
if (child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE') {
|
|
612
944
|
this.lines.push(this.renderAttribute(child as HTMLAttributeNode))
|
|
613
945
|
} else {
|
|
614
946
|
this.visit(child)
|
|
615
947
|
}
|
|
616
|
-
|
|
617
|
-
if (index < node.statements.length - 1) {
|
|
618
|
-
this.lines.push(" ")
|
|
619
|
-
}
|
|
620
948
|
})
|
|
621
949
|
|
|
622
|
-
if (node.statements.length > 0) {
|
|
950
|
+
if (node.statements.length > 0 && node.end_node) {
|
|
623
951
|
this.lines.push(" ")
|
|
624
952
|
}
|
|
625
953
|
|
|
@@ -632,8 +960,9 @@ export class Printer extends Visitor {
|
|
|
632
960
|
const endOpen = endNode.tag_opening?.value ?? ""
|
|
633
961
|
const endContent = endNode.content?.value ?? ""
|
|
634
962
|
const endClose = endNode.tag_closing?.value ?? ""
|
|
963
|
+
const endInner = this.formatERBContent(endContent)
|
|
635
964
|
|
|
636
|
-
this.lines.push(endOpen +
|
|
965
|
+
this.lines.push(endOpen + endInner + endClose)
|
|
637
966
|
}
|
|
638
967
|
} else {
|
|
639
968
|
this.printERBNode(node)
|
|
@@ -669,11 +998,11 @@ export class Printer extends Visitor {
|
|
|
669
998
|
}
|
|
670
999
|
|
|
671
1000
|
visitERBCaseNode(node: ERBCaseNode): void {
|
|
672
|
-
const baseLevel = this.indentLevel
|
|
673
1001
|
const indent = this.indent()
|
|
674
1002
|
const open = node.tag_opening?.value ?? ""
|
|
675
1003
|
const content = node.content?.value ?? ""
|
|
676
1004
|
const close = node.tag_closing?.value ?? ""
|
|
1005
|
+
|
|
677
1006
|
this.push(indent + open + content + close)
|
|
678
1007
|
|
|
679
1008
|
node.conditions.forEach(condition => this.visit(condition))
|
|
@@ -746,6 +1075,186 @@ export class Printer extends Visitor {
|
|
|
746
1075
|
|
|
747
1076
|
// --- Utility methods ---
|
|
748
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
|
+
|
|
749
1258
|
private renderInlineOpen(name: string, attributes: HTMLAttributeNode[], selfClose: boolean, inlineNodes: Node[] = [], allChildren: Node[] = []): string {
|
|
750
1259
|
const parts = attributes.map(attribute => this.renderAttribute(attribute))
|
|
751
1260
|
|
|
@@ -763,10 +1272,10 @@ export class Printer extends Visitor {
|
|
|
763
1272
|
this.lines.push(" " + this.renderAttribute(child as HTMLAttributeNode))
|
|
764
1273
|
} else if (!(child instanceof WhitespaceNode || (child as any).type === 'AST_WHITESPACE_NODE')) {
|
|
765
1274
|
const wasInlineMode = this.inlineMode
|
|
1275
|
+
|
|
766
1276
|
this.inlineMode = true
|
|
767
1277
|
|
|
768
1278
|
this.lines.push(" ")
|
|
769
|
-
|
|
770
1279
|
this.visit(child)
|
|
771
1280
|
this.inlineMode = wasInlineMode
|
|
772
1281
|
}
|
|
@@ -789,8 +1298,13 @@ export class Printer extends Visitor {
|
|
|
789
1298
|
|
|
790
1299
|
inlineNodes.forEach(node => {
|
|
791
1300
|
const wasInlineMode = this.inlineMode
|
|
792
|
-
|
|
1301
|
+
|
|
1302
|
+
if (!this.isERBControlFlow(node)) {
|
|
1303
|
+
this.inlineMode = true
|
|
1304
|
+
}
|
|
1305
|
+
|
|
793
1306
|
this.visit(node)
|
|
1307
|
+
|
|
794
1308
|
this.inlineMode = wasInlineMode
|
|
795
1309
|
})
|
|
796
1310
|
|
|
@@ -816,41 +1330,161 @@ export class Printer extends Visitor {
|
|
|
816
1330
|
let value = ""
|
|
817
1331
|
|
|
818
1332
|
if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || (attribute.value as any)?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
|
|
819
|
-
const
|
|
820
|
-
const open_quote = (attrValue.open_quote?.value ?? "")
|
|
821
|
-
const close_quote = (attrValue.close_quote?.value ?? "")
|
|
822
|
-
const attribute_value = attrValue.children.map((attr: any) => {
|
|
823
|
-
if (attr instanceof HTMLTextNode || (attr as any).type === 'AST_HTML_TEXT_NODE' || attr instanceof LiteralNode || (attr as any).type === 'AST_LITERAL_NODE') {
|
|
1333
|
+
const attributeValue = attribute.value as HTMLAttributeValueNode
|
|
824
1334
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
1335
|
+
let open_quote = attributeValue.open_quote?.value ?? ""
|
|
1336
|
+
let close_quote = attributeValue.close_quote?.value ?? ""
|
|
1337
|
+
let htmlTextContent = ""
|
|
828
1338
|
|
|
829
|
-
|
|
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
|
|
1343
|
+
|
|
1344
|
+
return textContent
|
|
1345
|
+
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
1346
|
+
const erbAttribute = child as ERBContentNode
|
|
1347
|
+
|
|
1348
|
+
return erbAttribute.tag_opening!.value + erbAttribute.content!.value + erbAttribute.tag_closing!.value
|
|
830
1349
|
}
|
|
831
1350
|
|
|
832
1351
|
return ""
|
|
833
1352
|
}).join("")
|
|
834
1353
|
|
|
835
|
-
|
|
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
|
|
836
1363
|
}
|
|
837
1364
|
|
|
838
1365
|
return name + equals + value
|
|
839
1366
|
}
|
|
840
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
|
+
|
|
841
1464
|
/**
|
|
842
1465
|
* Try to render children inline if they are simple enough.
|
|
843
1466
|
* Returns the inline string if possible, null otherwise.
|
|
844
1467
|
*/
|
|
845
|
-
private tryRenderInline(children: Node[], tagName: string, depth: number = 0): string | null {
|
|
846
|
-
if (children.length > 10) {
|
|
1468
|
+
private tryRenderInline(children: Node[], tagName: string, depth: number = 0, forceInline: boolean = false, hasTextFlow: boolean = false): string | null {
|
|
1469
|
+
if (!forceInline && children.length > 10) {
|
|
847
1470
|
return null
|
|
848
1471
|
}
|
|
849
1472
|
|
|
850
1473
|
const maxNestingDepth = this.getMaxNestingDepth(children, 0)
|
|
851
1474
|
|
|
852
|
-
|
|
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) {
|
|
853
1486
|
this.isInComplexNesting = true
|
|
1487
|
+
|
|
854
1488
|
return null
|
|
855
1489
|
}
|
|
856
1490
|
|
|
@@ -864,12 +1498,10 @@ export class Printer extends Visitor {
|
|
|
864
1498
|
} else if (child instanceof HTMLElementNode || (child as any).type === 'AST_HTML_ELEMENT_NODE') {
|
|
865
1499
|
const element = child as HTMLElementNode
|
|
866
1500
|
const openTag = element.open_tag as HTMLOpenTagNode
|
|
1501
|
+
const elementTagName = openTag?.tag_name?.value || ''
|
|
1502
|
+
const isInlineElement = this.isInlineElement(elementTagName)
|
|
867
1503
|
|
|
868
|
-
|
|
869
|
-
child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
870
|
-
)
|
|
871
|
-
|
|
872
|
-
if (attributes.length > 0) {
|
|
1504
|
+
if (!isInlineElement) {
|
|
873
1505
|
return null
|
|
874
1506
|
}
|
|
875
1507
|
|
|
@@ -897,13 +1529,9 @@ export class Printer extends Visitor {
|
|
|
897
1529
|
const openTag = element.open_tag as HTMLOpenTagNode
|
|
898
1530
|
const childTagName = openTag?.tag_name?.value || ''
|
|
899
1531
|
|
|
900
|
-
const attributes = openTag.children
|
|
901
|
-
child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
902
|
-
)
|
|
1532
|
+
const attributes = this.extractAttributes(openTag.children)
|
|
903
1533
|
|
|
904
|
-
const attributesString = attributes
|
|
905
|
-
? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
|
|
906
|
-
: ''
|
|
1534
|
+
const attributesString = this.renderAttributesString(attributes)
|
|
907
1535
|
|
|
908
1536
|
const elementContent = this.renderElementInline(element)
|
|
909
1537
|
|
|
@@ -914,7 +1542,7 @@ export class Printer extends Visitor {
|
|
|
914
1542
|
const erbContent = erbNode.content?.value ?? ""
|
|
915
1543
|
const close = erbNode.tag_closing?.value ?? ""
|
|
916
1544
|
|
|
917
|
-
content += `${open}
|
|
1545
|
+
content += `${open}${this.formatERBContent(erbContent)}${close}`
|
|
918
1546
|
}
|
|
919
1547
|
}
|
|
920
1548
|
|
|
@@ -928,6 +1556,29 @@ export class Printer extends Visitor {
|
|
|
928
1556
|
}
|
|
929
1557
|
}
|
|
930
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
|
+
|
|
931
1582
|
/**
|
|
932
1583
|
* Calculate the maximum nesting depth in a subtree of nodes.
|
|
933
1584
|
*/
|
|
@@ -962,6 +1613,7 @@ export class Printer extends Visitor {
|
|
|
962
1613
|
)
|
|
963
1614
|
|
|
964
1615
|
let content = ''
|
|
1616
|
+
|
|
965
1617
|
for (const child of children) {
|
|
966
1618
|
if (child instanceof HTMLTextNode || (child as any).type === 'AST_HTML_TEXT_NODE') {
|
|
967
1619
|
content += (child as HTMLTextNode).content
|
|
@@ -970,15 +1622,12 @@ export class Printer extends Visitor {
|
|
|
970
1622
|
const openTag = childElement.open_tag as HTMLOpenTagNode
|
|
971
1623
|
const childTagName = openTag?.tag_name?.value || ''
|
|
972
1624
|
|
|
973
|
-
const attributes = openTag.children
|
|
974
|
-
child instanceof HTMLAttributeNode || (child as any).type === 'AST_HTML_ATTRIBUTE_NODE'
|
|
975
|
-
)
|
|
1625
|
+
const attributes = this.extractAttributes(openTag.children)
|
|
976
1626
|
|
|
977
|
-
const attributesString = attributes
|
|
978
|
-
? ' ' + attributes.map(attr => this.renderAttribute(attr)).join(' ')
|
|
979
|
-
: ''
|
|
1627
|
+
const attributesString = this.renderAttributesString(attributes)
|
|
980
1628
|
|
|
981
1629
|
const childContent = this.renderElementInline(childElement)
|
|
1630
|
+
|
|
982
1631
|
content += `<${childTagName}${attributesString}>${childContent}</${childTagName}>`
|
|
983
1632
|
} else if (child instanceof ERBContentNode || (child as any).type === 'AST_ERB_CONTENT_NODE') {
|
|
984
1633
|
const erbNode = child as ERBContentNode
|
|
@@ -986,7 +1635,7 @@ export class Printer extends Visitor {
|
|
|
986
1635
|
const erbContent = erbNode.content?.value ?? ""
|
|
987
1636
|
const close = erbNode.tag_closing?.value ?? ""
|
|
988
1637
|
|
|
989
|
-
content += `${open}
|
|
1638
|
+
content += `${open}${this.formatERBContent(erbContent)}${close}`
|
|
990
1639
|
}
|
|
991
1640
|
}
|
|
992
1641
|
|