a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: binding evaluator + updates

+654 -13
+6 -1
dprint.json
··· 1 1 { 2 2 "typescript": { "preferSingleLine": true, "jsx.bracketPosition": "sameLine" }, 3 3 "json": { "preferSingleLine": true, "lineWidth": 121, "indentWidth": 2 }, 4 + "markup": { "preferAttrsSingleLine": true, "printWidth": 121, "styleIndent": true }, 4 5 "excludes": ["**/node_modules"], 5 - "plugins": ["https://plugins.dprint.dev/typescript-0.95.8.wasm", "https://plugins.dprint.dev/json-0.20.0.wasm"] 6 + "plugins": [ 7 + "https://plugins.dprint.dev/typescript-0.95.8.wasm", 8 + "https://plugins.dprint.dev/json-0.20.0.wasm", 9 + "https://plugins.dprint.dev/g-plane/markup_fmt-v0.24.0.wasm" 10 + ] 6 11 }
+67 -3
index.html
··· 1 - <!doctype html> 1 + <!DOCTYPE html> 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>volt</title> 7 + <title>Volt.js Demo</title> 8 + <style> 9 + body { 10 + font-family: system-ui, -apple-system, sans-serif; 11 + max-width: 800px; 12 + margin: 2rem auto; 13 + padding: 0 1rem; 14 + line-height: 1.6; 15 + } 16 + .card { 17 + border: 1px solid #ddd; 18 + border-radius: 8px; 19 + padding: 1.5rem; 20 + margin: 1rem 0; 21 + } 22 + button { 23 + background: #646cff; 24 + color: white; 25 + border: none; 26 + padding: 0.6rem 1.2rem; 27 + border-radius: 4px; 28 + cursor: pointer; 29 + margin: 0.5rem 0.5rem 0.5rem 0; 30 + font-size: 1rem; 31 + } 32 + button:hover { 33 + background: #535bf2; 34 + } 35 + .active { 36 + color: #646cff; 37 + font-weight: bold; 38 + } 39 + .highlight { 40 + background: #fff3cd; 41 + padding: 0.25rem 0.5rem; 42 + border-radius: 4px; 43 + } 44 + </style> 8 45 </head> 9 46 <body> 10 - <div id="app"></div> 47 + <div id="app"> 48 + <h1 data-x-text="message">Loading...</h1> 49 + 50 + <div class="card"> 51 + <h2>Reactive Counter</h2> 52 + <p>Current count: <strong data-x-text="count">0</strong></p> 53 + <button onclick="increment()">Increment</button> 54 + <button onclick="updateMessage()">Update Message</button> 55 + </div> 56 + 57 + <div class="card"> 58 + <h2>Class Bindings</h2> 59 + <p data-x-class="classes"> 60 + This text has dynamic classes applied. 61 + </p> 62 + <p> 63 + Active: <span data-x-text="isActive">false</span> 64 + </p> 65 + <button onclick="toggleActive()">Toggle Active</button> 66 + </div> 67 + 68 + <div class="card"> 69 + <h2>HTML Binding</h2> 70 + <div data-x-html="'<em>This is rendered as HTML</em>'"> 71 + Fallback content 72 + </div> 73 + </div> 74 + </div> 11 75 <script type="module" src="/src/main.ts"></script> 12 76 </body> 13 77 </html>
+2 -9
package.json
··· 1 1 { 2 2 "name": "volt", 3 3 "private": true, 4 - "version": "0.0.0", 4 + "version": "0.1.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite", ··· 31 31 "vitest": "^3.2.4", 32 32 "vue": "^3.5.22" 33 33 }, 34 - "pnpm": { 35 - "overrides": { 36 - "vite": "npm:rolldown-vite@7.1.14" 37 - }, 38 - "onlyBuiltDependencies": [ 39 - "dprint" 40 - ] 41 - } 34 + "pnpm": { "overrides": { "vite": "npm:rolldown-vite@7.1.14" }, "onlyBuiltDependencies": ["dprint"] } 42 35 }
+199
src/core/binder.ts
··· 1 + /** 2 + * Binder system for mounting and managing Volt.js bindings 3 + */ 4 + 5 + import { getVoltAttributes, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 6 + import { evaluate, type Scope } from "./evaluator"; 7 + import type { Signal } from "./signal"; 8 + 9 + /** 10 + * Cleanup function returned by binding handlers 11 + */ 12 + type CleanupFunction = () => void; 13 + 14 + /** 15 + * Context object available to all bindings 16 + */ 17 + interface BindingContext { 18 + element: Element; 19 + scope: Scope; 20 + cleanups: CleanupFunction[]; 21 + } 22 + 23 + /** 24 + * Mount Volt.js on a root element and its descendants. 25 + * Binds all data-x-* attributes to the provided scope. 26 + * Returns a cleanup function to unmount and dispose all bindings. 27 + * 28 + * @param root - Root element to mount on 29 + * @param scope - Scope object containing signals and data 30 + * @returns Cleanup function to unmount 31 + */ 32 + export function mount(root: Element, scope: Scope): CleanupFunction { 33 + const elements = walkDOM(root); 34 + const allCleanups: CleanupFunction[] = []; 35 + 36 + for (const element of elements) { 37 + const attributes = getVoltAttributes(element); 38 + const context: BindingContext = { element, scope, cleanups: [] }; 39 + 40 + for (const [name, value] of attributes) { 41 + bindAttribute(context, name, value); 42 + } 43 + 44 + allCleanups.push(...context.cleanups); 45 + } 46 + 47 + return () => { 48 + for (const cleanup of allCleanups) { 49 + try { 50 + cleanup(); 51 + } catch (error) { 52 + console.error("Error during unmount:", error); 53 + } 54 + } 55 + }; 56 + } 57 + 58 + /** 59 + * Bind a single data-x-* attribute to an element. 60 + * Routes to the appropriate binding handler. 61 + * 62 + * @param context - Binding context 63 + * @param name - Attribute name (without data-x- prefix) 64 + * @param value - Attribute value (expression) 65 + */ 66 + function bindAttribute(context: BindingContext, name: string, value: string): void { 67 + switch (name) { 68 + case "text": { 69 + bindText(context, value); 70 + break; 71 + } 72 + case "html": { 73 + bindHTML(context, value); 74 + break; 75 + } 76 + case "class": { 77 + bindClass(context, value); 78 + break; 79 + } 80 + default: { 81 + console.warn(`Unknown binding: data-x-${name}`); 82 + } 83 + } 84 + } 85 + 86 + /** 87 + * Bind data-x-text to update element's text content. 88 + * Subscribes to signals in the expression and updates on change. 89 + * 90 + * @param context - Binding context 91 + * @param expression - Expression to evaluate 92 + */ 93 + function bindText(context: BindingContext, expression: string): void { 94 + const update = () => { 95 + const value = evaluate(expression, context.scope); 96 + setText(context.element, value); 97 + }; 98 + 99 + update(); 100 + 101 + const signal = findSignalInScope(context.scope, expression); 102 + if (signal) { 103 + const unsubscribe = signal.subscribe(update); 104 + context.cleanups.push(unsubscribe); 105 + } 106 + } 107 + 108 + /** 109 + * Bind data-x-html to update element's HTML content. 110 + * Subscribes to signals in the expression and updates on change. 111 + * 112 + * @param context - Binding context 113 + * @param expression - Expression to evaluate 114 + */ 115 + function bindHTML(context: BindingContext, expression: string): void { 116 + const update = () => { 117 + const value = evaluate(expression, context.scope); 118 + setHTML(context.element, String(value ?? "")); 119 + }; 120 + 121 + update(); 122 + 123 + const signal = findSignalInScope(context.scope, expression); 124 + if (signal) { 125 + const unsubscribe = signal.subscribe(update); 126 + context.cleanups.push(unsubscribe); 127 + } 128 + } 129 + 130 + /** 131 + * Bind data-x-class to toggle CSS classes. 132 + * Supports both string and object notation. 133 + * Subscribes to signals in the expression and updates on change. 134 + * 135 + * @param context - Binding context 136 + * @param expression - Expression to evaluate 137 + */ 138 + function bindClass(context: BindingContext, expression: string): void { 139 + let previousClasses = new Map<string, boolean>(); 140 + 141 + const update = () => { 142 + const value = evaluate(expression, context.scope); 143 + const classes = parseClassBinding(value); 144 + 145 + for (const [className] of previousClasses) { 146 + if (!classes.has(className)) { 147 + toggleClass(context.element, className, false); 148 + } 149 + } 150 + 151 + for (const [className, shouldAdd] of classes) { 152 + toggleClass(context.element, className, shouldAdd); 153 + } 154 + 155 + previousClasses = classes; 156 + }; 157 + 158 + update(); 159 + 160 + const signal = findSignalInScope(context.scope, expression); 161 + if (signal) { 162 + const unsubscribe = signal.subscribe(update); 163 + context.cleanups.push(unsubscribe); 164 + } 165 + } 166 + 167 + /** 168 + * Find a signal in the scope by resolving a simple property path. 169 + * Returns the signal if found, otherwise undefined. 170 + * 171 + * @param scope - Scope object 172 + * @param path - Property path (e.g., "count" or "user.name") 173 + * @returns Signal if found, undefined otherwise 174 + */ 175 + function findSignalInScope(scope: Scope, path: string): Signal<unknown> | undefined { 176 + const trimmed = path.trim(); 177 + const parts = trimmed.split("."); 178 + let current: unknown = scope; 179 + 180 + for (const part of parts) { 181 + if (current === null || current === undefined) { 182 + return undefined; 183 + } 184 + 185 + if (typeof current === "object" && part in (current as Record<string, unknown>)) { 186 + current = (current as Record<string, unknown>)[part]; 187 + } else { 188 + return undefined; 189 + } 190 + } 191 + 192 + if ( 193 + typeof current === "object" && current !== null && "get" in current && "set" in current && "subscribe" in current 194 + ) { 195 + return current as Signal<unknown>; 196 + } 197 + 198 + return undefined; 199 + }
+22
src/core/evaluator.ts
··· 51 51 /** 52 52 * Resolve a property path in a scope object. 53 53 * Supports nested property access like "user.profile.name". 54 + * Automatically unwraps signals by calling .get(). 54 55 * 55 56 * @param path - The property path (e.g., "user.name") 56 57 * @param scope - The scope object ··· 72 73 } 73 74 } 74 75 76 + if (isSignal(current)) { 77 + return current.get(); 78 + } 79 + 75 80 return current; 76 81 } 82 + 83 + /** 84 + * Check if a value is a Signal. 85 + * 86 + * @param value - Value to check 87 + * @returns true if the value is a Signal 88 + */ 89 + function isSignal(value: unknown): value is { get: () => unknown } { 90 + return ( 91 + typeof value === "object" && 92 + value !== null && 93 + "get" in value && 94 + "set" in value && 95 + "subscribe" in value && 96 + typeof value.get === "function" 97 + ); 98 + }
+1
src/index.ts
··· 5 5 */ 6 6 7 7 export { signal, type Signal } from "./core/signal"; 8 + export { mount } from "./core/binder";
+29
src/main.ts
··· 1 + import { mount, signal } from "./index"; 2 + 3 + const count = signal(0); 4 + const message = signal("Welcome to Volt.js!"); 5 + const isActive = signal(true); 6 + 7 + const scope = { 8 + count, 9 + message, 10 + isActive, 11 + classes: signal({ active: true, highlight: false }), 12 + }; 13 + 14 + const app = document.querySelector("#app"); 15 + if (app) { 16 + mount(app, scope); 17 + } 18 + 19 + globalThis.increment = () => { 20 + count.set(count.get() + 1); 21 + }; 22 + 23 + globalThis.toggleActive = () => { 24 + isActive.set(!isActive.get()); 25 + }; 26 + 27 + globalThis.updateMessage = () => { 28 + message.set(`Count is now ${count.get()}`); 29 + };
+207
test/core/binder.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { mount } from "../../src/core/binder"; 3 + import { signal } from "../../src/core/signal"; 4 + 5 + describe("binder", () => { 6 + describe("mount", () => { 7 + it("returns a cleanup function", () => { 8 + const element = document.createElement("div"); 9 + const cleanup = mount(element, {}); 10 + 11 + expect(typeof cleanup).toBe("function"); 12 + cleanup(); 13 + }); 14 + 15 + it("binds data-x-text to element text content", () => { 16 + const element = document.createElement("div"); 17 + element.dataset.xText = "message"; 18 + 19 + const scope = { message: "Hello, World!" }; 20 + mount(element, scope); 21 + 22 + expect(element.textContent).toBe("Hello, World!"); 23 + }); 24 + 25 + it("updates text content when signal changes", () => { 26 + const element = document.createElement("div"); 27 + element.dataset.xText = "count"; 28 + 29 + const count = signal(0); 30 + const scope = { count }; 31 + mount(element, scope); 32 + 33 + expect(element.textContent).toBe("0"); 34 + 35 + count.set(5); 36 + expect(element.textContent).toBe("5"); 37 + 38 + count.set(10); 39 + expect(element.textContent).toBe("10"); 40 + }); 41 + 42 + it("binds data-x-html to element HTML content", () => { 43 + const element = document.createElement("div"); 44 + element.dataset.xHtml = "content"; 45 + 46 + const scope = { content: "<strong>Bold</strong>" }; 47 + mount(element, scope); 48 + 49 + expect(element.innerHTML).toBe("<strong>Bold</strong>"); 50 + }); 51 + 52 + it("updates HTML content when signal changes", () => { 53 + const element = document.createElement("div"); 54 + element.dataset.xHtml = "html"; 55 + 56 + const html = signal("<em>Italic</em>"); 57 + const scope = { html }; 58 + mount(element, scope); 59 + 60 + expect(element.innerHTML).toBe("<em>Italic</em>"); 61 + 62 + html.set("<strong>Bold</strong>"); 63 + expect(element.innerHTML).toBe("<strong>Bold</strong>"); 64 + }); 65 + 66 + it("binds data-x-class with string value", () => { 67 + const element = document.createElement("div"); 68 + element.dataset.xClass = "classes"; 69 + 70 + const scope = { classes: "active highlight" }; 71 + mount(element, scope); 72 + 73 + expect(element.classList.contains("active")).toBe(true); 74 + expect(element.classList.contains("highlight")).toBe(true); 75 + }); 76 + 77 + it("binds data-x-class with object value", () => { 78 + const element = document.createElement("div"); 79 + element.dataset.xClass = "classes"; 80 + 81 + const scope = { classes: { active: true, disabled: false } }; 82 + mount(element, scope); 83 + 84 + expect(element.classList.contains("active")).toBe(true); 85 + expect(element.classList.contains("disabled")).toBe(false); 86 + }); 87 + 88 + it("updates classes when signal changes", () => { 89 + const element = document.createElement("div"); 90 + element.dataset.xClass = "classes"; 91 + 92 + const classes = signal({ active: false, disabled: false }); 93 + const scope = { classes }; 94 + mount(element, scope); 95 + 96 + expect(element.classList.contains("active")).toBe(false); 97 + 98 + classes.set({ active: true, disabled: false }); 99 + expect(element.classList.contains("active")).toBe(true); 100 + expect(element.classList.contains("disabled")).toBe(false); 101 + 102 + classes.set({ active: false, disabled: true }); 103 + expect(element.classList.contains("active")).toBe(false); 104 + expect(element.classList.contains("disabled")).toBe(true); 105 + }); 106 + 107 + it("removes old classes when signal changes", () => { 108 + const element = document.createElement("div"); 109 + element.dataset.xClass = "classes"; 110 + 111 + const classes = signal("foo bar"); 112 + const scope = { classes }; 113 + mount(element, scope); 114 + 115 + expect(element.classList.contains("foo")).toBe(true); 116 + expect(element.classList.contains("bar")).toBe(true); 117 + 118 + classes.set("baz"); 119 + expect(element.classList.contains("foo")).toBe(false); 120 + expect(element.classList.contains("bar")).toBe(false); 121 + expect(element.classList.contains("baz")).toBe(true); 122 + }); 123 + 124 + it("binds nested elements", () => { 125 + const parent = document.createElement("div"); 126 + const child1 = document.createElement("span"); 127 + const child2 = document.createElement("span"); 128 + parent.append(child1, child2); 129 + 130 + child1.dataset.xText = "first"; 131 + child2.dataset.xText = "second"; 132 + 133 + const scope = { first: "First", second: "Second" }; 134 + mount(parent, scope); 135 + 136 + expect(child1.textContent).toBe("First"); 137 + expect(child2.textContent).toBe("Second"); 138 + expect(parent.textContent).toBe("FirstSecond"); 139 + }); 140 + 141 + it("cleans up subscriptions on unmount", () => { 142 + const element = document.createElement("div"); 143 + element.dataset.xText = "count"; 144 + 145 + const count = signal(0); 146 + const scope = { count }; 147 + const cleanup = mount(element, scope); 148 + 149 + count.set(5); 150 + expect(element.textContent).toBe("5"); 151 + 152 + cleanup(); 153 + 154 + count.set(10); 155 + expect(element.textContent).toBe("5"); 156 + }); 157 + 158 + it("handles multiple bindings on the same element", () => { 159 + const element = document.createElement("div"); 160 + element.dataset.xText = "message"; 161 + element.dataset.xClass = "classes"; 162 + 163 + const message = signal("Hello"); 164 + const classes = signal("active"); 165 + const scope = { message, classes }; 166 + mount(element, scope); 167 + 168 + expect(element.textContent).toBe("Hello"); 169 + expect(element.classList.contains("active")).toBe(true); 170 + 171 + message.set("Goodbye"); 172 + classes.set("inactive"); 173 + 174 + expect(element.textContent).toBe("Goodbye"); 175 + expect(element.classList.contains("inactive")).toBe(true); 176 + }); 177 + 178 + it("evaluates nested property paths", () => { 179 + const element = document.createElement("div"); 180 + element.dataset.xText = "user.name"; 181 + 182 + const scope = { user: { name: "Alice" } }; 183 + mount(element, scope); 184 + 185 + expect(element.textContent).toBe("Alice"); 186 + }); 187 + 188 + it("handles static values (no signals)", () => { 189 + const element = document.createElement("div"); 190 + element.dataset.xText = "message"; 191 + 192 + const scope = { message: "Static" }; 193 + mount(element, scope); 194 + 195 + expect(element.textContent).toBe("Static"); 196 + }); 197 + 198 + it("handles literal expressions", () => { 199 + const element = document.createElement("div"); 200 + element.dataset.xText = "'Hello'"; 201 + 202 + mount(element, {}); 203 + 204 + expect(element.textContent).toBe("Hello"); 205 + }); 206 + }); 207 + });
+121
test/integration/mount.test.ts
··· 1 + import { describe, expect, it } from "vitest"; 2 + import { mount, signal } from "../../src/index"; 3 + 4 + describe("integration: mount", () => { 5 + it("creates a reactive counter", () => { 6 + const container = document.createElement("div"); 7 + container.innerHTML = ` 8 + <div> 9 + <p data-x-text="count">0</p> 10 + <p data-x-class="countClass">Classes</p> 11 + </div> 12 + `; 13 + 14 + const count = signal(0); 15 + const countClass = signal({ positive: false, zero: true }); 16 + 17 + mount(container, { count, countClass }); 18 + 19 + const textElement = container.querySelector("p:first-child"); 20 + const classElement = container.querySelector("p:last-child"); 21 + 22 + expect(textElement?.textContent).toBe("0"); 23 + expect(classElement?.classList.contains("zero")).toBe(true); 24 + expect(classElement?.classList.contains("positive")).toBe(false); 25 + 26 + count.set(5); 27 + countClass.set({ positive: true, zero: false }); 28 + 29 + expect(textElement?.textContent).toBe("5"); 30 + expect(classElement?.classList.contains("zero")).toBe(false); 31 + expect(classElement?.classList.contains("positive")).toBe(true); 32 + }); 33 + 34 + it("handles complex nested structures", () => { 35 + const container = document.createElement("div"); 36 + container.innerHTML = ` 37 + <div data-x-text="title">Title</div> 38 + <ul> 39 + <li data-x-text="items.first">First</li> 40 + <li data-x-text="items.second">Second</li> 41 + </ul> 42 + <footer data-x-html="footer">Footer</footer> 43 + `; 44 + 45 + const title = signal("My App"); 46 + const items = { first: "Item 1", second: "Item 2" }; 47 + const footer = signal("<strong>© 2025</strong>"); 48 + 49 + mount(container, { title, items, footer }); 50 + 51 + expect(container.querySelector("[data-x-text='title']")?.textContent).toBe("My App"); 52 + expect(container.querySelector("li:first-child")?.textContent).toBe("Item 1"); 53 + expect(container.querySelector("li:last-child")?.textContent).toBe("Item 2"); 54 + expect(container.querySelector("footer")?.innerHTML).toBe("<strong>© 2025</strong>"); 55 + 56 + title.set("Updated App"); 57 + footer.set("<em>New Footer</em>"); 58 + 59 + expect(container.querySelector("[data-x-text='title']")?.textContent).toBe("Updated App"); 60 + expect(container.querySelector("footer")?.innerHTML).toBe("<em>New Footer</em>"); 61 + }); 62 + 63 + it("properly cleans up all bindings", () => { 64 + const container = document.createElement("div"); 65 + container.innerHTML = ` 66 + <div data-x-text="a">A</div> 67 + <div data-x-text="b">B</div> 68 + <div data-x-text="c">C</div> 69 + `; 70 + 71 + const a = signal("A"); 72 + const b = signal("B"); 73 + const c = signal("C"); 74 + 75 + const cleanup = mount(container, { a, b, c }); 76 + 77 + const divs = [...container.querySelectorAll("div")]; 78 + expect(divs[0]?.textContent).toBe("A"); 79 + expect(divs[1]?.textContent).toBe("B"); 80 + expect(divs[2]?.textContent).toBe("C"); 81 + 82 + a.set("A1"); 83 + b.set("B1"); 84 + c.set("C1"); 85 + 86 + expect(divs[0]?.textContent).toBe("A1"); 87 + expect(divs[1]?.textContent).toBe("B1"); 88 + expect(divs[2]?.textContent).toBe("C1"); 89 + 90 + cleanup(); 91 + 92 + a.set("A2"); 93 + b.set("B2"); 94 + c.set("C2"); 95 + 96 + expect(divs[0]?.textContent).toBe("A1"); 97 + expect(divs[1]?.textContent).toBe("B1"); 98 + expect(divs[2]?.textContent).toBe("C1"); 99 + }); 100 + 101 + it("supports mixed static and reactive values", () => { 102 + const container = document.createElement("div"); 103 + container.innerHTML = ` 104 + <h1 data-x-text="staticTitle">Title</h1> 105 + <p data-x-text="dynamicContent">Content</p> 106 + <span data-x-class="'always-visible'">Visible</span> 107 + `; 108 + 109 + const staticTitle = "Welcome"; 110 + const dynamicContent = signal("Loading..."); 111 + 112 + mount(container, { staticTitle, dynamicContent }); 113 + 114 + expect(container.querySelector("h1")?.textContent).toBe("Welcome"); 115 + expect(container.querySelector("p")?.textContent).toBe("Loading..."); 116 + expect(container.querySelector("span")?.classList.contains("always-visible")).toBe(true); 117 + 118 + dynamicContent.set("Content loaded!"); 119 + expect(container.querySelector("p")?.textContent).toBe("Content loaded!"); 120 + }); 121 + });