@herb-tools/rewriter 0.8.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,278 @@
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
+ HTMLAttributeNode,
11
+ HTMLAttributeValueNode,
12
+ Node,
13
+ ERBIfNode,
14
+ ERBUnlessNode,
15
+ ERBElseNode,
16
+ ERBBlockNode,
17
+ ERBForNode,
18
+ ERBCaseNode,
19
+ ERBWhenNode,
20
+ ERBCaseMatchNode,
21
+ ERBInNode,
22
+ ERBWhileNode,
23
+ ERBUntilNode,
24
+ ERBBeginNode,
25
+ ERBRescueNode,
26
+ ERBEnsureNode
27
+ } from "@herb-tools/core"
28
+
29
+ /**
30
+ * Visitor that traverses the AST and sorts Tailwind CSS classes in class attributes.
31
+ */
32
+ class TailwindClassSorterVisitor extends Visitor {
33
+ private sorter: TailwindClassSorter
34
+
35
+ constructor(sorter: TailwindClassSorter) {
36
+ super()
37
+
38
+ this.sorter = sorter
39
+ }
40
+
41
+ visitHTMLAttributeNode(node: HTMLAttributeNode): void {
42
+ if (!node.name) return
43
+ if (!node.value) return
44
+
45
+ const attributeName = getStaticAttributeName(node.name)
46
+ if (attributeName !== "class") return
47
+
48
+ this.visit(node.value)
49
+ }
50
+
51
+ visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void {
52
+ asMutable(node).children = this.formatNodes(node.children, false)
53
+ }
54
+
55
+ visitERBIfNode(node: ERBIfNode): void {
56
+ asMutable(node).statements = this.formatNodes(node.statements, true)
57
+
58
+ this.visit(node.subsequent)
59
+ }
60
+
61
+ visitERBElseNode(node: ERBElseNode): void {
62
+ asMutable(node).statements = this.formatNodes(node.statements, true)
63
+ }
64
+
65
+ visitERBUnlessNode(node: ERBUnlessNode): void {
66
+ asMutable(node).statements = this.formatNodes(node.statements, true)
67
+
68
+ this.visit(node.else_clause)
69
+ }
70
+
71
+ visitERBBlockNode(node: ERBBlockNode): void {
72
+ asMutable(node).body = this.formatNodes(node.body, true)
73
+ }
74
+
75
+ visitERBForNode(node: ERBForNode): void {
76
+ asMutable(node).statements = this.formatNodes(node.statements, true)
77
+ }
78
+
79
+ visitERBWhenNode(node: ERBWhenNode): void {
80
+ asMutable(node).statements = this.formatNodes(node.statements, true)
81
+ }
82
+
83
+ visitERBCaseNode(node: ERBCaseNode): void {
84
+ this.visitAll(node.children)
85
+ this.visit(node.else_clause)
86
+ }
87
+
88
+ visitERBCaseMatchNode(node: ERBCaseMatchNode): void {
89
+ this.visitAll(node.children)
90
+ this.visit(node.else_clause)
91
+ }
92
+
93
+ visitERBInNode(node: ERBInNode): void {
94
+ asMutable(node).statements = this.formatNodes(node.statements, true)
95
+ }
96
+
97
+ visitERBWhileNode(node: ERBWhileNode): void {
98
+ asMutable(node).statements = this.formatNodes(node.statements, true)
99
+ }
100
+
101
+ visitERBUntilNode(node: ERBUntilNode): void {
102
+ asMutable(node).statements = this.formatNodes(node.statements, true)
103
+ }
104
+
105
+ visitERBBeginNode(node: ERBBeginNode): void {
106
+ asMutable(node).statements = this.formatNodes(node.statements, true)
107
+ this.visit(node.rescue_clause)
108
+ this.visit(node.else_clause)
109
+ this.visit(node.ensure_clause)
110
+ }
111
+
112
+ visitERBRescueNode(node: ERBRescueNode): void {
113
+ asMutable(node).statements = this.formatNodes(node.statements, true)
114
+ this.visit(node.subsequent)
115
+ }
116
+
117
+ visitERBEnsureNode(node: ERBEnsureNode): void {
118
+ asMutable(node).statements = this.formatNodes(node.statements, true)
119
+ }
120
+
121
+ private get spaceLiteral(): LiteralNode {
122
+ return new LiteralNode({
123
+ type: "AST_LITERAL_NODE",
124
+ content: " ",
125
+ errors: [],
126
+ location: Location.zero
127
+ })
128
+ }
129
+
130
+ private startsWithClassLiteral(nodes: Node[]): boolean {
131
+ return nodes.length > 0 && isLiteralNode(nodes[0]) && !!nodes[0].content.trim()
132
+ }
133
+
134
+ private isWhitespaceLiteral(node: Node): boolean {
135
+ return isLiteralNode(node) && !node.content.trim()
136
+ }
137
+
138
+ private formatNodes(nodes: Node[], isNested: boolean): Node[] {
139
+ const { classLiterals, others } = this.partitionNodes(nodes)
140
+ const preserveLeadingSpace = isNested || this.startsWithClassLiteral(nodes)
141
+
142
+ return this.formatSortedClasses(classLiterals, others, preserveLeadingSpace, isNested)
143
+ }
144
+
145
+ private partitionNodes(nodes: Node[]): { classLiterals: LiteralNode[], others: Node[] } {
146
+ const classLiterals: LiteralNode[] = []
147
+ const others: Node[] = []
148
+
149
+ for (const node of nodes) {
150
+ if (isLiteralNode(node)) {
151
+ if (node.content.trim()) {
152
+ classLiterals.push(node)
153
+ } else {
154
+ others.push(node)
155
+ }
156
+ } else {
157
+ this.visit(node)
158
+ others.push(node)
159
+ }
160
+ }
161
+
162
+ return { classLiterals, others }
163
+ }
164
+
165
+ private formatSortedClasses(literals: LiteralNode[], others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
166
+ if (literals.length === 0 && others.length === 0) return []
167
+ if (literals.length === 0) return others
168
+
169
+ const fullContent = literals.map(n => n.content).join("")
170
+ const trimmedClasses = fullContent.trim()
171
+
172
+ if (!trimmedClasses) return others.length > 0 ? others : []
173
+
174
+ try {
175
+ const sortedClasses = this.sorter.sortClasses(trimmedClasses)
176
+
177
+ if (others.length === 0) {
178
+ return this.formatSortedLiteral(literals[0], fullContent, sortedClasses, trimmedClasses)
179
+ }
180
+
181
+ return this.formatSortedLiteralWithERB(literals[0], fullContent, sortedClasses, others, preserveLeadingSpace, isNested)
182
+ } catch (error) {
183
+ return [...literals, ...others]
184
+ }
185
+ }
186
+
187
+ private formatSortedLiteral(literal: LiteralNode, fullContent: string, sortedClasses: string, trimmedClasses: string): Node[] {
188
+ const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
189
+ const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
190
+ const alreadySorted = sortedClasses === trimmedClasses
191
+
192
+ const sortedContent = alreadySorted ? fullContent : (leadingSpace + sortedClasses + trailingSpace)
193
+
194
+ asMutable(literal).content = sortedContent
195
+
196
+ return [literal]
197
+ }
198
+
199
+ private formatSortedLiteralWithERB(literal: LiteralNode, fullContent: string, sortedClasses: string, others: Node[], preserveLeadingSpace: boolean, isNested: boolean): Node[] {
200
+ const leadingSpace = fullContent.match(/^\s*/)?.[0] || ""
201
+ const trailingSpace = fullContent.match(/\s*$/)?.[0] || ""
202
+
203
+ const leading = preserveLeadingSpace ? leadingSpace : ""
204
+ const firstIsWhitespace = this.isWhitespaceLiteral(others[0])
205
+ const spaceBetween = firstIsWhitespace ? "" : " "
206
+
207
+ asMutable(literal).content = leading + sortedClasses + spaceBetween
208
+
209
+ const othersWithWhitespace = this.addSpacingBetweenERBNodes(others, isNested, trailingSpace)
210
+
211
+ return [literal, ...othersWithWhitespace]
212
+ }
213
+
214
+ private addSpacingBetweenERBNodes(nodes: Node[], isNested: boolean, trailingSpace: string): Node[] {
215
+ return nodes.flatMap((node, index) => {
216
+ const isLast = index >= nodes.length - 1
217
+
218
+ if (isLast) {
219
+ return isNested && trailingSpace ? [node, this.spaceLiteral] : [node]
220
+ }
221
+
222
+ const currentIsWhitespace = this.isWhitespaceLiteral(node)
223
+ const nextIsWhitespace = this.isWhitespaceLiteral(nodes[index + 1])
224
+ const needsSpace = !currentIsWhitespace && !nextIsWhitespace
225
+
226
+ return needsSpace ? [node, this.spaceLiteral] : [node]
227
+ })
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Built-in rewriter that sorts Tailwind CSS classes in class and className attributes
233
+ */
234
+ export class TailwindClassSorterRewriter extends ASTRewriter {
235
+ private sorter?: TailwindClassSorter
236
+
237
+ get name(): string {
238
+ return "tailwind-class-sorter"
239
+ }
240
+
241
+ get description(): string {
242
+ return "Sorts Tailwind CSS classes in class and className attributes according to the recommended class order"
243
+ }
244
+
245
+ async initialize(context: RewriteContext): Promise<void> {
246
+ try {
247
+ this.sorter = await TailwindClassSorter.fromConfig({
248
+ baseDir: context.baseDir
249
+ })
250
+ } catch (error) {
251
+ const errorMessage = error instanceof Error ? error.message : String(error)
252
+
253
+ if (errorMessage.includes('Cannot find module') || errorMessage.includes('ENOENT')) {
254
+ throw new Error(
255
+ `Tailwind CSS is not installed in this project. ` +
256
+ `To use the Tailwind class sorter, install Tailwind CSS itself using: npm install -D tailwindcss, ` +
257
+ `or remove the "tailwind-class-sorter" rewriter from your .herb.yml config file.\n` +
258
+ `If "tailwindcss" is already part of your package.json, make sure your NPM dependencies are installed.\n` +
259
+ `Original error: ${errorMessage}.`
260
+ )
261
+ }
262
+
263
+ throw error
264
+ }
265
+ }
266
+
267
+ rewrite<T extends Node>(node: T, _context: RewriteContext): T {
268
+ if (!this.sorter) {
269
+ return node
270
+ }
271
+
272
+ const visitor = new TailwindClassSorterVisitor(this.sorter)
273
+
274
+ visitor.visit(node)
275
+
276
+ return node
277
+ }
278
+ }
package/src/context.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Context passed to rewriters during initialization and rewriting
3
+ *
4
+ * Provides information about the current file and project being processed
5
+ */
6
+ export interface RewriteContext {
7
+ /**
8
+ * Path to the file being rewritten (if available)
9
+ */
10
+ filePath?: string
11
+
12
+ /**
13
+ * Base directory of the project
14
+ */
15
+ baseDir: string
16
+
17
+ /**
18
+ * Additional context data that can be added by the framework
19
+ */
20
+ [key: string]: any
21
+ }
@@ -0,0 +1,187 @@
1
+ import { pathToFileURL } from "url"
2
+ import { glob } from "glob"
3
+ import { isRewriterClass } from "./type-guards.js"
4
+
5
+ import type { RewriterClass } from "./type-guards.js"
6
+
7
+ export interface CustomRewriterLoaderOptions {
8
+ /**
9
+ * Base directory to search for custom rewriters
10
+ * Defaults to current working directory
11
+ */
12
+ baseDir?: string
13
+
14
+ /**
15
+ * Glob patterns to search for custom rewriter files
16
+ * Defaults to looking in .herb/rewriters/
17
+ */
18
+ patterns?: string[]
19
+
20
+ /**
21
+ * Whether to suppress errors when loading custom rewriters
22
+ * Defaults to false
23
+ */
24
+ silent?: boolean
25
+ }
26
+
27
+ const DEFAULT_PATTERNS = [
28
+ ".herb/rewriters/**/*.mjs",
29
+ ]
30
+
31
+ /**
32
+ * Loads custom rewriters from the user's project
33
+ *
34
+ * Auto-discovers rewriter files in `.herb/rewriters/` by default
35
+ * and dynamically imports them for use in the formatter.
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const loader = new CustomRewriterLoader({ baseDir: process.cwd() })
40
+ * const customRewriters = await loader.loadRewriters()
41
+ * ```
42
+ */
43
+ export class CustomRewriterLoader {
44
+ private baseDir: string
45
+ private patterns: string[]
46
+ private silent: boolean
47
+
48
+ constructor(options: CustomRewriterLoaderOptions = {}) {
49
+ this.baseDir = options.baseDir || process.cwd()
50
+ this.patterns = options.patterns || DEFAULT_PATTERNS
51
+ this.silent = options.silent || false
52
+ }
53
+
54
+ /**
55
+ * Discovers custom rewriter files in the project
56
+ */
57
+ async discoverRewriterFiles(): Promise<string[]> {
58
+ const allFiles: string[] = []
59
+
60
+ for (const pattern of this.patterns) {
61
+ try {
62
+ const files = await glob(pattern, {
63
+ cwd: this.baseDir,
64
+ absolute: true,
65
+ nodir: true
66
+ })
67
+
68
+ allFiles.push(...files)
69
+ } catch (error) {
70
+ if (!this.silent) {
71
+ console.warn(`Warning: Failed to search pattern "${pattern}": ${error}`)
72
+ }
73
+ }
74
+ }
75
+
76
+ return [...new Set(allFiles)]
77
+ }
78
+
79
+ /**
80
+ * Loads a single rewriter file
81
+ */
82
+ async loadRewriterFile(filePath: string): Promise<RewriterClass[]> {
83
+ try {
84
+ const fileUrl = pathToFileURL(filePath).href
85
+ const cacheBustedUrl = `${fileUrl}?t=${Date.now()}`
86
+ const module = await import(cacheBustedUrl)
87
+
88
+ if (module.default && isRewriterClass(module.default)) {
89
+ return [module.default]
90
+ }
91
+
92
+ if (!this.silent) {
93
+ console.warn(`Warning: No valid default export found in "${filePath}". Custom rewriters must use default export.`)
94
+ }
95
+
96
+ return []
97
+ } catch (error) {
98
+ if (!this.silent) {
99
+ console.error(`Error loading rewriter file "${filePath}": ${error}`)
100
+ }
101
+ return []
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Loads all custom rewriters from the project
107
+ */
108
+ async loadRewriters(): Promise<RewriterClass[]> {
109
+ const rewriterFiles = await this.discoverRewriterFiles()
110
+
111
+ if (rewriterFiles.length === 0) {
112
+ return []
113
+ }
114
+
115
+ const allRewriters: RewriterClass[] = []
116
+
117
+ for (const filePath of rewriterFiles) {
118
+ const rewriters = await this.loadRewriterFile(filePath)
119
+ allRewriters.push(...rewriters)
120
+ }
121
+
122
+ return allRewriters
123
+ }
124
+
125
+ /**
126
+ * Loads all custom rewriters and returns detailed information about each rewriter
127
+ */
128
+ async loadRewritersWithInfo(): Promise<{rewriters: RewriterClass[], rewriterInfo: Array<{ name: string, path: string }>, duplicateWarnings: string[]}> {
129
+ const rewriterFiles = await this.discoverRewriterFiles()
130
+
131
+ if (rewriterFiles.length === 0) {
132
+ return { rewriters: [], rewriterInfo: [], duplicateWarnings: [] }
133
+ }
134
+
135
+ const allRewriters: RewriterClass[] = []
136
+ const rewriterInfo: Array<{ name: string, path: string }> = []
137
+ const duplicateWarnings: string[] = []
138
+ const seenNames = new Map<string, string>()
139
+
140
+ for (const filePath of rewriterFiles) {
141
+ const rewriters = await this.loadRewriterFile(filePath)
142
+
143
+ for (const RewriterClass of rewriters) {
144
+ const instance = new RewriterClass()
145
+ const rewriterName = instance.name
146
+
147
+ if (seenNames.has(rewriterName)) {
148
+ const firstPath = seenNames.get(rewriterName)!
149
+
150
+ duplicateWarnings.push(
151
+ `Custom rewriter "${rewriterName}" is defined in multiple files: "${firstPath}" and "${filePath}". The later one will be used.`
152
+ )
153
+ } else {
154
+ seenNames.set(rewriterName, filePath)
155
+ }
156
+
157
+ allRewriters.push(RewriterClass)
158
+ rewriterInfo.push({
159
+ name: rewriterName,
160
+ path: filePath
161
+ })
162
+ }
163
+ }
164
+
165
+ return { rewriters: allRewriters, rewriterInfo, duplicateWarnings }
166
+ }
167
+
168
+ /**
169
+ * Static helper to check if custom rewriters exist in a project
170
+ */
171
+ static async hasCustomRewriters(baseDir: string = process.cwd()): Promise<boolean> {
172
+ const loader = new CustomRewriterLoader({ baseDir, silent: true })
173
+ const files = await loader.discoverRewriterFiles()
174
+
175
+ return files.length > 0
176
+ }
177
+
178
+ /**
179
+ * Static helper to load custom rewriters and merge with built-in rewriters
180
+ */
181
+ static async loadAndMergeRewriters(builtinRewriters: RewriterClass[], options: CustomRewriterLoaderOptions = {}): Promise<RewriterClass[]> {
182
+ const loader = new CustomRewriterLoader(options)
183
+ const customRewriters = await loader.loadRewriters()
184
+
185
+ return [...builtinRewriters, ...customRewriters]
186
+ }
187
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { ASTRewriter } from "./ast-rewriter.js"
2
+ export { StringRewriter } from "./string-rewriter.js"
3
+
4
+ export { asMutable } from "./mutable.js"
5
+ export { isASTRewriterClass, isStringRewriterClass, isRewriterClass } from "./type-guards.js"
6
+
7
+ export { rewrite, rewriteString } from "./rewrite.js"
8
+
9
+ export type { RewriteContext } from "./context.js"
10
+ export type { Mutable } from "./mutable.js"
11
+ export type { RewriterClass } from "./type-guards.js"
12
+ export type { Rewriter, RewriteOptions, RewriteResult } from "./rewrite.js"
13
+ export type { TailwindClassSorterOptions } from "./rewriter-factories.js"
package/src/loader.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./index.js"
2
+
3
+ export { CustomRewriterLoader } from "./custom-rewriter-loader.js"
4
+ export type { CustomRewriterLoaderOptions } from "./custom-rewriter-loader.js"
5
+
6
+ export { TailwindClassSorterRewriter } from "./built-ins/index.js"
7
+ export { tailwindClassSorter } from "./rewriter-factories.js"
8
+ export { builtinRewriters, getBuiltinRewriter, getBuiltinRewriterNames } from "./built-ins/index.js"
package/src/mutable.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Utility type for making readonly properties mutable
3
+ *
4
+ * This is useful when you need to modify AST nodes which typically have
5
+ * readonly properties. Use sparingly and only in rewriter contexts.
6
+ */
7
+ export type Mutable<T> = T extends ReadonlyArray<infer U>
8
+ ? Array<Mutable<U>>
9
+ : T extends object
10
+ ? { -readonly [K in keyof T]: Mutable<T[K]> }
11
+ : T
12
+
13
+ /**
14
+ * Cast a readonly value to a mutable version
15
+ *
16
+ * @example
17
+ * const literalNode = asMutable(node)
18
+ * literalNode.content = "new value"
19
+ */
20
+ export function asMutable<T>(node: T): Mutable<T> {
21
+ return node as Mutable<T>
22
+ }
package/src/rewrite.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { IdentityPrinter } from "@herb-tools/printer"
2
+
3
+ import { ASTRewriter } from "./ast-rewriter.js"
4
+ import { StringRewriter } from "./string-rewriter.js"
5
+
6
+ import type { HerbBackend, Node } from "@herb-tools/core"
7
+ import type { RewriteContext } from "./context.js"
8
+
9
+ export type Rewriter = ASTRewriter | StringRewriter
10
+
11
+ export interface RewriteOptions {
12
+ /**
13
+ * Base directory for resolving configuration files
14
+ * Defaults to process.cwd()
15
+ */
16
+ baseDir?: string
17
+
18
+ /**
19
+ * Optional file path for context
20
+ */
21
+ filePath?: string
22
+ }
23
+
24
+ export interface RewriteResult {
25
+ /**
26
+ * The rewritten template string
27
+ */
28
+ output: string
29
+
30
+ /**
31
+ * The rewritten AST node
32
+ */
33
+ node: Node
34
+ }
35
+
36
+ /**
37
+ * Rewrite an AST Node using the provided rewriters
38
+ *
39
+ * This is the main rewrite function that operates on AST nodes.
40
+ * For string input, use `rewriteString()` instead.
41
+ *
42
+ * @example
43
+ * ```typescript
44
+ * import { Herb } from '@herb-tools/node-wasm'
45
+ * import { rewrite } from '@herb-tools/rewriter'
46
+ * import { tailwindClassSorter } from '@herb-tools/rewriter/loader'
47
+ *
48
+ * await Herb.load()
49
+ *
50
+ * const template = '<div class="text-red-500 p-4 mt-2"></div>'
51
+ * const parseResult = Herb.parse(template)
52
+ * const { output, node } = rewrite(parseResult.value, [tailwindClassSorter()])
53
+ * ```
54
+ *
55
+ * @param node - The AST Node to rewrite
56
+ * @param rewriters - Array of rewriter instances to apply
57
+ * @param options - Optional configuration for the rewrite operation
58
+ * @returns Object containing the rewritten string and Node
59
+ */
60
+ export function rewrite<T extends Node>(node: T, rewriters: Rewriter[], options: RewriteOptions = {}): RewriteResult & { node: T } {
61
+ const { baseDir = process.cwd(), filePath } = options
62
+
63
+ const context: RewriteContext = { baseDir, filePath }
64
+
65
+ let currentNode = node
66
+
67
+ const astRewriters = rewriters.filter(rewriter => rewriter instanceof ASTRewriter)
68
+ const stringRewriters = rewriters.filter(rewriter => rewriter instanceof StringRewriter)
69
+
70
+ for (const rewriter of astRewriters) {
71
+ try {
72
+ currentNode = rewriter.rewrite(currentNode, context)
73
+ } catch (error) {
74
+ console.error(`AST rewriter "${rewriter.name}" failed:`, error)
75
+ }
76
+ }
77
+
78
+ let result = IdentityPrinter.print(currentNode)
79
+
80
+ for (const rewriter of stringRewriters) {
81
+ try {
82
+ result = rewriter.rewrite(result, context)
83
+ } catch (error) {
84
+ console.error(`String rewriter "${rewriter.name}" failed:`, error)
85
+ }
86
+ }
87
+
88
+ return {
89
+ output: result,
90
+ node: currentNode
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Rewrite an HTML+ERB template string
96
+ *
97
+ * Convenience wrapper around `rewrite()` that parses the string first.
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * import { Herb } from '@herb-tools/node-wasm'
102
+ * import { rewriteString } from '@herb-tools/rewriter'
103
+ * import { tailwindClassSorter } from '@herb-tools/rewriter/loader'
104
+ *
105
+ * await Herb.load()
106
+ *
107
+ * const template = '<div class="text-red-500 p-4 mt-2"></div>'
108
+ * const output = rewriteString(Herb, template, [tailwindClassSorter()])
109
+ * // output: '<div class="mt-2 p-4 text-red-500"></div>'
110
+ * ```
111
+ *
112
+ * @param herb - The Herb backend instance for parsing
113
+ * @param template - The HTML+ERB template string to rewrite
114
+ * @param rewriters - Array of rewriter instances to apply
115
+ * @param options - Optional configuration for the rewrite operation
116
+ * @returns The rewritten template string
117
+ */
118
+ export function rewriteString(herb: HerbBackend, template: string, rewriters: Rewriter[], options: RewriteOptions = {}): string {
119
+ const parseResult = herb.parse(template, { track_whitespace: true })
120
+
121
+ if (parseResult.failed) {
122
+ return template
123
+ }
124
+
125
+ const { output } = rewrite(
126
+ parseResult.value,
127
+ rewriters,
128
+ options
129
+ )
130
+
131
+ return output
132
+ }
@@ -0,0 +1,37 @@
1
+ import { TailwindClassSorterRewriter } from "./built-ins/tailwind-class-sorter.js"
2
+
3
+ export interface TailwindClassSorterOptions {
4
+ /**
5
+ * Base directory for resolving Tailwind configuration
6
+ * Defaults to process.cwd()
7
+ */
8
+ baseDir?: string
9
+ }
10
+
11
+ /**
12
+ * Factory function for creating a Tailwind class sorter rewriter
13
+ *
14
+ * Automatically initializes the rewriter before returning it.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * import { rewrite } from '@herb-tools/rewriter'
19
+ * import { tailwindClassSorter } from '@herb-tools/rewriter/loader'
20
+ *
21
+ * const template = '<div class="text-red-500 p-4 mt-2"></div>'
22
+ * const sorter = await tailwindClassSorter()
23
+ * const result = rewrite(template, [sorter])
24
+ * // Result: '<div class="mt-2 p-4 text-red-500"></div>'
25
+ * ```
26
+ *
27
+ * @param options - Optional configuration for the Tailwind class sorter
28
+ * @returns A configured and initialized TailwindClassSorterRewriter instance
29
+ */
30
+ export async function tailwindClassSorter(options: TailwindClassSorterOptions = {}): Promise<TailwindClassSorterRewriter> {
31
+ const rewriter = new TailwindClassSorterRewriter()
32
+ const baseDir = options.baseDir || process.cwd()
33
+
34
+ await rewriter.initialize({ baseDir })
35
+
36
+ return rewriter
37
+ }