Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 250 additions & 62 deletions javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,23 @@ class TailwindClassSorterVisitor extends Visitor {
const attributeName = getStaticAttributeName(node.name)
if (attributeName !== "class") return

this.visit(node.value)
const classAttributeSorter = new ClassAttributeSorter(this.sorter)

classAttributeSorter.visit(node.value)
}
}

/**
* Visitor that sorts classes within a single class attribute value.
* Only operates on the content of a class attribute, not the full document.
*/
class ClassAttributeSorter extends Visitor {
private sorter: TailwindClassSorter

constructor(sorter: TailwindClassSorter) {
super()

this.sorter = sorter
}

visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void {
Expand Down Expand Up @@ -127,104 +143,276 @@ class TailwindClassSorterVisitor extends Visitor {
})
}

private startsWithClassLiteral(nodes: Node[]): boolean {
return nodes.length > 0 && isLiteralNode(nodes[0]) && !!nodes[0].content.trim()
}

private isWhitespaceLiteral(node: Node): boolean {
return isLiteralNode(node) && !node.content.trim()
}

private formatNodes(nodes: Node[], isNested: boolean): Node[] {
const { classLiterals, others } = this.partitionNodes(nodes)
const preserveLeadingSpace = isNested || this.startsWithClassLiteral(nodes)

return this.formatSortedClasses(classLiterals, others, preserveLeadingSpace, isNested)
}

private partitionNodes(nodes: Node[]): { classLiterals: LiteralNode[], others: Node[] } {
const classLiterals: LiteralNode[] = []
const others: Node[] = []
private splitLiteralsAtWhitespace(nodes: Node[]): Node[] {
const result: Node[] = []

for (const node of nodes) {
if (isLiteralNode(node)) {
if (node.content.trim()) {
classLiterals.push(node)
} else {
others.push(node)
const parts = node.content.match(/(\S+|\s+)/g) || []

for (const part of parts) {
result.push(new LiteralNode({
type: "AST_LITERAL_NODE",
content: part,
errors: [],
location: node.location
}))
}
} else {
this.visit(node)
others.push(node)
result.push(node)
}
}

return { classLiterals, others }
return result
}

private formatSortedClasses(literals: LiteralNode[], others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
if (literals.length === 0 && others.length === 0) return []
if (literals.length === 0) return others

const fullContent = literals.map(n => n.content).join("")
const trimmedClasses = fullContent.trim()
/**
* Groups split nodes into "class groups" where each group represents a single
* class name (possibly spanning multiple nodes when ERB is interpolated).
*
* For example, `text-<%= color %>-500 bg-blue-500` produces two groups:
* - [`text-`, ERB, `-500`] (interpolated class)
* - [`bg-blue-500`] (static class)
*
* The key heuristic: a hyphen at a node boundary means the nodes are part of
* the same class name (e.g., `bg-` + ERB + `-500`), while whitespace means
* a new class name starts.
*/
private groupNodesByClass(nodes: Node[]): Node[][] {
if (nodes.length === 0) return []

const groups: Node[][] = []
let currentGroup: Node[] = []

for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
const previousNode = i > 0 ? nodes[i - 1] : null

let startNewGroup = false

if (currentGroup.length === 0) {
startNewGroup = false
} else if (isLiteralNode(node)) {
if (/^\s/.test(node.content)) {
startNewGroup = true
} else if (/^-/.test(node.content)) {
startNewGroup = false
} else if (previousNode && !isLiteralNode(previousNode)) {
startNewGroup = true
} else if (currentGroup.every(member => this.isWhitespaceLiteral(member))) {
startNewGroup = true
}

if (!trimmedClasses) return others.length > 0 ? others : []
} else {
if (previousNode && isLiteralNode(previousNode)) {
if (/\s$/.test(previousNode.content)) {
startNewGroup = true
} else if (/-$/.test(previousNode.content)) {
startNewGroup = false
} else {
startNewGroup = true
}

} else if (previousNode && !isLiteralNode(previousNode)) {
startNewGroup = false
}
}

try {
const sortedClasses = this.sorter.sortClasses(trimmedClasses)
if (startNewGroup && currentGroup.length > 0) {
groups.push(currentGroup)

if (others.length === 0) {
return this.formatSortedLiteral(literals[0], fullContent, sortedClasses, trimmedClasses)
currentGroup = []
}

return this.formatSortedLiteralWithERB(literals[0], fullContent, sortedClasses, others, preserveLeadingSpace, isNested)
} catch (_error) {
return [...literals, ...others]
currentGroup.push(node)
}

if (currentGroup.length > 0) {
groups.push(currentGroup)
}

return groups
}

private formatSortedLiteral(literal: LiteralNode, fullContent: string, sortedClasses: string, trimmedClasses: string): Node[] {
const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
const alreadySorted = sortedClasses === trimmedClasses
private isInterpolatedGroup(group: Node[]): boolean {
return group.some(node => !isLiteralNode(node))
}

const sortedContent = alreadySorted ? fullContent : (leadingSpace + sortedClasses + trailingSpace)
private isWhitespaceGroup(group: Node[]): boolean {
return group.every(node => this.isWhitespaceLiteral(node))
}

private getStaticClassContent(group: Node[]): string {
return group
.filter(node => isLiteralNode(node))
.map(node => (node as LiteralNode).content)
.join("")
}

asMutable(literal).content = sortedContent
private categorizeGroups(groups: Node[][]): { staticClasses: string[], interpolationGroups: Node[][], standaloneERBNodes: Node[] } {
const staticClasses: string[] = []
const interpolationGroups: Node[][] = []
const standaloneERBNodes: Node[] = []

for (const group of groups) {
if (this.isWhitespaceGroup(group)) {
continue
}

return [literal]
if (this.isInterpolatedGroup(group)) {
const hasAttachedLiteral = group.some(node => isLiteralNode(node) && node.content.trim())

if (hasAttachedLiteral) {
for (const node of group) {
if (!isLiteralNode(node)) {
this.visit(node)
}
}

interpolationGroups.push(group)
} else {
for (const node of group) {
if (!isLiteralNode(node)) {
this.visit(node)
standaloneERBNodes.push(node)
}
}
}
} else {
const content = this.getStaticClassContent(group).trim()

if (content) {
staticClasses.push(content)
}
}
}

return { staticClasses, interpolationGroups, standaloneERBNodes }
}

private formatSortedLiteralWithERB(literal: LiteralNode, fullContent: string, sortedClasses: string, others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
private formatNodes(nodes: Node[], isNested: boolean): Node[] {
if (nodes.length === 0) return nodes
if (nodes.every(child => this.isWhitespaceLiteral(child))) return nodes

const splitNodes = this.splitLiteralsAtWhitespace(nodes)
const groups = this.groupNodesByClass(splitNodes)
const groupPrecedingWhitespace = new Map<Node[], Node[]>()
const nodePrecedingWhitespace = new Map<Node, Node[]>()

for (let i = 1; i < groups.length; i++) {
if (!this.isWhitespaceGroup(groups[i]) && this.isWhitespaceGroup(groups[i - 1])) {
groupPrecedingWhitespace.set(groups[i], groups[i - 1])

for (const node of groups[i]) {
if (!isLiteralNode(node)) {
nodePrecedingWhitespace.set(node, groups[i - 1])
}
}
}
}

const leading = preserveLeadingSpace ? leadingSpace : ""
const firstIsWhitespace = this.isWhitespaceLiteral(others[0])
const spaceBetween = firstIsWhitespace ? "" : " "
let leadingWhitespace: Node[] | null = null
let trailingWhitespace: Node[] | null = null

asMutable(literal).content = leading + sortedClasses + spaceBetween
if (isNested && groups.length > 0) {
if (this.isWhitespaceGroup(groups[0])) {
leadingWhitespace = groups[0]
}
if (groups.length > 1 && this.isWhitespaceGroup(groups[groups.length - 1])) {
trailingWhitespace = groups[groups.length - 1]
}
}

const othersWithWhitespace = this.addSpacingBetweenERBNodes(others, isNested, trailingSpace)
const { staticClasses, interpolationGroups, standaloneERBNodes } = this.categorizeGroups(groups)

return [literal, ...othersWithWhitespace]
const allStaticContent = staticClasses.join(" ")
let sortedContent = allStaticContent

if (allStaticContent) {
try {
sortedContent = this.sorter.sortClasses(allStaticContent)
} catch {
// Keep original on error
}
}

const parts: Node[] = []

if (sortedContent) {
parts.push(new LiteralNode({
type: "AST_LITERAL_NODE",
content: sortedContent,
errors: [],
location: Location.zero
}))
}

for (const group of interpolationGroups) {
if (parts.length > 0) {
const whitespace = groupPrecedingWhitespace.get(group)
parts.push(...(whitespace ?? [this.spaceLiteral]))
}

parts.push(...this.trimGroupWhitespace(group))
}

for (const node of standaloneERBNodes) {
if (parts.length > 0) {
const whitespace = nodePrecedingWhitespace.get(node)
parts.push(...(whitespace ?? [this.spaceLiteral]))
}
parts.push(node)
}

if (isNested && parts.length > 0) {
const leading = leadingWhitespace ?? [this.spaceLiteral]
const trailing = trailingWhitespace ?? [this.spaceLiteral]
return [...leading, ...parts, ...trailing]
}

return parts
}

private addSpacingBetweenERBNodes(nodes: Node[], isNested: boolean, trailingSpace: string): Node[] {
return nodes.flatMap((node, index) => {
const isLast = index >= nodes.length - 1
private trimGroupWhitespace(group: Node[]): Node[] {
if (group.length === 0) return group

const result = [...group]

if (isLiteralNode(result[0])) {
const first = result[0] as LiteralNode
const trimmed = first.content.trimStart()

if (isLast) {
return isNested && trailingSpace ? [node, this.spaceLiteral] : [node]
if (trimmed !== first.content) {
result[0] = new LiteralNode({
type: "AST_LITERAL_NODE",
content: trimmed,
errors: [],
location: first.location
})
}
}

const currentIsWhitespace = this.isWhitespaceLiteral(node)
const nextIsWhitespace = this.isWhitespaceLiteral(nodes[index + 1])
const needsSpace = !currentIsWhitespace && !nextIsWhitespace
const lastIndex = result.length - 1

return needsSpace ? [node, this.spaceLiteral] : [node]
})
if (isLiteralNode(result[lastIndex])) {
const last = result[lastIndex] as LiteralNode
const trimmed = last.content.trimEnd()

if (trimmed !== last.content) {
result[lastIndex] = new LiteralNode({
type: "AST_LITERAL_NODE",
content: trimmed,
errors: [],
location: last.location
})
}
}

return result
}
}

Expand Down
Loading
Loading