a reactive (signals based) hypermedia web framework (wip) stormlightlabs.github.io/volt/
hypermedia frontend signals
at main 4.8 kB view raw
1/** 2 * Charge system (bootstrap) for auto-discovery and initialization of Volt roots 3 * 4 * Handles declarative state initialization via data-volt-state and data-volt-computed 5 */ 6 7import type { ChargedRoot, ChargeResult, Scope } from "$types/volt"; 8import { mount } from "./binder"; 9import { report } from "./error"; 10import { evaluate } from "./evaluator"; 11import { getComputedAttributes, isNil } from "./shared"; 12import { computed, signal } from "./signal"; 13import { registerStore } from "./store"; 14 15/** 16 * Discover and mount all Volt roots in the document. 17 * 18 * Parses data-volt-state for initial state and data-volt-computed for derived values. 19 * Also parses declarative global store from script[data-volt-store] elements. 20 * 21 * @param rootSelector - Selector for root elements (default: "[data-volt]") 22 * @returns ChargeResult containing mounted roots and cleanup function 23 * 24 * @example 25 * ```html 26 * <!-- Global store (declarative) --> 27 * <script type="application/json" data-volt-store> 28 * { 29 * "theme": "dark", 30 * "user": { "name": "Alice" } 31 * } 32 * </script> 33 * 34 * <div data-volt data-volt-state='{"count": 0}' data-volt-computed:double="count * 2"> 35 * <p data-volt-text="count"></p> 36 * <p data-volt-text="double"></p> 37 * <p data-volt-text="$store.theme"></p> 38 * </div> 39 * ``` 40 * 41 * ```ts 42 * const { cleanup } = charge(); 43 * // Later: cleanup() to unmount all 44 * ``` 45 */ 46export function charge(rootSelector = "[data-volt]"): ChargeResult { 47 parseDeclarativeStore(); 48 49 const elements = document.querySelectorAll(rootSelector); 50 const chargedRoots: ChargedRoot[] = []; 51 52 for (const element of elements) { 53 try { 54 const scope = createScopeFromElement(element); 55 const cleanup = mount(element, scope); 56 57 chargedRoots.push({ element, scope, cleanup }); 58 } catch (error) { 59 report(error as Error, { source: "charge", element: element as HTMLElement }); 60 } 61 } 62 63 return { 64 roots: chargedRoots, 65 cleanup: () => { 66 for (const root of chargedRoots) { 67 try { 68 root.cleanup(); 69 } catch (error) { 70 report(error as Error, { source: "charge", element: root.element as HTMLElement }); 71 } 72 } 73 }, 74 }; 75} 76 77/** 78 * Create a reactive scope from element's data-volt-state and data-volt-computed attributes 79 */ 80function createScopeFromElement(el: Element): Scope { 81 const scope: Scope = {}; 82 83 const stateAttr = (el as HTMLElement).dataset.voltState; 84 if (stateAttr) { 85 try { 86 const stateData = JSON.parse(stateAttr); 87 88 if (typeof stateData !== "object" || isNil(stateData) || Array.isArray(stateData)) { 89 report(new Error(`data-volt-state must be a JSON object, got ${typeof stateData}`), { 90 source: "charge", 91 level: "fatal", 92 element: el as HTMLElement, 93 directive: "data-volt-state", 94 expression: stateAttr, 95 }); 96 } else { 97 for (const [key, value] of Object.entries(stateData)) { 98 scope[key] = signal(value); 99 } 100 } 101 } catch (error) { 102 report(error as Error, { 103 source: "charge", 104 level: "fatal", 105 element: el as HTMLElement, 106 directive: "data-volt-state", 107 expression: stateAttr, 108 }); 109 } 110 } 111 112 const computedAttrs = getComputedAttributes(el); 113 for (const [name, expression] of computedAttrs) { 114 try { 115 scope[name] = computed(() => evaluate(expression, scope)); 116 } catch (error) { 117 report(error as Error, { 118 source: "charge", 119 element: el as HTMLElement, 120 directive: `data-volt-computed:${name}`, 121 expression: expression, 122 }); 123 } 124 } 125 126 return scope; 127} 128 129/** 130 * Parse and register global store from declarative script tags. 131 * 132 * Looks for: <script type="application/json" data-volt-store> 133 * Expects JSON object with key-value pairs to register in global store. 134 */ 135function parseDeclarativeStore(): void { 136 const scripts = document.querySelectorAll("script[data-volt-store][type=\"application/json\"]"); 137 138 for (const script of scripts) { 139 try { 140 const content = script.textContent?.trim(); 141 if (!content) continue; 142 143 const data = JSON.parse(content); 144 145 if (typeof data !== "object" || isNil(data) || Array.isArray(data)) { 146 report(new Error(`data-volt-store script must contain a JSON object, got: ${typeof data}`), { 147 source: "charge", 148 level: "fatal", 149 element: script as HTMLElement, 150 directive: "data-volt-store", 151 }); 152 continue; 153 } 154 155 registerStore(data); 156 } catch (error) { 157 report(error as Error, { 158 source: "charge", 159 level: "fatal", 160 element: script as HTMLElement, 161 directive: "data-volt-store", 162 }); 163 } 164 } 165}