@daz4126/helium 0.31.0 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +3 -1
- package/CLAUDE.md +1 -1
- package/examples/index-lite.html +396 -0
- package/examples/index.html +27 -11
- package/examples/password-utils.js +80 -0
- package/helium-csp.js +252 -0
- package/helium-csp.test.js +776 -0
- package/helium-lite.js +369 -0
- package/helium-lite.test.js +120 -0
- package/helium.js +35 -9
- package/jexpr.js +666 -0
- package/jexpr.test.js +551 -0
- package/package.json +6 -1
- package/test-csp.html +50 -0
package/helium-lite.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helium Lite - Lightweight reactive library
|
|
3
|
+
* Core features only: @data, @text, @html, @bind, :attr, @ref, @calculate, @effect, @init, events
|
|
4
|
+
* No AJAX, no @import, no SSE, no Turbo
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const parseEx = v => { try { return Function(`return(${v})`)() } catch { return v } };
|
|
8
|
+
const tryNum = v => { const t = String(v).trim(), n = +t; return t && !isNaN(n) ? n : v };
|
|
9
|
+
const isValidIdentifier = v => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v);
|
|
10
|
+
const RESERVED = new Set(['undefined', 'null', 'true', 'false', 'NaN', 'Infinity', 'this', 'arguments']);
|
|
11
|
+
const KEY_MODS = { shift: "shiftKey", ctrl: "ctrlKey", alt: "altKey", meta: "metaKey" };
|
|
12
|
+
const debounce = (f, d) => { let t; return (...a) => (clearTimeout(t), t = setTimeout(f, d, ...a)) };
|
|
13
|
+
|
|
14
|
+
const ATTR_REGEX = /^(@|:|data-he)/;
|
|
15
|
+
|
|
16
|
+
const match = (name, ...attrs) => {
|
|
17
|
+
const p = name.split(/[.:]/)[0];
|
|
18
|
+
if (p === ":" || p === "") return false;
|
|
19
|
+
return attrs.some(a => p === `@${a}` || p === `data-he-${a}`);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
let HELIUM = null;
|
|
23
|
+
|
|
24
|
+
async function helium(initialState = {}) {
|
|
25
|
+
const inits = [];
|
|
26
|
+
const ALL = Symbol("all");
|
|
27
|
+
const root = document.querySelector('[\\@helium]') || document.querySelector('[data-helium]') || document.body;
|
|
28
|
+
const storageKey = root.getAttribute('@local-storage') || root.getAttribute('data-he-local-storage');
|
|
29
|
+
|
|
30
|
+
if (!HELIUM) {
|
|
31
|
+
HELIUM = {
|
|
32
|
+
observer: null,
|
|
33
|
+
bindings: new Map(),
|
|
34
|
+
refs: new Map(),
|
|
35
|
+
listeners: new WeakMap(),
|
|
36
|
+
processed: new WeakSet(),
|
|
37
|
+
parentKeys: new WeakMap(),
|
|
38
|
+
fnCache: new Map(),
|
|
39
|
+
proxyCache: new WeakMap()
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const $ = s => document.querySelector(s);
|
|
44
|
+
const html = s => Object.assign(document.createElement("template"), { innerHTML: s.trim() }).content.firstChild;
|
|
45
|
+
|
|
46
|
+
const applyingBindings = new Set();
|
|
47
|
+
|
|
48
|
+
const safeApplyBinding = (b) => {
|
|
49
|
+
if (applyingBindings.has(b)) return;
|
|
50
|
+
applyingBindings.add(b);
|
|
51
|
+
try {
|
|
52
|
+
applyBinding(b);
|
|
53
|
+
} finally {
|
|
54
|
+
applyingBindings.delete(b);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handler = {
|
|
59
|
+
has(t, p) {
|
|
60
|
+
if (typeof p === 'string' && p.startsWith('$')) return false;
|
|
61
|
+
if (Reflect.has(t, p)) return true;
|
|
62
|
+
if (typeof globalThis[p] !== 'undefined') return false;
|
|
63
|
+
return true;
|
|
64
|
+
},
|
|
65
|
+
get(t, p, r) {
|
|
66
|
+
const v = Reflect.get(t, p, r);
|
|
67
|
+
if (v && typeof v === "object") {
|
|
68
|
+
if (HELIUM.proxyCache.has(v)) return HELIUM.proxyCache.get(v);
|
|
69
|
+
const proxy = new Proxy(v, handler);
|
|
70
|
+
HELIUM.proxyCache.set(v, proxy);
|
|
71
|
+
HELIUM.parentKeys.set(v, p);
|
|
72
|
+
return proxy;
|
|
73
|
+
}
|
|
74
|
+
return v;
|
|
75
|
+
},
|
|
76
|
+
set: (t, p, v) => {
|
|
77
|
+
const res = Reflect.set(t, p, v);
|
|
78
|
+
HELIUM.bindings.get(p)?.forEach(safeApplyBinding);
|
|
79
|
+
const parentKey = HELIUM.parentKeys.get(t);
|
|
80
|
+
if (parentKey) HELIUM.bindings.get(parentKey)?.forEach(safeApplyBinding);
|
|
81
|
+
HELIUM.bindings.get(ALL)?.forEach(safeApplyBinding);
|
|
82
|
+
if (storageKey) localStorage.setItem(storageKey, JSON.stringify(state));
|
|
83
|
+
return res;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const state = new Proxy({}, handler);
|
|
88
|
+
if (storageKey) try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey))); } catch {}
|
|
89
|
+
Object.assign(state, initialState);
|
|
90
|
+
|
|
91
|
+
const getRefsObject = () => {
|
|
92
|
+
const refs = {};
|
|
93
|
+
for (const [name, element] of HELIUM.refs) {
|
|
94
|
+
refs[name] = element;
|
|
95
|
+
}
|
|
96
|
+
return refs;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const createScope = (el, event = {}) => {
|
|
100
|
+
return {
|
|
101
|
+
$,
|
|
102
|
+
$data: state,
|
|
103
|
+
$event: event,
|
|
104
|
+
$el: el,
|
|
105
|
+
$html: html,
|
|
106
|
+
...getRefsObject()
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const compile = (expr, withReturn = true) => {
|
|
111
|
+
const key = `${withReturn}:${expr}`;
|
|
112
|
+
if (HELIUM.fnCache.has(key)) return HELIUM.fnCache.get(key);
|
|
113
|
+
try {
|
|
114
|
+
const fn = new Function(
|
|
115
|
+
"$scope",
|
|
116
|
+
withReturn
|
|
117
|
+
? `with($scope){with($scope.$data){return(${expr.trim()})}}`
|
|
118
|
+
: `with($scope){with($scope.$data){${expr.trim()}}}`
|
|
119
|
+
);
|
|
120
|
+
const compiled = { execute: (scope) => fn(scope), getIds: null };
|
|
121
|
+
HELIUM.fnCache.set(key, compiled);
|
|
122
|
+
return compiled;
|
|
123
|
+
} catch {
|
|
124
|
+
const compiled = { execute: () => expr, getIds: null };
|
|
125
|
+
HELIUM.fnCache.set(key, compiled);
|
|
126
|
+
return compiled;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function applyBinding(b, e = {}, elCtx = b.el) {
|
|
131
|
+
const { el, prop, compiled, calc, isHiddenAttr, isRadio } = b;
|
|
132
|
+
const scope = createScope(elCtx, e);
|
|
133
|
+
const r = compiled.execute(scope);
|
|
134
|
+
if (calc) state[calc] = r;
|
|
135
|
+
|
|
136
|
+
if (prop === "innerHTML") {
|
|
137
|
+
const content = Array.isArray(r) ? r.join`` : r;
|
|
138
|
+
return typeof Idiomorph === "object"
|
|
139
|
+
? Idiomorph.morph(el, content, { morphStyle: 'innerHTML' })
|
|
140
|
+
: el.innerHTML = content;
|
|
141
|
+
}
|
|
142
|
+
if (prop === "class" && r && typeof r === "object")
|
|
143
|
+
return Object.entries(r).forEach(([k, v]) =>
|
|
144
|
+
k.split(/\s+/).forEach(c => el.classList.toggle(c, v)));
|
|
145
|
+
|
|
146
|
+
if (prop === "style" && r && typeof r === "object")
|
|
147
|
+
return el.style.cssText = Object.entries(r)
|
|
148
|
+
.filter(([_, v]) => v != null && v !== false)
|
|
149
|
+
.map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`)
|
|
150
|
+
.join("; ");
|
|
151
|
+
|
|
152
|
+
if (prop === "hidden") {
|
|
153
|
+
el.hidden = isHiddenAttr ? !!r : !r;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (prop in el) {
|
|
158
|
+
if (isRadio && prop !== "checked") el.checked = el.value === r;
|
|
159
|
+
else if (el.type === "radio" && prop !== "checked") el.checked = el.value === r;
|
|
160
|
+
else el[prop] = prop === "textContent" ? (r ?? '') : parseEx(r);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
el.setAttribute(prop, parseEx(r));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const trackDependenciesProxy = (compiled, el, excludeChanged = false) => {
|
|
168
|
+
const accessed = excludeChanged ? new Map() : new Set();
|
|
169
|
+
const trackProxy = new Proxy(state, {
|
|
170
|
+
has(target, prop) {
|
|
171
|
+
return true;
|
|
172
|
+
},
|
|
173
|
+
get(target, prop) {
|
|
174
|
+
if (typeof prop == 'string') {
|
|
175
|
+
excludeChanged ? !accessed.has(prop) && accessed.set(prop, target[prop]) : accessed.add(prop);
|
|
176
|
+
}
|
|
177
|
+
const val = target[prop];
|
|
178
|
+
return typeof val == "object" && val != null ? new Proxy(val, this) : val;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const scope = {
|
|
184
|
+
$,
|
|
185
|
+
$data: trackProxy,
|
|
186
|
+
$event: {},
|
|
187
|
+
$el: el,
|
|
188
|
+
$html: html,
|
|
189
|
+
...getRefsObject()
|
|
190
|
+
};
|
|
191
|
+
compiled.execute(scope);
|
|
192
|
+
} catch {}
|
|
193
|
+
|
|
194
|
+
return excludeChanged ? [...accessed.keys()].filter(prop => state[prop] === accessed.get(prop)) : [...accessed];
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const getDependencies = (compiled, el, excludeChanged = false) => {
|
|
198
|
+
return trackDependenciesProxy(compiled, el, excludeChanged);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const cleanup = el => {
|
|
202
|
+
[el, ...el.querySelectorAll('*')].forEach(e => {
|
|
203
|
+
HELIUM.listeners.get(e)?.forEach(({ receiver, event, handler }) => receiver.removeEventListener(event, handler));
|
|
204
|
+
HELIUM.listeners.delete(e);
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
async function processElements(element) {
|
|
209
|
+
const newBindings = [];
|
|
210
|
+
const deferredBindings = [];
|
|
211
|
+
|
|
212
|
+
const heElements = [element, ...element.querySelectorAll("*")].filter(e => {
|
|
213
|
+
if (HELIUM.processed.has(e)) return false;
|
|
214
|
+
for (let i = 0; i < e.attributes.length; i++) {
|
|
215
|
+
if (ATTR_REGEX.test(e.attributes[i].name)) return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const addBinding = (val, b) => {
|
|
221
|
+
HELIUM.bindings.set(val, b.calc ? [b, ...(HELIUM.bindings.get(val) || [])] : [...(HELIUM.bindings.get(val) || []), b]);
|
|
222
|
+
b.calc ? newBindings.unshift(b) : newBindings.push(b);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
heElements.forEach(el => {
|
|
226
|
+
HELIUM.processed.add(el);
|
|
227
|
+
|
|
228
|
+
const attrs = el.attributes;
|
|
229
|
+
const inputType = el.type?.toLowerCase();
|
|
230
|
+
const isCheckbox = inputType == "checkbox", isRadio = inputType == "radio", isSelect = el.tagName == "SELECT";
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
233
|
+
const { name, value } = attrs[i];
|
|
234
|
+
|
|
235
|
+
if (!name.startsWith('@') && !name.startsWith(':') && !name.startsWith('data-he')) continue;
|
|
236
|
+
|
|
237
|
+
// Initialize state first if needed (skip reserved words, only if not already set)
|
|
238
|
+
if (match(name, "text", "html", "bind") && isValidIdentifier(value) && !RESERVED.has(value) && state[value] == null) {
|
|
239
|
+
state[value] = match(name, "bind") ? (el.type == "checkbox" ? el.checked : tryNum(el.value)) : tryNum(el.textContent);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Process the attribute
|
|
243
|
+
if (name === "@data" || name === "data-he") {
|
|
244
|
+
Object.assign(state, parseEx(value));
|
|
245
|
+
}
|
|
246
|
+
else if (name.startsWith(":") || name.startsWith("data-he-attr:")) {
|
|
247
|
+
const propName = name.startsWith(":") ? name.slice(1) : name.slice(13);
|
|
248
|
+
deferredBindings.push({ el, prop: propName, compiled: compile(value, true) });
|
|
249
|
+
}
|
|
250
|
+
else if (match(name, "ref")) {
|
|
251
|
+
HELIUM.refs.set("$" + value, el);
|
|
252
|
+
}
|
|
253
|
+
else if (match(name, "text", "html")) {
|
|
254
|
+
deferredBindings.push({ el, prop: match(name, "text") ? "textContent" : "innerHTML", compiled: compile(value, true) });
|
|
255
|
+
}
|
|
256
|
+
else if (match(name, "bind")) {
|
|
257
|
+
const event = (isCheckbox || isRadio || isSelect) ? "change" : "input";
|
|
258
|
+
const prop = isCheckbox ? "checked" : "value";
|
|
259
|
+
const inputHandler = e => state[value] = isCheckbox ? e.target.checked : e.target.value;
|
|
260
|
+
el.addEventListener(event, inputHandler);
|
|
261
|
+
if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
|
|
262
|
+
HELIUM.listeners.get(el).push({ receiver: el, event, handler: inputHandler });
|
|
263
|
+
deferredBindings.push({ el, prop, compiled: compile(value, true), isRadio });
|
|
264
|
+
if (isCheckbox) el.checked = !!state[value];
|
|
265
|
+
else if (isRadio) el.checked = el.value == state[value];
|
|
266
|
+
else el.value = state[value] ?? "";
|
|
267
|
+
}
|
|
268
|
+
else if (match(name, "hidden", "visible")) {
|
|
269
|
+
const isHidden = match(name, "hidden");
|
|
270
|
+
deferredBindings.push({ el, prop: "hidden", compiled: compile(value, true), isHiddenAttr: isHidden });
|
|
271
|
+
}
|
|
272
|
+
else if (match(name, "calculate")) {
|
|
273
|
+
const calcName = name.split(":")[1];
|
|
274
|
+
if (!(calcName in state)) state[calcName] = undefined;
|
|
275
|
+
deferredBindings.push({ el, calc: calcName, prop: null, compiled: compile(value, true) });
|
|
276
|
+
}
|
|
277
|
+
else if (match(name, "effect")) {
|
|
278
|
+
deferredBindings.push({ el, prop: null, compiled: compile(value, true), keys: name.split(":").slice(1) });
|
|
279
|
+
}
|
|
280
|
+
else if (match(name, "init")) {
|
|
281
|
+
inits.push({ compiled: compile(value, true), el });
|
|
282
|
+
}
|
|
283
|
+
else if (name.startsWith("@") || name.startsWith("data-he")) {
|
|
284
|
+
const fullName = name.startsWith("@") ? name.slice(1) : name.slice(8);
|
|
285
|
+
const [event, ...mods] = fullName.split(".");
|
|
286
|
+
const receiver = mods.includes("outside") || mods.includes("document") ? document : el;
|
|
287
|
+
const debounceMod = mods.find(m => m.startsWith("debounce"));
|
|
288
|
+
const debounceDelay = debounceMod ? (t => t && !isNaN(t) ? Number(t) : 300)(debounceMod.split(":")[1]) : 0;
|
|
289
|
+
const _handler = e => {
|
|
290
|
+
if (mods.includes("prevent")) e.preventDefault();
|
|
291
|
+
if (mods.includes("stop")) e.stopPropagation();
|
|
292
|
+
for (const [mod, prop] of Object.entries(KEY_MODS)) if (mods.includes(mod) && !e[prop]) return;
|
|
293
|
+
if (["keydown", "keyup", "keypress"].includes(event)) {
|
|
294
|
+
const last = mods[mods.length - 1];
|
|
295
|
+
if (last && !["prevent", "once", "outside", "document", "stop"].includes(last) && !last.startsWith("debounce") && !Object.keys(KEY_MODS).includes(last)) {
|
|
296
|
+
const keyName = e.key == " " ? "Space" : e.key == "Escape" ? "Esc" : e.key;
|
|
297
|
+
if (keyName.toLowerCase() !== last.toLowerCase()) return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (!mods.includes("outside") || !el.contains(e.target)) {
|
|
301
|
+
const scope = createScope(el, e);
|
|
302
|
+
const compiled = compile(value, true);
|
|
303
|
+
compiled.execute(scope);
|
|
304
|
+
}
|
|
305
|
+
if (mods.includes("once")) receiver.removeEventListener(event, handler);
|
|
306
|
+
};
|
|
307
|
+
const handler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
|
|
308
|
+
const eventOptions = mods.includes("once") ? { once: true } : undefined;
|
|
309
|
+
receiver.addEventListener(event, handler, eventOptions);
|
|
310
|
+
if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
|
|
311
|
+
HELIUM.listeners.get(el).push({ receiver, event, handler });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
deferredBindings.forEach(b => {
|
|
317
|
+
const tracked = b.keys?.includes("*") ? [ALL] : getDependencies(b.compiled, b.el, true).concat(b.keys || []);
|
|
318
|
+
tracked.forEach(key => addBinding(key, b));
|
|
319
|
+
if (tracked.length === 0) {
|
|
320
|
+
newBindings.push(b);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
return newBindings;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
HELIUM.observer?.disconnect();
|
|
328
|
+
HELIUM.observer = new MutationObserver(async (ms) => {
|
|
329
|
+
for (const m of ms) {
|
|
330
|
+
m.removedNodes.forEach((n) => n.nodeType === 1 && cleanup(n));
|
|
331
|
+
|
|
332
|
+
for (const n of m.addedNodes) {
|
|
333
|
+
if (n.nodeType === 1 && !HELIUM.processed.has(n)) {
|
|
334
|
+
const bindings = await processElements(n);
|
|
335
|
+
bindings?.forEach(applyBinding);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
HELIUM.observer.observe(root, { childList: true, subtree: true });
|
|
341
|
+
|
|
342
|
+
const initialBindings = await processElements(root);
|
|
343
|
+
initialBindings.forEach(applyBinding);
|
|
344
|
+
inits.forEach(({ compiled, el }) => {
|
|
345
|
+
const scope = createScope(el);
|
|
346
|
+
compiled.execute(scope);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
return state;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function heliumTeardown() {
|
|
353
|
+
if (HELIUM?.observer) HELIUM.observer.disconnect();
|
|
354
|
+
HELIUM = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Browser globals
|
|
358
|
+
if (typeof window !== 'undefined') {
|
|
359
|
+
window.helium = helium;
|
|
360
|
+
window.heliumTeardown = heliumTeardown;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Auto-initialize
|
|
364
|
+
if (typeof document !== 'undefined') {
|
|
365
|
+
document.addEventListener("DOMContentLoaded", () => helium());
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export { helium, heliumTeardown };
|
|
369
|
+
export default helium;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { helium, heliumTeardown } from './helium-lite.js';
|
|
3
|
+
|
|
4
|
+
describe('Helium Lite', () => {
|
|
5
|
+
let container;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
container = document.createElement('div');
|
|
9
|
+
container.setAttribute('data-helium', '');
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
heliumTeardown();
|
|
15
|
+
document.body.innerHTML = '';
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('Default State Values', () => {
|
|
19
|
+
it('should initialize state from @text element content', async () => {
|
|
20
|
+
container.innerHTML = '<span @text="name">John</span>';
|
|
21
|
+
const state = await helium();
|
|
22
|
+
expect(state.name).toBe('John');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should initialize state from @bind input value', async () => {
|
|
26
|
+
container.innerHTML = '<input @bind="email" value="test@example.com" />';
|
|
27
|
+
const state = await helium();
|
|
28
|
+
expect(state.email).toBe('test@example.com');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should initialize numeric state from @text', async () => {
|
|
32
|
+
container.innerHTML = '<span @text="count">42</span>';
|
|
33
|
+
const state = await helium();
|
|
34
|
+
expect(state.count).toBe(42);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should not override initial state with defaults', async () => {
|
|
38
|
+
container.innerHTML = '<span @text="name">Default</span>';
|
|
39
|
+
const state = await helium({ name: 'Custom' });
|
|
40
|
+
expect(state.name).toBe('Custom');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should initialize checkbox state from checked attribute', async () => {
|
|
44
|
+
container.innerHTML = '<input type="checkbox" @bind="agreed" checked />';
|
|
45
|
+
const state = await helium();
|
|
46
|
+
expect(state.agreed).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Basic Reactivity', () => {
|
|
51
|
+
it('should update text on state change', async () => {
|
|
52
|
+
container.innerHTML = '<div @text="message"></div>';
|
|
53
|
+
const state = await helium({ message: 'Hello' });
|
|
54
|
+
expect(container.querySelector('div').textContent).toBe('Hello');
|
|
55
|
+
|
|
56
|
+
state.message = 'World';
|
|
57
|
+
await new Promise(r => setTimeout(r, 0));
|
|
58
|
+
expect(container.querySelector('div').textContent).toBe('World');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should handle @bind two-way binding', async () => {
|
|
62
|
+
container.innerHTML = '<input @bind="name" /><span @text="name"></span>';
|
|
63
|
+
const state = await helium({ name: 'John' });
|
|
64
|
+
|
|
65
|
+
const input = container.querySelector('input');
|
|
66
|
+
expect(input.value).toBe('John');
|
|
67
|
+
|
|
68
|
+
input.value = 'Jane';
|
|
69
|
+
input.dispatchEvent(new Event('input'));
|
|
70
|
+
await new Promise(r => setTimeout(r, 0));
|
|
71
|
+
|
|
72
|
+
expect(state.name).toBe('Jane');
|
|
73
|
+
expect(container.querySelector('span').textContent).toBe('Jane');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('Events', () => {
|
|
78
|
+
it('should handle click events', async () => {
|
|
79
|
+
container.innerHTML = '<button @click="count++">Click</button><span @text="count"></span>';
|
|
80
|
+
const state = await helium({ count: 0 });
|
|
81
|
+
|
|
82
|
+
container.querySelector('button').click();
|
|
83
|
+
await new Promise(r => setTimeout(r, 0));
|
|
84
|
+
expect(state.count).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('@calculate', () => {
|
|
89
|
+
it('should create computed properties', async () => {
|
|
90
|
+
container.innerHTML = '<div @calculate:double="count * 2"></div><span @text="double"></span>';
|
|
91
|
+
const state = await helium({ count: 5 });
|
|
92
|
+
expect(container.querySelector('span').textContent).toBe('10');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('@ref', () => {
|
|
97
|
+
it('should create element references', async () => {
|
|
98
|
+
container.innerHTML = '<div @ref="myDiv"></div><button @click="$myDiv.textContent = \'Changed\'">Change</button>';
|
|
99
|
+
await helium({});
|
|
100
|
+
|
|
101
|
+
container.querySelector('button').click();
|
|
102
|
+
await new Promise(r => setTimeout(r, 0));
|
|
103
|
+
expect(container.querySelector('div').textContent).toBe('Changed');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('Dynamic Attributes', () => {
|
|
108
|
+
it('should bind :class', async () => {
|
|
109
|
+
container.innerHTML = '<div :class="className"></div>';
|
|
110
|
+
await helium({ className: 'active' });
|
|
111
|
+
expect(container.querySelector('div').getAttribute('class')).toBe('active');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should handle :class with object syntax', async () => {
|
|
115
|
+
container.innerHTML = '<div :class="{ active: isActive }"></div>';
|
|
116
|
+
await helium({ isActive: true });
|
|
117
|
+
expect(container.querySelector('div').classList.contains('active')).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
});
|
package/helium.js
CHANGED
|
@@ -52,6 +52,15 @@ export function createHelium(options = {}) {
|
|
|
52
52
|
const prefix = options.prefix ?? 'he'; // 'he' or 'xe'
|
|
53
53
|
const rootAttr = options.rootAttr ?? 'helium'; // 'helium' or 'xenon'
|
|
54
54
|
|
|
55
|
+
// Engine-aware expression parser (CSP-safe when using jexpr engine)
|
|
56
|
+
const evalExpr = v => {
|
|
57
|
+
try {
|
|
58
|
+
const compiled = engine.compile(v, true);
|
|
59
|
+
const scope = engine.createScope ? engine.createScope({ state: {}, el: null, event: {}, refs: {}, $: null, html: null, get: null, post: null, put: null, patch: null, del: null }) : {};
|
|
60
|
+
return compiled.execute(scope);
|
|
61
|
+
} catch { return v; }
|
|
62
|
+
};
|
|
63
|
+
|
|
55
64
|
// Build prefix-aware regex and matcher
|
|
56
65
|
const ATTR_REGEX = new RegExp(`^(@|:|data-${prefix})`);
|
|
57
66
|
|
|
@@ -68,6 +77,7 @@ export function createHelium(options = {}) {
|
|
|
68
77
|
const inits = [];
|
|
69
78
|
const ALL = Symbol("all");
|
|
70
79
|
const root = document.querySelector(`[\\@${rootAttr}]`) || document.querySelector(`[data-${rootAttr}]`) || document.body;
|
|
80
|
+
const storageKey = root.getAttribute('@local-storage') || root.getAttribute(`data-${prefix}-local-storage`);
|
|
71
81
|
|
|
72
82
|
// Initialize or reuse HELIUM object
|
|
73
83
|
if (!HELIUM) {
|
|
@@ -225,6 +235,7 @@ export function createHelium(options = {}) {
|
|
|
225
235
|
const parentKey = HELIUM.parentKeys.get(t);
|
|
226
236
|
if (parentKey) HELIUM.bindings.get(parentKey)?.forEach(safeApplyBinding);
|
|
227
237
|
HELIUM.bindings.get(ALL)?.forEach(safeApplyBinding);
|
|
238
|
+
if (storageKey) localStorage.setItem(storageKey, JSON.stringify(state));
|
|
228
239
|
return res
|
|
229
240
|
}
|
|
230
241
|
};
|
|
@@ -232,6 +243,9 @@ export function createHelium(options = {}) {
|
|
|
232
243
|
// Initialize state if it doesn't exist
|
|
233
244
|
const state = new Proxy({}, handler);
|
|
234
245
|
|
|
246
|
+
// Load from localStorage if key exists
|
|
247
|
+
if (storageKey) try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey))); } catch {}
|
|
248
|
+
|
|
235
249
|
// Merge initial state
|
|
236
250
|
Object.assign(state, initialState);
|
|
237
251
|
|
|
@@ -389,11 +403,14 @@ export function createHelium(options = {}) {
|
|
|
389
403
|
const importAttr = el.getAttribute("@import") || el.getAttribute(`data-${prefix}-import`);
|
|
390
404
|
if (importAttr) {
|
|
391
405
|
importAttr.split(",").map((m) => m.trim()).forEach((moduleName) => {
|
|
392
|
-
// Build path: use as-is for URLs, otherwise
|
|
406
|
+
// Build path: use as-is for URLs, otherwise resolve relative to document location
|
|
393
407
|
const isUrl = moduleName.startsWith("http://") || moduleName.startsWith("https://");
|
|
394
408
|
const hasExtension = moduleName.endsWith(".js");
|
|
395
409
|
const hasPathPrefix = moduleName.startsWith("/") || moduleName.startsWith("./") || moduleName.startsWith("../");
|
|
396
|
-
const
|
|
410
|
+
const relativePath = (isUrl || hasPathPrefix ? "" : "./") + moduleName + (isUrl || hasExtension ? "" : ".js");
|
|
411
|
+
// Ensure base URL ends with / so relative paths resolve correctly
|
|
412
|
+
const baseUrl = location.href.endsWith('/') || location.href.includes('.html') ? location.href : location.href + '/';
|
|
413
|
+
const path = isUrl ? relativePath : new URL(relativePath, baseUrl).href;
|
|
397
414
|
importPromises.push(
|
|
398
415
|
import(path)
|
|
399
416
|
.then((module) => {
|
|
@@ -434,13 +451,13 @@ export function createHelium(options = {}) {
|
|
|
434
451
|
if (!name.startsWith('@') && !name.startsWith(':') && !name.startsWith(`data-${prefix}`)) continue;
|
|
435
452
|
|
|
436
453
|
// Initialize state first if needed (skip reserved words, only if not already set)
|
|
437
|
-
if (match(name, "text", "html", "bind") && isValidIdentifier(value) && !RESERVED.has(value) &&
|
|
454
|
+
if (match(name, "text", "html", "bind") && isValidIdentifier(value) && !RESERVED.has(value) && state[value] == null) {
|
|
438
455
|
state[value] = match(name, "bind") ? (el.type == "checkbox" ? el.checked : tryNum(el.value)) : tryNum(el.textContent);
|
|
439
456
|
}
|
|
440
457
|
|
|
441
458
|
// Process the attribute
|
|
442
459
|
if (name === "@data" || name === `data-${prefix}`) {
|
|
443
|
-
Object.assign(state,
|
|
460
|
+
Object.assign(state, evalExpr(value));
|
|
444
461
|
}
|
|
445
462
|
else if (name.startsWith(":") || name.startsWith(`data-${prefix}-attr:`)) {
|
|
446
463
|
const propName = name.startsWith(":") ? name.slice(1) : name.slice(prefix.length + 11);
|
|
@@ -505,7 +522,7 @@ export function createHelium(options = {}) {
|
|
|
505
522
|
const target = pairs.map(([target]) => target);
|
|
506
523
|
const action = pairs.map(([, action]) => action);
|
|
507
524
|
const options = {
|
|
508
|
-
...(getAttr("options") &&
|
|
525
|
+
...(getAttr("options") && evalExpr(getAttr("options") || "{}")),
|
|
509
526
|
...(target && { target }),
|
|
510
527
|
...(action && { action }),
|
|
511
528
|
...(getAttr("template") && { template: execExpr(getAttr("template")),}),
|
|
@@ -587,7 +604,16 @@ export function createHelium(options = {}) {
|
|
|
587
604
|
}
|
|
588
605
|
|
|
589
606
|
// Create default instance for backwards compatibility
|
|
590
|
-
const { helium: defaultHelium, heliumTeardown:
|
|
607
|
+
const { helium: defaultHelium, heliumTeardown: _defaultTeardown } = createHelium();
|
|
608
|
+
|
|
609
|
+
// Flag to disable default auto-init (used by helium-csp.js and other variants)
|
|
610
|
+
let defaultDisabled = false;
|
|
611
|
+
|
|
612
|
+
// Wrapped teardown that also prevents future auto-init
|
|
613
|
+
function defaultTeardown() {
|
|
614
|
+
defaultDisabled = true;
|
|
615
|
+
_defaultTeardown();
|
|
616
|
+
}
|
|
591
617
|
|
|
592
618
|
// Setup browser globals and auto-initialization
|
|
593
619
|
if (typeof window !== 'undefined') {
|
|
@@ -597,11 +623,11 @@ if (typeof window !== 'undefined') {
|
|
|
597
623
|
|
|
598
624
|
// Initialize on load
|
|
599
625
|
if (typeof document !== 'undefined') {
|
|
600
|
-
document.addEventListener("DOMContentLoaded",_ => defaultHelium());
|
|
626
|
+
document.addEventListener("DOMContentLoaded",_ => !defaultDisabled && defaultHelium());
|
|
601
627
|
// Turbo integration
|
|
602
628
|
document.addEventListener("turbo:before-render",_ => defaultTeardown());
|
|
603
|
-
document.addEventListener("turbo:render",_ => defaultHelium());
|
|
629
|
+
document.addEventListener("turbo:render",_ => !defaultDisabled && defaultHelium());
|
|
604
630
|
}
|
|
605
631
|
|
|
606
|
-
export {
|
|
632
|
+
export { defaultTeardown };
|
|
607
633
|
export default defaultHelium;
|