@herb-tools/formatter 0.8.9 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/herb-format.js +57408 -17354
- package/dist/herb-format.js.map +1 -1
- package/dist/index.cjs +23348 -2973
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +23348 -2973
- package/dist/index.esm.js.map +1 -1
- package/dist/types/attribute-renderer.d.ts +44 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/comment-helpers.d.ts +45 -0
- package/dist/types/format-helpers.d.ts +15 -11
- package/dist/types/format-printer.d.ts +33 -138
- package/dist/types/formatter.d.ts +3 -2
- package/dist/types/spacing-analyzer.d.ts +47 -0
- package/dist/types/text-flow-analyzer.d.ts +22 -0
- package/dist/types/text-flow-engine.d.ts +37 -0
- package/dist/types/text-flow-helpers.d.ts +58 -0
- package/package.json +5 -5
- package/src/attribute-renderer.ts +309 -0
- package/src/cli.ts +32 -11
- package/src/comment-helpers.ts +129 -0
- package/src/format-helpers.ts +73 -29
- package/src/format-printer.ts +450 -1464
- package/src/formatter.ts +10 -4
- package/src/spacing-analyzer.ts +244 -0
- package/src/text-flow-analyzer.ts +212 -0
- package/src/text-flow-engine.ts +311 -0
- package/src/text-flow-helpers.ts +319 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { IdentityPrinter } from "@herb-tools/printer"
|
|
2
|
+
import { HTMLAttributeNode, HTMLAttributeValueNode, HTMLTextNode, LiteralNode, ERBContentNode } from "@herb-tools/core"
|
|
3
|
+
|
|
4
|
+
import { getCombinedAttributeName, getCombinedStringFromNodes, isNode, TOKEN_LIST_ATTRIBUTES } from "@herb-tools/core"
|
|
5
|
+
|
|
6
|
+
import { ASCII_WHITESPACE, FORMATTABLE_ATTRIBUTES } from "./format-helpers.js"
|
|
7
|
+
|
|
8
|
+
import type { Node, ERBNode } from "@herb-tools/core"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Interface that the delegate must implement to provide
|
|
12
|
+
* ERB reconstruction capabilities to the AttributeRenderer.
|
|
13
|
+
*/
|
|
14
|
+
export interface AttributeRendererDelegate {
|
|
15
|
+
reconstructERBNode(node: ERBNode, withFormatting: boolean): string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* AttributeRenderer converts HTMLAttributeNode AST nodes into formatted strings.
|
|
20
|
+
* It handles class attribute wrapping, multiline attribute formatting,
|
|
21
|
+
* quote normalization, and token list attribute spacing.
|
|
22
|
+
*/
|
|
23
|
+
export class AttributeRenderer {
|
|
24
|
+
private delegate: AttributeRendererDelegate
|
|
25
|
+
private maxLineLength: number
|
|
26
|
+
private indentWidth: number
|
|
27
|
+
|
|
28
|
+
public currentAttributeName: string | null = null
|
|
29
|
+
public indentLevel: number = 0
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
delegate: AttributeRendererDelegate,
|
|
33
|
+
maxLineLength: number,
|
|
34
|
+
indentWidth: number,
|
|
35
|
+
) {
|
|
36
|
+
this.delegate = delegate
|
|
37
|
+
this.maxLineLength = maxLineLength
|
|
38
|
+
this.indentWidth = indentWidth
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if we're currently processing a token list attribute that needs spacing
|
|
43
|
+
*/
|
|
44
|
+
get isInTokenListAttribute(): boolean {
|
|
45
|
+
return this.currentAttributeName !== null && TOKEN_LIST_ATTRIBUTES.has(this.currentAttributeName)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Render attributes as a space-separated string
|
|
50
|
+
*/
|
|
51
|
+
renderAttributesString(attributes: HTMLAttributeNode[], tagName: string): string {
|
|
52
|
+
if (attributes.length === 0) return ""
|
|
53
|
+
|
|
54
|
+
return ` ${attributes.map(attribute => this.renderAttribute(attribute, tagName)).join(" ")}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Determine if a tag should be rendered inline based on attribute count and other factors
|
|
59
|
+
*/
|
|
60
|
+
shouldRenderInline(
|
|
61
|
+
totalAttributeCount: number,
|
|
62
|
+
inlineLength: number,
|
|
63
|
+
indentLength: number,
|
|
64
|
+
maxLineLength: number = this.maxLineLength,
|
|
65
|
+
hasComplexERB: boolean = false,
|
|
66
|
+
hasMultilineAttributes: boolean = false,
|
|
67
|
+
attributes: HTMLAttributeNode[] = []
|
|
68
|
+
): boolean {
|
|
69
|
+
if (hasComplexERB || hasMultilineAttributes) return false
|
|
70
|
+
|
|
71
|
+
if (totalAttributeCount === 0) {
|
|
72
|
+
return inlineLength + indentLength <= maxLineLength
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (totalAttributeCount === 1 && attributes.length === 1) {
|
|
76
|
+
const attribute = attributes[0]
|
|
77
|
+
const attributeName = this.getAttributeName(attribute)
|
|
78
|
+
|
|
79
|
+
if (attributeName === 'class') {
|
|
80
|
+
const attributeValue = this.getAttributeValue(attribute)
|
|
81
|
+
const wouldBeMultiline = this.wouldClassAttributeBeMultiline(attributeValue, indentLength)
|
|
82
|
+
|
|
83
|
+
if (!wouldBeMultiline) {
|
|
84
|
+
return true
|
|
85
|
+
} else {
|
|
86
|
+
return false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (totalAttributeCount > 3 || inlineLength + indentLength > maxLineLength) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
wouldClassAttributeBeMultiline(content: string, indentLength: number): boolean {
|
|
99
|
+
const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
|
|
100
|
+
const hasActualNewlines = /\r?\n/.test(content)
|
|
101
|
+
|
|
102
|
+
if (hasActualNewlines && normalizedContent.length > 80) {
|
|
103
|
+
const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
|
|
104
|
+
|
|
105
|
+
if (lines.length > 1) {
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const attributeLine = `class="${normalizedContent}"`
|
|
111
|
+
const currentIndent = indentLength
|
|
112
|
+
|
|
113
|
+
if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
|
|
114
|
+
if (/<%[^%]*%>/.test(normalizedContent)) {
|
|
115
|
+
return false
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const classes = normalizedContent.split(' ')
|
|
119
|
+
const lines = this.breakTokensIntoLines(classes, currentIndent)
|
|
120
|
+
return lines.length > 1
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// TOOD: extract to core or reuse function from core
|
|
127
|
+
getAttributeName(attribute: HTMLAttributeNode): string {
|
|
128
|
+
return attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// TOOD: extract to core or reuse function from core
|
|
132
|
+
getAttributeValue(attribute: HTMLAttributeNode): string {
|
|
133
|
+
if (isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
134
|
+
return attribute.value.children.map(child => isNode(child, HTMLTextNode) ? child.content : IdentityPrinter.print(child)).join('')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
hasMultilineAttributes(attributes: HTMLAttributeNode[]): boolean {
|
|
141
|
+
return attributes.some(attribute => {
|
|
142
|
+
if (isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
143
|
+
const content = getCombinedStringFromNodes(attribute.value.children)
|
|
144
|
+
|
|
145
|
+
if (/\r?\n/.test(content)) {
|
|
146
|
+
const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
147
|
+
|
|
148
|
+
if (name === "class") {
|
|
149
|
+
const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
|
|
150
|
+
|
|
151
|
+
return normalizedContent.length > 80
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const lines = content.split(/\r?\n/)
|
|
155
|
+
|
|
156
|
+
if (lines.length > 1) {
|
|
157
|
+
return lines.slice(1).some(line => /^[ \t\n\r]+/.test(line))
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return false
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
formatClassAttribute(content: string, name: string, equals: string, open_quote: string, close_quote: string): string {
|
|
167
|
+
const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
|
|
168
|
+
const hasActualNewlines = /\r?\n/.test(content)
|
|
169
|
+
|
|
170
|
+
if (hasActualNewlines && normalizedContent.length > 80) {
|
|
171
|
+
const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line)
|
|
172
|
+
|
|
173
|
+
if (lines.length > 1) {
|
|
174
|
+
return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const currentIndent = this.indentLevel * this.indentWidth
|
|
179
|
+
const attributeLine = `${name}${equals}${open_quote}${normalizedContent}${close_quote}`
|
|
180
|
+
|
|
181
|
+
if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
|
|
182
|
+
if (/<%[^%]*%>/.test(normalizedContent)) {
|
|
183
|
+
return open_quote + normalizedContent + close_quote
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const classes = normalizedContent.split(' ')
|
|
187
|
+
const lines = this.breakTokensIntoLines(classes, currentIndent)
|
|
188
|
+
|
|
189
|
+
if (lines.length > 1) {
|
|
190
|
+
return open_quote + this.formatMultilineAttributeValue(lines) + close_quote
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return open_quote + normalizedContent + close_quote
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
isFormattableAttribute(attributeName: string, tagName: string): boolean {
|
|
198
|
+
const globalFormattable = FORMATTABLE_ATTRIBUTES['*'] || []
|
|
199
|
+
const tagSpecificFormattable = FORMATTABLE_ATTRIBUTES[tagName.toLowerCase()] || []
|
|
200
|
+
|
|
201
|
+
return globalFormattable.includes(attributeName) || tagSpecificFormattable.includes(attributeName)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
formatMultilineAttribute(content: string, name: string, open_quote: string, close_quote: string): string {
|
|
205
|
+
if (name === 'srcset' || name === 'sizes') {
|
|
206
|
+
const normalizedContent = content.replace(ASCII_WHITESPACE, ' ').trim()
|
|
207
|
+
|
|
208
|
+
return open_quote + normalizedContent + close_quote
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const lines = content.split('\n')
|
|
212
|
+
|
|
213
|
+
if (lines.length <= 1) {
|
|
214
|
+
return open_quote + content + close_quote
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const formattedContent = this.formatMultilineAttributeValue(lines)
|
|
218
|
+
|
|
219
|
+
return open_quote + formattedContent + close_quote
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
formatMultilineAttributeValue(lines: string[]): string {
|
|
223
|
+
const indent = " ".repeat((this.indentLevel + 1) * this.indentWidth)
|
|
224
|
+
const closeIndent = " ".repeat(this.indentLevel * this.indentWidth)
|
|
225
|
+
|
|
226
|
+
return "\n" + lines.map(line => indent + line).join("\n") + "\n" + closeIndent
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
breakTokensIntoLines(tokens: string[], currentIndent: number, separator: string = ' '): string[] {
|
|
230
|
+
const lines: string[] = []
|
|
231
|
+
let currentLine = ''
|
|
232
|
+
|
|
233
|
+
for (const token of tokens) {
|
|
234
|
+
const testLine = currentLine ? currentLine + separator + token : token
|
|
235
|
+
|
|
236
|
+
if (testLine.length > (this.maxLineLength - currentIndent - 6)) {
|
|
237
|
+
if (currentLine) {
|
|
238
|
+
lines.push(currentLine)
|
|
239
|
+
currentLine = token
|
|
240
|
+
} else {
|
|
241
|
+
lines.push(token)
|
|
242
|
+
}
|
|
243
|
+
} else {
|
|
244
|
+
currentLine = testLine
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (currentLine) lines.push(currentLine)
|
|
249
|
+
|
|
250
|
+
return lines
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
renderAttribute(attribute: HTMLAttributeNode, tagName: string): string {
|
|
254
|
+
const name = attribute.name ? getCombinedAttributeName(attribute.name) : ""
|
|
255
|
+
const equals = attribute.equals?.value ?? ""
|
|
256
|
+
|
|
257
|
+
this.currentAttributeName = name
|
|
258
|
+
|
|
259
|
+
let value = ""
|
|
260
|
+
|
|
261
|
+
if (isNode(attribute.value, HTMLAttributeValueNode)) {
|
|
262
|
+
const attributeValue = attribute.value
|
|
263
|
+
|
|
264
|
+
let open_quote = attributeValue.open_quote?.value ?? ""
|
|
265
|
+
let close_quote = attributeValue.close_quote?.value ?? ""
|
|
266
|
+
let htmlTextContent = ""
|
|
267
|
+
|
|
268
|
+
const content = attributeValue.children.map((child: Node) => {
|
|
269
|
+
if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
|
|
270
|
+
htmlTextContent += child.content
|
|
271
|
+
|
|
272
|
+
return child.content
|
|
273
|
+
} else if (isNode(child, ERBContentNode)) {
|
|
274
|
+
return this.delegate.reconstructERBNode(child, true)
|
|
275
|
+
} else {
|
|
276
|
+
const printed = IdentityPrinter.print(child)
|
|
277
|
+
|
|
278
|
+
if (this.isInTokenListAttribute) {
|
|
279
|
+
return printed.replace(/%>([^<\s])/g, '%> $1').replace(/([^>\s])<%/g, '$1 <%')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return printed
|
|
283
|
+
}
|
|
284
|
+
}).join("")
|
|
285
|
+
|
|
286
|
+
if (open_quote === "" && close_quote === "") {
|
|
287
|
+
open_quote = '"'
|
|
288
|
+
close_quote = '"'
|
|
289
|
+
} else if (open_quote === "'" && close_quote === "'" && !htmlTextContent.includes('"')) {
|
|
290
|
+
open_quote = '"'
|
|
291
|
+
close_quote = '"'
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (this.isFormattableAttribute(name, tagName)) {
|
|
295
|
+
if (name === 'class') {
|
|
296
|
+
value = this.formatClassAttribute(content, name, equals, open_quote, close_quote)
|
|
297
|
+
} else {
|
|
298
|
+
value = this.formatMultilineAttribute(content, name, open_quote, close_quote)
|
|
299
|
+
}
|
|
300
|
+
} else {
|
|
301
|
+
value = open_quote + content + close_quote
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.currentAttributeName = null
|
|
306
|
+
|
|
307
|
+
return name + equals + value
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import dedent from "dedent"
|
|
2
|
-
import { readFileSync, writeFileSync, statSync } from "fs"
|
|
2
|
+
import { readFileSync, writeFileSync, statSync, existsSync } from "fs"
|
|
3
3
|
import { glob } from "tinyglobby"
|
|
4
4
|
import { resolve, relative } from "path"
|
|
5
5
|
|
|
@@ -18,6 +18,26 @@ const pluralize = (count: number, singular: string, plural: string = singular +
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export class CLI {
|
|
21
|
+
protected projectPath: string = process.cwd()
|
|
22
|
+
|
|
23
|
+
protected determineProjectPath(patterns: string[]): void {
|
|
24
|
+
const pattern = patterns[0]
|
|
25
|
+
|
|
26
|
+
if (pattern && pattern !== '-') {
|
|
27
|
+
const resolvedPattern = resolve(pattern)
|
|
28
|
+
|
|
29
|
+
if (existsSync(resolvedPattern)) {
|
|
30
|
+
this.projectPath = Config.findProjectRootSync(resolvedPattern)
|
|
31
|
+
} else {
|
|
32
|
+
const baseDir = pattern.split(/[*?{[\]]/)[0].replace(/\/$/, '')
|
|
33
|
+
|
|
34
|
+
if (baseDir && existsSync(baseDir)) {
|
|
35
|
+
this.projectPath = Config.findProjectRootSync(resolve(baseDir))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
private usage = dedent`
|
|
22
42
|
Usage: herb-format [files|directories|glob-patterns...] [options]
|
|
23
43
|
|
|
@@ -133,14 +153,15 @@ export class CLI {
|
|
|
133
153
|
process.exit(1)
|
|
134
154
|
}
|
|
135
155
|
|
|
156
|
+
this.determineProjectPath(positionals)
|
|
157
|
+
|
|
136
158
|
const file = positionals[0]
|
|
137
|
-
const startPath = file || process.cwd()
|
|
138
159
|
|
|
139
160
|
if (isInitMode) {
|
|
140
|
-
const configPath = configFile ||
|
|
161
|
+
const configPath = configFile || this.projectPath
|
|
141
162
|
|
|
142
163
|
if (Config.exists(configPath)) {
|
|
143
|
-
const fullPath = configFile || Config.configPathFromProjectPath(
|
|
164
|
+
const fullPath = configFile || Config.configPathFromProjectPath(this.projectPath)
|
|
144
165
|
console.log(`\n✗ Configuration file already exists at ${fullPath}`)
|
|
145
166
|
console.log(` Use --config-file to specify a different location.\n`)
|
|
146
167
|
process.exit(1)
|
|
@@ -154,8 +175,8 @@ export class CLI {
|
|
|
154
175
|
}
|
|
155
176
|
})
|
|
156
177
|
|
|
157
|
-
const
|
|
158
|
-
const projectDir = statSync(
|
|
178
|
+
const initProjectPath = configFile ? resolve(configFile) : this.projectPath
|
|
179
|
+
const projectDir = statSync(initProjectPath).isDirectory() ? initProjectPath : resolve(initProjectPath, '..')
|
|
159
180
|
const extensionAdded = addHerbExtensionRecommendation(projectDir)
|
|
160
181
|
|
|
161
182
|
console.log(`\n✓ Configuration initialized at ${config.path}`)
|
|
@@ -170,7 +191,7 @@ export class CLI {
|
|
|
170
191
|
process.exit(0)
|
|
171
192
|
}
|
|
172
193
|
|
|
173
|
-
const config = await Config.loadForCLI(configFile ||
|
|
194
|
+
const config = await Config.loadForCLI(configFile || this.projectPath, version)
|
|
174
195
|
const hasConfigFile = Config.exists(config.projectPath)
|
|
175
196
|
const formatterConfig = config.formatter || {}
|
|
176
197
|
|
|
@@ -198,8 +219,8 @@ export class CLI {
|
|
|
198
219
|
formatterConfig.maxLineLength = maxLineLength
|
|
199
220
|
}
|
|
200
221
|
|
|
201
|
-
|
|
202
|
-
|
|
222
|
+
const preRewriters: ASTRewriter[] = []
|
|
223
|
+
const postRewriters: StringRewriter[] = []
|
|
203
224
|
const rewriterNames = { pre: formatterConfig.rewriter?.pre || [], post: formatterConfig.rewriter?.post || [] }
|
|
204
225
|
|
|
205
226
|
if (formatterConfig.rewriter && (rewriterNames.pre.length > 0 || rewriterNames.post.length > 0)) {
|
|
@@ -398,7 +419,7 @@ export class CLI {
|
|
|
398
419
|
}
|
|
399
420
|
|
|
400
421
|
let formattedCount = 0
|
|
401
|
-
|
|
422
|
+
const unformattedFiles: string[] = []
|
|
402
423
|
|
|
403
424
|
for (const filePath of files) {
|
|
404
425
|
const displayPath = relative(process.cwd(), filePath)
|
|
@@ -446,7 +467,7 @@ export class CLI {
|
|
|
446
467
|
}
|
|
447
468
|
|
|
448
469
|
let formattedCount = 0
|
|
449
|
-
|
|
470
|
+
const unformattedFiles: string[] = []
|
|
450
471
|
|
|
451
472
|
for (const filePath of files) {
|
|
452
473
|
const displayPath = relative(process.cwd(), filePath)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import dedent from "dedent"
|
|
2
|
+
|
|
3
|
+
import { isNode, isERBNode } from "@herb-tools/core"
|
|
4
|
+
import { IdentityPrinter } from "@herb-tools/printer"
|
|
5
|
+
import { Node, HTMLTextNode, LiteralNode } from "@herb-tools/core"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Result of formatting an ERB comment.
|
|
9
|
+
* - `single-line`: the caller emits the text on a single line (using push or pushWithIndent)
|
|
10
|
+
* - `multi-line`: the caller emits header, indented content lines, and footer separately
|
|
11
|
+
*/
|
|
12
|
+
export type ERBCommentResult =
|
|
13
|
+
| { type: 'single-line'; text: string }
|
|
14
|
+
| { type: 'multi-line'; header: string; contentLines: string[]; footer: string }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract the raw inner text from HTML comment children.
|
|
18
|
+
* Joins text/literal nodes by content and ERB nodes via IdentityPrinter.
|
|
19
|
+
*/
|
|
20
|
+
export function extractHTMLCommentContent(children: Node[]): string {
|
|
21
|
+
return children.map(child => {
|
|
22
|
+
if (isNode(child, HTMLTextNode) || isNode(child, LiteralNode)) {
|
|
23
|
+
return child.content
|
|
24
|
+
} else if (isERBNode(child)) {
|
|
25
|
+
return IdentityPrinter.print(child)
|
|
26
|
+
} else {
|
|
27
|
+
return ""
|
|
28
|
+
}
|
|
29
|
+
}).join("")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format the inner content of an HTML comment.
|
|
34
|
+
*
|
|
35
|
+
* Handles three cases:
|
|
36
|
+
* 1. IE conditional comments (`[if ...` / `<![endif]`) — returned as-is
|
|
37
|
+
* 2. Multiline comments — re-indented with relative indent preservation
|
|
38
|
+
* 3. Single-line comments — wrapped with spaces: ` content `
|
|
39
|
+
*
|
|
40
|
+
* Returns null for IE conditional comments to signal the caller
|
|
41
|
+
* should emit the raw content without reformatting.
|
|
42
|
+
*
|
|
43
|
+
* @param rawInner - The joined children content string (may be empty)
|
|
44
|
+
* @param indentWidth - Number of spaces per indent level
|
|
45
|
+
* @returns The formatted inner string, or null if rawInner is empty-ish
|
|
46
|
+
*/
|
|
47
|
+
export function formatHTMLCommentInner(rawInner: string, indentWidth: number): string {
|
|
48
|
+
if (!rawInner && rawInner !== "") return ""
|
|
49
|
+
|
|
50
|
+
const trimmedInner = rawInner.trim()
|
|
51
|
+
|
|
52
|
+
if (trimmedInner.startsWith('[if ') && trimmedInner.endsWith('<![endif]')) {
|
|
53
|
+
return rawInner
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hasNewlines = rawInner.includes('\n')
|
|
57
|
+
|
|
58
|
+
if (hasNewlines) {
|
|
59
|
+
const lines = rawInner.split('\n')
|
|
60
|
+
const childIndent = " ".repeat(indentWidth)
|
|
61
|
+
const firstLineHasContent = lines[0].trim() !== ''
|
|
62
|
+
|
|
63
|
+
if (firstLineHasContent && lines.length > 1) {
|
|
64
|
+
const contentLines = lines.map(line => line.trim()).filter(line => line !== '')
|
|
65
|
+
return '\n' + contentLines.map(line => childIndent + line).join('\n') + '\n'
|
|
66
|
+
} else {
|
|
67
|
+
const contentLines = lines.filter((line, index) => {
|
|
68
|
+
return line.trim() !== '' && !(index === 0 || index === lines.length - 1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const minIndent = contentLines.length > 0 ? Math.min(...contentLines.map(line => line.length - line.trimStart().length)) : 0
|
|
72
|
+
|
|
73
|
+
const processedLines = lines.map((line, index) => {
|
|
74
|
+
const trimmedLine = line.trim()
|
|
75
|
+
|
|
76
|
+
if ((index === 0 || index === lines.length - 1) && trimmedLine === '') {
|
|
77
|
+
return line
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (trimmedLine !== '') {
|
|
81
|
+
const currentIndent = line.length - line.trimStart().length
|
|
82
|
+
const relativeIndent = Math.max(0, currentIndent - minIndent)
|
|
83
|
+
|
|
84
|
+
return childIndent + " ".repeat(relativeIndent) + trimmedLine
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return line
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return processedLines.join('\n')
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
return ` ${rawInner.trim()} `
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format an ERB comment into either a single-line or multi-line result.
|
|
99
|
+
*
|
|
100
|
+
* @param open - The opening tag (e.g. "<%#")
|
|
101
|
+
* @param content - The raw content string between open/close tags
|
|
102
|
+
* @param close - The closing tag (e.g. "%>")
|
|
103
|
+
* @returns A discriminated union describing how to render the comment
|
|
104
|
+
*/
|
|
105
|
+
export function formatERBCommentLines(open: string, content: string, close: string): ERBCommentResult {
|
|
106
|
+
const contentLines = content.split("\n")
|
|
107
|
+
const contentTrimmedLines = content.trim().split("\n")
|
|
108
|
+
|
|
109
|
+
if (contentLines.length === 1 && contentTrimmedLines.length === 1) {
|
|
110
|
+
const startsWithSpace = content[0] === " "
|
|
111
|
+
const before = startsWithSpace ? "" : " "
|
|
112
|
+
|
|
113
|
+
return { type: 'single-line', text: open + before + content.trimEnd() + ' ' + close }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (contentTrimmedLines.length === 1) {
|
|
117
|
+
return { type: 'single-line', text: open + ' ' + content.trim() + ' ' + close }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const firstLineEmpty = contentLines[0].trim() === ""
|
|
121
|
+
const dedentedContent = dedent(firstLineEmpty ? content : content.trimStart())
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
type: 'multi-line',
|
|
125
|
+
header: open,
|
|
126
|
+
contentLines: dedentedContent.split("\n"),
|
|
127
|
+
footer: close
|
|
128
|
+
}
|
|
129
|
+
}
|