@herb-tools/formatter 0.8.10 → 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.
@@ -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 || startPath
161
+ const configPath = configFile || this.projectPath
141
162
 
142
163
  if (Config.exists(configPath)) {
143
- const fullPath = configFile || Config.configPathFromProjectPath(startPath)
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 projectPath = configFile ? resolve(configFile) : startPath
158
- const projectDir = statSync(projectPath).isDirectory() ? projectPath : resolve(projectPath, '..')
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 || startPath, version)
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
- let preRewriters: ASTRewriter[] = []
202
- let postRewriters: StringRewriter[] = []
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
- let unformattedFiles: string[] = []
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
- let unformattedFiles: string[] = []
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
+ }