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(`

Hello

Keep
`) + + 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 {
Herb Debug Tools
+
+ + Dev Server + +
+