Skip to content

Commit 9f6e3cf

Browse files
authored
Formatter: Extract Attribute Renderer (#1269)
Follow up to #1267
1 parent 3b2376d commit 9f6e3cf

File tree

5 files changed

+767
-302
lines changed

5 files changed

+767
-302
lines changed
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 } from "@herb-tools/core"
5+
6+
import { ASCII_WHITESPACE, FORMATTABLE_ATTRIBUTES, TOKEN_LIST_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+
}

javascript/packages/formatter/src/format-helpers.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ export interface ContentUnitWithNode {
3434

3535
// --- Constants ---
3636

37+
/**
38+
* ASCII whitespace pattern - use instead of \s to preserve Unicode whitespace
39+
* characters like NBSP (U+00A0) and full-width space (U+3000)
40+
*/
41+
export const ASCII_WHITESPACE = /[ \t\n\r]+/g
42+
3743
// TODO: we can probably expand this list with more tags/attributes
3844
export const FORMATTABLE_ATTRIBUTES: Record<string, string[]> = {
3945
'*': ['class'],

0 commit comments

Comments
 (0)