diff --git a/.github/labeler.yml b/.github/labeler.yml
index 7bd18b836..f982cab0f 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -183,6 +183,16 @@ vscode:
- any-glob-to-any-file:
- '**/javascript/packages/vscode/**/*'
+dev-client:
+ - changed-files:
+ - any-glob-to-any-file:
+ - '**/javascript/packages/client/**/*'
+
+dev-server:
+ - changed-files:
+ - any-glob-to-any-file:
+ - 'lib/herb/dev/**/*'
+
dev-tools:
- changed-files:
- any-glob-to-any-file:
diff --git a/.rubocop.yml b/.rubocop.yml
index 4f2982a1f..73b493bec 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -40,6 +40,10 @@ Style/SymbolArray:
Style/BlockDelimiters:
Enabled: false
+Style/SafeNavigationChainLength:
+ Exclude:
+ - lib/herb/dev/runner.rb
+
Style/GlobalVars:
Exclude:
- ext/herb/extconf.rb
@@ -55,61 +59,68 @@ Style/TrailingCommaInHashLiteral:
Metrics/CyclomaticComplexity:
Max: 15
Exclude:
- - lib/herb/project.rb
+ - bench/**/*
- lib/herb/ast/nodes.rb
- lib/herb/cli.rb
+ - lib/herb/dev/runner.rb
- lib/herb/engine.rb
- - lib/herb/prism_inspect.rb
- lib/herb/engine/**/*.rb
+ - lib/herb/prism_inspect.rb
+ - lib/herb/project.rb
- templates/template.rb
- - bench/**/*
Metrics/MethodLength:
Max: 20
Exclude:
+ - bench/**/*
+ - bin/**/*
- lib/herb/ast/nodes.rb
- lib/herb/cli.rb
- - lib/herb/project.rb
+ - lib/herb/dev/runner.rb
+ - lib/herb/dev/server.rb
- lib/herb/engine.rb
- lib/herb/engine/**/*.rb
- lib/herb/prism_inspect.rb
+ - lib/herb/project.rb
- templates/template.rb
- test/fork_helper.rb
- test/snapshot_utils.rb
- - bin/**/*
- - bench/**/*
Metrics/AbcSize:
Exclude:
+ - bench/**/*
+ - bin/**/*
- lib/herb/ast/node.rb
- lib/herb/ast/nodes.rb
- lib/herb/cli.rb
- - lib/herb/errors.rb
- - lib/herb/project.rb
+ - lib/herb/dev/server.rb
+ - lib/herb/dev/runner.rb
- lib/herb/engine.rb
- lib/herb/engine/**/*.rb
- - lib/herb/token.rb
+ - lib/herb/errors.rb
- lib/herb/prism_inspect.rb
+ - lib/herb/project.rb
+ - lib/herb/token.rb
- templates/template.rb
+ - test/engine/action_view/action_view_test_helper.rb
+ - test/engine/rails_compatibility_test.rb
- test/fork_helper.rb
- test/snapshot_utils.rb
- - test/engine/rails_compatibility_test.rb
- - bin/**/*
- - bench/**/*
- - test/engine/action_view/action_view_test_helper.rb
Metrics/ClassLength:
Exclude:
- bin/**/*
+ - lib/herb/ast/nodes.rb
- lib/herb/cli.rb
- lib/herb/configuration.rb
+ - lib/herb/dev/runner.rb
+ - lib/herb/dev/server.rb
- lib/herb/engine.rb
- lib/herb/engine/**/*.rb
- lib/herb/project.rb
- lib/herb/visitor.rb
- - lib/herb/ast/nodes.rb
- - test/**/*_test.rb
- templates/template.rb
+ - test/**/*_test.rb
Metrics/ModuleLength:
Exclude:
@@ -133,6 +144,7 @@ Metrics/ParameterLists:
Exclude:
- lib/herb/ast/node.rb
- lib/herb/ast/nodes.rb
+ - lib/herb/dev/runner.rb
- lib/herb/diff_operation.rb
- lib/herb/engine/validators/security_validator.rb
- lib/herb/errors.rb
@@ -141,16 +153,18 @@ Metrics/ParameterLists:
Metrics/PerceivedComplexity:
Exclude:
+ - bench/**/*
+ - bin/**/*
- lib/herb/ast/nodes.rb
- lib/herb/cli.rb
- - lib/herb/project.rb
- - lib/herb/prism_inspect.rb
+ - lib/herb/dev/runner.rb
+ - lib/herb/dev/server.rb
- lib/herb/engine.rb
- lib/herb/engine/**/*.rb
+ - lib/herb/prism_inspect.rb
+ - lib/herb/project.rb
- templates/template.rb
- test/**/*.rb
- - bin/**/*
- - bench/**/*
Layout/LineLength:
Enabled: false
diff --git a/Gemfile b/Gemfile
index 4eac9dd42..7dd113e1c 100644
--- a/Gemfile
+++ b/Gemfile
@@ -9,6 +9,7 @@ gem "prism", github: "ruby/prism", tag: "v1.9.0"
gem "actionview", "~> 8.0"
gem "benchmark"
gem "charm"
+gem "cruise"
gem "digest", "~> 3.2"
gem "erubi"
gem "irb", "~> 1.16"
diff --git a/Gemfile.lock b/Gemfile.lock
index 344852281..ccce56e6f 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -114,6 +114,12 @@ GEM
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
crass (1.0.6)
+ cruise (0.1.0-aarch64-linux-gnu)
+ cruise (0.1.0-aarch64-linux-musl)
+ cruise (0.1.0-arm64-darwin)
+ cruise (0.1.0-x86_64-darwin)
+ cruise (0.1.0-x86_64-linux-gnu)
+ cruise (0.1.0-x86_64-linux-musl)
csv (3.3.5)
date (3.5.1)
difftastic (0.8.0-aarch64-linux)
@@ -309,6 +315,7 @@ DEPENDENCIES
actionview (~> 8.0)
benchmark
charm
+ cruise
digest (~> 3.2)
erubi
herb!
diff --git a/README.md b/README.md
index 6b7c6cd9e..bb04df96f 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,7 @@ The Herb ecosystem offers multiple tools that integrate seamlessly into editors,
| [Herb Language Service](https://herb-tools.dev/projects/language-service) | HTML+ERB language service with ActionView tag helper support. |
| [Herb Language Server](https://herb-tools.dev/projects/language-server) | Rich editor integration for VS Code, Zed, Neovim, and more. |
| [Herb Engine](https://herb-tools.dev/projects/engine) | HTML-aware ERB rendering engine, API-compatible with Erubi. |
+| [Herb Dev Server](https://herb-tools.dev/projects/dev-server) | File-watching dev server with live DOM patching for templates. *(experimental)* |
| [Herb Dev Tools](https://herb-tools.dev/projects/dev-tools) | In-browser dev tools for inspecting and debugging templates, shipped with ReActionView. |
| [ReActionView](https://reactionview.dev) | ActionView-compatible ERB engine with modern DX for Rails. |
diff --git a/Steepfile b/Steepfile
index 8f5ac818e..f5fc5daaa 100644
--- a/Steepfile
+++ b/Steepfile
@@ -17,4 +17,7 @@ target :lib do
ignore "lib/herb/project.rb"
ignore "lib/herb/engine/error_formatter.rb"
ignore "lib/herb/bootstrap.rb"
+ ignore "lib/herb/dev/server.rb"
+ ignore "lib/herb/dev/server_entry.rb"
+ ignore "lib/herb/dev/runner.rb"
end
diff --git a/docs/.vitepress/config/theme.mts b/docs/.vitepress/config/theme.mts
index 7bb0a743c..e88733c42 100644
--- a/docs/.vitepress/config/theme.mts
+++ b/docs/.vitepress/config/theme.mts
@@ -18,6 +18,7 @@ const defaultSidebar = [
{ text: "Formatter", link: "/projects/formatter" },
{ text: "Linter", link: "/projects/linter" },
{ text: "CLI", link: "/projects/cli" },
+ { text: "Dev Server", link: "/projects/dev-server" },
{ text: "Dev Tools", link: "/projects/dev-tools" },
],
},
@@ -25,6 +26,7 @@ const defaultSidebar = [
text: "Utility Libraries",
collapsed: false,
items: [
+ { text: "Dev Server Client", link: "/projects/client" },
{ text: "Language Service", link: "/projects/language-service" },
{ text: "Highlighter", link: "/projects/highlighter" },
{ text: "Syntax Tree Printer", link: "/projects/printer" },
diff --git a/docs/docs/projects.md b/docs/docs/projects.md
index 36bad2209..40b02f03e 100644
--- a/docs/docs/projects.md
+++ b/docs/docs/projects.md
@@ -9,6 +9,7 @@ These are the user-facing tools that integrate into your development workflow:
* [Herb Language Server](/projects/language-server)
* [Herb Formatter](/projects/formatter)
* [Herb Linter](/projects/linter)
+* [Herb Dev Server](/projects/dev-server)
* [Herb CLI](/projects/cli)
## Language Bindings
@@ -24,6 +25,7 @@ These libraries provide access to the Herb Parser in different programming langu
These specialized libraries provide additional functionality for working with HTML+ERB:
+* [Dev Server Client](/projects/client)
* [Language Service](/projects/language-service)
* [Highlighter](/projects/highlighter)
* [Syntax Tree Printer](/projects/minifier)
diff --git a/docs/docs/projects/client.md b/docs/docs/projects/client.md
new file mode 100644
index 000000000..b9561df41
--- /dev/null
+++ b/docs/docs/projects/client.md
@@ -0,0 +1 @@
+
diff --git a/docs/docs/projects/dev-server.md b/docs/docs/projects/dev-server.md
new file mode 100644
index 000000000..8c566d577
--- /dev/null
+++ b/docs/docs/projects/dev-server.md
@@ -0,0 +1,56 @@
+# Herb Dev Server
+
+The Herb Dev Server watches template files and provides real-time hot-reloading during development via WebSocket.
+
+> [!WARNING]
+> The dev server is experimental and may not work correctly in all cases.
+
+## Usage
+
+```bash
+herb dev [directory] [--port 8592]
+```
+
+The server starts watching all template files in the directory, diffs changes using the Herb diff engine, and broadcasts updates to connected clients.
+
+## How It Works
+
+1. **File watching**: monitors all `.html.erb`, `.html.herb`, and other template files for changes
+2. **AST diffing**: when a file changes, diffs the old and new AST to determine what changed
+3. **Smart patching**: for text and attribute changes, sends a patch that the client applies without reloading
+4. **Reload fallback**: for structural changes (insertions, removals, ERB changes), tells the client to reload
+
+## Architecture
+
+The dev server consists of two parts:
+
+- **Server** (`lib/herb/dev/`): Ruby WebSocket server that watches files and broadcasts changes
+- **Client** (`@herb-tools/client`): JavaScript package that connects to the server and applies DOM patches
+
+## CLI Output
+
+```
+ 🌿 Herb Dev Server
+
+ ⚠️ Experimental: The dev server is experimental and may not work correctly in all cases.
+
+ Herb: 0.9.7
+ Project: /path/to/project
+ Config: .herb.yml
+ Files: 453 templates indexed
+ WebSocket: ws://localhost:8592
+
+ Ready! Watching for changes...
+
+ Recent changes:
+
+ 20:13:40 ✓ patch app/views/posts/show.html.erb (1 operation) [1 client]
+ #1 text changed [4, 8]
+ 20:13:45 ↻ reload app/views/posts/index.html.erb (2 operations) [1 client]
+ #1 node inserted [0, 3]
+ #2 text changed [0, 4]
+```
+
+## Links
+
+- [Dev Server Client (`@herb-tools/client`)](/projects/client)
diff --git a/javascript/packages/client/README.md b/javascript/packages/client/README.md
new file mode 100644
index 000000000..2947b9b1b
--- /dev/null
+++ b/javascript/packages/client/README.md
@@ -0,0 +1,45 @@
+# Herb Client
+
+Dev server client for Herb templates, connects to the Herb Dev Server and applies live DOM patches during development.
+
+> [!WARNING]
+> The dev server and client are experimental and may not work correctly in all cases.
+
+## How It Works
+
+The client connects to the Herb Dev Server via WebSocket and receives messages when template files change. Depending on the type of change:
+
+- **Text and attribute changes** are patched directly in the DOM without a page reload
+- **Structural changes** (insertions, removals, ERB changes) trigger a full page reload
+
+## Usage
+
+The client auto-initializes when `` is present in the page.
+
+> [!NOTE]
+> No manual setup required when using ReActionView with `debug_mode` enabled.
+
+### Manual initialization
+
+```typescript
+import { initHerbClient } from "@herb-tools/client"
+
+const client = initHerbClient({
+ port: 8592,
+ host: "localhost",
+ onPatch: (message) => console.log("Patched:", message.file),
+ onReload: (message) => console.log("Reloading for:", message.file),
+})
+```
+
+## Protocol
+
+The client communicates with the Herb Dev Server using these message types:
+
+| Message | Direction | Description |
+|---------|-----------|-------------|
+| `welcome` | Server → Client | Handshake with project path |
+| `patch` | Server → Client | Text/attribute changes to apply |
+| `reload` | Server → Client | Structural change requiring reload |
+| `error` | Server → Client | Parse errors detected |
+| `fixed` | Server → Client | Parse errors resolved |
diff --git a/javascript/packages/client/package.json b/javascript/packages/client/package.json
new file mode 100644
index 000000000..97da4b7e4
--- /dev/null
+++ b/javascript/packages/client/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@herb-tools/client",
+ "version": "0.9.7",
+ "description": "HMR client for Herb templates - connects to herb dev server and applies live DOM patches",
+ "type": "module",
+ "license": "MIT",
+ "homepage": "https://herb-tools.dev",
+ "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/client%60:%20",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/marcoroth/herb.git",
+ "directory": "javascript/packages/client"
+ },
+ "main": "./dist/herb-client.umd.js",
+ "module": "./dist/herb-client.esm.js",
+ "types": "./dist/types/index.d.ts",
+ "scripts": {
+ "build": "yarn clean && rollup -c",
+ "dev": "rollup -c -w",
+ "clean": "rimraf dist",
+ "test": "vitest run",
+ "prepublishOnly": "yarn clean && yarn build && yarn test"
+ },
+ "exports": {
+ "./package.json": "./package.json",
+ ".": {
+ "types": "./dist/types/index.d.ts",
+ "import": "./dist/herb-client.esm.js",
+ "default": "./dist/herb-client.umd.js"
+ }
+ },
+ "dependencies": {},
+ "files": [
+ "package.json",
+ "README.md",
+ "dist/",
+ "src/"
+ ]
+}
diff --git a/javascript/packages/client/project.json b/javascript/packages/client/project.json
new file mode 100644
index 000000000..9925658c1
--- /dev/null
+++ b/javascript/packages/client/project.json
@@ -0,0 +1,27 @@
+{
+ "name": "@herb-tools/client",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "javascript/packages/client/src",
+ "projectType": "library",
+ "targets": {
+ "build": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "build"
+ }
+ },
+ "test": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "test"
+ }
+ },
+ "clean": {
+ "executor": "nx:run-script",
+ "options": {
+ "script": "clean"
+ }
+ }
+ },
+ "tags": []
+}
diff --git a/javascript/packages/client/rollup.config.mjs b/javascript/packages/client/rollup.config.mjs
new file mode 100644
index 000000000..cd979320d
--- /dev/null
+++ b/javascript/packages/client/rollup.config.mjs
@@ -0,0 +1,39 @@
+import typescript from "@rollup/plugin-typescript"
+import { nodeResolve } from "@rollup/plugin-node-resolve"
+
+export default [
+ {
+ input: "src/index.ts",
+ output: {
+ file: "dist/herb-client.esm.js",
+ format: "esm",
+ sourcemap: true,
+ },
+ plugins: [
+ nodeResolve({ browser: true }),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ declaration: true,
+ declarationDir: "./dist/types",
+ rootDir: "src/",
+ }),
+ ],
+ },
+ {
+ input: "src/index.ts",
+ output: {
+ file: "dist/herb-client.umd.js",
+ format: "umd",
+ name: "HerbClient",
+ sourcemap: true,
+ },
+ plugins: [
+ nodeResolve({ browser: true }),
+ typescript({
+ tsconfig: "./tsconfig.json",
+ declaration: false,
+ rootDir: "src/",
+ }),
+ ],
+ },
+]
diff --git a/javascript/packages/client/src/client.ts b/javascript/packages/client/src/client.ts
new file mode 100644
index 000000000..a1f7dcaf8
--- /dev/null
+++ b/javascript/packages/client/src/client.ts
@@ -0,0 +1,199 @@
+import { Connection } from "./connection"
+import { Toast } from "./toast"
+import { ConnectionDot } from "./connection-dot"
+import { MismatchAlert } from "./mismatch-alert"
+
+import { applyPatch } from "./patch"
+
+import type {
+ HerbClientOptions,
+ HerbMessage,
+ WelcomeMessage,
+ PatchMessage,
+ ReloadMessage,
+ ErrorMessage,
+ FixedMessage,
+} from "./types"
+
+const DEFAULT_PORT = 8592
+
+type ClientState = "connected" | "disconnected" | "given-up"
+
+export class HerbClient {
+ private connection: Connection
+ private options: HerbClientOptions
+ private port: number
+ private state: ClientState = "disconnected"
+ private hasConnectedBefore = false
+ private projectMatch: boolean | null = null
+ private connectionDot: ConnectionDot
+
+ constructor(options: HerbClientOptions = {}) {
+ this.options = options
+
+ const port = options.port ?? this.detectPort() ?? DEFAULT_PORT
+ const host = options.host ?? "localhost"
+ this.port = port
+ this.connectionDot = new ConnectionDot(this)
+
+ this.connection = new Connection({
+ url: `ws://${host}:${port}`,
+ onMessage: (message) => this.handleMessage(message),
+ onConnect: () => this.onConnect(),
+ onDisconnect: () => this.onDisconnect(),
+ onReconnecting: (attempt, maxAttempts, delay) => this.onReconnecting(attempt, maxAttempts, delay),
+ onGivenUp: () => this.onGivenUp(),
+ })
+ }
+
+ connect(): void {
+ this.connection.connect()
+ }
+
+ disconnect(): void {
+ this.connection.disconnect()
+ }
+
+ retry(): void {
+ this.updateState("disconnected")
+ this.connection.retry()
+ }
+
+ getState(): ClientState {
+ return this.state
+ }
+
+ getPort(): number {
+ return this.port
+ }
+
+ applyConnectionDot(): void {
+ this.connectionDot.apply()
+ }
+
+ private onConnect(): void {
+ const wasDisconnected = this.state === "disconnected" || this.state === "given-up"
+
+ if (this.hasConnectedBefore && wasDisconnected) {
+ Toast.show("Herb Dev Server reconnected", "connected")
+ }
+
+ this.hasConnectedBefore = true
+ this.updateState("connected")
+ this.options.onConnect?.()
+ }
+
+ private onDisconnect(): void {
+ if (this.hasConnectedBefore && this.state === "connected") {
+ Toast.show("Herb Dev Server disconnected", "disconnected")
+ }
+
+ this.updateState("disconnected")
+ this.options.onDisconnect?.()
+ }
+
+ private onReconnecting(attempt: number, maxAttempts: number, delay: number): void {
+ console.debug(`[herb-client] reconnecting (attempt ${attempt}/${maxAttempts}, next try in ${(delay / 1000).toFixed(1)}s)...`)
+ this.connectionDot.updateReconnectCountdown(attempt, maxAttempts, delay)
+ }
+
+ private onGivenUp(): void {
+ this.updateState("given-up")
+ Toast.show("Herb Dev Server not available — click the dot to retry", "warning")
+ }
+
+ private handleMessage(message: HerbMessage): void {
+ if (message.type !== "welcome" && this.projectMatch === false) return
+
+ switch (message.type) {
+ case "welcome":
+ this.handleWelcome(message)
+ break
+ case "patch":
+ this.handlePatch(message)
+ break
+ case "reload":
+ this.handleReload(message)
+ break
+ case "error":
+ this.handleError(message)
+ break
+ case "fixed":
+ this.handleFixed(message)
+ break
+ }
+ }
+
+ private handleWelcome(message: WelcomeMessage): void {
+ const clientProject = document.querySelector('meta[name="herb-project-path"]')?.getAttribute("content")
+
+ if (clientProject && message.project && clientProject !== message.project) {
+ this.projectMatch = false
+ console.warn(`[herb-client] project mismatch — server: ${message.project}, client: ${clientProject}. Ignoring messages.`)
+ this.updateState("disconnected")
+ MismatchAlert.show(message.project, clientProject)
+ } else {
+ this.projectMatch = true
+ }
+ }
+
+ private handlePatch(message: PatchMessage): void {
+ this.options.onPatch?.(message)
+
+ const applied = applyPatch(message)
+
+ if (!applied) {
+ window.location.reload()
+ }
+ }
+
+ private handleReload(message: ReloadMessage): void {
+ this.options.onReload?.(message)
+ window.location.reload()
+ }
+
+
+ private handleError(message: ErrorMessage): void {
+ this.options.onError?.(message)
+
+ const overlay = this.getErrorOverlay()
+
+ if (overlay) {
+ const errors = message.errors.map((error) => ({
+ severity: "error" as const,
+ message: error.message,
+ name: error.name,
+ location: { line: error.line, column: error.column },
+ }))
+
+ overlay.showErrors(errors, message.file)
+ }
+ }
+
+ private handleFixed(message: FixedMessage): void {
+ this.options.onFixed?.(message)
+ this.getErrorOverlay()?.clearErrors()
+ }
+
+ private updateState(state: ClientState): void {
+ this.state = state
+ this.connectionDot.apply()
+ }
+
+ private getErrorOverlay(): { showErrors: Function; clearErrors: Function } | null {
+ const devTools = (window as any).HerbDevTools
+
+ return devTools?._errorOverlay ?? devTools?._overlay?.errorOverlay ?? null
+ }
+
+ private detectPort(): number | null {
+ const meta = document.querySelector('meta[name="herb-dev-server-port"]')
+
+ if (meta) {
+ const port = parseInt(meta.getAttribute("content") ?? "", 10)
+ if (!isNaN(port)) return port
+ }
+
+ return null
+ }
+}
diff --git a/javascript/packages/client/src/colors.ts b/javascript/packages/client/src/colors.ts
new file mode 100644
index 000000000..54bdb3a4f
--- /dev/null
+++ b/javascript/packages/client/src/colors.ts
@@ -0,0 +1,21 @@
+export const colors = {
+ green: "#22c55e",
+ greenDark: "#059669",
+ greenLight: "#ecfdf5",
+ greenBorder: "#10b981",
+ greenGlow: "0 0 4px rgba(34, 197, 94, 0.5)",
+
+ red: "#ef4444",
+ redDark: "#991b1b",
+ redLight: "#fef2f2",
+
+ amber: "#f59e0b",
+ amberDark: "#92400e",
+ amberDarker: "#d97706",
+ amberLight: "#fffbeb",
+ amberBorder: "#f59e0b",
+
+ gray: "#6b7280",
+ grayLight: "#9ca3af",
+ grayLighter: "#a16207",
+} as const
diff --git a/javascript/packages/client/src/connection-dot.ts b/javascript/packages/client/src/connection-dot.ts
new file mode 100644
index 000000000..7d84e375c
--- /dev/null
+++ b/javascript/packages/client/src/connection-dot.ts
@@ -0,0 +1,142 @@
+import type { HerbClient } from "./client"
+import { colors } from "./colors"
+
+export class ConnectionDot {
+ private client: HerbClient
+ private reconnectCountdown: ReturnType | null = null
+
+ constructor(client: HerbClient) {
+ this.client = client
+ }
+
+ apply(): void {
+ if (this.reconnectCountdown) {
+ clearInterval(this.reconnectCountdown)
+ this.reconnectCountdown = null
+ }
+
+ const dot = document.getElementById("herbConnectionDot")
+ if (!dot) return
+
+ const panelDot = document.getElementById("herbDevServerDot")
+ const panelStatus = document.getElementById("herbDevServerStatus")
+ const panelRetry = document.getElementById("herbDevServerRetry") as HTMLButtonElement | null
+ const retryHandler = (e: MouseEvent) => { e.stopPropagation(); this.client.retry() }
+
+ const state = this.client.getState()
+
+ switch (state) {
+ case "connected":
+ this.setDotStyle(dot, colors.green, true, true)
+ dot.style.cursor = "default"
+ dot.title = "Connected to herb dev server"
+ dot.onclick = null
+
+ this.updatePanel(panelDot, panelStatus, panelRetry, {
+ dotColor: colors.green,
+ statusText: `Dev Server connected (port ${this.client.getPort()})`,
+ statusColor: colors.greenDark,
+ retryVisible: false,
+ })
+
+ break
+
+ case "disconnected":
+ this.setDotStyle(dot, colors.red, false, false)
+ dot.style.cursor = "default"
+ dot.title = "Disconnected from herb dev server"
+ dot.onclick = null
+
+ this.updatePanel(panelDot, panelStatus, panelRetry, {
+ dotColor: colors.red,
+ statusText: "Dev Server disconnected",
+ statusColor: colors.gray,
+ retryVisible: true,
+ retryHandler,
+ })
+
+ break
+
+ case "given-up":
+ this.setDotStyle(dot, colors.amber, false, false)
+ dot.style.cursor = "pointer"
+ dot.title = "Connection to herb dev server failed — click to retry"
+ dot.onclick = retryHandler
+
+ this.updatePanel(panelDot, panelStatus, panelRetry, {
+ dotColor: colors.amber,
+ statusText: "Dev Server not available",
+ statusColor: colors.amberDarker,
+ retryVisible: true,
+ retryHandler,
+ })
+
+ break
+ }
+ }
+
+ updateReconnectCountdown(attempt: number, maxAttempts: number, delay: number): void {
+ const panelStatus = document.getElementById("herbDevServerStatus")
+ if (!panelStatus) return
+
+ if (this.reconnectCountdown) {
+ clearInterval(this.reconnectCountdown)
+ this.reconnectCountdown = null
+ }
+
+ let remaining = Math.ceil(delay / 1000)
+ panelStatus.textContent = `Retry ${attempt}/${maxAttempts} in ${remaining}s`
+ panelStatus.style.color = colors.gray
+
+ this.reconnectCountdown = setInterval(() => {
+ remaining--
+
+ if (remaining <= 0) {
+ if (this.reconnectCountdown) {
+ clearInterval(this.reconnectCountdown)
+ this.reconnectCountdown = null
+ }
+
+ panelStatus.textContent = `Retry ${attempt}/${maxAttempts} connecting...`
+
+ return
+ }
+
+ panelStatus.textContent = `Retry ${attempt}/${maxAttempts} in ${remaining}s`
+ }, 1000)
+ }
+
+ private updatePanel(
+ panelDot: HTMLElement | null,
+ panelStatus: HTMLElement | null,
+ panelRetry: HTMLButtonElement | null,
+ options: {
+ dotColor: string
+ statusText: string
+ statusColor: string
+ retryVisible: boolean
+ retryHandler?: (e: MouseEvent) => void
+ }
+ ): void {
+ if (panelDot) this.setDotStyle(panelDot, options.dotColor, false, false)
+
+ if (panelStatus) {
+ panelStatus.textContent = options.statusText
+ panelStatus.style.color = options.statusColor
+ }
+
+ if (panelRetry) {
+ panelRetry.style.display = options.retryVisible ? "block" : "none"
+
+ if (options.retryHandler) {
+ panelRetry.onclick = options.retryHandler
+ }
+ }
+ }
+
+ private setDotStyle(element: HTMLElement, background: string, glow: boolean, pulse: boolean): void {
+ element.style.background = background
+ element.style.boxShadow = glow ? colors.greenGlow : "none"
+ element.style.animation = pulse ? "herb-dot-pulse 2s ease-in-out infinite" : "none"
+ }
+}
diff --git a/javascript/packages/client/src/connection.ts b/javascript/packages/client/src/connection.ts
new file mode 100644
index 000000000..f01090531
--- /dev/null
+++ b/javascript/packages/client/src/connection.ts
@@ -0,0 +1,131 @@
+import type { HerbMessage, ConnectionOptions } from "./types"
+
+const DEFAULT_RECONNECT_INTERVAL = 1000
+const DEFAULT_MAX_RECONNECT_ATTEMPTS = 10
+
+export class Connection {
+ private socket: WebSocket | null = null
+ private reconnectAttempts = 0
+ private reconnectTimer: ReturnType | null = null
+ private givenUp = false
+ private options: ConnectionOptions
+
+ constructor(options: ConnectionOptions) {
+ this.options = {
+ reconnectInterval: DEFAULT_RECONNECT_INTERVAL,
+ maxReconnectAttempts: DEFAULT_MAX_RECONNECT_ATTEMPTS,
+ ...options,
+ }
+ }
+
+ private get reconnectInterval(): number {
+ return this.options.reconnectInterval ?? DEFAULT_RECONNECT_INTERVAL
+ }
+
+ private get maxReconnectAttempts(): number {
+ return this.options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS
+ }
+
+ connect(): void {
+ if (this.socket?.readyState === WebSocket.OPEN) return
+
+ this.givenUp = false
+ this.reconnectAttempts = 0
+
+ this.attemptConnect()
+ }
+
+ disconnect(): void {
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+
+ this.givenUp = false
+ this.reconnectAttempts = this.maxReconnectAttempts
+
+ if (this.socket) {
+ this.socket.close()
+ this.socket = null
+ }
+ }
+
+ retry(): void {
+ if (this.reconnectTimer) {
+ clearTimeout(this.reconnectTimer)
+ this.reconnectTimer = null
+ }
+
+ if (this.socket) {
+ this.socket.onclose = null
+ this.socket.close()
+ this.socket = null
+ }
+
+ this.givenUp = false
+ this.reconnectAttempts = 0
+ this.attemptConnect()
+ }
+
+ get hasGivenUp(): boolean {
+ return this.givenUp
+ }
+
+ private attemptConnect(): void {
+ try {
+ this.socket = new WebSocket(this.options.url)
+
+ this.socket.onopen = () => {
+ this.reconnectAttempts = 0
+ this.options.onConnect?.()
+ }
+
+ this.socket.onmessage = (event) => {
+ try {
+ const message: HerbMessage = JSON.parse(event.data)
+ this.options.onMessage?.(message)
+ } catch (error) {
+ console.warn("[herb-client] failed to parse message:", error)
+ }
+ }
+
+ this.socket.onclose = () => {
+ console.debug("[herb-client] disconnected from dev server")
+ this.options.onDisconnect?.()
+ this.scheduleReconnect()
+ }
+
+ this.socket.onerror = () => {
+ try {
+ this.socket?.close()
+ } catch {
+ this.scheduleReconnect()
+ }
+ }
+ } catch {
+ this.scheduleReconnect()
+ }
+ }
+
+ private scheduleReconnect(): void {
+ if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+ console.debug("[herb-client] gave up reconnecting after %d attempts", this.reconnectAttempts)
+ this.givenUp = true
+ this.options.onGivenUp?.()
+ return
+ }
+
+ this.reconnectAttempts++
+
+ const delay = Math.min(
+ this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
+ 10000
+ )
+
+ this.options.onReconnecting?.(this.reconnectAttempts, this.maxReconnectAttempts, delay)
+
+ this.reconnectTimer = setTimeout(() => {
+ this.attemptConnect()
+ }, delay)
+ }
+}
diff --git a/javascript/packages/client/src/index.ts b/javascript/packages/client/src/index.ts
new file mode 100644
index 000000000..c4006661a
--- /dev/null
+++ b/javascript/packages/client/src/index.ts
@@ -0,0 +1,63 @@
+export { Connection } from "./connection"
+export { HerbClient } from "./client"
+export { Toast } from "./toast"
+export { MismatchAlert } from "./mismatch-alert"
+export { ConnectionDot } from "./connection-dot"
+export { applyPatch } from "./patch"
+
+export type {
+ ConnectionOptions,
+ ConnectionState,
+ HerbClientOptions,
+ HerbMessage,
+ WelcomeMessage,
+ PatchMessage,
+ ReloadMessage,
+ ErrorMessage,
+ FixedMessage,
+ DiffOperation,
+ ParseError,
+} from "./types"
+
+import { HerbClient } from "./client"
+import type { HerbClientOptions } from "./types"
+
+declare global {
+ interface Window {
+ __herbClient?: HerbClient
+ }
+}
+
+let instance: HerbClient | null = null
+
+export function initHerbClient(options: HerbClientOptions = {}): HerbClient {
+ if (instance) {
+ instance.disconnect()
+ }
+
+ instance = new HerbClient(options)
+ window.__herbClient = instance
+ instance.connect()
+
+ return instance
+}
+
+export function getHerbClient(): HerbClient | null {
+ return instance
+}
+
+function autoInitialize(): void {
+ const debugMeta = document.querySelector('meta[name="herb-debug-mode"]')
+
+ if (!debugMeta || debugMeta.getAttribute("content") !== "true") return
+
+ initHerbClient()
+}
+
+if (typeof document !== "undefined") {
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", autoInitialize)
+ } else {
+ autoInitialize()
+ }
+}
diff --git a/javascript/packages/client/src/mismatch-alert.ts b/javascript/packages/client/src/mismatch-alert.ts
new file mode 100644
index 000000000..24aafea4c
--- /dev/null
+++ b/javascript/packages/client/src/mismatch-alert.ts
@@ -0,0 +1,56 @@
+import { colors } from "./colors"
+
+const ALERT_ID = "herbProjectMismatchAlert"
+
+export class MismatchAlert {
+ static show(serverProject: string, clientProject: string): void {
+ if (document.getElementById(ALERT_ID)) return
+
+ const serverName = serverProject.split("/").pop() ?? serverProject
+ const clientName = clientProject.split("/").pop() ?? clientProject
+
+ const alert = document.createElement("div")
+ alert.id = ALERT_ID
+ alert.style.cssText = `position:fixed;top:32px;right:10px;z-index:999998;background:${colors.amberLight};border:1px solid ${colors.amber};border-radius:8px;padding:12px 16px;max-width:320px;font-family:system-ui,sans-serif;font-size:13px;color:${colors.amberDark};box-shadow:0 4px 12px rgba(0,0,0,0.1);display:flex;gap:10px;align-items:flex-start;`
+
+ const iconElement = document.createElement("span")
+ iconElement.style.cssText = "font-size:18px;line-height:1;"
+ iconElement.textContent = "\u26A0\uFE0F"
+
+ const content = document.createElement("div")
+ content.style.flex = "1"
+
+ const title = document.createElement("div")
+ title.style.cssText = "font-weight:600;margin-bottom:4px;"
+ title.textContent = "Herb Dev Server mismatch"
+
+ const description = document.createElement("div")
+ description.style.cssText = `font-size:12px;color:${colors.grayLighter};`
+ description.textContent = `The dev server is watching ${serverName} but this page is from ${clientName}. Messages will be ignored.`
+
+ content.appendChild(title)
+ content.appendChild(description)
+
+ const dismiss = document.createElement("button")
+ dismiss.style.cssText = `background:none;border:none;cursor:pointer;font-size:16px;color:${colors.amberDark};padding:0;line-height:1;`
+ dismiss.textContent = "\u2715"
+ dismiss.addEventListener("click", () => alert.remove())
+
+ alert.appendChild(iconElement)
+ alert.appendChild(content)
+ alert.appendChild(dismiss)
+ document.body.appendChild(alert)
+
+ const panelStatus = document.getElementById("herbDevServerStatus")
+ const panelDot = document.getElementById("herbDevServerDot")
+
+ if (panelStatus) {
+ panelStatus.textContent = `Wrong project (${serverName})`
+ panelStatus.style.color = colors.amberDarker
+ }
+
+ if (panelDot) {
+ panelDot.style.background = colors.amber
+ }
+ }
+}
diff --git a/javascript/packages/client/src/patch.ts b/javascript/packages/client/src/patch.ts
new file mode 100644
index 000000000..57cf52d81
--- /dev/null
+++ b/javascript/packages/client/src/patch.ts
@@ -0,0 +1,141 @@
+import type { DiffOperation, PatchMessage } from "./types"
+
+export function applyPatch(message: PatchMessage): boolean {
+ const selector = `[data-herb-debug-file-relative-path="${message.file}"]`
+ const roots = document.querySelectorAll(selector)
+
+ if (roots.length === 0) {
+ console.debug("[herb-client] no roots found for selector:", selector)
+ return false
+ }
+
+ let applied = false
+
+ for (const operation of message.operations) {
+ let operationApplied = false
+
+ for (let i = 0; i < roots.length; i++) {
+ if (applyOperation(roots[i], operation)) {
+ operationApplied = true
+ } else {
+ console.debug(`[herb-client] operation not applied to root ${i}:`, roots[i])
+ }
+ }
+
+ if (operationApplied) {
+ applied = true
+ } else {
+ console.debug("[herb-client] operation not applied:", operation)
+ }
+ }
+
+ return applied
+}
+
+function applyOperation(root: Element, operation: DiffOperation): boolean {
+ switch (operation.type) {
+ case "text_changed":
+ return applyTextChange(root, operation)
+ case "attribute_value_changed":
+ return applyAttributeChange(root, operation)
+ case "attribute_added":
+ return applyAttributeAdd(root, operation)
+ case "attribute_removed":
+ return applyAttributeRemove(root, operation)
+ default:
+ console.debug(`[herb-client] unhandled operation type: ${operation.type}`)
+ return false
+ }
+}
+
+function parseAttribute(value: string): { name: string; value: string } | null {
+ const match = value.match(/^([^=]+)="(.*)"$/)
+ if (!match) return null
+
+ return { name: match[1], value: match[2] }
+}
+
+function findTarget(root: Element, operation: DiffOperation): Element | null {
+ if (!operation.old_value) return null
+
+ const attribute = parseAttribute(operation.old_value)
+ if (!attribute) return null
+
+ if (root.getAttribute(attribute.name) === attribute.value) return root
+
+ const target = root.querySelector(`[${attribute.name}="${CSS.escape(attribute.value)}"]`)
+
+ return target as Element | null
+}
+
+function findTextTarget(root: Element, operation: DiffOperation): Text | null {
+ if (operation.old_value === null) return null
+
+ const trimmedOld = operation.old_value.trim()
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
+
+ let node: Text | null
+
+ while ((node = walker.nextNode() as Text | null)) {
+ if (node.textContent?.trim() === trimmedOld) {
+ return node
+ }
+ }
+
+ return null
+}
+
+function applyTextChange(root: Element, operation: DiffOperation): boolean {
+ if (operation.new_value === null) return false
+
+ const textNode = findTextTarget(root, operation)
+
+ if (textNode) {
+ textNode.textContent = operation.new_value
+
+ return true
+ }
+
+ return false
+}
+
+function applyAttributeChange(root: Element, operation: DiffOperation): boolean {
+ if (operation.old_value === null || operation.new_value === null) return false
+
+ const node = findTarget(root, operation)
+ if (!node) return false
+
+ const newAttr = parseAttribute(operation.new_value)
+ if (!newAttr) return false
+
+ node.setAttribute(newAttr.name, newAttr.value)
+
+ return true
+}
+
+function applyAttributeAdd(root: Element, operation: DiffOperation): boolean {
+ if (operation.new_value === null) return false
+
+ const attribute = parseAttribute(operation.new_value)
+ if (!attribute) return false
+
+ const node = findTarget(root, operation) ?? root
+
+ node.setAttribute(attribute.name, attribute.value)
+
+ return true
+}
+
+function applyAttributeRemove(root: Element, operation: DiffOperation): boolean {
+ if (operation.old_value === null) return false
+
+ const node = findTarget(root, operation)
+ if (!node) return false
+
+ const match = operation.old_value.match(/^([^=]+)(?:=".*")?$/)
+ if (!match) return false
+
+ node.removeAttribute(match[1])
+
+ return true
+}
diff --git a/javascript/packages/client/src/toast.ts b/javascript/packages/client/src/toast.ts
new file mode 100644
index 000000000..78b3c6fed
--- /dev/null
+++ b/javascript/packages/client/src/toast.ts
@@ -0,0 +1,40 @@
+import { colors } from "./colors"
+
+const TOAST_DURATION = 3000
+const TOAST_FADE_DURATION = 300
+const TOAST_ID = "herbDevServerToast"
+
+type ToastType = "connected" | "disconnected" | "warning"
+
+const TOAST_STYLES: Record = {
+ connected: { background: colors.greenLight, border: colors.greenBorder, text: "#065f46", icon: "\u{1F7E2}" },
+ disconnected: { background: colors.redLight, border: colors.red, text: colors.redDark, icon: "\u{1F534}" },
+ warning: { background: colors.amberLight, border: colors.amber, text: colors.amberDark, icon: "\u{1F7E1}" },
+}
+
+export class Toast {
+ static show(message: string, type: ToastType): void {
+ document.getElementById(TOAST_ID)?.remove()
+
+ const style = TOAST_STYLES[type]
+
+ const toast = document.createElement("div")
+ toast.id = TOAST_ID
+ toast.style.cssText = `position:fixed;top:36px;right:10px;z-index:999997;background:${style.background};border:1px solid ${style.border};border-radius:8px;padding:8px 14px;font-family:system-ui,sans-serif;font-size:12px;color:${style.text};box-shadow:0 4px 12px rgba(0,0,0,0.1);display:flex;align-items:center;gap:8px;transition:opacity 0.3s ease;`
+
+ const icon = document.createElement("span")
+ icon.textContent = style.icon
+
+ const text = document.createElement("span")
+ text.textContent = message
+
+ toast.appendChild(icon)
+ toast.appendChild(text)
+ document.body.appendChild(toast)
+
+ setTimeout(() => {
+ toast.style.opacity = "0"
+ setTimeout(() => toast.remove(), TOAST_FADE_DURATION)
+ }, TOAST_DURATION)
+ }
+}
diff --git a/javascript/packages/client/src/types.ts b/javascript/packages/client/src/types.ts
new file mode 100644
index 000000000..28f479dc1
--- /dev/null
+++ b/javascript/packages/client/src/types.ts
@@ -0,0 +1,84 @@
+export type DiffOperationType =
+ | "text_changed"
+ | "attribute_value_changed"
+ | "attribute_added"
+ | "attribute_removed"
+ | "node_inserted"
+ | "node_removed"
+ | "node_replaced"
+ | "node_moved"
+ | "node_wrapped"
+ | "node_unwrapped"
+ | "erb_content_changed"
+ | "tag_name_changed"
+
+export interface DiffOperation {
+ type: DiffOperationType
+ path: number[]
+ old_value: string | null
+ new_value: string | null
+ old_node_type: string | null
+ new_node_type: string | null
+}
+
+export interface PatchMessage {
+ type: "patch"
+ file: string
+ operations: DiffOperation[]
+}
+
+export interface ReloadMessage {
+ type: "reload"
+ file: string
+}
+
+export interface ParseError {
+ name: string
+ message: string
+ line: number
+ column: number
+}
+
+export interface ErrorMessage {
+ type: "error"
+ file: string
+ errors: ParseError[]
+}
+
+export interface FixedMessage {
+ type: "fixed"
+ file: string
+}
+
+export interface WelcomeMessage {
+ type: "welcome"
+ project: string
+}
+
+export type HerbMessage = WelcomeMessage | PatchMessage | ReloadMessage | ErrorMessage | FixedMessage
+
+export type ConnectionState = "connected" | "disconnected" | "given-up"
+
+export type MessageHandler = (message: HerbMessage) => void
+
+export interface ConnectionOptions {
+ url: string
+ reconnectInterval?: number
+ maxReconnectAttempts?: number
+ onMessage?: MessageHandler
+ onConnect?: () => void
+ onDisconnect?: () => void
+ onGivenUp?: () => void
+ onReconnecting?: (attempt: number, maxAttempts: number, delay: number) => void
+}
+
+export interface HerbClientOptions {
+ port?: number
+ host?: string
+ onPatch?: (message: PatchMessage) => void
+ onReload?: (message: ReloadMessage) => void
+ onError?: (message: ErrorMessage) => void
+ onFixed?: (message: FixedMessage) => void
+ onConnect?: () => void
+ onDisconnect?: () => void
+}
diff --git a/javascript/packages/client/test/patch.test.ts b/javascript/packages/client/test/patch.test.ts
new file mode 100644
index 000000000..657741062
--- /dev/null
+++ b/javascript/packages/client/test/patch.test.ts
@@ -0,0 +1,221 @@
+import { describe, test, expect, beforeEach } from "vitest"
+import { applyPatch } from "../src/patch"
+import type { PatchMessage } from "../src/types"
+
+function createPatch(file: string, operations: PatchMessage["operations"]): PatchMessage {
+ return { type: "patch", file, operations }
+}
+
+function createElement(html: string): HTMLElement {
+ const template = document.createElement("template")
+ template.innerHTML = html.trim()
+
+ const element = template.content.firstElementChild as HTMLElement
+ document.body.appendChild(element)
+
+ return element
+}
+
+describe("applyPatch", () => {
+ beforeEach(() => {
+ document.body.innerHTML = ""
+ })
+
+ describe("text_changed", () => {
+ test("updates text content in matching root", () => {
+ const root = createElement(`Hello
`)
+
+ const result = applyPatch(createPatch("app/views/test.erb", [{
+ type: "text_changed",
+ path: [0],
+ old_value: "Hello",
+ new_value: "World",
+ old_node_type: "AST_HTML_TEXT_NODE",
+ new_node_type: "AST_HTML_TEXT_NODE",
+ }]))
+
+ expect(result).toBe(true)
+ expect(root.textContent).toBe("World")
+ })
+
+ test("updates text in nested elements", () => {
+ const root = createElement(``)
+
+ const p = root.querySelector("p")!
+ const span = root.querySelector("span")!
+
+ const result = applyPatch(createPatch("app/views/test.erb", [{
+ type: "text_changed",
+ path: [0, 0],
+ old_value: "Hello",
+ new_value: "Changed",
+ old_node_type: "AST_HTML_TEXT_NODE",
+ new_node_type: "AST_HTML_TEXT_NODE",
+ }]))
+
+ expect(result).toBe(true)
+ expect(p.textContent).toBe("Changed")
+ expect(span.textContent).toBe("Keep")
+ })
+
+ test("trims whitespace when matching text nodes", () => {
+ const root = createElement(` Hello
`)
+
+ const result = applyPatch(createPatch("test.erb", [{
+ type: "text_changed",
+ path: [0],
+ old_value: "Hello",
+ new_value: "World",
+ old_node_type: "AST_HTML_TEXT_NODE",
+ new_node_type: "AST_HTML_TEXT_NODE",
+ }]))
+
+ expect(result).toBe(true)
+ expect(root.textContent).toBe("World")
+ })
+
+ test("returns false when old text not found", () => {
+ createElement(`Existing
`)
+
+ const result = applyPatch(createPatch("test.erb", [{
+ type: "text_changed",
+ path: [0],
+ old_value: "NotHere",
+ new_value: "World",
+ old_node_type: "AST_HTML_TEXT_NODE",
+ new_node_type: "AST_HTML_TEXT_NODE",
+ }]))
+
+ expect(result).toBe(false)
+ })
+
+ test("patches all matching roots", () => {
+ const root1 = createElement(`Hello
`)
+ const root2 = createElement(`Hello
`)
+
+ const result = applyPatch(createPatch("app/views/_card.erb", [{
+ type: "text_changed",
+ path: [0],
+ old_value: "Hello",
+ new_value: "Updated",
+ old_node_type: "AST_HTML_TEXT_NODE",
+ new_node_type: "AST_HTML_TEXT_NODE",
+ }]))
+
+ expect(result).toBe(true)
+ expect(root1.textContent).toBe("Updated")
+ expect(root2.textContent).toBe("Updated")
+ })
+ })
+
+ describe("attribute_value_changed", () => {
+ test("updates attribute value", () => {
+ const root = createElement(`Content
`)
+
+ const result = applyPatch(createPatch("test.erb", [{
+ type: "attribute_value_changed",
+ path: [0],
+ old_value: 'class="old"',
+ new_value: 'class="new"',
+ old_node_type: "AST_HTML_ATTRIBUTE_NODE",
+ new_node_type: "AST_HTML_ATTRIBUTE_NODE",
+ }]))
+
+ expect(result).toBe(true)
+ expect(root.className).toBe("new")
+ })
+
+ test("updates attribute on nested element", () => {
+ const root = createElement(`Content
`)
+ const span = root.querySelector("span")!
+
+ const result = applyPatch(createPatch("test.erb", [{
+ type: "attribute_value_changed",
+ path: [0, 0],
+ old_value: 'class="old"',
+ new_value: 'class="new"',
+ old_node_type: "AST_HTML_ATTRIBUTE_NODE",
+ new_node_type: "AST_HTML_ATTRIBUTE_NODE",
+ }]))
+
+ expect(result).toBe(true)
+ expect(span.className).toBe("new")
+ })
+ })
+
+ describe("attribute_added", () => {
+ test("adds new attribute to root element", () => {
+ const root = createElement(`Content
`)
+
+ const result = applyPatch(createPatch("test.erb", [{
+ type: "attribute_added",
+ path: [0],
+ old_value: null,
+ new_value: 'id="main"',
+ old_node_type: null,
+ new_node_type: "AST_HTML_ATTRIBUTE_NODE",
+ }]))
+
+ expect(result).toBe(true)
+ expect(root.id).toBe("main")
+ })
+ })
+
+ describe("attribute_removed", () => {
+ test("removes attribute from element", () => {
+ const root = createElement(`Content
`)
+
+ const result = applyPatch(createPatch("test.erb", [{
+ type: "attribute_removed",
+ path: [0],
+ old_value: 'id="remove-me"',
+ new_value: null,
+ old_node_type: "AST_HTML_ATTRIBUTE_NODE",
+ new_node_type: null,
+ }]))
+
+ expect(result).toBe(true)
+ expect(root.id).toBe("")
+ })
+ })
+
+ describe("edge cases", () => {
+ test("returns false when no roots match file", () => {
+ createElement(`No debug attributes
`)
+
+ const result = applyPatch(createPatch("missing.erb", [{
+ type: "text_changed",
+ path: [0],
+ old_value: "Hello",
+ new_value: "World",
+ old_node_type: "AST_HTML_TEXT_NODE",
+ new_node_type: "AST_HTML_TEXT_NODE",
+ }]))
+
+ expect(result).toBe(false)
+ })
+
+ test("returns false for empty operations array", () => {
+ createElement(`Content
`)
+
+ const result = applyPatch(createPatch("test.erb", []))
+
+ expect(result).toBe(false)
+ })
+
+ test("returns false for unhandled operation type", () => {
+ createElement(`Content
`)
+
+ const result = applyPatch(createPatch("test.erb", [{
+ type: "node_inserted",
+ path: [0],
+ old_value: null,
+ new_value: null,
+ old_node_type: null,
+ new_node_type: null,
+ }]))
+
+ expect(result).toBe(false)
+ })
+ })
+})
diff --git a/javascript/packages/client/tsconfig.json b/javascript/packages/client/tsconfig.json
new file mode 100644
index 000000000..ff88a6157
--- /dev/null
+++ b/javascript/packages/client/tsconfig.json
@@ -0,0 +1,26 @@
+{
+ "compilerOptions": {
+ "types": [],
+ "rootDir": "./src",
+ "target": "ES2020",
+ "module": "ES2020",
+ "lib": ["ES2020", "dom"],
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": false,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": false,
+ "strict": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "declaration": false
+ },
+ "include": [
+ "src/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist",
+ "test"
+ ]
+}
diff --git a/javascript/packages/client/vitest.config.ts b/javascript/packages/client/vitest.config.ts
new file mode 100644
index 000000000..7c26d9b7b
--- /dev/null
+++ b/javascript/packages/client/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig } from "vitest/config"
+import { playwright } from "@vitest/browser-playwright"
+
+export default defineConfig({
+ test: {
+ browser: {
+ enabled: true,
+ headless: true,
+ provider: playwright(),
+ instances: [{ browser: "chromium" }],
+ },
+ },
+})
diff --git a/javascript/packages/dev-tools/src/error-overlay.ts b/javascript/packages/dev-tools/src/error-overlay.ts
index e784e35fd..2f42f54d7 100644
--- a/javascript/packages/dev-tools/src/error-overlay.ts
+++ b/javascript/packages/dev-tools/src/error-overlay.ts
@@ -602,6 +602,34 @@ export class ErrorOverlay {
return this.getTotalErrorCount();
}
+ public showErrors(errors: ValidationError[], filename: string): void {
+ this.allValidationData = this.allValidationData.filter(data => data.filename !== filename);
+
+ this.allValidationData.push({
+ validationErrors: errors,
+ filename,
+ timestamp: new Date().toISOString(),
+ });
+
+ if (this.overlay) {
+ this.overlay.remove();
+ this.overlay = null;
+ }
+
+ this.createOverlay();
+ this.show();
+ }
+
+ public clearErrors(): void {
+ this.allValidationData = [];
+
+ if (this.overlay) {
+ this.overlay.remove();
+ this.overlay = null;
+ this.isVisible = false;
+ }
+ }
+
private displayParserErrorOverlay(htmlContent: string) {
const existingOverlay = document.querySelector('.herb-parser-error-overlay');
if (existingOverlay) {
diff --git a/javascript/packages/dev-tools/src/herb-overlay.ts b/javascript/packages/dev-tools/src/herb-overlay.ts
index 9f0080909..4b8ddee33 100644
--- a/javascript/packages/dev-tools/src/herb-overlay.ts
+++ b/javascript/packages/dev-tools/src/herb-overlay.ts
@@ -46,11 +46,19 @@ export class HerbOverlay {
}
}
+ private syncConnectionDot() {
+ const herbClient = (window as any).__herbClient;
+ if (herbClient) {
+ herbClient.applyConnectionDot();
+ }
+ }
+
private init() {
this.loadProjectPath();
this.loadDefaultEditor();
this.loadSettings();
this.injectMenu();
+ this.syncConnectionDot();
this.setupMenuToggle();
this.setupToggleSwitches();
this.setupEditorDropdown();
@@ -148,11 +156,18 @@ export class HerbOverlay {