a reactive (signals based) hypermedia web framework (wip)
stormlightlabs.github.io/volt/
hypermedia
frontend
signals
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}