Skip to content

Commit d6f003d

Browse files
authored
Rewriter: Expose rewrite/rewriteString functions and refine types (#786)
This pull request exposes new `rewrite` and `rewriteString` functions in the `@herb-tools/rewriter` package. This allows for more ergonomic use of rewriters. Examples: **`rewrite`**: ```ts import { Herb } from "@herb-tools/node-wasm" import { rewrite } from "@herb-tools/rewriter" import { tailwindClassSorter } from "@herb-tools/rewriter/loader" await Herb.load() const template = `<div class="text-red-500 p-4 mt-2"></div>` const parseResult = Herb.parse(template) const { node, output } = await rewrite(template, [tailwindClassSorter()]) output // Result: "<div class="mt-2 p-4 text-red-500"></div>" ``` **`rewriteString`**: ```ts import { Herb } from "@herb-tools/node-wasm" import { rewrite } from "@herb-tools/rewriter" import { tailwindClassSorter } from "@herb-tools/rewriter/loader" const template = `<div class="text-red-500 p-4 mt-2"></div>` const output = await rewriteString(Herb, template, [tailwindClassSorter()]) // Result: "<div class="mt-2 p-4 text-red-500"></div>" ``` Additionally, it cleans up the interface of the rewriters to accept and return `Node` instead of `ParseResult`. This allows for more flexibility and also allows for partial rewrites of a tree/document.
1 parent 893f490 commit d6f003d

File tree

12 files changed

+533
-103
lines changed

12 files changed

+533
-103
lines changed

javascript/packages/formatter/src/formatter.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,29 +64,27 @@ export class Formatter {
6464

6565
const resolvedOptions = resolveFormatOptions({ ...this.options, ...options })
6666

67+
const context: RewriteContext = {
68+
filePath,
69+
baseDir: process.cwd()
70+
}
71+
72+
let node = result.value
73+
6774
if (resolvedOptions.preRewriters.length > 0) {
68-
const context: RewriteContext = {
69-
filePath,
70-
baseDir: process.cwd()
71-
}
7275

7376
for (const rewriter of resolvedOptions.preRewriters) {
7477
try {
75-
result = rewriter.rewrite(result, context)
78+
node = rewriter.rewrite(node, context)
7679
} catch (error) {
7780
console.error(`Pre-format rewriter "${rewriter.name}" failed:`, error)
7881
}
7982
}
8083
}
8184

82-
let formatted = new FormatPrinter(source, resolvedOptions).print(result.value)
85+
let formatted = new FormatPrinter(source, resolvedOptions).print(node)
8386

8487
if (resolvedOptions.postRewriters.length > 0) {
85-
const context: RewriteContext = {
86-
filePath,
87-
baseDir: process.cwd()
88-
}
89-
9088
for (const rewriter of resolvedOptions.postRewriters) {
9189
try {
9290
formatted = rewriter.rewrite(formatted, context)

javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,9 @@ export class UppercaseTagsRewriter extends ASTRewriter {
4343
// No initialization needed
4444
}
4545

46-
rewrite(parseResult, context) {
46+
rewrite(node, context) {
4747
const visitor = new UppercaseTagsVisitor()
48-
visitor.visit(parseResult.value)
49-
return parseResult
48+
visitor.visit(node)
49+
return node
5050
}
5151
}

javascript/packages/rewriter/README.md

Lines changed: 130 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,6 @@
66

77
Rewriter system for transforming HTML+ERB AST nodes and formatted strings. Provides base classes and utilities for creating custom rewriters that can modify templates.
88

9-
## Overview
10-
11-
The rewriter package provides a plugin architecture for transforming HTML+ERB templates. Rewriters can be used to transform templates before formatting, implement linter autofixes, or perform any custom AST or string transformations.
12-
13-
### Rewriter Types
14-
15-
- **`ASTRewriter`**: Transform the parsed AST (e.g., sorting Tailwind classes, restructuring HTML)
16-
- **`StringRewriter`**: Transform formatted strings (e.g., adding trailing newlines, normalizing whitespace)
17-
189
## Installation
1910

2011
:::code-group
@@ -37,6 +28,61 @@ bun add @herb-tools/rewriter
3728

3829
:::
3930

31+
32+
## Overview
33+
34+
The rewriter package provides a plugin architecture for transforming HTML+ERB templates. Rewriters can be used to transform templates before formatting, implement linter autofixes, or perform any custom AST or string transformations.
35+
36+
37+
### Rewriter Types
38+
39+
- **`ASTRewriter`**: Transform the parsed AST (e.g., sorting Tailwind classes, restructuring HTML)
40+
- **`StringRewriter`**: Transform formatted strings (e.g., adding trailing newlines, normalizing whitespace)
41+
42+
## Usage
43+
44+
### Quick Start
45+
46+
The rewriter package exposes two main functions for applying rewriters to templates:
47+
48+
#### `rewrite()` - Transform AST Nodes
49+
50+
Use `rewrite()` when you already have a parsed AST node:
51+
52+
```typescript
53+
import { Herb } from "@herb-tools/node-wasm"
54+
import { rewrite } from "@herb-tools/rewriter"
55+
import { tailwindClassSorter } from "@herb-tools/rewriter/loader"
56+
57+
await Herb.load()
58+
59+
const template = `<div class="text-red-500 p-4 mt-2"></div>`
60+
const parseResult = Herb.parse(template, { track_whitespace: true })
61+
62+
const { output, node } = await rewrite(parseResult.value, [tailwindClassSorter()])
63+
// output: "<div class="mt-2 p-4 text-red-500"></div>"
64+
// node: The rewritten AST node
65+
```
66+
67+
#### `rewriteString()` - Transform Template Strings
68+
69+
Use `rewriteString()` as a convenience wrapper when working with template strings:
70+
71+
```typescript
72+
import { Herb } from "@herb-tools/node-wasm"
73+
import { rewriteString } from "@herb-tools/rewriter"
74+
import { tailwindClassSorter } from "@herb-tools/rewriter/loader"
75+
76+
await Herb.load()
77+
78+
const template = `<div class="text-red-500 p-4 mt-2"></div>`
79+
80+
const output = await rewriteString(Herb, template, [tailwindClassSorter()])
81+
// output: "<div class="mt-2 p-4 text-red-500"></div>"
82+
```
83+
84+
**Note:** `rewrite()` returns both the rewritten string (`output`) and the transformed AST (`node`), which allows for partial rewrites and further processing. `rewriteString()` is a convenience wrapper that returns just the string.
85+
4086
## Built-in Rewriters
4187

4288
### Tailwind Class Sorter
@@ -45,18 +91,22 @@ Automatically sorts Tailwind CSS classes in `class` attributes according to Tail
4591

4692
**Usage:**
4793
```typescript
48-
import { TailwindClassSorterRewriter } from "@herb-tools/rewriter"
94+
import { Herb } from "@herb-tools/node-wasm"
95+
import { rewriteString } from "@herb-tools/rewriter"
96+
import { tailwindClassSorter } from "@herb-tools/rewriter/loader"
4997

50-
const rewriter = new TailwindClassSorterRewriter()
51-
await rewriter.initialize({ baseDir: process.cwd() })
98+
await Herb.load()
5299

53-
const result = rewriter.rewrite(parseResult, { baseDir: process.cwd() })
100+
const template = `<div class="px-4 bg-blue-500 text-white rounded py-2"></div>`
101+
const output = await rewriteString(Herb, template, [tailwindClassSorter()])
102+
// output: "<div class="rounded bg-blue-500 px-4 py-2 text-white"></div>"
54103
```
55104

56105
**Features:**
57106
- Sorts classes in `class` attributes
58107
- Auto-discovers Tailwind configuration from your project
59108
- Supports both Tailwind v3 and v4
109+
- Works with ERB expressions inside class attributes
60110

61111
**Example transformation:**
62112

@@ -78,10 +128,11 @@ You can create custom rewriters to transform templates in any way you need.
78128

79129
### Creating an ASTRewriter
80130

81-
ASTRewriters receive and modify the parsed AST:
131+
ASTRewriters receive and modify AST nodes:
82132

83133
```javascript [.herb/rewriters/my-rewriter.mjs]
84-
import { ASTRewriter, asMutable } from "@herb-tools/rewriter"
134+
import { ASTRewriter } from "@herb-tools/rewriter"
135+
import { Visitor } from "@herb-tools/core"
85136

86137
export default class MyASTRewriter extends ASTRewriter {
87138
get name() {
@@ -98,38 +149,23 @@ export default class MyASTRewriter extends ASTRewriter {
98149
// context.filePath - current file being processed (optional)
99150
}
100151

101-
// Transform the parsed AST
102-
rewrite(parseResult, context) {
103-
if (parseResult.failed) return parseResult
104-
105-
// Access and modify the AST
106-
// parseResult.value contains the root AST node
152+
// Transform the AST node
153+
rewrite(node, context) {
154+
// Use the Visitor pattern to traverse and modify the AST
155+
const visitor = new MyVisitor()
156+
visitor.visit(node)
107157

108-
// To mutate readonly properties, use asMutable():
109-
// const node = asMutable(someNode)
110-
// node.content = "new value"
111-
112-
return parseResult
158+
// Return the modified node
159+
return node
113160
}
114161
}
115-
```
116-
117-
**Mutating AST Nodes:**
118-
119-
AST nodes have readonly properties. To modify them, use the `asMutable()` helper:
120-
121-
```javascript
122-
import { asMutable } from "@herb-tools/rewriter"
123-
import { Visitor } from "@herb-tools/core"
124162

125163
class MyVisitor extends Visitor {
126-
visitHTMLAttributeNode(node) {
127-
if (node.value?.children?.[0]?.type === "AST_LITERAL_NODE") {
128-
const literalNode = asMutable(node.value.children[0])
129-
literalNode.content = "modified"
130-
}
164+
visitHTMLElementNode(node) {
165+
// Modify nodes as needed
166+
// node.someProperty = "new value"
131167

132-
super.visitHTMLAttributeNode(node)
168+
this.visitChildNodes(node)
133169
}
134170
}
135171
```
@@ -168,13 +204,61 @@ Which means you can just reference and configure them in `.herb.yml` using their
168204

169205
## API Reference
170206

171-
### `ASTRewriter`
207+
### Functions
208+
209+
#### `rewrite()`
210+
211+
Transform an AST node using the provided rewriters.
212+
213+
```typescript
214+
async function rewrite<T extends Node>(
215+
node: T,
216+
rewriters: Rewriter[],
217+
options?: RewriteOptions
218+
): Promise<RewriteResult>
219+
```
220+
221+
**Parameters:**
222+
- `node`: The AST node to transform
223+
- `rewriters`: Array of rewriter instances to apply
224+
- `options`: Optional configuration
225+
- `baseDir`: Base directory for resolving config files (defaults to `process.cwd()`)
226+
- `filePath`: Optional file path for context
227+
228+
**Returns:** Object with:
229+
- `output`: The rewritten template string
230+
- `node`: The transformed AST node (preserves input type)
231+
232+
#### `rewriteString()`
233+
234+
Convenience wrapper around `rewrite()` that parses the template string first and returns just the output string.
235+
236+
```typescript
237+
async function rewriteString(
238+
herb: HerbBackend,
239+
template: string,
240+
rewriters: Rewriter[],
241+
options?: RewriteOptions
242+
): Promise<string>
243+
```
244+
245+
**Parameters:**
246+
- `herb`: The Herb backend instance for parsing
247+
- `template`: The HTML+ERB template string to rewrite
248+
- `rewriters`: Array of rewriter instances to apply
249+
- `options`: Optional configuration (same as `rewrite()`)
250+
251+
**Returns:** The rewritten template string
252+
253+
### Base Classes
254+
255+
#### `ASTRewriter`
172256

173-
Base class for rewriters that transform the parsed AST:
257+
Base class for rewriters that transform AST nodes:
174258

175259
```typescript
176260
import { ASTRewriter } from "@herb-tools/rewriter"
177-
import type { ParseResult, RewriteContext } from "@herb-tools/rewriter"
261+
import type { Node, RewriteContext } from "@herb-tools/rewriter"
178262

179263
class MyRewriter extends ASTRewriter {
180264
abstract get name(): string
@@ -184,11 +268,11 @@ class MyRewriter extends ASTRewriter {
184268
// Optional initialization
185269
}
186270

187-
abstract rewrite(parseResult: ParseResult, context: RewriteContext): ParseResult
271+
abstract rewrite<T extends Node>(node: T, context: RewriteContext): T
188272
}
189273
```
190274

191-
### `StringRewriter`
275+
#### `StringRewriter`
192276

193277
Base class for rewriters that transform strings:
194278

javascript/packages/rewriter/src/ast-rewriter.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import type { ParseResult } from "@herb-tools/core"
1+
import type { Node } from "@herb-tools/core"
22
import type { RewriteContext } from "./context.js"
33

44
/**
5-
* Base class for AST rewriters that transform the parsed AST before formatting
5+
* Base class for AST rewriters that transform AST nodes before formatting
66
*
7-
* AST rewriters receive a ParseResult and can mutate the AST nodes in place
8-
* or return a modified ParseResult. They run before the formatting step.
7+
* AST rewriters receive a Node and can mutate it in place or return a modified Node.
8+
* They run before the formatting step.
99
*
1010
* @example
1111
* ```typescript
@@ -20,14 +20,12 @@ import type { RewriteContext } from "./context.js"
2020
* // Load config, initialize dependencies, etc.
2121
* }
2222
*
23-
* rewrite(parseResult, context) {
24-
* if (parseResult.failed) return parseResult
25-
*
23+
* rewrite(node, context) {
2624
* // Use visitor pattern to traverse and modify AST
2725
* const visitor = new MyVisitor()
28-
* visitor.visit(parseResult.value)
26+
* visitor.visit(node)
2927
*
30-
* return parseResult
28+
* return node
3129
* }
3230
* }
3331
* ```
@@ -59,14 +57,14 @@ export abstract class ASTRewriter {
5957
}
6058

6159
/**
62-
* Transform the parsed AST
60+
* Transform the AST node
6361
*
6462
* This method is called synchronously for each file being formatted.
65-
* Modify the AST in place or return a new ParseResult.
63+
* Modify the AST in place or return a new Node.
6664
*
67-
* @param parseResult - The parsed AST from @herb-tools/core
65+
* @param node - The AST node from @herb-tools/core
6866
* @param context - Context with filePath and baseDir
69-
* @returns The modified ParseResult (can be the same object mutated in place)
67+
* @returns The modified Node (can be the same object mutated in place)
7068
*/
71-
abstract rewrite(parseResult: ParseResult, context: RewriteContext): ParseResult
69+
abstract rewrite<T extends Node>(node: T, context: RewriteContext): T
7270
}

javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { asMutable } from "../mutable.js"
77

88
import type { RewriteContext } from "../context.js"
99
import type {
10-
ParseResult,
1110
HTMLAttributeNode,
1211
HTMLAttributeValueNode,
1312
Node,
@@ -249,15 +248,15 @@ export class TailwindClassSorterRewriter extends ASTRewriter {
249248
})
250249
}
251250

252-
rewrite(parseResult: ParseResult, _context: RewriteContext): ParseResult {
251+
rewrite<T extends Node>(node: T, _context: RewriteContext): T {
253252
if (!this.sorter) {
254-
return parseResult
253+
return node
255254
}
256255

257256
const visitor = new TailwindClassSorterVisitor(this.sorter)
258257

259-
visitor.visit(parseResult.value)
258+
visitor.visit(node)
260259

261-
return parseResult
260+
return node
262261
}
263262
}

javascript/packages/rewriter/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ export { StringRewriter } from "./string-rewriter.js"
44
export { asMutable } from "./mutable.js"
55
export { isASTRewriterClass, isStringRewriterClass, isRewriterClass } from "./type-guards.js"
66

7+
export { rewrite, rewriteString } from "./rewrite.js"
8+
79
export type { RewriteContext } from "./context.js"
810
export type { Mutable } from "./mutable.js"
911
export type { RewriterClass } from "./type-guards.js"
12+
export type { Rewriter, RewriteOptions, RewriteResult } from "./rewrite.js"
13+
export type { TailwindClassSorterOptions } from "./rewriter-factories.js"

javascript/packages/rewriter/src/loader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { CustomRewriterLoader } from "./custom-rewriter-loader.js"
44
export type { CustomRewriterLoaderOptions } from "./custom-rewriter-loader.js"
55

66
export { TailwindClassSorterRewriter } from "./built-ins/index.js"
7+
export { tailwindClassSorter } from "./rewriter-factories.js"
78
export { builtinRewriters, getBuiltinRewriter, getBuiltinRewriterNames } from "./built-ins/index.js"

0 commit comments

Comments
 (0)