From f2db50637d0324295b431aa4200f24dc6a3eac4f Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Thu, 6 Nov 2025 02:36:25 +0100 Subject: [PATCH 1/3] Rewriter: Expose `rewrite` function and refine types --- .../packages/formatter/src/formatter.ts | 20 +- .../test/rewriters/fixtures/uppercase-tags.js | 6 +- .../packages/rewriter/src/ast-rewriter.ts | 26 ++- .../src/built-ins/tailwind-class-sorter.ts | 9 +- javascript/packages/rewriter/src/index.ts | 4 + javascript/packages/rewriter/src/loader.ts | 1 + javascript/packages/rewriter/src/rewrite.ts | 130 ++++++++++++ .../rewriter/src/rewriter-factories.ts | 38 ++++ .../built-ins/tailwind-class-sorter.test.ts | 11 +- .../test/helpers/rewriter-test-helper.ts | 26 +-- .../packages/rewriter/test/rewrite.test.ts | 185 ++++++++++++++++++ 11 files changed, 399 insertions(+), 57 deletions(-) create mode 100644 javascript/packages/rewriter/src/rewrite.ts create mode 100644 javascript/packages/rewriter/src/rewriter-factories.ts create mode 100644 javascript/packages/rewriter/test/rewrite.test.ts diff --git a/javascript/packages/formatter/src/formatter.ts b/javascript/packages/formatter/src/formatter.ts index 68062cd65..06b5ce7b9 100644 --- a/javascript/packages/formatter/src/formatter.ts +++ b/javascript/packages/formatter/src/formatter.ts @@ -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) diff --git a/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js b/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js index 8918029b1..357c8557c 100644 --- a/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js +++ b/javascript/packages/formatter/test/rewriters/fixtures/uppercase-tags.js @@ -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 } } diff --git a/javascript/packages/rewriter/src/ast-rewriter.ts b/javascript/packages/rewriter/src/ast-rewriter.ts index 58bcbfaa8..a8d758e54 100644 --- a/javascript/packages/rewriter/src/ast-rewriter.ts +++ b/javascript/packages/rewriter/src/ast-rewriter.ts @@ -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 @@ -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 * } * } * ``` @@ -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(node: T, context: RewriteContext): T } diff --git a/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts b/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts index a7fca11bb..e1262a964 100644 --- a/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts +++ b/javascript/packages/rewriter/src/built-ins/tailwind-class-sorter.ts @@ -7,7 +7,6 @@ import { asMutable } from "../mutable.js" import type { RewriteContext } from "../context.js" import type { - ParseResult, HTMLAttributeNode, HTMLAttributeValueNode, Node, @@ -249,15 +248,15 @@ export class TailwindClassSorterRewriter extends ASTRewriter { }) } - rewrite(parseResult: ParseResult, _context: RewriteContext): ParseResult { + rewrite(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 } } diff --git a/javascript/packages/rewriter/src/index.ts b/javascript/packages/rewriter/src/index.ts index 2625c7db1..da8a644b7 100644 --- a/javascript/packages/rewriter/src/index.ts +++ b/javascript/packages/rewriter/src/index.ts @@ -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" diff --git a/javascript/packages/rewriter/src/loader.ts b/javascript/packages/rewriter/src/loader.ts index 381f45983..22da46c5e 100644 --- a/javascript/packages/rewriter/src/loader.ts +++ b/javascript/packages/rewriter/src/loader.ts @@ -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" diff --git a/javascript/packages/rewriter/src/rewrite.ts b/javascript/packages/rewriter/src/rewrite.ts new file mode 100644 index 000000000..df7c13bb0 --- /dev/null +++ b/javascript/packages/rewriter/src/rewrite.ts @@ -0,0 +1,130 @@ +import { IdentityPrinter } from "@herb-tools/printer" + +import { ASTRewriter } from "./ast-rewriter.js" +import { StringRewriter } from "./string-rewriter.js" + +import type { HerbBackend, Node } from "@herb-tools/core" +import type { RewriteContext } from "./context.js" + +export type Rewriter = ASTRewriter | StringRewriter + +export interface RewriteOptions { + /** + * Base directory for resolving configuration files + * Defaults to process.cwd() + */ + baseDir?: string + + /** + * Optional file path for context + */ + filePath?: string +} + +export interface RewriteResult { + /** + * The rewritten template string + */ + output: string + + /** + * The rewritten AST node + */ + node: Node +} + +/** + * Rewrite an AST Node using the provided rewriters + * + * This is the main rewrite function that operates on AST nodes. + * For string input, use `rewriteString()` instead. + * + * @example + * ```typescript + * import { Herb } from '@herb-tools/node-wasm' + * import { rewrite, tailwindClassSorter } from '@herb-tools/rewriter' + * + * await Herb.load() + * + * const template = '
' + * const parseResult = Herb.parse(template) + * const { output, document } = await rewrite(parseResult.value, [tailwindClassSorter()]) + * ``` + * + * @param node - The AST Node to rewrite + * @param rewriters - Array of rewriter instances to apply + * @param options - Optional configuration for the rewrite operation + * @returns Object containing the rewritten string and Node + */ +export async function rewrite(node: T, rewriters: Rewriter[], options: RewriteOptions = {}): Promise { + const { baseDir = process.cwd(), filePath } = options + + const context: RewriteContext = { baseDir, filePath } + + for (const rewriter of rewriters) { + await rewriter.initialize(context) + } + + let currentNode = node + + const astRewriters = rewriters.filter(rewriter => rewriter instanceof ASTRewriter) + const stringRewriters = rewriters.filter(rewriter => rewriter instanceof StringRewriter) + + for (const rewriter of astRewriters) { + try { + currentNode = rewriter.rewrite(currentNode, context) + } catch (error) { + console.error(`AST rewriter "${rewriter.name}" failed:`, error) + } + } + + let result = IdentityPrinter.print(currentNode) + + for (const rewriter of stringRewriters) { + try { + result = rewriter.rewrite(result, context) + } catch (error) { + console.error(`String rewriter "${rewriter.name}" failed:`, error) + } + } + + return { + output: result, + node: currentNode + } +} + +/** + * Rewrite an HTML+ERB template string + * + * Convenience wrapper around `rewrite()` that parses the string first. + * + * @example + * ```typescript + * import { Herb } from '@herb-tools/node-wasm' + * import { rewriteString, tailwindClassSorter } from '@herb-tools/rewriter' + * + * await Herb.load() + * + * const template = '
' + * const { output, node } = await rewriteString(Herb, template, [tailwindClassSorter()]) + * ``` + * + * @param herb - The Herb backend instance for parsing + * @param template - The HTML+ERB template string to rewrite + * @param rewriters - Array of rewriter instances to apply + * @param options - Optional configuration for the rewrite operation + * @returns Object containing the rewritten string and Node + */ +export async function rewriteString(herb: HerbBackend, template: string, rewriters: Rewriter[], options: RewriteOptions = {}): Promise { + const parseResult = herb.parse(template, { track_whitespace: true }) + + if (parseResult.failed) { + return { + output: template, + node: parseResult.value + } + } + + return await rewrite(parseResult.value, rewriters, options) +} diff --git a/javascript/packages/rewriter/src/rewriter-factories.ts b/javascript/packages/rewriter/src/rewriter-factories.ts new file mode 100644 index 000000000..9c6cc8fe4 --- /dev/null +++ b/javascript/packages/rewriter/src/rewriter-factories.ts @@ -0,0 +1,38 @@ +import { TailwindClassSorterRewriter } from "./built-ins/tailwind-class-sorter.js" + +export interface TailwindClassSorterOptions { + /** + * Base directory for resolving Tailwind configuration + * Defaults to process.cwd() + */ + baseDir?: string +} + +/** + * Factory function for creating a Tailwind class sorter rewriter + * + * @example + * ```typescript + * import { rewrite, tailwindClassSorter } from '@herb-tools/rewriter' + * + * const template = '
' + * const result = await rewrite(template, [tailwindClassSorter()]) + * // Result: '
' + * ``` + * + * @param options - Optional configuration for the Tailwind class sorter + * @returns A configured TailwindClassSorterRewriter instance + */ +export function tailwindClassSorter(options: TailwindClassSorterOptions = {}): TailwindClassSorterRewriter { + const rewriter = new TailwindClassSorterRewriter() + + if (options.baseDir) { + const originalInitialize = rewriter.initialize.bind(rewriter) + + rewriter.initialize = async (context) => { + return originalInitialize({ ...context, baseDir: options.baseDir || context.baseDir }) + } + } + + return rewriter +} diff --git a/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts b/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts index b0b12ea20..9c0502c70 100644 --- a/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts +++ b/javascript/packages/rewriter/test/built-ins/tailwind-class-sorter.test.ts @@ -37,22 +37,21 @@ describe("tailwind-class-sorter", () => { const rewriter = new TailwindClassSorterRewriter() const parseResult = Herb.parse(input, { track_whitespace: true }) - const rewritten = rewriter.rewrite(parseResult, { baseDir: process.cwd() }) + const document = rewriter.rewrite(parseResult.value, { baseDir: process.cwd() }) - expect(rewritten).toBe(parseResult) + expect(document).toBe(parseResult.value) - const output = IdentityPrinter.print(rewritten.value) + const output = IdentityPrinter.print(document) expect(output).toBe(input) }) test("returns rewritten AST for inspection", async () => { - const result = await expectTransform( + const document = await expectTransform( `
`, `
` ) - expect(result.value.type).toBe("AST_DOCUMENT_NODE") - expect(result.failed).toBe(false) + expect(document.type).toBe("AST_DOCUMENT_NODE") }) }) diff --git a/javascript/packages/rewriter/test/helpers/rewriter-test-helper.ts b/javascript/packages/rewriter/test/helpers/rewriter-test-helper.ts index df44ffd27..d03dce7a7 100644 --- a/javascript/packages/rewriter/test/helpers/rewriter-test-helper.ts +++ b/javascript/packages/rewriter/test/helpers/rewriter-test-helper.ts @@ -4,15 +4,15 @@ import { Herb } from "@herb-tools/node-wasm" import { IdentityPrinter } from "@herb-tools/printer" import type { ASTRewriter, RewriteContext } from "../../src/index.js" -import type { ParseResult } from "@herb-tools/core" +import type { Node } from "@herb-tools/core" interface RewriterTestOptions { context?: RewriteContext } interface RewriterTestHelpers { - expectTransform: (input: string, expected: string, options?: RewriterTestOptions) => Promise - expectNoTransform: (input: string, options?: RewriterTestOptions) => Promise + expectTransform: (input: string, expected: string, options?: RewriterTestOptions) => Promise + expectNoTransform: (input: string, options?: RewriterTestOptions) => Promise } /** @@ -36,9 +36,8 @@ export function createRewriterTest( input: string, expected: string, options?: RewriterTestOptions - ): Promise => { + ): Promise => { const context = options?.context ?? { baseDir: process.cwd() } - const parseResult = Herb.parse(input, { track_whitespace: true }) if (parseResult.failed) { @@ -49,27 +48,18 @@ export function createRewriterTest( ) } - const rewritten = rewriter.rewrite(parseResult, context) - - if (rewritten.failed) { - throw new Error( - `Rewriter failed to process input.\n` + - `Input:\n${input}\n\n` + - `Errors:\n${rewritten.recursiveErrors().map(e => ` - ${e.message}`).join('\n')}` - ) - } - - const output = IdentityPrinter.print(rewritten.value) + const node = rewriter.rewrite(parseResult.value, context) + const output = IdentityPrinter.print(node) expect(output).toBe(expected) - return rewritten + return node } const expectNoTransform = async ( input: string, options?: RewriterTestOptions - ): Promise => { + ): Promise => { return await expectTransform(input, input, options) } diff --git a/javascript/packages/rewriter/test/rewrite.test.ts b/javascript/packages/rewriter/test/rewrite.test.ts new file mode 100644 index 000000000..d1b971c9a --- /dev/null +++ b/javascript/packages/rewriter/test/rewrite.test.ts @@ -0,0 +1,185 @@ +import dedent from "dedent" +import { describe, test, expect, beforeAll } from "vitest" + +import { Herb } from "@herb-tools/node-wasm" +import { rewrite, rewriteString, tailwindClassSorter } from "../src/loader.js" + +describe("rewrite", () => { + beforeAll(async () => { + await Herb.load() + }) + + describe("basic usage", () => { + test("rewrites Node with tailwindClassSorter", async () => { + const template = '
' + const parseResult = Herb.parse(template, { track_whitespace: true }) + const { output, node } = await rewrite(parseResult.value, [tailwindClassSorter()]) + + expect(output).toBe('
') + expect(node.type).toBe("AST_DOCUMENT_NODE") + }) + + test("handles empty rewriters array", async () => { + const template = '
' + const parseResult = Herb.parse(template, { track_whitespace: true }) + const { output } = await rewrite(parseResult.value, []) + + expect(output).toBe(template) + }) + + test("handles multiple elements", async () => { + const template = dedent` +
+ +
+ ` + const expected = dedent` +
+ +
+ ` + + const parseResult = Herb.parse(template, { track_whitespace: true }) + const { output } = await rewrite(parseResult.value, [tailwindClassSorter()]) + + expect(output).toBe(expected) + }) + }) + + describe("rewriteString", () => { + test("rewrites string with tailwindClassSorter", async () => { + const template = '
' + const { output, node } = await rewriteString(Herb, template, [tailwindClassSorter()]) + + expect(output).toBe('
') + expect(node.type).toBe("AST_DOCUMENT_NODE") + }) + + test("handles empty rewriters array", async () => { + const template = '
' + const { output, node } = await rewriteString(Herb, template, []) + + expect(output).toBe(template) + expect(node.type).toBe("AST_DOCUMENT_NODE") + }) + + test("returns original template on parse failure", async () => { + const template = '
{ + test("accepts baseDir option", async () => { + const template = '
' + const parseResult = Herb.parse(template, { track_whitespace: true }) + const { output } = await rewrite(parseResult.value, [tailwindClassSorter()], { + baseDir: "/custom/path" + }) + + expect(output).toBe('
') + }) + + test("accepts filePath option", async () => { + const template = '
' + const parseResult = Herb.parse(template, { track_whitespace: true }) + const { output } = await rewrite(parseResult.value, [tailwindClassSorter()], { + filePath: "/custom/path/file.html.erb" + }) + + expect(output).toBe('
') + }) + + test("uses process.cwd() as default baseDir", async () => { + const template = '
' + const parseResult = Herb.parse(template, { track_whitespace: true }) + const { output } = await rewrite(parseResult.value, [tailwindClassSorter()]) + + expect(output).toBe('
') + }) + }) + + describe("complex templates", () => { + test("handles ERB expressions", async () => { + const template = '
' + const { output } = await rewriteString(Herb, template, [tailwindClassSorter()]) + + expect(output).toBe('
') + }) + + test("handles ERB conditionals", async () => { + const template = '
' + const { output } = await rewriteString(Herb, template, [tailwindClassSorter()]) + + expect(output).toBe('
') + }) + + test("handles multiline templates", async () => { + const template = dedent` +
+
+ ` + const expected = dedent` +
+
+ ` + + const { output } = await rewriteString(Herb, template, [tailwindClassSorter()]) + + expect(output).toBe(expected) + }) + }) + + describe("error handling", () => { + test("continues processing on rewriter error", async () => { + const template = '
' + + const errorRewriter = tailwindClassSorter() + errorRewriter.rewrite.bind(errorRewriter) + + errorRewriter.rewrite = () => { + throw new Error("Test error") + } + + const parseResult = Herb.parse(template, { track_whitespace: true }) + const { output } = await rewrite(parseResult.value, [errorRewriter]) + + expect(output).toBe(template) + }) + }) + + describe("tailwindClassSorter factory", () => { + test("creates rewriter with default options", () => { + const rewriter = tailwindClassSorter() + + expect(rewriter.name).toBe("tailwind-class-sorter") + }) + + test("creates rewriter with custom baseDir", () => { + const rewriter = tailwindClassSorter({ baseDir: "/custom/path" }) + + expect(rewriter.name).toBe("tailwind-class-sorter") + }) + + test("rewriter has initialize method", async () => { + const rewriter = tailwindClassSorter() + + await expect( + rewriter.initialize({ baseDir: process.cwd() }) + ).resolves.not.toThrow() + }) + }) +}) From 58bfdb4b9122887094859dcb335bbf5ac9ee4973 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Thu, 6 Nov 2025 02:45:25 +0100 Subject: [PATCH 2/3] Update docs --- javascript/packages/rewriter/README.md | 178 +++++++++++++----- javascript/packages/rewriter/src/rewrite.ts | 26 ++- .../rewriter/src/rewriter-factories.ts | 3 +- .../packages/rewriter/test/rewrite.test.ts | 9 +- 4 files changed, 153 insertions(+), 63 deletions(-) diff --git a/javascript/packages/rewriter/README.md b/javascript/packages/rewriter/README.md index ae03e9683..d181b04b2 100644 --- a/javascript/packages/rewriter/README.md +++ b/javascript/packages/rewriter/README.md @@ -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 @@ -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 = `
` +const parseResult = Herb.parse(template, { track_whitespace: true }) + +const { output, node } = await rewrite(parseResult.value, [tailwindClassSorter()]) +// output: "
" +// 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 = `
` + +const output = await rewriteString(Herb, template, [tailwindClassSorter()]) +// output: "
" +``` + +**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 @@ -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 = `
` +const output = await rewriteString(Herb, template, [tailwindClassSorter()]) +// output: "
" ``` **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:** @@ -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() { @@ -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) } } ``` @@ -168,13 +204,63 @@ 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( + node: T, + rewriters: Rewriter[], + options?: RewriteOptions +): Promise +``` + +**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. + +```typescript +async function rewriteString( + herb: HerbBackend, + template: string, + rewriters: Rewriter[], + options?: RewriteOptions +): Promise +``` + +**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:** Object with: +- `output`: The rewritten template string +- `node`: The transformed AST node + +### 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 @@ -184,11 +270,11 @@ class MyRewriter extends ASTRewriter { // Optional initialization } - abstract rewrite(parseResult: ParseResult, context: RewriteContext): ParseResult + abstract rewrite(node: T, context: RewriteContext): T } ``` -### `StringRewriter` +#### `StringRewriter` Base class for rewriters that transform strings: diff --git a/javascript/packages/rewriter/src/rewrite.ts b/javascript/packages/rewriter/src/rewrite.ts index df7c13bb0..4156ec345 100644 --- a/javascript/packages/rewriter/src/rewrite.ts +++ b/javascript/packages/rewriter/src/rewrite.ts @@ -42,7 +42,8 @@ export interface RewriteResult { * @example * ```typescript * import { Herb } from '@herb-tools/node-wasm' - * import { rewrite, tailwindClassSorter } from '@herb-tools/rewriter' + * import { rewrite } from '@herb-tools/rewriter' + * import { tailwindClassSorter } from '@herb-tools/rewriter/loader' * * await Herb.load() * @@ -102,29 +103,34 @@ export async function rewrite(node: T, rewriters: Rewriter[], op * @example * ```typescript * import { Herb } from '@herb-tools/node-wasm' - * import { rewriteString, tailwindClassSorter } from '@herb-tools/rewriter' + * import { rewriteString } from '@herb-tools/rewriter' + * import { tailwindClassSorter } from '@herb-tools/rewriter/loader' * * await Herb.load() * * const template = '
' - * const { output, node } = await rewriteString(Herb, template, [tailwindClassSorter()]) + * const output = await rewriteString(Herb, template, [tailwindClassSorter()]) + * // output: '
' * ``` * * @param herb - The Herb backend instance for parsing * @param template - The HTML+ERB template string to rewrite * @param rewriters - Array of rewriter instances to apply * @param options - Optional configuration for the rewrite operation - * @returns Object containing the rewritten string and Node + * @returns The rewritten template string */ -export async function rewriteString(herb: HerbBackend, template: string, rewriters: Rewriter[], options: RewriteOptions = {}): Promise { +export async function rewriteString(herb: HerbBackend, template: string, rewriters: Rewriter[], options: RewriteOptions = {}): Promise { const parseResult = herb.parse(template, { track_whitespace: true }) if (parseResult.failed) { - return { - output: template, - node: parseResult.value - } + return template } - return await rewrite(parseResult.value, rewriters, options) + const { output } = await rewrite( + parseResult.value, + rewriters, + options + ) + + return output } diff --git a/javascript/packages/rewriter/src/rewriter-factories.ts b/javascript/packages/rewriter/src/rewriter-factories.ts index 9c6cc8fe4..f299d5264 100644 --- a/javascript/packages/rewriter/src/rewriter-factories.ts +++ b/javascript/packages/rewriter/src/rewriter-factories.ts @@ -13,7 +13,8 @@ export interface TailwindClassSorterOptions { * * @example * ```typescript - * import { rewrite, tailwindClassSorter } from '@herb-tools/rewriter' + * import { rewrite } from '@herb-tools/rewriter' + * import { tailwindClassSorter } from '@herb-tools/rewriter/loader' * * const template = '
' * const result = await rewrite(template, [tailwindClassSorter()]) diff --git a/javascript/packages/rewriter/test/rewrite.test.ts b/javascript/packages/rewriter/test/rewrite.test.ts index d1b971c9a..876bbf172 100644 --- a/javascript/packages/rewriter/test/rewrite.test.ts +++ b/javascript/packages/rewriter/test/rewrite.test.ts @@ -49,26 +49,23 @@ describe("rewrite", () => { describe("rewriteString", () => { test("rewrites string with tailwindClassSorter", async () => { const template = '
' - const { output, node } = await rewriteString(Herb, template, [tailwindClassSorter()]) + const output = await rewriteString(Herb, template, [tailwindClassSorter()]) expect(output).toBe('
') - expect(node.type).toBe("AST_DOCUMENT_NODE") }) test("handles empty rewriters array", async () => { const template = '
' - const { output, node } = await rewriteString(Herb, template, []) + const output = await rewriteString(Herb, template, []) expect(output).toBe(template) - expect(node.type).toBe("AST_DOCUMENT_NODE") }) test("returns original template on parse failure", async () => { const template = '
Date: Thu, 6 Nov 2025 02:59:54 +0100 Subject: [PATCH 3/3] Fix rewriteString signature --- javascript/packages/rewriter/README.md | 8 +++----- javascript/packages/rewriter/test/rewrite.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/javascript/packages/rewriter/README.md b/javascript/packages/rewriter/README.md index d181b04b2..587fdd0fe 100644 --- a/javascript/packages/rewriter/README.md +++ b/javascript/packages/rewriter/README.md @@ -231,7 +231,7 @@ async function rewrite( #### `rewriteString()` -Convenience wrapper around `rewrite()` that parses the template string first. +Convenience wrapper around `rewrite()` that parses the template string first and returns just the output string. ```typescript async function rewriteString( @@ -239,7 +239,7 @@ async function rewriteString( template: string, rewriters: Rewriter[], options?: RewriteOptions -): Promise +): Promise ``` **Parameters:** @@ -248,9 +248,7 @@ async function rewriteString( - `rewriters`: Array of rewriter instances to apply - `options`: Optional configuration (same as `rewrite()`) -**Returns:** Object with: -- `output`: The rewritten template string -- `node`: The transformed AST node +**Returns:** The rewritten template string ### Base Classes diff --git a/javascript/packages/rewriter/test/rewrite.test.ts b/javascript/packages/rewriter/test/rewrite.test.ts index 876bbf172..2dcc4f38a 100644 --- a/javascript/packages/rewriter/test/rewrite.test.ts +++ b/javascript/packages/rewriter/test/rewrite.test.ts @@ -102,14 +102,14 @@ describe("rewrite", () => { describe("complex templates", () => { test("handles ERB expressions", async () => { const template = '
' - const { output } = await rewriteString(Herb, template, [tailwindClassSorter()]) + const output = await rewriteString(Herb, template, [tailwindClassSorter()]) expect(output).toBe('
') }) test("handles ERB conditionals", async () => { const template = '
' - const { output } = await rewriteString(Herb, template, [tailwindClassSorter()]) + const output = await rewriteString(Herb, template, [tailwindClassSorter()]) expect(output).toBe('
') }) @@ -134,7 +134,7 @@ describe("rewrite", () => {
` - const { output } = await rewriteString(Herb, template, [tailwindClassSorter()]) + const output = await rewriteString(Herb, template, [tailwindClassSorter()]) expect(output).toBe(expected) })