From 1345fcd76245c5675e97a595eec4bb120c91195e Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Tue, 24 Mar 2026 15:57:49 +0100 Subject: [PATCH 01/11] Herb: Introduce Herb Dev Server and `@herb-tools/client` --- .rubocop.yml | 38 +- Gemfile | 1 + Gemfile.lock | 7 + javascript/packages/client/package.json | 39 ++ javascript/packages/client/rollup.config.mjs | 39 ++ javascript/packages/client/src/connection.ts | 128 ++++++ javascript/packages/client/src/index.ts | 405 ++++++++++++++++++ javascript/packages/client/src/patch.ts | 107 +++++ javascript/packages/client/src/types.ts | 70 +++ javascript/packages/client/tsconfig.json | 26 ++ .../packages/dev-tools/src/error-overlay.ts | 28 ++ .../packages/dev-tools/src/herb-overlay.ts | 16 + javascript/packages/dev-tools/src/index.ts | 17 +- javascript/packages/dev-tools/src/styles.css | 55 +++ lib/herb.rb | 10 + lib/herb/cli.rb | 391 ++++++++++++++++- lib/herb/colors.rb | 18 + lib/herb/dev/server.rb | 194 +++++++++ lib/herb/dev/server_entry.rb | 128 ++++++ 19 files changed, 1695 insertions(+), 22 deletions(-) create mode 100644 javascript/packages/client/package.json create mode 100644 javascript/packages/client/rollup.config.mjs create mode 100644 javascript/packages/client/src/connection.ts create mode 100644 javascript/packages/client/src/index.ts create mode 100644 javascript/packages/client/src/patch.ts create mode 100644 javascript/packages/client/src/types.ts create mode 100644 javascript/packages/client/tsconfig.json create mode 100644 lib/herb/dev/server.rb create mode 100644 lib/herb/dev/server_entry.rb diff --git a/.rubocop.yml b/.rubocop.yml index 4f2982a1f..94b3acf9d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -55,48 +55,50 @@ Style/TrailingCommaInHashLiteral: Metrics/CyclomaticComplexity: Max: 15 Exclude: - - lib/herb/project.rb + - bench/**/* - lib/herb/ast/nodes.rb - lib/herb/cli.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/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/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: @@ -108,6 +110,7 @@ Metrics/ClassLength: - lib/herb/project.rb - lib/herb/visitor.rb - lib/herb/ast/nodes.rb + - lib/herb/dev/server.rb - test/**/*_test.rb - templates/template.rb @@ -141,16 +144,17 @@ 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/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/javascript/packages/client/package.json b/javascript/packages/client/package.json new file mode 100644 index 000000000..bea481c1f --- /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": "echo 'TODO: add tests'", + "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/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/connection.ts b/javascript/packages/client/src/connection.ts new file mode 100644 index 000000000..8afc3ad21 --- /dev/null +++ b/javascript/packages/client/src/connection.ts @@ -0,0 +1,128 @@ +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 + console.debug("[herb-client] connected to dev server") + 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 = () => { + this.socket?.close() + } + } 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.onGiveUp?.() + 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..25d972688 --- /dev/null +++ b/javascript/packages/client/src/index.ts @@ -0,0 +1,405 @@ +import { Connection } from "./connection" +import { applyPatch } from "./patch" + +import type { + ConnectionOptions, + ConnectionState, + HerbClientOptions, + HerbMessage, + WelcomeMessage, + PatchMessage, + ReloadMessage, + ErrorMessage, + FixedMessage, + DiffOperation, + ParseError, +} from "./types" + +export type { + ConnectionOptions, + ConnectionState, + HerbClientOptions, + HerbMessage, + WelcomeMessage, + PatchMessage, + ReloadMessage, + ErrorMessage, + FixedMessage, + DiffOperation, + ParseError, +} + +export { Connection, applyPatch } + +const DEFAULT_PORT = 8592 + +export class HerbClient { + private connection: Connection + private options: HerbClientOptions + private port: number + private hasConnectedBefore = false + + constructor(options: HerbClientOptions = {}) { + this.options = options + + const port = options.port ?? this.detectPort() ?? DEFAULT_PORT + const host = options.host ?? "localhost" + this.port = port + + this.hasConnectedBefore = false + + this.connection = new Connection({ + url: `ws://${host}:${port}`, + onMessage: (message) => this.handleMessage(message), + onConnect: () => { + const wasDisconnected = (window as any).__herbClientState === "disconnected" || (window as any).__herbClientState === "gave-up" + + if (this.hasConnectedBefore && wasDisconnected) { + this.showToast("Herb Dev Server reconnected", "connected") + } + this.hasConnectedBefore = true + this.updateConnectionDot("connected") + options.onConnect?.() + }, + onDisconnect: () => { + if (this.hasConnectedBefore && (window as any).__herbClientState === "connected") { + this.showToast("Herb Dev Server disconnected", "disconnected") + } + this.updateConnectionDot("disconnected") + options.onDisconnect?.() + }, + onReconnecting: (attempt, maxAttempts, delay) => { + this.updateReconnectStatus(attempt, maxAttempts, delay) + }, + onGiveUp: () => { + this.updateConnectionDot("gave-up") + this.showToast("Herb Dev Server not available — click the dot to retry", "warning") + }, + }) + } + + connect(): void { + this.connection.connect() + } + + disconnect(): void { + this.connection.disconnect() + } + + retry(): void { + this.updateConnectionDot("disconnected") + this.connection.retry() + } + + private projectMatch: boolean | null = null + + private handleMessage(message: HerbMessage): void { + switch (message.type) { + case "welcome": + this.handleWelcome(message) + break + + case "patch": + if (this.projectMatch === false) return + this.handlePatch(message) + break + + case "reload": + if (this.projectMatch === false) return + this.handleReload(message) + break + + case "error": + if (this.projectMatch === false) return + this.handleError(message) + break + + case "fixed": + if (this.projectMatch === false) return + this.handleFixed(message) + break + } + } + + private handleWelcome(message: WelcomeMessage): void { + const projectMeta = document.querySelector('meta[name="herb-project-path"]') + const clientProject = projectMeta?.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.updateConnectionDot("disconnected") + this.showMismatchAlert(message.project, clientProject) + } else { + this.projectMatch = true + console.debug(`[herb-client] project matched: ${message.project}`) + } + } + + private showMismatchAlert(serverProject: string, clientProject: string): void { + const existing = document.getElementById("herbProjectMismatchAlert") + if (existing) return + + const serverName = serverProject.split("/").pop() + const clientName = clientProject.split("/").pop() + + const alert = document.createElement("div") + alert.id = "herbProjectMismatchAlert" + alert.style.cssText = "position:fixed;top:32px;right:10px;z-index:999998;background:#fffbeb;border:1px solid #f59e0b;border-radius:8px;padding:12px 16px;max-width:320px;font-family:system-ui,sans-serif;font-size:13px;color:#92400e;box-shadow:0 4px 12px rgba(0,0,0,0.1);display:flex;gap:10px;align-items:flex-start;" + + alert.innerHTML = ` + ⚠️ +
+
Herb Dev Server mismatch
+
+ The dev server is watching ${serverName} but this page is from ${clientName}. + Messages will be ignored. +
+
+ + ` + + document.body.appendChild(alert) + + document.getElementById("herbMismatchDismiss")?.addEventListener("click", () => { + alert.remove() + }) + + const panelStatus = document.getElementById("herbDevServerStatus") + const panelDot = document.getElementById("herbDevServerDot") + + if (panelStatus) { + panelStatus.textContent = `Wrong project (${serverName})` + panelStatus.style.color = "#d97706" + } + if (panelDot) panelDot.style.background = "#f59e0b" + } + + private handlePatch(message: PatchMessage): void { + this.options.onPatch?.(message) + + const applied = applyPatch(message) + + if (applied) { + console.debug(`[herb-client] patched ${message.file} (${message.operations.length} operations)`) + } else { + console.debug(`[herb-client] no matching elements for ${message.file}, skipping`) + } + } + + private handleReload(message: ReloadMessage): void { + this.options.onReload?.(message) + console.debug(`[herb-client] reloading for ${message.file}`) + window.location.reload() + } + + private getErrorOverlay(): any { + return (window as any).HerbDevTools?._errorOverlay + ?? (window as any).HerbDevTools?._overlay?.errorOverlay + ?? null + } + + private handleError(message: ErrorMessage): void { + this.options.onError?.(message) + console.debug(`[herb-client] ${message.errors.length} error(s) in ${message.file}`) + + 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) + + const overlay = this.getErrorOverlay() + console.debug(`[herb-client] errors fixed in ${message.file}`, { overlay, devTools: (window as any).HerbDevTools }) + + if (overlay) { + overlay.clearErrors() + } else { + console.debug("[herb-client] no error overlay found to clear") + } + } + + private updateConnectionDot(state: "connected" | "disconnected" | "gave-up"): void { + (window as any).__herbClientConnected = state === "connected" + ;(window as any).__herbClientState = state + + if (this.reconnectCountdown) { + clearInterval(this.reconnectCountdown) + this.reconnectCountdown = null + } + + this.applyConnectionDot() + } + + private applyConnectionDot(): void { + const dot = document.getElementById("herbConnectionDot") + if (!dot) return + + const state = (window as any).__herbClientState ?? "disconnected" + + const panelDot = document.getElementById("herbDevServerDot") + const panelStatus = document.getElementById("herbDevServerStatus") + const panelRetry = document.getElementById("herbDevServerRetry") as HTMLButtonElement | null + + const setDot = (element: HTMLElement, background: string, glow: boolean, pulse: boolean) => { + element.style.background = background + element.style.boxShadow = glow ? "0 0 4px rgba(34, 197, 94, 0.5)" : "none" + element.style.animation = pulse ? "herb-dot-pulse 2s ease-in-out infinite" : "none" + } + + const retryHandler = (event: MouseEvent) => { + event.stopPropagation() + instance?.retry() + } + + switch (state) { + case "connected": + setDot(dot, "#22c55e", true, true) + dot.style.cursor = "default" + dot.title = "Connected to herb dev server" + dot.onclick = null + + if (panelDot) setDot(panelDot, "#22c55e", false, false) + if (panelStatus) { panelStatus.textContent = `Dev Server connected (port ${this.port})`; panelStatus.style.color = "#059669" } + if (panelRetry) panelRetry.style.display = "none" + break + + case "disconnected": + setDot(dot, "#ef4444", false, false) + dot.style.cursor = "default" + dot.title = "Disconnected from herb dev server" + dot.onclick = null + + if (panelDot) setDot(panelDot, "#ef4444", false, false) + if (panelStatus) { panelStatus.textContent = "Dev Server disconnected"; panelStatus.style.color = "#6b7280" } + if (panelRetry) { panelRetry.style.display = "block"; panelRetry.onclick = retryHandler } + break + + case "gave-up": + setDot(dot, "#f59e0b", false, false) + dot.style.cursor = "pointer" + dot.title = "Connection to herb dev server failed — click to retry" + dot.onclick = retryHandler + + if (panelDot) setDot(panelDot, "#f59e0b", false, false) + if (panelStatus) { panelStatus.textContent = "Dev Server not available"; panelStatus.style.color = "#d97706" } + if (panelRetry) { panelRetry.style.display = "block"; panelRetry.onclick = retryHandler } + break + } + } + + private reconnectCountdown: ReturnType | null = null + + private updateReconnectStatus(attempt: number, maxAttempts: number, delay: number): void { + console.debug(`[herb-client] reconnecting (attempt ${attempt}/${maxAttempts}, next try in ${(delay / 1000).toFixed(1)}s)...`) + + const panelStatus = document.getElementById("herbDevServerStatus") + + if (!panelStatus) return + if (this.reconnectCountdown) clearInterval(this.reconnectCountdown) + + let remaining = Math.ceil(delay / 1000) + + panelStatus.textContent = `Retry ${attempt}/${maxAttempts} in ${remaining}s` + panelStatus.style.color = "#6b7280" + + this.reconnectCountdown = setInterval(() => { + remaining-- + + if (remaining <= 0) { + if (this.reconnectCountdown) clearInterval(this.reconnectCountdown) + panelStatus.textContent = `Retry ${attempt}/${maxAttempts} connecting...` + return + } + + panelStatus.textContent = `Retry ${attempt}/${maxAttempts} in ${remaining}s` + }, 1000) + } + + private showToast(message: string, type: "connected" | "disconnected" | "warning"): void { + const existing = document.getElementById("herbDevServerToast") + if (existing) existing.remove() + + const colors = { + connected: { background: "#ecfdf5", border: "#10b981", text: "#065f46", icon: "🟢" }, + disconnected: { background: "#fef2f2", border: "#ef4444", text: "#991b1b", icon: "🔴" }, + warning: { background: "#fffbeb", border: "#f59e0b", text: "#92400e", icon: "🟡" }, + } + + const style = colors[type] + + const toast = document.createElement("div") + toast.id = "herbDevServerToast" + 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;` + toast.innerHTML = `${style.icon}${message}` + + document.body.appendChild(toast) + + setTimeout(() => { + toast.style.opacity = "0" + setTimeout(() => toast.remove(), 300) + }, 3000) + } + + 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 + } +} + +let instance: HerbClient | null = null + +export function initHerbClient(options: HerbClientOptions = {}): HerbClient { + if (instance) { + instance.disconnect() + } + + instance = new HerbClient(options) + ;(window as any).__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"]') + + console.debug("[herb-client] auto-init check:", { + debugMeta: debugMeta?.getAttribute("content"), + readyState: document.readyState, + port: document.querySelector('meta[name="herb-dev-server-port"]')?.getAttribute("content"), + }) + + 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/patch.ts b/javascript/packages/client/src/patch.ts new file mode 100644 index 000000000..07c47d063 --- /dev/null +++ b/javascript/packages/client/src/patch.ts @@ -0,0 +1,107 @@ +import type { DiffOperation, PatchMessage } from "./types" + +export function applyPatch(message: PatchMessage): boolean { + const selector = `[data-herb-debug-file-relative-path="${message.file}"]` + const elements = document.querySelectorAll(selector) + + if (elements.length === 0) { + return false + } + + for (const operation of message.operations) { + applyOperation(elements, operation) + } + + return true +} + +function applyOperation(elements: NodeListOf, operation: DiffOperation): void { + switch (operation.type) { + case "text_changed": + applyTextChange(elements, operation) + break + + case "attribute_value_changed": + applyAttributeChange(elements, operation) + break + + case "attribute_added": + applyAttributeAdd(elements, operation) + break + + case "attribute_removed": + applyAttributeRemove(elements, operation) + break + + default: + console.debug(`[herb-client] unhandled operation type: ${operation.type}`) + } +} + +function applyTextChange(elements: NodeListOf, operation: DiffOperation): void { + if (operation.old_value === null || operation.new_value === null) return + + for (const element of elements) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT) + let node: Text | null + + while ((node = walker.nextNode() as Text | null)) { + if (node.textContent?.trim() === operation.old_value!.trim()) { + node.textContent = operation.new_value! + return + } + } + } +} + +function applyAttributeChange(elements: NodeListOf, operation: DiffOperation): void { + if (operation.old_value === null || operation.new_value === null) return + + const oldMatch = operation.old_value.match(/^([^=]+)="(.*)"$/) + const newMatch = operation.new_value.match(/^([^=]+)="(.*)"$/) + + if (!oldMatch || !newMatch) return + + const attributeName = oldMatch[1] + const oldAttributeValue = oldMatch[2] + const newAttributeValue = newMatch[2] + + for (const element of elements) { + const targets = element.querySelectorAll(`[${attributeName}="${oldAttributeValue}"]`) + + for (const target of targets) { + target.setAttribute(attributeName, newAttributeValue) + } + + if (element.getAttribute(attributeName) === oldAttributeValue) { + element.setAttribute(attributeName, newAttributeValue) + } + } +} + +function applyAttributeAdd(elements: NodeListOf, operation: DiffOperation): void { + if (operation.new_value === null) return + + const match = operation.new_value.match(/^([^=]+)="(.*)"$/) + if (!match) return + + const attributeName = match[1] + const attributeValue = match[2] + + for (const element of elements) { + element.setAttribute(attributeName, attributeValue) + } +} + +function applyAttributeRemove(elements: NodeListOf, operation: DiffOperation): void { + if (operation.old_value === null) return + + const match = operation.old_value.match(/^([^=]+)(?:=".*")?$/) + if (!match) return + + const attributeName = match[1] + + for (const element of elements) { + element.removeAttribute(attributeName) + } +} diff --git a/javascript/packages/client/src/types.ts b/javascript/packages/client/src/types.ts new file mode 100644 index 000000000..6f932cd68 --- /dev/null +++ b/javascript/packages/client/src/types.ts @@ -0,0 +1,70 @@ +export interface DiffOperation { + type: string + 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" | "gave-up" + +export type MessageHandler = (message: HerbMessage) => void + +export interface ConnectionOptions { + url: string + reconnectInterval?: number + maxReconnectAttempts?: number + onMessage?: MessageHandler + onConnect?: () => void + onDisconnect?: () => void + onGiveUp?: () => 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/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/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 + +
+