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 +# Herb Minifier **Package:** [`@herb-tools/minifier`](https://www.npmjs.com/package/@herb-tools/minifier) --- -HTML+ERB template minification. +HTML+ERB template minification. Removes non-significant whitespace while preserving whitespace in `
` 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",