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.

refactor: created shared utilities to remove recycled code in binding module

* renamed asyncEffect (linting)

* created shared constants module

+125 -119
+1 -1
lib/jsr.json
··· 1 1 { 2 2 "name": "@voltx/core", 3 - "version": "0.1.0", 3 + "version": "0.2.0", 4 4 "license": "MIT", 5 5 "exports": { ".": "./src/index.ts", "./debug": "./src/debug.ts", "./css": "./dist/volt.css" }, 6 6 "publish": { "include": ["src", "dist", "README.md", "LICENSE"] }
lib/src/core/asyncEffect.ts lib/src/core/async-effect.ts
+62 -78
lib/src/core/binder.ts
··· 9 9 FormControlElement, 10 10 Modifier, 11 11 PluginContext, 12 + PluginHandler, 12 13 Scope, 13 14 Signal, 14 15 } from "$types/volt"; 16 + import { BOOLEAN_ATTRS } from "./constants"; 15 17 import { getVoltAttrs, parseClassBinding, setHTML, setText, toggleClass, walkDOM } from "./dom"; 16 18 import { evaluate, extractDeps } from "./evaluator"; 17 19 import { bindDelete, bindGet, bindPatch, bindPost, bindPut } from "./http"; ··· 96 98 }; 97 99 } 98 100 101 + function execPlugin(plugin: PluginHandler, ctx: BindingContext, val: string, base: string) { 102 + const pluginCtx = createPluginCtx(ctx); 103 + try { 104 + plugin(pluginCtx, val); 105 + } catch (error) { 106 + console.error(`Error in plugin "${base}":`, error); 107 + } 108 + } 109 + 99 110 /** 100 111 * Bind a single data-volt-* attribute to an element. 101 112 * Routes to the appropriate binding handler. ··· 115 126 return; 116 127 } 117 128 129 + if (name.includes(":")) { 130 + const colonIndex = name.indexOf(":"); 131 + const pluginName = name.slice(0, colonIndex); 132 + const suffix = name.slice(colonIndex + 1); 133 + const plugin = getPlugin(pluginName); 134 + 135 + if (plugin) { 136 + const combinedVal = `${suffix}:${value}`; 137 + execPlugin(plugin, ctx, combinedVal, pluginName); 138 + return; 139 + } 140 + } 141 + 118 142 const { baseName, modifiers } = parseModifiers(name); 119 143 120 144 switch (baseName) { 121 145 case "text": { 146 + const bindText = bindNode("text"); 122 147 bindText(ctx, value); 123 148 break; 124 149 } 125 150 case "html": { 151 + const bindHTML = bindNode("html"); 126 152 bindHTML(ctx, value); 127 153 break; 128 154 } ··· 146 172 bindFor(ctx, value); 147 173 break; 148 174 } 175 + // data-volt-else is a marker attribute handled by bindIf when processing data-volt-if 176 + case "else": { 177 + break; 178 + } 149 179 case "get": { 150 180 bindGet(ctx, value); 151 181 break; ··· 169 199 default: { 170 200 const plugin = getPlugin(baseName); 171 201 if (plugin) { 172 - const pluginContext = createPluginCtx(ctx); 173 - try { 174 - plugin(pluginContext, value); 175 - } catch (error) { 176 - console.error(`Error in plugin "${baseName}":`, error); 177 - } 202 + execPlugin(plugin, ctx, value, baseName); 178 203 } else { 179 204 console.warn(`Unknown binding: data-volt-${baseName}`); 180 205 } ··· 183 208 } 184 209 185 210 /** 186 - * Bind data-volt-text to update element's text content. 211 + * Creates a reactive binding for data-volt-text or data-volt-html that updates element content. 212 + * Returns a curried function that handles binding data-volt-text|html to update an element's text or html content 187 213 * Subscribes to signals in the expression and updates on change. 188 214 */ 189 - function bindText(ctx: BindingContext, expr: string): void { 190 - const update = () => { 191 - const value = evaluate(expr, ctx.scope); 192 - setText(ctx.element, value); 215 + function bindNode(kind: "text" | "html") { 216 + return function(ctx: BindingContext, expr: string): void { 217 + const update = () => { 218 + const value = evaluate(expr, ctx.scope); 219 + if (kind === "text") { 220 + setText(ctx.element, value); 221 + } else { 222 + setHTML(ctx.element, String(value ?? "")); 223 + } 224 + }; 225 + update(); 226 + 227 + const deps = extractDeps(expr, ctx.scope); 228 + for (const dep of deps) { 229 + const unsubscribe = dep.subscribe(update); 230 + ctx.cleanups.push(unsubscribe); 231 + } 193 232 }; 194 - 195 - update(); 196 - 197 - const deps = extractDeps(expr, ctx.scope); 198 - for (const dep of deps) { 199 - const unsubscribe = dep.subscribe(update); 200 - ctx.cleanups.push(unsubscribe); 201 - } 202 233 } 203 234 204 235 /** 205 - * Bind data-volt-html to update element's HTML content. 206 - * Subscribes to signals in the expression and updates on change. 236 + * Helper function to execute an update function and subscribe to all signal dependencies. 237 + * Used by bindings that need reactive updates (class, show, style, for, if). 207 238 */ 208 - function bindHTML(ctx: BindingContext, expr: string): void { 209 - const update = () => { 210 - const value = evaluate(expr, ctx.scope); 211 - setHTML(ctx.element, String(value ?? "")); 212 - }; 213 - 239 + function updateAndUnsub(ctx: BindingContext, update: () => void, expr: string) { 214 240 update(); 215 - 216 241 const deps = extractDeps(expr, ctx.scope); 217 242 for (const dep of deps) { 218 243 const unsubscribe = dep.subscribe(update); ··· 244 269 prevClasses = classes; 245 270 }; 246 271 247 - update(); 248 - 249 - const deps = extractDeps(expr, ctx.scope); 250 - for (const dep of deps) { 251 - const unsubscribe = dep.subscribe(update); 252 - ctx.cleanups.push(unsubscribe); 253 - } 272 + updateAndUnsub(ctx, update, expr); 254 273 } 255 274 256 275 /** ··· 272 291 } 273 292 }; 274 293 275 - update(); 276 - 277 - const deps = extractDeps(expr, ctx.scope); 278 - for (const dep of deps) { 279 - const unsubscribe = dep.subscribe(update); 280 - ctx.cleanups.push(unsubscribe); 281 - } 294 + updateAndUnsub(ctx, update, expr); 282 295 } 283 296 284 297 /** 285 298 * Bind data-volt-style to reactively apply inline styles. 286 - * Supports object notation {color: 'red', fontSize: '16px'} or string notation 'color: red; font-size: 16px'. 299 + * Supports 300 + * - object notation {color: 'red', fontSize: '16px'} 301 + * - string notation 'color: red; font-size: 16px'. 287 302 */ 288 303 function bindStyle(ctx: BindingContext, expr: string): void { 289 304 const element = ctx.element as HTMLElement; ··· 310 325 } 311 326 }; 312 327 313 - update(); 314 - 315 - const deps = extractDeps(expr, ctx.scope); 316 - for (const dep of deps) { 317 - const unsubscribe = dep.subscribe(update); 318 - ctx.cleanups.push(unsubscribe); 319 - } 328 + updateAndUnsub(ctx, update, expr); 320 329 } 321 330 322 331 /** ··· 467 476 switch (type) { 468 477 case "checkbox": { 469 478 el.checked = Boolean(value); 470 - 471 479 break; 472 480 } 473 481 case "radio": { ··· 535 543 } 536 544 } 537 545 538 - const booleanAttrs = new Set([ 539 - "disabled", 540 - "checked", 541 - "selected", 542 - "readonly", 543 - "required", 544 - "multiple", 545 - "autofocus", 546 - "autoplay", 547 - "controls", 548 - "loop", 549 - "muted", 550 - ]); 546 + const booleanAttrs = new Set(BOOLEAN_ATTRS); 551 547 552 548 if (booleanAttrs.has(attrName)) { 553 549 if (value) { ··· 633 629 } 634 630 }; 635 631 636 - render(); 637 - 638 - const deps = extractDeps(arrayPath, ctx.scope); 639 - for (const dep of deps) { 640 - const unsubscribe = dep.subscribe(render); 641 - ctx.cleanups.push(unsubscribe); 642 - } 632 + updateAndUnsub(ctx, render, expr); 643 633 644 634 ctx.cleanups.push(() => { 645 635 for (const cleanup of renderedCleanups) { ··· 717 707 } 718 708 }; 719 709 720 - render(); 721 - 722 - const deps = extractDeps(expr, ctx.scope); 723 - for (const dep of deps) { 724 - const unsubscribe = dep.subscribe(render); 725 - ctx.cleanups.push(unsubscribe); 726 - } 710 + updateAndUnsub(ctx, render, expr); 727 711 728 712 ctx.cleanups.push(() => { 729 713 if (currentCleanup) {
+43
lib/src/core/constants.ts
··· 1 + export const BOOLEAN_ATTRS = [ 2 + "disabled", 3 + "checked", 4 + "selected", 5 + "readonly", 6 + "required", 7 + "multiple", 8 + "autofocus", 9 + "autoplay", 10 + "controls", 11 + "loop", 12 + "muted", 13 + ]; 14 + 15 + export const DANGEROUS_PROPERTIES = ["__proto__", "prototype", "constructor"]; 16 + 17 + export const DANGEROUS_GLOBALS = [ 18 + "Function", 19 + "eval", 20 + "globalThis", 21 + "window", 22 + "global", 23 + "process", 24 + "require", 25 + "import", 26 + "module", 27 + "exports", 28 + ]; 29 + 30 + export const SAFE_GLOBALS = [ 31 + "Array", 32 + "Object", 33 + "String", 34 + "Number", 35 + "Boolean", 36 + "Date", 37 + "Math", 38 + "JSON", 39 + "RegExp", 40 + "Map", 41 + "Set", 42 + "Promise", 43 + ];
+14 -35
lib/src/core/evaluator.ts
··· 6 6 */ 7 7 8 8 import type { Dep, Scope } from "$types/volt"; 9 + import { DANGEROUS_GLOBALS, DANGEROUS_PROPERTIES, SAFE_GLOBALS } from "./constants"; 9 10 import { findScopedSignal, isNil, isSignal } from "./shared"; 10 11 11 - const DANGEROUS_PROPERTIES = new Set(["__proto__", "prototype", "constructor"]); 12 - 13 - const SAFE_GLOBALS = new Set([ 14 - "Array", 15 - "Object", 16 - "String", 17 - "Number", 18 - "Boolean", 19 - "Date", 20 - "Math", 21 - "JSON", 22 - "RegExp", 23 - "Map", 24 - "Set", 25 - "Promise", 26 - ]); 27 - 28 - const DANGEROUS_GLOBALS = new Set([ 29 - "Function", 30 - "eval", 31 - "globalThis", 32 - "window", 33 - "global", 34 - "process", 35 - "require", 36 - "import", 37 - "module", 38 - "exports", 39 - ]); 12 + const dangerousProps = new Set(DANGEROUS_PROPERTIES); 13 + const safeGlobals = new Set(SAFE_GLOBALS); 40 14 41 15 function isSafeProp(key: unknown): boolean { 42 16 if (typeof key !== "string" && typeof key !== "number") { ··· 44 18 } 45 19 46 20 const keyStr = String(key); 47 - return !DANGEROUS_PROPERTIES.has(keyStr); 21 + return !dangerousProps.has(keyStr); 48 22 } 49 23 50 24 function isSafeAccess(object: unknown, key: unknown): boolean { ··· 54 28 55 29 if (typeof object === "function") { 56 30 const keyStr = String(key); 57 - if (keyStr === "constructor" && object.name && !SAFE_GLOBALS.has(object.name)) { 31 + if (keyStr === "constructor" && object.name && !safeGlobals.has(object.name)) { 58 32 return false; 59 33 } 60 34 } ··· 334 308 private tokens: Token[]; 335 309 private current = 0; 336 310 private scope: Scope; 311 + private dangerousGlobals = new Set(DANGEROUS_GLOBALS); 337 312 338 313 constructor(tokens: Token[], scope: Scope) { 339 314 this.tokens = tokens; ··· 824 799 throw new Error(`Unsafe property access: ${path}`); 825 800 } 826 801 827 - if (DANGEROUS_GLOBALS.has(path)) { 802 + if (this.dangerousGlobals.has(path)) { 828 803 throw new Error(`Access to dangerous global: ${path}`); 829 804 } 830 805 831 - if (!(path in this.scope)) { 832 - return undefined; 806 + if (path in this.scope) { 807 + return this.scope[path]; 833 808 } 834 809 835 - return this.scope[path]; 810 + if (safeGlobals.has(path)) { 811 + return (globalThis as Record<string, unknown>)[path]; 812 + } 813 + 814 + return undefined; 836 815 } 837 816 838 817 private match(...types: TokenType[]): boolean {
+2 -2
lib/src/core/lifecycle.ts
··· 15 15 ["afterUnmount", new Set()], 16 16 ]); 17 17 18 + const elementLifecycleStates = new WeakMap<Element, ElementLifecycleState>(); 19 + 18 20 /** 19 21 * Register a global lifecycle hook. 20 22 * Global hooks run for every mount/unmount operation in the application. ··· 126 128 } 127 129 } 128 130 } 129 - 130 - const elementLifecycleStates = new WeakMap<Element, ElementLifecycleState>(); 131 131 132 132 /** 133 133 * Get or create lifecycle state for an element.
+1 -1
lib/src/index.ts
··· 4 4 * @packageDocumentation 5 5 */ 6 6 7 - export { asyncEffect } from "$core/asyncEffect"; 7 + export { asyncEffect } from "$core/async-effect"; 8 8 export { mount } from "$core/binder"; 9 9 export { charge } from "$core/charge"; 10 10 export { parseHttpConfig, request, serializeForm, serializeFormToJSON, swap } from "$core/http";
+2 -2
lib/test/core/asyncEffect.test.ts lib/test/core/async-effect.test.ts
··· 1 - import { asyncEffect } from "$core/asyncEffect"; 1 + import { asyncEffect } from "$core/async-effect"; 2 2 import { signal } from "$core/signal"; 3 3 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 4 4 ··· 208 208 209 209 await vi.runAllTimersAsync(); 210 210 211 - expect(results[results.length - 1]).toBe(1); 211 + expect(results.at(-1)).toBe(1); 212 212 }); 213 213 214 214 it("tracks execution order with race conditions", async () => {