diff --git a/javascript/packages/minifier/README.md b/javascript/packages/minifier/README.md
index 8e2b4ceef..c947ac609 100644
--- a/javascript/packages/minifier/README.md
+++ b/javascript/packages/minifier/README.md
@@ -1,10 +1,10 @@
-# Herb Minifier
` and `` tags.
## Installation
@@ -26,14 +26,139 @@ bun add @herb-tools/minifier
```
:::
-
+## Usage
-
+### Basic Usage
-
+```typescript
+import { Herb } from '@herb-tools/node-wasm'
+import { Minifier } from '@herb-tools/minifier'
-
+// Create and initialize minifier
+const minifier = new Minifier(Herb)
+await minifier.initialize()
-
+const template = `
+
+ Hello World
+ This is a test
+
+`
-
+const minified = minifier.minifyString(template)
+// Result: 'Hello World
This is a test
'
+```
+
+### Minifying AST Nodes
+
+You can also minify AST nodes directly:
+
+```typescript
+import { Herb } from '@herb-tools/node-wasm'
+import { Minifier } from '@herb-tools/minifier'
+
+const minifier = new Minifier(Herb)
+await minifier.initialize()
+
+const parseResult = Herb.parse(template, { track_whitespace: true })
+const minifiedNode = minifier.minify(parseResult.value)
+```
+
+### Whitespace Preservation
+
+The minifier preserves whitespace in `` and `` tags:
+
+```typescript
+const template = `
+
+
+ Line 1
+ Line 2
+ Line 3
+
+
+`
+
+const minified = minifier.minifyString(template)
+// Result: '\n Line 1\n Line 2\n Line 3\n
'
+```
+
+### ERB Support
+
+The minifier works seamlessly with ERB templates:
+
+```typescript
+const template = `
+
+ <% if admin? %>
+ Admin
+ <% else %>
+ User
+ <% end %>
+
+`
+
+const minified = minifier.minifyString(template)
+// Result: '<%if admin?%>Admin<%else%>User<%end%>'
+```
+
+## API Reference
+
+### `Minifier`
+
+The main minifier class.
+
+#### Constructor
+
+```typescript
+new Minifier(herb: HerbBackend)
+```
+
+**Parameters:**
+- `herb`: The Herb backend instance
+
+#### Methods
+
+##### `initialize()`
+
+```typescript
+async initialize(): Promise
+```
+
+Initializes the minifier by loading the Herb backend. Must be called before using minification methods.
+
+##### `minifyString()`
+
+```typescript
+minifyString(template: string): string
+```
+
+Minifies an HTML+ERB template string.
+
+**Parameters:**
+- `template`: The template string to minify
+
+**Returns:** The minified template string
+
+##### `minify()`
+
+```typescript
+minify(node: T): T
+```
+
+Minifies an HTML+ERB AST node.
+
+**Parameters:**
+- `node`: The AST node to minify
+
+**Returns:** The minified AST node
+
+## Features
+
+- ✅ Removes non-significant whitespace
+- ✅ Preserves whitespace in `` and `` tags
+- ✅ Supports nested preserve-whitespace tags
+- ✅ Works with ERB templates
+- ✅ Preserves HTML attributes
+- ✅ Handles self-closing tags
+- ✅ Gracefully handles parse errors (returns original template)
diff --git a/javascript/packages/minifier/package.json b/javascript/packages/minifier/package.json
new file mode 100644
index 000000000..96f7c3468
--- /dev/null
+++ b/javascript/packages/minifier/package.json
@@ -0,0 +1,45 @@
+{
+ "name": "@herb-tools/minifier",
+ "version": "0.7.5",
+ "description": "HTML+ERB template minification",
+ "license": "MIT",
+ "homepage": "https://herb-tools.dev",
+ "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/minifier%60:%20",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/marcoroth/herb.git",
+ "directory": "javascript/packages/minifier"
+ },
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.esm.js",
+ "require": "./dist/index.cjs",
+ "types": "./dist/types/index.d.ts",
+ "scripts": {
+ "build": "yarn clean && rollup -c rollup.config.mjs",
+ "dev": "rollup -c rollup.config.mjs -w",
+ "clean": "rimraf dist",
+ "test": "vitest run",
+ "test:watch": "vitest --watch",
+ "prepublishOnly": "yarn clean && yarn build && yarn test"
+ },
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/index.esm.js",
+ "require": "./dist/index.cjs",
+ "default": "./dist/index.esm.js"
+ }
+ },
+ "dependencies": {
+ "@herb-tools/core": "0.7.5",
+ "@herb-tools/printer": "0.7.5",
+ "@herb-tools/rewriter": "0.7.5"
+ },
+ "files": [
+ "package.json",
+ "README.md",
+ "dist/",
+ "src/"
+ ]
+}
diff --git a/javascript/packages/minifier/project.json b/javascript/packages/minifier/project.json
new file mode 100644
index 000000000..d5c974146
--- /dev/null
+++ b/javascript/packages/minifier/project.json
@@ -0,0 +1,32 @@
+{
+ "name": "@herb-tools/minifier",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "javascript/packages/minifier/src",
+ "projectType": "library",
+ "targets": {
+ "build": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "build"
+ },
+ "dependsOn": [
+ "@herb-tools/core:build",
+ "@herb-tools/printer:build",
+ "@herb-tools/rewriter:build"
+ ]
+ },
+ "test": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "test"
+ }
+ },
+ "clean": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "clean"
+ }
+ }
+ },
+ "tags": []
+}
diff --git a/javascript/packages/minifier/rollup.config.mjs b/javascript/packages/minifier/rollup.config.mjs
new file mode 100644
index 000000000..016a332e1
--- /dev/null
+++ b/javascript/packages/minifier/rollup.config.mjs
@@ -0,0 +1,61 @@
+import typescript from "@rollup/plugin-typescript"
+import { nodeResolve } from "@rollup/plugin-node-resolve"
+import commonjs from "@rollup/plugin-commonjs"
+import json from "@rollup/plugin-json"
+
+const external = [
+ "path",
+ "url",
+ "fs",
+ "module",
+ "@herb-tools/tailwind-class-sorter"
+]
+
+function isExternal(id) {
+ return (
+ external.includes(id) ||
+ external.some((pkg) => id === pkg || id.startsWith(pkg + "/"))
+ )
+}
+
+export default [
+ // Browser-compatible entry point (core APIs only)
+ {
+ input: "src/index.ts",
+ output: {
+ file: "dist/index.esm.js",
+ format: "esm",
+ sourcemap: true,
+ },
+ external: [],
+ plugins: [
+ nodeResolve({ preferBuiltins: true }),
+ commonjs(),
+ json(),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ declaration: true,
+ declarationDir: "./dist/types",
+ rootDir: "src/",
+ }),
+ ],
+ },
+ {
+ input: "src/index.ts",
+ output: {
+ file: "dist/index.cjs",
+ format: "cjs",
+ sourcemap: true,
+ },
+ external: [],
+ plugins: [
+ nodeResolve({ preferBuiltins: true }),
+ commonjs(),
+ json(),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ rootDir: "src/",
+ }),
+ ],
+ }
+]
diff --git a/javascript/packages/minifier/src/index.ts b/javascript/packages/minifier/src/index.ts
new file mode 100644
index 000000000..aab8abdbb
--- /dev/null
+++ b/javascript/packages/minifier/src/index.ts
@@ -0,0 +1 @@
+export { Minifier, minify } from "./minifier.js"
diff --git a/javascript/packages/minifier/src/minifier-rewriter.ts b/javascript/packages/minifier/src/minifier-rewriter.ts
new file mode 100644
index 000000000..e8ebb061b
--- /dev/null
+++ b/javascript/packages/minifier/src/minifier-rewriter.ts
@@ -0,0 +1,23 @@
+import { ASTRewriter } from "@herb-tools/rewriter"
+import { MinifierVisitor } from "./minifier-visitor.js"
+
+import type { RewriteContext } from "@herb-tools/rewriter"
+import type { Node } from "@herb-tools/core"
+
+export class MinifyRewriter extends ASTRewriter {
+ get name() {
+ return "Minifier"
+ }
+
+ get description() {
+ return "Minifies HTML+ERB documents by removing non-significant whitespace."
+ }
+
+ rewrite(node: T, _context?: RewriteContext): T {
+ const visitor = new MinifierVisitor()
+
+ node.accept(visitor)
+
+ return node
+ }
+}
diff --git a/javascript/packages/minifier/src/minifier-visitor.ts b/javascript/packages/minifier/src/minifier-visitor.ts
new file mode 100644
index 000000000..762a24cac
--- /dev/null
+++ b/javascript/packages/minifier/src/minifier-visitor.ts
@@ -0,0 +1,459 @@
+import { Visitor } from "@herb-tools/core"
+import { asMutable } from "@herb-tools/rewriter"
+
+import type {
+ LiteralNode,
+ HTMLElementNode,
+ HTMLTextNode,
+ Node,
+ WhitespaceNode,
+ HTMLAttributeNode,
+ HTMLAttributeValueNode,
+ HTMLOpenTagNode,
+ ERBContentNode,
+ ERBIfNode,
+ ERBEndNode,
+ ERBElseNode,
+} from "@herb-tools/core"
+
+import {
+ isHTMLTextNode,
+ isERBContentNode,
+ isLiteralNode,
+ isWhitespaceNode,
+ isERBIfNode,
+ isHTMLAttributeNode,
+ isERBNode,
+} from "@herb-tools/core"
+
+/**
+ * Visitor that minifies HTML+ERB documents by removing non-significant whitespace
+ */
+export class MinifierVisitor extends Visitor {
+ private preserveWhitespaceDepth = 0
+ private preserveWhitespaceTags = new Set(["pre", "code"])
+ private currentParent: HTMLElementNode | null = null
+ private currentAttributeName: string | null = null
+ private currentOpenTag: HTMLOpenTagNode | null = null
+ private currentAttributeValue: HTMLAttributeValueNode | null = null
+ private currentERBIf: ERBIfNode | null = null
+
+ private shouldPreserveWhitespace(): boolean {
+ return this.preserveWhitespaceDepth > 0
+ }
+
+ private isPreserveWhitespaceTag(tagName: string): boolean {
+ return this.preserveWhitespaceTags.has(tagName.toLowerCase())
+ }
+
+ private isFirstChild(node: Node): boolean {
+ if (!this.currentParent?.body) return true
+ return this.currentParent.body[0] === node
+ }
+
+ private isLastChild(node: Node): boolean {
+ if (!this.currentParent?.body) return true
+ const body = this.currentParent.body
+
+ return body[body.length - 1] === node
+ }
+
+ private hasAdjacentInlineContent(node: Node): { before: boolean; after: boolean } {
+ if (!this.currentParent?.body) return { before: false, after: false }
+
+ const body = this.currentParent.body
+ const index = body.indexOf(node)
+
+ if (index === -1) return { before: false, after: false }
+
+ const isInlineNode = (node: Node | undefined): boolean => {
+ if (!node) return false
+
+ return isHTMLTextNode(node) || isERBContentNode(node) || isLiteralNode(node)
+ }
+
+ const before = index > 0 && isInlineNode(body[index - 1])
+ const after = index < body.length - 1 && isInlineNode(body[index + 1])
+
+ return { before, after }
+ }
+
+ private minifyWhitespace(content: string, node: Node): string {
+ let minified = content.replace(/\s+/g, " ")
+
+ const isFirst = this.isFirstChild(node)
+ const isLast = this.isLastChild(node)
+ const { before, after } = this.hasAdjacentInlineContent(node)
+
+ if (minified === " " && !before && !after) {
+ return ""
+ }
+
+ if (isFirst || !before) {
+ minified = minified.replace(/^\s+/, "")
+ }
+
+ if (isLast || !after) {
+ minified = minified.replace(/\s+$/, "")
+ }
+
+ return minified
+ }
+
+ visitHTMLElementNode(node: HTMLElementNode): void {
+ const tagName = node.tag_name?.value || ""
+ const shouldPreserve = this.isPreserveWhitespaceTag(tagName)
+
+ if (shouldPreserve) {
+ this.preserveWhitespaceDepth++
+ }
+
+ const previousParent = this.currentParent
+ this.currentParent = node
+
+ super.visitHTMLElementNode(node)
+
+ this.currentParent = previousParent
+
+ if (shouldPreserve) {
+ this.preserveWhitespaceDepth--
+ }
+ }
+
+ visitHTMLTextNode(node: HTMLTextNode): void {
+ if (!this.shouldPreserveWhitespace() && node.content) {
+ const minified = this.minifyWhitespace(node.content, node)
+ asMutable(node).content = minified
+ }
+
+ super.visitHTMLTextNode(node)
+ }
+
+ visitLiteralNode(node: LiteralNode): void {
+ if (!this.shouldPreserveWhitespace() && node.content) {
+ if (this.currentAttributeName === "class" && this.currentAttributeValue) {
+ let minified = node.content.replace(/\s+/g, " ")
+
+ const children = this.currentAttributeValue.children
+
+ if (children) {
+ const index = children.indexOf(node)
+
+ if (index !== -1) {
+ const hasERBBefore = index > 0 && isERBContentNode(children[index - 1])
+ const hasERBAfter = index < children.length - 1 && isERBContentNode(children[index + 1])
+
+ if (!hasERBBefore) {
+ minified = minified.replace(/^\s+/, "")
+ }
+
+ if (!hasERBAfter) {
+ minified = minified.replace(/\s+$/, "")
+ }
+ }
+ } else {
+ minified = minified.trim()
+ }
+
+ asMutable(node).content = minified
+ } else if (!this.currentAttributeName) {
+ const minified = this.minifyWhitespace(node.content, node)
+ asMutable(node).content = minified
+ }
+ }
+
+ super.visitLiteralNode(node)
+ }
+
+ visitWhitespaceNode(node: WhitespaceNode): void {
+ if (node.value?.value) {
+ const token = node.value
+ const originalValue = token.value
+
+ const context = this.currentERBIf || this.currentOpenTag
+ let children: Node[] | undefined
+
+ if (this.currentERBIf) {
+ children = this.currentERBIf.statements
+ } else if (this.currentOpenTag) {
+ children = this.currentOpenTag.children
+ }
+
+
+ if (context && children) {
+ if (this.currentOpenTag && children[children.length - 1] === node) {
+ const tagClosing = this.currentOpenTag.tag_closing?.value || ""
+ const isSelfClosing = tagClosing.includes("/")
+
+ if (isSelfClosing && originalValue === " ") {
+ asMutable(token).value = " "
+ } else {
+ asMutable(token).value = ""
+ }
+
+ super.visitWhitespaceNode(node)
+ return
+ }
+
+ const index = children.indexOf(node)
+
+ if (index >= 0) {
+ const prevNode = index > 0 ? children[index - 1] : null
+ const nextNode = index < children.length - 1 ? children[index + 1] : null
+
+ if (this.currentERBIf) {
+ let firstNonWhitespaceIndex = 0
+
+ while (firstNonWhitespaceIndex < children.length && isWhitespaceNode(children[firstNonWhitespaceIndex])) {
+ firstNonWhitespaceIndex++
+ }
+
+ let lastNonWhitespaceIndex = children.length - 1
+
+ while (lastNonWhitespaceIndex >= 0 && isWhitespaceNode(children[lastNonWhitespaceIndex])) {
+ lastNonWhitespaceIndex--
+ }
+
+ if (index < firstNonWhitespaceIndex) {
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+
+ if (index > lastNonWhitespaceIndex) {
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+ }
+
+ if (this.currentOpenTag) {
+ if (isERBIfNode(prevNode)) {
+ let nextNonWhitespace: Node | null = nextNode
+ let searchIndex = index + 1
+
+ while (isWhitespaceNode(nextNonWhitespace) && searchIndex < children.length - 1) {
+ searchIndex++
+ nextNonWhitespace = children[searchIndex]
+ }
+
+ if (isHTMLAttributeNode(nextNonWhitespace)) {
+ asMutable(token).value = " "
+ super.visitWhitespaceNode(node)
+
+ return
+ } else {
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+ }
+
+ if (isERBIfNode(nextNode)) {
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+
+ if (isWhitespaceNode(prevNode)) {
+ let searchIndex = index - 1
+
+ while (searchIndex >= 0 && isWhitespaceNode(children[searchIndex])) {
+ searchIndex--
+ }
+
+ if (searchIndex >= 0 && isERBIfNode(children[searchIndex])) {
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+
+ if (isERBNode(prevNode) && isHTMLAttributeNode(nextNode)) {
+ asMutable(token).value = " "
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+
+ if (isERBNode(prevNode)) {
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+
+ if (isERBNode(nextNode)) {
+ asMutable(token).value = ""
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+ }
+ }
+
+ asMutable(token).value = " "
+ super.visitWhitespaceNode(node)
+
+ return
+ }
+
+ asMutable(token).value = " "
+ }
+
+ super.visitWhitespaceNode(node)
+ }
+
+ visitHTMLAttributeNode(node: HTMLAttributeNode): void {
+ const nameNode = node.name?.children?.[0] as LiteralNode | undefined
+ const previousAttributeName = this.currentAttributeName
+
+ if (nameNode?.content) {
+ this.currentAttributeName = nameNode.content
+ }
+
+ if (node.equals) {
+ const trimmed = node.equals.value.trim()
+
+ if (trimmed !== node.equals.value) {
+ asMutable(node.equals).value = trimmed
+ }
+ }
+
+ super.visitHTMLAttributeNode(node)
+
+ this.currentAttributeName = previousAttributeName
+ }
+
+ visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void {
+ const previousAttributeValue = this.currentAttributeValue
+ this.currentAttributeValue = node
+
+ super.visitHTMLAttributeValueNode(node)
+
+ this.currentAttributeValue = previousAttributeValue
+ }
+
+ visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
+ const previousOpenTag = this.currentOpenTag
+ this.currentOpenTag = node
+
+ super.visitHTMLOpenTagNode(node)
+
+ this.currentOpenTag = previousOpenTag
+ }
+
+ visitERBIfNode(node: ERBIfNode): void {
+ if (node.content) {
+ const trimmed = node.content.value.trim()
+
+ if (trimmed !== node.content.value) {
+ asMutable(node.content).value = trimmed
+ }
+ }
+
+ const previousERBIf = this.currentERBIf
+ this.currentERBIf = node
+
+ super.visitERBIfNode(node)
+
+ this.currentERBIf = previousERBIf
+ }
+
+ visitERBElseNode(node: ERBElseNode): void {
+ if (node.content) {
+ const trimmed = node.content.value.trim()
+
+ if (trimmed !== node.content.value) {
+ asMutable(node.content).value = trimmed
+ }
+ }
+
+ super.visitERBElseNode(node)
+ }
+
+ visitERBContentNode(node: ERBContentNode): void {
+ if (node.content) {
+ const inAttributeValue = !!this.currentAttributeValue
+ const inERBIf = !!this.currentERBIf
+ const inMultiLineAttribute = inAttributeValue && this.isMultiLineAttribute()
+ const hasExcessiveSurroundingWhitespace = this.hasExcessiveWhitespaceAround(node)
+
+ if ((inAttributeValue && (inERBIf || inMultiLineAttribute)) || hasExcessiveSurroundingWhitespace) {
+ const trimmed = node.content.value.trim()
+
+ if (trimmed !== node.content.value) {
+ asMutable(node.content).value = trimmed
+ }
+ }
+ }
+
+ super.visitERBContentNode(node)
+ }
+
+ private isMultiLineAttribute(): boolean {
+ if (!this.currentAttributeValue) return false
+
+ const checkForNewlines = (nodes: Node[] | undefined): boolean => {
+ if (!nodes) return false
+
+ return nodes.some(node => {
+ if (isLiteralNode(node)) {
+ const literal = node as LiteralNode
+
+ return !!(literal.content && literal.content.includes('\n'))
+ }
+
+ return false
+ })
+ }
+
+ return checkForNewlines(this.currentAttributeValue.children)
+ }
+
+ private hasExcessiveWhitespaceAround(node: Node): boolean {
+ if (!this.currentParent?.body) return false
+
+ const body = this.currentParent.body
+ const index = body.indexOf(node)
+
+ if (index === -1) return false
+
+ const checkWhitespace = (node: Node | undefined): boolean => {
+ if (!node) return false
+
+ if (isHTMLTextNode(node)) {
+ return !!(node.content && node.content.match(/\s{2,}|\n/))
+ }
+
+ return false
+ }
+
+ const before = index > 0 && checkWhitespace(body[index - 1])
+ const after = index < body.length - 1 && checkWhitespace(body[index + 1])
+
+ return before || after
+ }
+
+ visitERBEndNode(node: ERBEndNode): void {
+ if (node.content) {
+ const trimmed = node.content.value.trim()
+
+ if (trimmed !== node.content.value) {
+ asMutable(node.content).value = trimmed
+ }
+ }
+
+ super.visitERBEndNode(node)
+ }
+}
diff --git a/javascript/packages/minifier/src/minifier.ts b/javascript/packages/minifier/src/minifier.ts
new file mode 100644
index 000000000..166115b7b
--- /dev/null
+++ b/javascript/packages/minifier/src/minifier.ts
@@ -0,0 +1,92 @@
+import { IdentityPrinter } from "@herb-tools/printer"
+import { MinifyRewriter } from "./minifier-rewriter.js"
+
+import type { HerbBackend, Node } from "@herb-tools/core"
+
+/**
+ * Minifier for HTML+ERB templates
+ *
+ * Removes non-significant whitespace while preserving:
+ * - Whitespace in and tags
+ * - Document structure
+ *
+ * @example
+ * ```typescript
+ * import { Herb } from '@herb-tools/node-wasm'
+ * import { Minifier } from '@herb-tools/minifier'
+ * import dedent from 'dedent'
+ *
+ * const minifier = new Minifier(Herb)
+ * await minifier.initialize()
+ *
+ * const template = dedent`
+ *
+ * Hello World
+ * This is a test
+ *
+ * `
+ *
+ * const minified = minifier.minifyString(template)
+ * // Result: 'Hello World
This is a test
'
+ * ```
+ */
+export class Minifier {
+ private rewriter: MinifyRewriter
+ private herb?: HerbBackend
+
+ constructor(herb?: HerbBackend) {
+ this.herb = herb
+ this.rewriter = new MinifyRewriter()
+ }
+
+ /**
+ * Initialize the minifier (loads Herb if needed)
+ */
+ async initialize(): Promise {
+ if (this.herb) {
+ await this.herb.load()
+ }
+ }
+
+ /**
+ * Minify an HTML+ERB template string
+ *
+ * @param template - The template string to minify
+ * @returns The minified template string
+ */
+ minifyString(template: string): string {
+ if (!this.herb) {
+ throw new Error("You need to pass a Herb Backend to new Minifier() and initialize the Minifier before calling minifyString()")
+ }
+
+ const parseResult = this.herb.parse(template, { track_whitespace: true })
+
+ if (parseResult.failed) {
+ return template
+ }
+
+ const node = this.rewriter.rewrite(parseResult.value)
+
+ return IdentityPrinter.print(node)
+ }
+
+ /**
+ * Minify an HTML+ERB AST node
+ *
+ * @param node - The AST node to minify
+ * @returns The minified AST node
+ */
+ minify(node: T): T {
+ return this.rewriter.rewrite(node)
+ }
+}
+
+export function minify(node: T): { node: T, output: string } {
+ const minifier = new Minifier()
+ const minified = minifier.minify(node)
+
+ return {
+ node: minified,
+ output: IdentityPrinter.print(minified)
+ }
+}
diff --git a/javascript/packages/minifier/test/minifier.test.ts b/javascript/packages/minifier/test/minifier.test.ts
new file mode 100644
index 000000000..6389ecb52
--- /dev/null
+++ b/javascript/packages/minifier/test/minifier.test.ts
@@ -0,0 +1,332 @@
+import dedent from "dedent"
+import { describe, test, expect, beforeAll } from "vitest"
+
+import { Herb } from "@herb-tools/node-wasm"
+import { IdentityPrinter } from "@herb-tools/printer"
+import { Minifier, minify } from "../src/index.js"
+
+describe("Minifier", () => {
+ let minifier: Minifier
+
+ beforeAll(async () => {
+ minifier = new Minifier(Herb)
+ await minifier.initialize()
+ })
+
+ describe("top-level minify function", () => {
+ test("minify", () => {
+ const template = ` Hello `
+ const parseResult = Herb.parse(template)
+
+ const { output, node } = minify(parseResult.value)
+
+ expect(output).toBe(`Hello`)
+ expect(IdentityPrinter.print(node)).toBe(`Hello`)
+ })
+ })
+
+ describe("basic minification", () => {
+ test("removes whitespace between tags", () => {
+ const template = ` Hello `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello`)
+ })
+
+ test("collapses multiple spaces to single space in text", () => {
+ const template = `Hello World`
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello World`)
+ })
+
+ test("removes newlines and indentation", () => {
+ const template = dedent`
+
+ Hello World
+ This is a test
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello World
This is a test
`)
+ })
+
+ test("handles nested elements", () => {
+ const template = dedent`
+
+
+ - Item 1
+ - Item 2
+ - Item 3
+
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`- Item 1
- Item 2
- Item 3
`)
+ })
+ })
+
+ describe("preserve whitespace in special tags", () => {
+ test("preserves whitespace in tags", () => {
+ const template = dedent`
+
+
+ Line 1
+ Line 2
+ Line 3
+
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(dedent`
+
+ Line 1
+ Line 2
+ Line 3
+
+ `)
+ })
+
+ test("preserves whitespace in tags", () => {
+ const template = dedent`
+
+ const x = 1
+ const y = 2
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(dedent`
+ const x = 1
+ const y = 2
+ `)
+ })
+
+ test("handles nested preserve-whitespace tags", () => {
+ const template = dedent`
+
+
+
+ function test() {
+ return true
+ }
+
+
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(dedent`
+
+
+ function test() {
+ return true
+ }
+
+
+ `)
+ })
+ })
+
+ describe("attributes", () => {
+ test("preserves attributes", () => {
+ const template = `Content`
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Content`)
+ })
+
+ test("handles self-closing tags", () => {
+ const template = `
`
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`
`)
+ })
+ })
+
+ describe("ERB support", () => {
+ test("handles ERB output tags", () => {
+ const template = dedent`
+
+ <%= user.name %>
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`<%=user.name%>`)
+ })
+
+ test("handles ERB conditionals", () => {
+ const template = dedent`
+
+ <% if admin? %>
+ Admin
+ <% else %>
+ User
+ <% end %>
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`<%if admin?%>Admin<%else%>User<%end%>`)
+ })
+
+ test("handles ERB output", () => {
+ const template = dedent`
+ Hello <%= world %>
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello <%= world %>`)
+ })
+
+ test("handles ERB output-2", () => {
+ const template = dedent`
+ Hello <%= world %>
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello <%=world%>`)
+ })
+
+ test("handles ERB output-2", () => {
+ const template = dedent`
+ Hello <%= world %> !
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello <%=world%> !`)
+ })
+ })
+
+ describe("HTML attributes", () => {
+ test("class", () => {
+ const template = ``
+
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(``)
+ })
+ test("class with erb", () => {
+ const template = ``
+
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(``)
+ })
+
+ test("non-class", () => {
+ const template = ``
+
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(``)
+ })
+
+ test("with newlines", () => {
+ const template = dedent`
+
+
+ Hello <%= world %> !
+
+
+ `
+
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello <%=world%> !`)
+ })
+
+ test("with spaces around =", () => {
+ const template = dedent`
+
+ `
+
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(``)
+ })
+
+ test("attribute wrapped in if", () => {
+ const template = dedent`
+
+
+ class="
+ one <%= two %> three
+ "
+
+
+ <% end %>
+
+ disabled
+ >
+
+ Hello <%= world %> !
+
+
+ `
+
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`class="one <%=two%> three"<%end%> disabled>Hello <%=world%> !`)
+ })
+
+ test("two attributes wrapped in if", () => {
+ const template = dedent`
+
+
+ class="
+ one <%= two %> three
+ "
+
+ id="one"
+ <% end %>
+
+ disabled
+ >
+
+ Hello <%= world %> !
+
+
+ `
+
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`class="one <%=two%> three" id="one"<%end%> disabled>Hello <%=world%> !`)
+ })
+ })
+
+ describe("error handling", () => {
+ test("returns original template on parse failure", () => {
+ const template = ` {
+ test("minifies using instance method", () => {
+ const template = dedent`
+
+ Hello World
+
+ `
+ const result = minifier.minifyString(template)
+
+ expect(result).toBe(`Hello World
`)
+ })
+ })
+})
diff --git a/javascript/packages/minifier/tsconfig.json b/javascript/packages/minifier/tsconfig.json
new file mode 100644
index 000000000..b7ae169dd
--- /dev/null
+++ b/javascript/packages/minifier/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ES2022"],
+ "outDir": "./dist",
+ "declaration": true,
+ "declarationDir": "./dist/types",
+ "esModuleInterop": true,
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "moduleDetection": "force",
+ "sourceMap": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/playground/index.html b/playground/index.html
index f976d6d6d..dc815de5b 100644
--- a/playground/index.html
+++ b/playground/index.html
@@ -340,6 +340,16 @@
Format
+
+
+
+
resolve((new IdentityPrinter()).print(parseResult.value, printerOptions))),
)
+ const minifier = new Minifier(herb)
+ await minifier.initialize()
+
+ const minified = await safeExecute(
+ new Promise((resolve) => resolve(minifier.minifyString(source))),
+ )
+
let lintResult: LintResult | null = null
if (parseResult && parseResult.value) {
@@ -82,6 +90,7 @@ export async function analyze(herb: HerbBackend, source: string, options: Parser
html,
formatted,
printed,
+ minified,
version,
lintResult,
duration: endTime - startTime,
diff --git a/playground/src/controllers/playground_controller.js b/playground/src/controllers/playground_controller.js
index a768c621f..7387bfd7f 100644
--- a/playground/src/controllers/playground_controller.js
+++ b/playground/src/controllers/playground_controller.js
@@ -64,6 +64,7 @@ export default class extends Controller {
"formatTooltip",
"autofixButton",
"autofixTooltip",
+ "minifyViewer",
"printerViewer",
"printerOutput",
"printerVerification",
@@ -312,6 +313,9 @@ export default class extends Controller {
content = blurredPre ? blurredPre.textContent : ''
}
break
+ case 'minify':
+ content = this.minifyViewerTarget.textContent
+ break
case 'printer':
content = this.printerOutputTarget.textContent
break
@@ -439,7 +443,7 @@ export default class extends Controller {
}
isValidTab(tab) {
- const validTabs = ['parse', 'lex', 'ruby', 'html', 'format', 'printer', 'diagnostics', 'full']
+ const validTabs = ['parse', 'lex', 'ruby', 'html', 'format', 'minify', 'printer', 'diagnostics', 'full']
return validTabs.includes(tab)
}
@@ -905,6 +909,13 @@ export default class extends Controller {
Prism.highlightElement(this.lexViewerTarget)
}
+ if (this.hasMinifyViewerTarget) {
+ this.minifyViewerTarget.classList.add("language-html")
+ this.minifyViewerTarget.textContent = result.minified
+
+ Prism.highlightElement(this.minifyViewerTarget)
+ }
+
if (this.hasPrinterViewerTarget) {
const printedContent = result.printed || 'No printed output available'
diff --git a/templates/javascript/packages/core/src/node-type-guards.ts.erb b/templates/javascript/packages/core/src/node-type-guards.ts.erb
index afa6cae24..5ea9997b5 100644
--- a/templates/javascript/packages/core/src/node-type-guards.ts.erb
+++ b/templates/javascript/packages/core/src/node-type-guards.ts.erb
@@ -20,7 +20,9 @@ import {
/**
* Checks if a node is a <%= node.name %>
*/
-export function is<%= node.name %>(node: Node): node is <%= node.name %> {
+export function is<%= node.name %>(node: Node | null | undefined): node is <%= node.name %> {
+ if (!node) return false
+
return node instanceof <%= node.name %> || node.type === "<%= node.type %>" || (node.constructor as any).type === "<%= node.type %>"
}
@@ -32,7 +34,9 @@ export function is<%= node.name %>(node: Node): node is <%= node.name %> {
/**
* Checks if a node is any HTML node type
*/
-export function isHTMLNode(node: Node): boolean {
+export function isHTMLNode(node: Node | undefined | null): boolean {
+ if (!node) return false
+
<%- html_nodes = nodes.select { |n| n.name.start_with?("HTML") } -%>
return <%= html_nodes.map { |n| "is#{n.name}(node)" }.join(" ||\n ") %>
}
@@ -40,7 +44,9 @@ export function isHTMLNode(node: Node): boolean {
/**
* Checks if a node is any ERB node type
*/
-export function isERBNode(node: Node): node is ERBNode {
+export function isERBNode(node: Node | undefined | null): node is ERBNode {
+ if (!node) return false
+
<%- erb_nodes = nodes.select { |n| n.name.start_with?("ERB") } -%>
return <%= erb_nodes.map { |n| "is#{n.name}(node)" }.join(" ||\n ") %>
}
diff --git a/workspace.json b/workspace.json
index d828e4849..d8aa79570 100644
--- a/workspace.json
+++ b/workspace.json
@@ -9,6 +9,7 @@
"@herb-tools/highlighter": "javascript/packages/highlighter",
"@herb-tools/language-server": "javascript/packages/language-server",
"@herb-tools/linter": "javascript/packages/linter",
+ "@herb-tools/minifier": "javascript/packages/minifier",
"@herb-tools/node-wasm": "javascript/packages/node-wasm",
"@herb-tools/node": "javascript/packages/node",
"@herb-tools/printer": "javascript/packages/printer",