@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.
- package/README.md +342 -0
- package/dist/index.cjs +3802 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.esm.js +3793 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/loader.cjs +12480 -0
- package/dist/loader.cjs.map +1 -0
- package/dist/loader.esm.js +12446 -0
- package/dist/loader.esm.js.map +1 -0
- package/dist/types/ast-rewriter.d.ts +64 -0
- package/dist/types/built-ins/index.d.ts +14 -0
- package/dist/types/built-ins/tailwind-class-sorter.d.ts +13 -0
- package/dist/types/context.d.ts +19 -0
- package/dist/types/custom-rewriter-loader.d.ts +67 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/loader.d.ts +6 -0
- package/dist/types/mutable.d.ts +17 -0
- package/dist/types/rewrite.d.ts +77 -0
- package/dist/types/rewriter-factories.d.ts +28 -0
- package/dist/types/string-rewriter.d.ts +54 -0
- package/dist/types/type-guards.d.ts +20 -0
- package/package.json +54 -0
- package/src/ast-rewriter.ts +70 -0
- package/src/built-ins/index.ts +33 -0
- package/src/built-ins/tailwind-class-sorter.ts +278 -0
- package/src/context.ts +21 -0
- package/src/custom-rewriter-loader.ts +187 -0
- package/src/index.ts +13 -0
- package/src/loader.ts +8 -0
- package/src/mutable.ts +22 -0
- package/src/rewrite.ts +132 -0
- package/src/rewriter-factories.ts +37 -0
- package/src/string-rewriter.ts +60 -0
- package/src/type-guards.ts +56 -0
|
@@ -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
|
+
}
|