Skip to content

Commit 73e6744

Browse files
authored
Introduce @herb-tools/rewriter package (marcoroth#759)
This pull request introduces a new `@herb-tools/rewriter` package that provides a plugin architecture for transforming HTML+ERB templates and enables users to create custom transformations. This pull request just introduces the `@herb-tools/rewriter` package and the building blocks. These transformations can later be used for pre- and post-format rewriters. It can also serve as a foundation for re-implementing the linter autofixes using the rewriter architecture. We also want use this architecture to integrate the `@herb-tools/tailwind-class-sorter` package into the `@herb-tools/formatter` so it can be run as part of the format-action.
1 parent c5c8c59 commit 73e6744

31 files changed

+1357
-37
lines changed

.github/workflows/deploy.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ jobs:
6565
- name: Yarn install
6666
run: yarn install --frozen-lockfile
6767

68+
- name: Build tailwind-class-sorter package
69+
run: yarn nx build @herb-tools/tailwind-class-sorter
70+
6871
- name: Build all JavaScript packages
6972
run: yarn build
7073

.github/workflows/javascript.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ jobs:
5858
- name: Install Playwright browsers
5959
run: yarn playwright install
6060

61+
- name: Build tailwind-class-sorter package
62+
run: yarn nx build @herb-tools/tailwind-class-sorter
63+
6164
- name: Run `build` for all NX packages
6265
run: yarn build
6366

docs/.vitepress/config/theme.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const defaultSidebar = [
3030
{ text: "Syntax Tree Printer", link: "/projects/printer" },
3131
{ text: "Minifier", link: "/projects/minifier" },
3232
{ text: "Config", link: "/projects/config" },
33+
{ text: "Rewriter", link: "/projects/rewriter" },
3334
{ text: "Core", link: "/projects/core" },
3435
],
3536
},

docs/docs/projects.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ These specialized libraries provide additional functionality for working with HT
2828
* [Highlighter](/projects/highlighter)
2929
* [Syntax Tree Printer](/projects/minifier)
3030
* [Printer](/projects/printer)
31+
* [Rewriter](/projects/rewriter)
3132
* [Config](/projects/config)
3233
* [Core](/projects/core)

docs/docs/projects/rewriter.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<!-- @include: ../../../javascript/packages/rewriter/README.md -->

javascript/packages/linter/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"@herb-tools/highlighter": "0.7.5",
4545
"@herb-tools/node-wasm": "0.7.5",
4646
"@herb-tools/printer": "0.7.5",
47+
"@herb-tools/rewriter": "0.7.5",
4748
"glob": "^11.0.3"
4849
},
4950
"files": [

javascript/packages/linter/project.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"@herb-tools/core:build",
1515
"@herb-tools/browser:build",
1616
"@herb-tools/highlighter:build",
17-
"@herb-tools/printer:build"
17+
"@herb-tools/printer:build",
18+
"@herb-tools/rewriter:build"
1819
]
1920
},
2021
"test": {

javascript/packages/linter/src/types.ts

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { Diagnostic, LexResult, ParseResult } from "@herb-tools/core"
33
import type { rules } from "./rules.js"
44
import type { Node } from "@herb-tools/core"
55
import type { RuleConfig } from "@herb-tools/config"
6+
import type { Mutable } from "@herb-tools/rewriter"
7+
8+
export type { Mutable } from "@herb-tools/rewriter"
69

710
export type LintSeverity = "error" | "warning" | "info" | "hint"
811

@@ -14,35 +17,6 @@ export type FullRuleConfig = Required<Pick<RuleConfig, 'enabled' | 'severity'>>
1417
*/
1518
export type LinterRule = InstanceType<typeof rules[number]>['name']
1619

17-
/**
18-
* Recursively removes readonly modifiers from a type, making it mutable.
19-
* Used internally during autofix to allow direct AST node mutation.
20-
*
21-
* @example
22-
* const node: HTMLOpenTagNode = ... // readonly properties
23-
* const mutable = node as Mutable<HTMLOpenTagNode> // can mutate
24-
* mutable.tag_name!.value = 'div' // ✓ allowed
25-
*/
26-
export type Mutable<T> = T extends ReadonlyArray<infer U>
27-
? Array<Mutable<U>>
28-
: T extends object
29-
? { -readonly [K in keyof T]: Mutable<T[K]> }
30-
: T
31-
32-
/**
33-
* Converts a readonly node or object to a mutable version.
34-
* Use this in autofix methods to enable direct mutation of AST nodes.
35-
* Follows the TypeScript pattern of 'as const' but for mutability.
36-
*
37-
* @example
38-
* const mutable = asMutable(node)
39-
* mutable.tag_name.value = 'div'
40-
* mutable.content.value = 'updated'
41-
*/
42-
export function asMutable<T>(node: T): Mutable<T> {
43-
return node as Mutable<T>
44-
}
45-
4620

4721
/**
4822
* Base context for autofix operations. Contains the offending node.
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Herb Rewriter
2+
3+
**Package:** [`@herb-tools/rewriter`](https://www.npmjs.com/package/@herb-tools/rewriter)
4+
5+
---
6+
7+
Rewriter system for transforming HTML+ERB AST nodes and formatted strings. Provides base classes and utilities for creating custom rewriters that can modify templates.
8+
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+
18+
## Installation
19+
20+
:::code-group
21+
22+
```shell [npm]
23+
npm add @herb-tools/rewriter
24+
```
25+
26+
```shell [pnpm]
27+
pnpm add @herb-tools/rewriter
28+
```
29+
30+
```shell [yarn]
31+
yarn add @herb-tools/rewriter
32+
```
33+
34+
```shell [bun]
35+
bun add @herb-tools/rewriter
36+
```
37+
38+
:::
39+
40+
## Built-in Rewriters
41+
42+
### Tailwind Class Sorter
43+
44+
Automatically sorts Tailwind CSS classes in `class` attributes according to Tailwind's recommended order.
45+
46+
**Usage:**
47+
```typescript
48+
import { TailwindClassSorterRewriter } from "@herb-tools/rewriter"
49+
50+
const rewriter = new TailwindClassSorterRewriter()
51+
await rewriter.initialize({ baseDir: process.cwd() })
52+
53+
const result = rewriter.rewrite(parseResult, { baseDir: process.cwd() })
54+
```
55+
56+
**Features:**
57+
- Sorts classes in `class` attributes
58+
- Auto-discovers Tailwind configuration from your project
59+
- Supports both Tailwind v3 and v4
60+
61+
**Example transformation:**
62+
63+
```erb
64+
<!-- Before -->
65+
<div class="px-4 bg-blue-500 text-white rounded py-2">
66+
<span class="font-bold text-lg">Hello</span>
67+
</div>
68+
69+
<!-- After -->
70+
<div class="rounded bg-blue-500 px-4 py-2 text-white">
71+
<span class="text-lg font-bold">Hello</span>
72+
</div>
73+
```
74+
75+
## Custom Rewriters
76+
77+
You can create custom rewriters to transform templates in any way you need.
78+
79+
### Creating an ASTRewriter
80+
81+
ASTRewriters receive and modify the parsed AST:
82+
83+
```javascript [.herb/rewriters/my-rewriter.mjs]
84+
import { ASTRewriter, asMutable } from "@herb-tools/rewriter"
85+
86+
export default class MyASTRewriter extends ASTRewriter {
87+
get name() {
88+
return "my-ast-rewriter"
89+
}
90+
91+
get description() {
92+
return "Transforms the AST"
93+
}
94+
95+
// Optional: Load configuration or setup
96+
async initialize(context) {
97+
// context.baseDir - project root directory
98+
// context.filePath - current file being processed (optional)
99+
}
100+
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
107+
108+
// To mutate readonly properties, use asMutable():
109+
// const node = asMutable(someNode)
110+
// node.content = "new value"
111+
112+
return parseResult
113+
}
114+
}
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"
124+
125+
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+
}
131+
132+
super.visitHTMLAttributeNode(node)
133+
}
134+
}
135+
```
136+
137+
### Creating a StringRewriter
138+
139+
StringRewriters receive and modify strings:
140+
141+
```javascript [.herb/rewriters/add-newline.mjs]
142+
import { StringRewriter } from "@herb-tools/rewriter"
143+
144+
export default class AddTrailingNewline extends StringRewriter {
145+
get name() {
146+
return "add-trailing-newline"
147+
}
148+
149+
get description() {
150+
return "Ensures file ends with a newline"
151+
}
152+
153+
async initialize(context) {
154+
// Optional setup
155+
}
156+
157+
rewrite(content, context) {
158+
return content.endsWith("\n") ? content : content + "\n"
159+
}
160+
}
161+
```
162+
163+
### Using Custom Rewriters
164+
165+
By default, rewriters are auto-discovered from: `.herb/rewriters/**/*.{js,mjs,cjs}`
166+
167+
Which means you can just reference and configure them in `.herb.yml` using their filename.
168+
169+
## API Reference
170+
171+
### `ASTRewriter`
172+
173+
Base class for rewriters that transform the parsed AST:
174+
175+
```typescript
176+
import { ASTRewriter } from "@herb-tools/rewriter"
177+
import type { ParseResult, RewriteContext } from "@herb-tools/rewriter"
178+
179+
class MyRewriter extends ASTRewriter {
180+
abstract get name(): string
181+
abstract get description(): string
182+
183+
async initialize(context: RewriteContext): Promise<void> {
184+
// Optional initialization
185+
}
186+
187+
abstract rewrite(parseResult: ParseResult, context: RewriteContext): ParseResult
188+
}
189+
```
190+
191+
### `StringRewriter`
192+
193+
Base class for rewriters that transform strings:
194+
195+
```typescript
196+
import { StringRewriter } from "@herb-tools/rewriter"
197+
import type { RewriteContext } from "@herb-tools/rewriter"
198+
199+
class MyRewriter extends StringRewriter {
200+
abstract get name(): string
201+
abstract get description(): string
202+
203+
async initialize(context: RewriteContext): Promise<void> {
204+
// Optional initialization
205+
}
206+
207+
abstract rewrite(content: string, context: RewriteContext): string
208+
}
209+
```
210+
211+
## See Also
212+
213+
- [Formatter Documentation](/projects/formatter) - Using rewriters with the formatter
214+
- [Core Documentation](/projects/core) - AST node types and visitor pattern
215+
- [Config Documentation](/projects/config) - Configuring rewriters in `.herb.yml`
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"name": "@herb-tools/rewriter",
3+
"version": "0.7.5",
4+
"description": "Rewriter system for transforming HTML+ERB AST nodes and formatted strings",
5+
"license": "MIT",
6+
"homepage": "https://herb-tools.dev",
7+
"bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/rewriter%60:%20",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/marcoroth/herb.git",
11+
"directory": "javascript/packages/rewriter"
12+
},
13+
"main": "./dist/index.cjs",
14+
"module": "./dist/index.esm.js",
15+
"require": "./dist/index.cjs",
16+
"types": "./dist/types/index.d.ts",
17+
"scripts": {
18+
"build": "yarn clean && rollup -c rollup.config.mjs",
19+
"dev": "rollup -c rollup.config.mjs -w",
20+
"clean": "rimraf dist",
21+
"test": "vitest run",
22+
"test:watch": "vitest --watch",
23+
"prepublishOnly": "yarn clean && yarn build && yarn test"
24+
},
25+
"exports": {
26+
"./package.json": "./package.json",
27+
".": {
28+
"types": "./dist/types/index.d.ts",
29+
"import": "./dist/index.esm.js",
30+
"require": "./dist/index.cjs",
31+
"default": "./dist/index.esm.js"
32+
}
33+
},
34+
"dependencies": {
35+
"@herb-tools/core": "0.7.5",
36+
"glob": "^11.0.3"
37+
},
38+
"devDependencies": {
39+
"@herb-tools/printer": "0.7.5"
40+
},
41+
"files": [
42+
"package.json",
43+
"README.md",
44+
"dist/",
45+
"src/"
46+
]
47+
}

0 commit comments

Comments
 (0)