Skip to content

Commit 0ba34c5

Browse files
authored
Rewriter: Implement tailwind-class-sorter built-in rewriter (marcoroth#760)
Building on top of marcoroth#759, this pull request implements a `tailwind-class-sorter` rewriter that's going to be built-in to the `@herb-tools/rewriter` package. Other tools will be able to refer to this built-in rewriter just by its name `tailwind-class-sorter`. This is going to enable us to plug it in to the `@herb-tools/formatter` (see marcoroth#671). **Example**: ```ts import { Herb } from "@herb-tools/node-wasm" import { IdentityPrinter } from "@herb-tools/printer" import { TailwindClassSorterRewriter } from "@herb-tools/rewriter" await Herb.load() const rewriter = new TailwindClassSorterRewriter() await rewriter.initialize({ baseDir: process.cwd() }) const input = `<div class="px-4 bg-blue-500 text-white"></div>` const parseResult = Herb.parse(input, { track_whitespace: true }) const rewritten = rewriter.rewrite(parseResult, { baseDir: process.cwd() }) const output = IdentityPrinter.print(rewritten.value) console.log(output) // Output: <div class="bg-blue-500 px-4 text-white"></div> ```
1 parent 73e6744 commit 0ba34c5

File tree

6 files changed

+711
-1
lines changed

6 files changed

+711
-1
lines changed

javascript/packages/rewriter/rollup.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const external = [
88
"url",
99
"fs",
1010
"module",
11+
"@herb-tools/tailwind-class-sorter"
1112
]
1213

1314
function isExternal(id) {

javascript/packages/rewriter/src/built-ins/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { RewriterClass } from "../type-guards.js"
22

3+
export { TailwindClassSorterRewriter } from "./tailwind-class-sorter.js"
4+
35
/**
46
* All built-in rewriters available in the package
57
*/
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { getStaticAttributeName, isLiteralNode } from "@herb-tools/core"
2+
import { LiteralNode, Location, Visitor } from "@herb-tools/core"
3+
4+
import { TailwindClassSorter } from "@herb-tools/tailwind-class-sorter"
5+
import { ASTRewriter } from "../ast-rewriter.js"
6+
import { asMutable } from "../mutable.js"
7+
8+
import type { RewriteContext } from "../context.js"
9+
import type {
10+
ParseResult,
11+
HTMLAttributeNode,
12+
HTMLAttributeValueNode,
13+
Node,
14+
ERBIfNode,
15+
ERBUnlessNode,
16+
ERBElseNode,
17+
ERBBlockNode,
18+
ERBForNode,
19+
ERBCaseNode,
20+
ERBWhenNode,
21+
ERBCaseMatchNode,
22+
ERBInNode,
23+
ERBWhileNode,
24+
ERBUntilNode,
25+
ERBBeginNode,
26+
ERBRescueNode,
27+
ERBEnsureNode
28+
} from "@herb-tools/core"
29+
30+
/**
31+
* Visitor that traverses the AST and sorts Tailwind CSS classes in class attributes.
32+
*/
33+
class TailwindClassSorterVisitor extends Visitor {
34+
private sorter: TailwindClassSorter
35+
36+
constructor(sorter: TailwindClassSorter) {
37+
super()
38+
39+
this.sorter = sorter
40+
}
41+
42+
visitHTMLAttributeNode(node: HTMLAttributeNode): void {
43+
if (!node.name) return
44+
if (!node.value) return
45+
46+
const attributeName = getStaticAttributeName(node.name)
47+
if (attributeName !== "class") return
48+
49+
this.visit(node.value)
50+
}
51+
52+
visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void {
53+
asMutable(node).children = this.formatNodes(node.children, false)
54+
}
55+
56+
visitERBIfNode(node: ERBIfNode): void {
57+
asMutable(node).statements = this.formatNodes(node.statements, true)
58+
59+
this.visit(node.subsequent)
60+
}
61+
62+
visitERBElseNode(node: ERBElseNode): void {
63+
asMutable(node).statements = this.formatNodes(node.statements, true)
64+
}
65+
66+
visitERBUnlessNode(node: ERBUnlessNode): void {
67+
asMutable(node).statements = this.formatNodes(node.statements, true)
68+
69+
this.visit(node.else_clause)
70+
}
71+
72+
visitERBBlockNode(node: ERBBlockNode): void {
73+
asMutable(node).body = this.formatNodes(node.body, true)
74+
}
75+
76+
visitERBForNode(node: ERBForNode): void {
77+
asMutable(node).statements = this.formatNodes(node.statements, true)
78+
}
79+
80+
visitERBWhenNode(node: ERBWhenNode): void {
81+
asMutable(node).statements = this.formatNodes(node.statements, true)
82+
}
83+
84+
visitERBCaseNode(node: ERBCaseNode): void {
85+
this.visitAll(node.children)
86+
this.visit(node.else_clause)
87+
}
88+
89+
visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
90+
this.visitAll(node.children)
91+
this.visit(node.else_clause)
92+
}
93+
94+
visitERBInNode(node: ERBInNode): void {
95+
asMutable(node).statements = this.formatNodes(node.statements, true)
96+
}
97+
98+
visitERBWhileNode(node: ERBWhileNode): void {
99+
asMutable(node).statements = this.formatNodes(node.statements, true)
100+
}
101+
102+
visitERBUntilNode(node: ERBUntilNode): void {
103+
asMutable(node).statements = this.formatNodes(node.statements, true)
104+
}
105+
106+
visitERBBeginNode(node: ERBBeginNode): void {
107+
asMutable(node).statements = this.formatNodes(node.statements, true)
108+
this.visit(node.rescue_clause)
109+
this.visit(node.else_clause)
110+
this.visit(node.ensure_clause)
111+
}
112+
113+
visitERBRescueNode(node: ERBRescueNode): void {
114+
asMutable(node).statements = this.formatNodes(node.statements, true)
115+
this.visit(node.subsequent)
116+
}
117+
118+
visitERBEnsureNode(node: ERBEnsureNode): void {
119+
asMutable(node).statements = this.formatNodes(node.statements, true)
120+
}
121+
122+
private get spaceLiteral(): LiteralNode {
123+
return new LiteralNode({
124+
type: "AST_LITERAL_NODE",
125+
content: " ",
126+
errors: [],
127+
location: Location.zero
128+
})
129+
}
130+
131+
private startsWithClassLiteral(nodes: Node[]): boolean {
132+
return nodes.length > 0 && isLiteralNode(nodes[0]) && !!nodes[0].content.trim()
133+
}
134+
135+
private isWhitespaceLiteral(node: Node): boolean {
136+
return isLiteralNode(node) && !node.content.trim()
137+
}
138+
139+
private formatNodes(nodes: Node[], isNested: boolean): Node[] {
140+
const { classLiterals, others } = this.partitionNodes(nodes)
141+
const preserveLeadingSpace = isNested || this.startsWithClassLiteral(nodes)
142+
143+
return this.formatSortedClasses(classLiterals, others, preserveLeadingSpace, isNested)
144+
}
145+
146+
private partitionNodes(nodes: Node[]): { classLiterals: LiteralNode[], others: Node[] } {
147+
const classLiterals: LiteralNode[] = []
148+
const others: Node[] = []
149+
150+
for (const node of nodes) {
151+
if (isLiteralNode(node)) {
152+
if (node.content.trim()) {
153+
classLiterals.push(node)
154+
} else {
155+
others.push(node)
156+
}
157+
} else {
158+
this.visit(node)
159+
others.push(node)
160+
}
161+
}
162+
163+
return { classLiterals, others }
164+
}
165+
166+
private formatSortedClasses(literals: LiteralNode[], others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
167+
if (literals.length === 0 && others.length === 0) return []
168+
if (literals.length === 0) return others
169+
170+
const fullContent = literals.map(n => n.content).join("")
171+
const trimmedClasses = fullContent.trim()
172+
173+
if (!trimmedClasses) return others.length > 0 ? others : []
174+
175+
try {
176+
const sortedClasses = this.sorter.sortClasses(trimmedClasses)
177+
178+
if (others.length === 0) {
179+
return this.formatSortedLiteral(literals[0], fullContent, sortedClasses, trimmedClasses)
180+
}
181+
182+
return this.formatSortedLiteralWithERB(literals[0], fullContent, sortedClasses, others, preserveLeadingSpace, isNested)
183+
} catch (error) {
184+
return [...literals, ...others]
185+
}
186+
}
187+
188+
private formatSortedLiteral(literal: LiteralNode, fullContent: string, sortedClasses: string, trimmedClasses: string): Node[] {
189+
const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
190+
const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
191+
const alreadySorted = sortedClasses === trimmedClasses
192+
193+
const sortedContent = alreadySorted ? fullContent : (leadingSpace + sortedClasses + trailingSpace)
194+
195+
asMutable(literal).content = sortedContent
196+
197+
return [literal]
198+
}
199+
200+
private formatSortedLiteralWithERB(literal: LiteralNode, fullContent: string, sortedClasses: string, others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
201+
const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
202+
const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
203+
204+
const leading = preserveLeadingSpace ? leadingSpace : ""
205+
const firstIsWhitespace = this.isWhitespaceLiteral(others[0])
206+
const spaceBetween = firstIsWhitespace ? "" : " "
207+
208+
asMutable(literal).content = leading + sortedClasses + spaceBetween
209+
210+
const othersWithWhitespace = this.addSpacingBetweenERBNodes(others, isNested, trailingSpace)
211+
212+
return [literal, ...othersWithWhitespace]
213+
}
214+
215+
private addSpacingBetweenERBNodes(nodes: Node[], isNested: boolean, trailingSpace: string): Node[] {
216+
return nodes.flatMap((node, index) => {
217+
const isLast = index >= nodes.length - 1
218+
219+
if (isLast) {
220+
return isNested && trailingSpace ? [node, this.spaceLiteral] : [node]
221+
}
222+
223+
const currentIsWhitespace = this.isWhitespaceLiteral(node)
224+
const nextIsWhitespace = this.isWhitespaceLiteral(nodes[index + 1])
225+
const needsSpace = !currentIsWhitespace && !nextIsWhitespace
226+
227+
return needsSpace ? [node, this.spaceLiteral] : [node]
228+
})
229+
}
230+
}
231+
232+
/**
233+
* Built-in rewriter that sorts Tailwind CSS classes in class and className attributes
234+
*/
235+
export class TailwindClassSorterRewriter extends ASTRewriter {
236+
private sorter?: TailwindClassSorter
237+
238+
get name(): string {
239+
return "tailwind-class-sorter"
240+
}
241+
242+
get description(): string {
243+
return "Sorts Tailwind CSS classes in class and className attributes according to the recommended class order"
244+
}
245+
246+
async initialize(context: RewriteContext): Promise<void> {
247+
this.sorter = await TailwindClassSorter.fromConfig({
248+
baseDir: context.baseDir
249+
})
250+
}
251+
252+
rewrite(parseResult: ParseResult, _context: RewriteContext): ParseResult {
253+
if (!this.sorter) {
254+
return parseResult
255+
}
256+
257+
const visitor = new TailwindClassSorterVisitor(this.sorter)
258+
259+
visitor.visit(parseResult.value)
260+
261+
return parseResult
262+
}
263+
}

javascript/packages/rewriter/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export { ASTRewriter } from "./ast-rewriter.js"
22
export { StringRewriter } from "./string-rewriter.js"
33
export { CustomRewriterLoader } from "./loader.js"
4-
export * from "./built-ins/index.js"
4+
export { TailwindClassSorterRewriter } from "./built-ins/index.js"
55

66
export { asMutable } from "./mutable.js"
77
export { isASTRewriterClass, isStringRewriterClass, isRewriterClass, } from "./type-guards.js"

javascript/packages/rewriter/test/built-ins/.keep

Whitespace-only changes.

0 commit comments

Comments
 (0)