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
20 changes: 9 additions & 11 deletions javascript/packages/formatter/src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,29 +64,27 @@ export class Formatter {

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

const context: RewriteContext = {
filePath,
baseDir: process.cwd()
}

let node = result.value

if (resolvedOptions.preRewriters.length > 0) {
const context: RewriteContext = {
filePath,
baseDir: process.cwd()
}

for (const rewriter of resolvedOptions.preRewriters) {
try {
result = rewriter.rewrite(result, context)
node = rewriter.rewrite(node, context)
} catch (error) {
console.error(`Pre-format rewriter "${rewriter.name}" failed:`, error)
}
}
}

let formatted = new FormatPrinter(source, resolvedOptions).print(result.value)
let formatted = new FormatPrinter(source, resolvedOptions).print(node)

if (resolvedOptions.postRewriters.length > 0) {
const context: RewriteContext = {
filePath,
baseDir: process.cwd()
}

for (const rewriter of resolvedOptions.postRewriters) {
try {
formatted = rewriter.rewrite(formatted, context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ export class UppercaseTagsRewriter extends ASTRewriter {
// No initialization needed
}

rewrite(parseResult, context) {
rewrite(node, context) {
const visitor = new UppercaseTagsVisitor()
visitor.visit(parseResult.value)
return parseResult
visitor.visit(node)
return node
}
}
176 changes: 130 additions & 46 deletions javascript/packages/rewriter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,6 @@

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

## Overview

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.

### Rewriter Types

- **`ASTRewriter`**: Transform the parsed AST (e.g., sorting Tailwind classes, restructuring HTML)
- **`StringRewriter`**: Transform formatted strings (e.g., adding trailing newlines, normalizing whitespace)

## Installation

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

:::


## Overview

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.


### Rewriter Types

- **`ASTRewriter`**: Transform the parsed AST (e.g., sorting Tailwind classes, restructuring HTML)
- **`StringRewriter`**: Transform formatted strings (e.g., adding trailing newlines, normalizing whitespace)

## Usage

### Quick Start

The rewriter package exposes two main functions for applying rewriters to templates:

#### `rewrite()` - Transform AST Nodes

Use `rewrite()` when you already have a parsed AST node:

```typescript
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, { track_whitespace: true })

const { output, node } = await rewrite(parseResult.value, [tailwindClassSorter()])
// output: "<div class="mt-2 p-4 text-red-500"></div>"
// node: The rewritten AST node
```

#### `rewriteString()` - Transform Template Strings

Use `rewriteString()` as a convenience wrapper when working with template strings:

```typescript
import { Herb } from "@herb-tools/node-wasm"
import { rewriteString } 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 output = await rewriteString(Herb, template, [tailwindClassSorter()])
// output: "<div class="mt-2 p-4 text-red-500"></div>"
```

**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.

## Built-in Rewriters

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

**Usage:**
```typescript
import { TailwindClassSorterRewriter } from "@herb-tools/rewriter"
import { Herb } from "@herb-tools/node-wasm"
import { rewriteString } from "@herb-tools/rewriter"
import { tailwindClassSorter } from "@herb-tools/rewriter/loader"

const rewriter = new TailwindClassSorterRewriter()
await rewriter.initialize({ baseDir: process.cwd() })
await Herb.load()

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

**Features:**
- Sorts classes in `class` attributes
- Auto-discovers Tailwind configuration from your project
- Supports both Tailwind v3 and v4
- Works with ERB expressions inside class attributes

**Example transformation:**

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

### Creating an ASTRewriter

ASTRewriters receive and modify the parsed AST:
ASTRewriters receive and modify AST nodes:

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

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

// Transform the parsed AST
rewrite(parseResult, context) {
if (parseResult.failed) return parseResult

// Access and modify the AST
// parseResult.value contains the root AST node
// Transform the AST node
rewrite(node, context) {
// Use the Visitor pattern to traverse and modify the AST
const visitor = new MyVisitor()
visitor.visit(node)

// To mutate readonly properties, use asMutable():
// const node = asMutable(someNode)
// node.content = "new value"

return parseResult
// Return the modified node
return node
}
}
```

**Mutating AST Nodes:**

AST nodes have readonly properties. To modify them, use the `asMutable()` helper:

```javascript
import { asMutable } from "@herb-tools/rewriter"
import { Visitor } from "@herb-tools/core"

class MyVisitor extends Visitor {
visitHTMLAttributeNode(node) {
if (node.value?.children?.[0]?.type === "AST_LITERAL_NODE") {
const literalNode = asMutable(node.value.children[0])
literalNode.content = "modified"
}
visitHTMLElementNode(node) {
// Modify nodes as needed
// node.someProperty = "new value"

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

## API Reference

### `ASTRewriter`
### Functions

#### `rewrite()`

Transform an AST node using the provided rewriters.

```typescript
async function rewrite<T extends Node>(
node: T,
rewriters: Rewriter[],
options?: RewriteOptions
): Promise<RewriteResult>
```

**Parameters:**
- `node`: The AST node to transform
- `rewriters`: Array of rewriter instances to apply
- `options`: Optional configuration
- `baseDir`: Base directory for resolving config files (defaults to `process.cwd()`)
- `filePath`: Optional file path for context

**Returns:** Object with:
- `output`: The rewritten template string
- `node`: The transformed AST node (preserves input type)

#### `rewriteString()`

Convenience wrapper around `rewrite()` that parses the template string first and returns just the output string.

```typescript
async function rewriteString(
herb: HerbBackend,
template: string,
rewriters: Rewriter[],
options?: RewriteOptions
): Promise<string>
```

**Parameters:**
- `herb`: The Herb backend instance for parsing
- `template`: The HTML+ERB template string to rewrite
- `rewriters`: Array of rewriter instances to apply
- `options`: Optional configuration (same as `rewrite()`)

**Returns:** The rewritten template string

### Base Classes

#### `ASTRewriter`

Base class for rewriters that transform the parsed AST:
Base class for rewriters that transform AST nodes:

```typescript
import { ASTRewriter } from "@herb-tools/rewriter"
import type { ParseResult, RewriteContext } from "@herb-tools/rewriter"
import type { Node, RewriteContext } from "@herb-tools/rewriter"

class MyRewriter extends ASTRewriter {
abstract get name(): string
Expand All @@ -184,11 +268,11 @@ class MyRewriter extends ASTRewriter {
// Optional initialization
}

abstract rewrite(parseResult: ParseResult, context: RewriteContext): ParseResult
abstract rewrite<T extends Node>(node: T, context: RewriteContext): T
}
```

### `StringRewriter`
#### `StringRewriter`

Base class for rewriters that transform strings:

Expand Down
26 changes: 12 additions & 14 deletions javascript/packages/rewriter/src/ast-rewriter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { ParseResult } from "@herb-tools/core"
import type { Node } from "@herb-tools/core"
import type { RewriteContext } from "./context.js"

/**
* Base class for AST rewriters that transform the parsed AST before formatting
* Base class for AST rewriters that transform AST nodes before formatting
*
* AST rewriters receive a ParseResult and can mutate the AST nodes in place
* or return a modified ParseResult. They run before the formatting step.
* AST rewriters receive a Node and can mutate it in place or return a modified Node.
* They run before the formatting step.
*
* @example
* ```typescript
Expand All @@ -20,14 +20,12 @@ import type { RewriteContext } from "./context.js"
* // Load config, initialize dependencies, etc.
* }
*
* rewrite(parseResult, context) {
* if (parseResult.failed) return parseResult
*
* rewrite(node, context) {
* // Use visitor pattern to traverse and modify AST
* const visitor = new MyVisitor()
* visitor.visit(parseResult.value)
* visitor.visit(node)
*
* return parseResult
* return node
* }
* }
* ```
Expand Down Expand Up @@ -59,14 +57,14 @@ export abstract class ASTRewriter {
}

/**
* Transform the parsed AST
* Transform the AST node
*
* This method is called synchronously for each file being formatted.
* Modify the AST in place or return a new ParseResult.
* Modify the AST in place or return a new Node.
*
* @param parseResult - The parsed AST from @herb-tools/core
* @param node - The AST node from @herb-tools/core
* @param context - Context with filePath and baseDir
* @returns The modified ParseResult (can be the same object mutated in place)
* @returns The modified Node (can be the same object mutated in place)
*/
abstract rewrite(parseResult: ParseResult, context: RewriteContext): ParseResult
abstract rewrite<T extends Node>(node: T, context: RewriteContext): T
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { asMutable } from "../mutable.js"

import type { RewriteContext } from "../context.js"
import type {
ParseResult,
HTMLAttributeNode,
HTMLAttributeValueNode,
Node,
Expand Down Expand Up @@ -249,15 +248,15 @@ export class TailwindClassSorterRewriter extends ASTRewriter {
})
}

rewrite(parseResult: ParseResult, _context: RewriteContext): ParseResult {
rewrite<T extends Node>(node: T, _context: RewriteContext): T {
if (!this.sorter) {
return parseResult
return node
}

const visitor = new TailwindClassSorterVisitor(this.sorter)

visitor.visit(parseResult.value)
visitor.visit(node)

return parseResult
return node
}
}
4 changes: 4 additions & 0 deletions javascript/packages/rewriter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export { StringRewriter } from "./string-rewriter.js"
export { asMutable } from "./mutable.js"
export { isASTRewriterClass, isStringRewriterClass, isRewriterClass } from "./type-guards.js"

export { rewrite, rewriteString } from "./rewrite.js"

export type { RewriteContext } from "./context.js"
export type { Mutable } from "./mutable.js"
export type { RewriterClass } from "./type-guards.js"
export type { Rewriter, RewriteOptions, RewriteResult } from "./rewrite.js"
export type { TailwindClassSorterOptions } from "./rewriter-factories.js"
1 change: 1 addition & 0 deletions javascript/packages/rewriter/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export { CustomRewriterLoader } from "./custom-rewriter-loader.js"
export type { CustomRewriterLoaderOptions } from "./custom-rewriter-loader.js"

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