@daz4126/helium 1.0.0-beta.2 → 1.0.0-beta.4

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.
@@ -0,0 +1,629 @@
1
+ const parseEx=v=>{try{return Function(`return(${v})`)()}catch{return v}}
2
+ const tryNum=v=>{const t=String(v).trim(),n=+t;return t&&!isNaN(n)?n:v}
3
+ const isValidIdentifier = v => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v);
4
+ const RESERVED = new Set(['undefined', 'null', 'true', 'false', 'NaN', 'Infinity', 'this', 'arguments']);
5
+ const INPUT_EVENTS = { form: "submit", input: "input", textarea:"input", select:"change" };
6
+ const KEY_MODS = { shift: "shiftKey", ctrl: "ctrlKey", alt: "altKey", meta: "metaKey" };
7
+ const getEvent = el => INPUT_EVENTS[el.tagName.toLowerCase()] || "click";
8
+ const debounce=(f,d)=>{let t;return(...a)=>(clearTimeout(t),t=setTimeout(f,d,...a))}
9
+
10
+ // Default expression engine using new Function()
11
+ const defaultEngine = {
12
+ compile(expr, withReturn = true) {
13
+ try {
14
+ const fn = new Function(
15
+ "$scope",
16
+ withReturn
17
+ ? `with($scope){with($scope.$data){return(${expr.trim()})}}`
18
+ : `with($scope){with($scope.$data){${expr.trim()}}}`
19
+ );
20
+ return {
21
+ execute: (scope) => fn(scope),
22
+ getIds: null // Will use trackDependencies instead
23
+ };
24
+ } catch {
25
+ return {
26
+ execute: () => expr,
27
+ getIds: null
28
+ };
29
+ }
30
+ },
31
+
32
+ createScope(ctx) {
33
+ return {
34
+ $: ctx.$,
35
+ $data: ctx.state,
36
+ $event: ctx.event,
37
+ $el: ctx.el,
38
+ $html: ctx.html,
39
+ $get: ctx.get,
40
+ $post: ctx.post,
41
+ $put: ctx.put,
42
+ $patch: ctx.patch,
43
+ $delete: ctx.del,
44
+ ...ctx.refs
45
+ };
46
+ }
47
+ };
48
+
49
+ // Factory function to create helium with custom engine
50
+ export function createHelium(options = {}) {
51
+ const engine = options.engine ?? defaultEngine;
52
+
53
+ // Engine-aware expression parser (CSP-safe when using jexpr engine)
54
+ const evalExpr = v => {
55
+ try {
56
+ const compiled = engine.compile(v, true);
57
+ const scope = engine.createScope ? engine.createScope({ state: {}, el: null, event: {}, refs: {}, $: null, html: null, get: null, post: null, put: null, patch: null, del: null }) : {};
58
+ return compiled.execute(scope);
59
+ } catch { return v; }
60
+ };
61
+
62
+ const match = (name, ...attrs) => {
63
+ const p = name.split(/[.:]/)[0];
64
+ if (p === ":" || p === "") return false;
65
+ return attrs.some(a => p === `@${a}` || p === 'data-he-' + a);
66
+ };
67
+
68
+ // Single global object to hold all settings (scoped to this instance)
69
+ let HELIUM = null;
70
+
71
+ async function helium(initialState = {}) {
72
+ const inits = [];
73
+ const ALL = Symbol("all");
74
+ const root = document.querySelector('[\\@helium]') || document.querySelector('[data-helium]') || document.body;
75
+ const storageKey = root.getAttribute('@local-storage') || root.getAttribute('data-he-local-storage');
76
+
77
+ // Initialize or reuse HELIUM object
78
+ if (!HELIUM) {
79
+ HELIUM = {
80
+ observer: null,
81
+ bindings: new Map(),
82
+ refs: new Map(),
83
+ listeners: new WeakMap(),
84
+ processed: new WeakSet(),
85
+ parentKeys: new WeakMap(),
86
+ fnCache: new Map(),
87
+ proxyCache: new WeakMap()
88
+ };
89
+ }
90
+
91
+ const $ = s => document.querySelector(s);
92
+ const html = s => Object.assign(document.createElement("template"),{innerHTML:s.trim()}).content.firstChild
93
+
94
+ const update = (data,targets,actions,template) => {
95
+ const newTargets = [];
96
+ (targets || []).forEach((target,i) => {
97
+ const element = target instanceof Node ? target : (HELIUM.refs.get(target.trim()) || $(target.trim()));
98
+ if(element){
99
+ const content = template ? template(data) : data;
100
+ const htmlContent = html(content)
101
+ actions?.[i] ? element[actions[i]=="replace"?"replaceWith":actions[i]](htmlContent) : element.innerHTML = content;
102
+ newTargets.push(actions?.[i] ? htmlContent : element)
103
+ } else state[target] = data
104
+ })
105
+ return newTargets
106
+ }
107
+
108
+ // SSE handling with retry support
109
+ const handleSSE = async (res, options, reconnect) => {
110
+ const reader = res.body.getReader();
111
+ const decoder = new TextDecoder();
112
+ let buffer = '', lastId = null, retryDelay = options.retry || 3000;
113
+
114
+ const processEvents = () => {
115
+ const parts = buffer.split('\n\n');
116
+ buffer = parts.pop();
117
+ for (const block of parts) {
118
+ if (!block.trim()) continue;
119
+ let data = '', id = null;
120
+ for (const line of block.split('\n')) {
121
+ if (line.startsWith('data:')) data += (data ? '\n' : '') + line.slice(5).trimStart();
122
+ else if (line.startsWith('id:')) id = line.slice(3).trim();
123
+ else if (line.startsWith('retry:')) retryDelay = parseInt(line.slice(6).trim()) || retryDelay;
124
+ }
125
+ if (id) lastId = id;
126
+ if (data) {
127
+ update(data, options.target, options.action, options.template);
128
+ }
129
+ }
130
+ };
131
+
132
+ try {
133
+ while (true) {
134
+ const {done, value} = await reader.read();
135
+ if (done) break;
136
+ buffer += decoder.decode(value, {stream: true});
137
+ processEvents();
138
+ }
139
+ // Process any remaining data
140
+ if (buffer.trim()) { buffer += '\n\n'; processEvents(); }
141
+ // Reconnect if configured
142
+ if (options.retryMode === 'always') setTimeout(() => reconnect(lastId), retryDelay);
143
+ } catch (e) {
144
+ console.error("SSE:", e.message);
145
+ if (options.retryMode === 'always' || options.retryMode === 'error') {
146
+ setTimeout(() => reconnect(lastId), retryDelay);
147
+ }
148
+ }
149
+ };
150
+
151
+ const ajax = (url,method,options={},params={}) => {
152
+ if(options.loading) {
153
+ const loadingTargets = update(options.loading,options.target,options.action);
154
+ if(loadingTargets.length) options.target = loadingTargets;
155
+ }
156
+ const fd = params instanceof FormData, token = document.querySelector('meta[name="csrf-token"]')?.content;
157
+ const path = new URL(url, window.location.href);
158
+ const sameOrigin = path.origin === window.location.origin;
159
+
160
+ const doFetch = (lastEventId) => fetch(url, {
161
+ method,
162
+ headers: {
163
+ Accept:"text/event-stream,text/vnd.turbo-stream.html,application/json,text/html",
164
+ ...(!fd && method !== "GET" && {"Content-Type":"application/json"}),
165
+ ...(sameOrigin && token ? {"X-CSRF-Token": token} : {}),
166
+ ...(lastEventId ? {"Last-Event-ID": lastEventId} : {})
167
+ },
168
+ body: method === "GET" ? null : (fd ? params : JSON.stringify(params)),
169
+ credentials: sameOrigin ? "same-origin" : "omit"
170
+ })
171
+ .then(res => {
172
+ const type = res.headers.get("content-type") || "";
173
+ if (type.includes("event-stream")) return handleSSE(res, options, doFetch);
174
+ return (type.includes("turbo-stream") ? res.text().then(data => ({ turbo: true, data })) :
175
+ type.includes("json") ? res.json() :
176
+ res.text());
177
+ }).then(data => {
178
+ if (data === undefined) return; // SSE handled separately
179
+ return (data.turbo && window.Turbo)
180
+ ? Turbo.renderStreamMessage(data.data)
181
+ : update(data, options.target, options.loading ? (options.action || []).map(a => a && "replace") : options.action, options.template)
182
+ }).catch(e => console.error("AJAX:", e.message));
183
+
184
+ doFetch(null);
185
+ }
186
+
187
+ const opts = o => typeof o === "string" ? { target: [o], action: [null] } : o;
188
+ const get = (url, options={}) => ajax(url, "GET", opts(options));
189
+ const [post, put, patch, del] = ["POST","PUT","PATCH","DELETE"].map(method => (url, params, options={}) => ajax(url, method, opts(options), params));
190
+
191
+ // Track bindings currently being applied to prevent infinite recursion
192
+ const applyingBindings = new Set();
193
+
194
+ const safeApplyBinding = (b) => {
195
+ if (applyingBindings.has(b)) return;
196
+ applyingBindings.add(b);
197
+ try {
198
+ applyBinding(b);
199
+ } finally {
200
+ applyingBindings.delete(b);
201
+ }
202
+ };
203
+
204
+ const handler = {
205
+ // 'has' trap needed for 'with' statement - it uses 'in' operator to check property existence
206
+ // Return false for $-prefixed names so they fall through to outer scope
207
+ // (where $el, $event, $html, and refs like $form are defined without proxy wrapping)
208
+ has(t, p) {
209
+ if (typeof p === 'string' && p.startsWith('$')) return false;
210
+ if (Reflect.has(t, p)) return true;
211
+ if (typeof globalThis[p] !== 'undefined') return false; // Allow globals like Date, Math, Array, console
212
+ return true;
213
+ },
214
+ get(t,p,r) {
215
+ const v = Reflect.get(t,p,r);
216
+ if (v && typeof v === "object") {
217
+ if (HELIUM.proxyCache.has(v)) return HELIUM.proxyCache.get(v);
218
+ const proxy = new Proxy(v, handler);
219
+ HELIUM.proxyCache.set(v, proxy);
220
+ HELIUM.parentKeys.set(v, p);
221
+ return proxy;
222
+ }
223
+ return v;
224
+ },
225
+ set: (t,p,v) => {
226
+ const res = Reflect.set(t,p,v);
227
+ // Trigger bindings for this property
228
+ HELIUM.bindings.get(p)?.forEach(safeApplyBinding);
229
+ // Also trigger parent object's bindings (for nested updates like user.name)
230
+ const parentKey = HELIUM.parentKeys.get(t);
231
+ if (parentKey) HELIUM.bindings.get(parentKey)?.forEach(safeApplyBinding);
232
+ HELIUM.bindings.get(ALL)?.forEach(safeApplyBinding);
233
+ if (storageKey) localStorage.setItem(storageKey, JSON.stringify(state));
234
+ return res
235
+ }
236
+ };
237
+
238
+ // Initialize state if it doesn't exist
239
+ const state = new Proxy({}, handler);
240
+
241
+ // Load from localStorage if key exists
242
+ if (storageKey) try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey))); } catch {}
243
+
244
+ // Merge initial state
245
+ Object.assign(state, initialState);
246
+
247
+ // Build refs object for scope
248
+ const getRefsObject = () => {
249
+ const refs = {};
250
+ for (const [name, element] of HELIUM.refs) {
251
+ refs[name] = element;
252
+ }
253
+ return refs;
254
+ };
255
+
256
+ // Create scope for expression execution
257
+ const createScope = (el, event = {}) => {
258
+ return engine.createScope({
259
+ $,
260
+ state,
261
+ event,
262
+ el,
263
+ html,
264
+ get,
265
+ post,
266
+ put,
267
+ patch,
268
+ del,
269
+ refs: getRefsObject()
270
+ });
271
+ };
272
+
273
+ // Compile expression with caching
274
+ const compile = (expr, withReturn = true) => {
275
+ const key = `${withReturn}:${expr}`;
276
+ if (HELIUM.fnCache.has(key)) return HELIUM.fnCache.get(key);
277
+ const compiled = engine.compile(expr, withReturn);
278
+ HELIUM.fnCache.set(key, compiled);
279
+ return compiled;
280
+ };
281
+
282
+ function applyBinding(b,e={},elCtx=b.el){
283
+ const {el,prop,compiled,calc,isHiddenAttr,isRadio}=b;
284
+ const scope = createScope(elCtx, e);
285
+ const r = compiled.execute(scope);
286
+ if (calc) state[calc] = r
287
+
288
+ if (prop==="innerHTML") {
289
+ const content = Array.isArray(r)?r.join``:r;
290
+ return typeof Idiomorph === "object"
291
+ ? Idiomorph.morph(el, content,{morphStyle:'innerHTML'})
292
+ : el.innerHTML = content;
293
+ }
294
+ if (prop==="class" && r && typeof r==="object")
295
+ return Object.entries(r).forEach(([k,v]) =>
296
+ k.split(/\s+/).forEach(c => el.classList.toggle(c,v)));
297
+
298
+ if (prop==="style" && r && typeof r==="object")
299
+ return el.style.cssText = Object.entries(r)
300
+ .filter(([_, v]) => v != null && v !== false)
301
+ .map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`)
302
+ .join("; ");
303
+
304
+ if (prop === "hidden") {
305
+ // @hidden="x" -> hide when x is truthy
306
+ // @visible="x" -> hide when x is falsy (invert via isHiddenAttr)
307
+ el.hidden = isHiddenAttr ? !!r : !r;
308
+ return;
309
+ }
310
+
311
+ if (prop in el) {
312
+ if (isRadio && prop !== "checked") el.checked = el.value===r;
313
+ else if (el.type === "radio" && prop !== "checked") el.checked = el.value===r;
314
+ else el[prop] = prop==="textContent" ? (r ?? '') : parseEx(r);
315
+ return;
316
+ }
317
+
318
+ el.setAttribute(prop, parseEx(r));
319
+ }
320
+
321
+ // Track dependencies using proxy (fallback when getIds is not available)
322
+ const trackDependenciesProxy = (compiled, el, excludeChanged = false) => {
323
+ const accessed = excludeChanged ? new Map() : new Set();
324
+ const trackProxy = new Proxy(state, {
325
+ // 'has' trap is needed for 'with' statement to work correctly
326
+ // Without it, 'with' checks if property exists using 'in' operator,
327
+ // and if not found, continues up scope chain causing ReferenceError
328
+ has(target, prop) {
329
+ return true;
330
+ },
331
+ get(target, prop) {
332
+ if (typeof prop == 'string') {
333
+ excludeChanged ? !accessed.has(prop) && accessed.set(prop, target[prop]) : accessed.add(prop);
334
+ }
335
+ const val = target[prop];
336
+ return typeof val == "object" && val != null ? new Proxy(val, this) : val;
337
+ }
338
+ });
339
+
340
+ try {
341
+ const scope = engine.createScope({
342
+ $,
343
+ state: trackProxy,
344
+ event: {},
345
+ el,
346
+ html,
347
+ get,
348
+ post,
349
+ put,
350
+ patch,
351
+ del,
352
+ refs: getRefsObject()
353
+ });
354
+ compiled.execute(scope);
355
+ } catch {}
356
+
357
+ return excludeChanged ? [...accessed.keys()].filter(prop => state[prop] === accessed.get(prop)) : [...accessed];
358
+ };
359
+
360
+ // Get dependencies - use getIds() if available, otherwise fall back to proxy tracking
361
+ // Note: When excludeChanged is true, we always use proxy tracking because getIds()
362
+ // doesn't distinguish between read and write operations, which can cause infinite loops
363
+ // when expressions both read and write state (e.g., @effect="lastSeen = count")
364
+ const getDependencies = (compiled, el, excludeChanged = false) => {
365
+ if (compiled.getIds && !excludeChanged) {
366
+ return compiled.getIds([]);
367
+ }
368
+ return trackDependenciesProxy(compiled, el, excludeChanged);
369
+ };
370
+
371
+ const cleanup = el => {
372
+ [el,...el.querySelectorAll('*')].forEach(e => {
373
+ HELIUM.listeners.get(e)?.forEach(({receiver,event,handler}) => receiver.removeEventListener(event,handler));
374
+ HELIUM.listeners.delete(e);
375
+ });
376
+ };
377
+
378
+ async function processElements(element) {
379
+ const newBindings = [];
380
+ const deferredBindings = [];
381
+
382
+ const heElements = [element, ...element.querySelectorAll("*")].filter(e => {
383
+ if (HELIUM.processed.has(e)) return false;
384
+ for (let i = 0; i < e.attributes.length; i++) {
385
+ const n = e.attributes[i].name;
386
+ if (n[0] === '@' || n[0] === ':' || n.startsWith('data-he')) return true;
387
+ }
388
+ return false;
389
+ });
390
+
391
+ const addBinding = (val, b) => {
392
+ HELIUM.bindings.set(val, b.calc ? [b,...(HELIUM.bindings.get(val) || [])] : [...(HELIUM.bindings.get(val) || []), b]);
393
+ b.calc ? newBindings.unshift(b) : newBindings.push(b);
394
+ };
395
+
396
+ const importPromises = [];
397
+
398
+ heElements.forEach((el) => {
399
+ const importAttr = el.getAttribute("@import") || el.getAttribute('data-he-import');
400
+ if (importAttr) {
401
+ importAttr.split(",").map((m) => m.trim()).forEach((moduleName) => {
402
+ // Build path: use as-is for URLs, otherwise resolve relative to document location
403
+ const isUrl = moduleName.startsWith("http://") || moduleName.startsWith("https://");
404
+ const hasExtension = moduleName.endsWith(".js");
405
+ const hasPathPrefix = moduleName.startsWith("/") || moduleName.startsWith("./") || moduleName.startsWith("../");
406
+ const relativePath = (isUrl || hasPathPrefix ? "" : "./") + moduleName + (isUrl || hasExtension ? "" : ".js");
407
+ // Ensure base URL ends with / so relative paths resolve correctly
408
+ const baseUrl = location.href.endsWith('/') || location.href.includes('.html') ? location.href : location.href + '/';
409
+ const path = isUrl ? relativePath : new URL(relativePath, baseUrl).href;
410
+ importPromises.push(
411
+ import(path)
412
+ .then((module) => {
413
+ Object.keys(module).forEach(
414
+ (key) => (state[key] = module[key]),
415
+ );
416
+ })
417
+ .catch((error) => {
418
+ console.error(
419
+ `Failed to import module: ${path}`,
420
+ error.message,
421
+ );
422
+ }),
423
+ );
424
+ });
425
+ }
426
+ });
427
+
428
+ if (importPromises.length > 0) await Promise.all(importPromises);
429
+
430
+ heElements.forEach(el => {
431
+ HELIUM.processed.add(el);
432
+
433
+ const attrs = el.attributes;
434
+ const execExpr = v => {
435
+ const compiled = compile(v, true);
436
+ const scope = createScope(el);
437
+ return compiled.execute(scope);
438
+ };
439
+ const inputType = el.type?.toLowerCase();
440
+ const isCheckbox = inputType == "checkbox", isRadio = inputType == "radio", isSelect = el.tagName == "SELECT";
441
+
442
+ // Single loop through attributes with early skipping
443
+ for (let i = 0; i < attrs.length; i++) {
444
+ const {name, value} = attrs[i];
445
+
446
+ // Skip non-helium attributes early
447
+ if (name[0] !== '@' && name[0] !== ':' && !name.startsWith('data-he')) continue;
448
+
449
+ // Initialize state first if needed (skip reserved words, only if not already set)
450
+ if (match(name, "text", "html", "bind") && isValidIdentifier(value) && !RESERVED.has(value) && state[value] == null) {
451
+ state[value] = match(name, "bind") ? (el.type == "checkbox" ? el.checked : tryNum(el.value)) : tryNum(el.textContent);
452
+ }
453
+
454
+ // Process the attribute
455
+ if (name === "@data" || name === 'data-he') {
456
+ Object.assign(state, evalExpr(value));
457
+ }
458
+ else if (name.startsWith(":") || name.startsWith('data-he-attr:')) {
459
+ const propName = name.startsWith(":") ? name.slice(1) : name.slice(13);
460
+ deferredBindings.push({el, prop: propName, compiled: compile(value, true)});
461
+ }
462
+ else if (match(name, "ref")) {
463
+ HELIUM.refs.set("$" + value, el);
464
+ }
465
+ else if (match(name, "text", "html")) {
466
+ deferredBindings.push({el, prop: match(name, "text") ? "textContent" : "innerHTML", compiled: compile(value, true)});
467
+ }
468
+ else if (match(name, "bind")) {
469
+ const event = (isCheckbox || isRadio || isSelect) ? "change" : "input";
470
+ const prop = isCheckbox ? "checked" : "value";
471
+ const inputHandler = e => state[value] = isCheckbox ? e.target.checked : e.target.value;
472
+ el.addEventListener(event, inputHandler);
473
+ if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
474
+ HELIUM.listeners.get(el).push({receiver: el, event, handler: inputHandler});
475
+ deferredBindings.push({el, prop, compiled: compile(value, true), isRadio});
476
+ if (isCheckbox) el.checked = !!state[value];
477
+ else if (isRadio) el.checked = el.value == state[value];
478
+ else el.value = state[value] ?? "";
479
+ }
480
+ else if (match(name, "hidden", "visible")) {
481
+ const isHidden = match(name, "hidden");
482
+ deferredBindings.push({el, prop: "hidden", compiled: compile(value, true), isHiddenAttr: isHidden});
483
+ }
484
+ else if (match(name, "calculate")) {
485
+ const calcName = name.split(":")[1];
486
+ if (!(calcName in state)) state[calcName] = undefined; // Initialize so other bindings can reference it
487
+ deferredBindings.push({el, calc: calcName, prop: null, compiled: compile(value, true)});
488
+ }
489
+ else if (match(name, "effect")) {
490
+ deferredBindings.push({el, prop: null, compiled: compile(value, true), keys: name.split(":").slice(1)});
491
+ }
492
+ else if (match(name, "init")) {
493
+ inits.push({ compiled: compile(value, true), el });
494
+ }
495
+ else if (name.startsWith("@") || name.startsWith('data-he')) {
496
+ const fullName = name.startsWith("@") ? name.slice(1) : name.slice(8);
497
+ const [eventName, ...mods] = fullName.split(".");
498
+ const isHttpMethod = ["get", "post", "put", "patch", "delete"].includes(eventName);
499
+ const event = isHttpMethod ? getEvent(el) : eventName;
500
+ const receiver = mods.includes("outside") || mods.includes("document") ? document : el;
501
+ const debounceMod = mods.find(m => m.startsWith("debounce"));
502
+ const debounceDelay = debounceMod ? (t => t && !isNaN(t) ? Number(t) : 300)(debounceMod.split(":")[1]) : 0;
503
+ const _handler = e => {
504
+ if (mods.includes("prevent")) e.preventDefault();
505
+ if (mods.includes("stop")) e.stopPropagation();
506
+ for (const [mod, prop] of Object.entries(KEY_MODS)) if (mods.includes(mod) && !e[prop]) return;
507
+ if (["keydown", "keyup", "keypress"].includes(event)) {
508
+ const last = mods[mods.length - 1];
509
+ if (last && !["prevent", "once", "outside", "document", "stop"].includes(last) && !last.startsWith("debounce") && !Object.keys(KEY_MODS).includes(last)) {
510
+ const keyName = e.key == " " ? "Space" : e.key == "Escape" ? "Esc" : e.key;
511
+ if (keyName.toLowerCase() !== last.toLowerCase()) return;
512
+ }
513
+ }
514
+ if (!mods.includes("outside") || !el.contains(e.target)) {
515
+ if (isHttpMethod) {
516
+ const getAttr = a => el.getAttribute('data-he-' + a) || el.getAttribute('@' + a);
517
+ const pairs = (getAttr('target') || "").split(",").map(p => p.split(":").map(s => s.trim()));
518
+ const target = pairs.map(([target]) => target);
519
+ const action = pairs.map(([, action]) => action);
520
+ const options = {
521
+ ...(getAttr("options") && evalExpr(getAttr("options") || "{}")),
522
+ ...(target && { target }),
523
+ ...(action && { action }),
524
+ ...(getAttr("template") && { template: execExpr(getAttr("template")),}),
525
+ ...(getAttr("loading") && { loading: execExpr(getAttr("loading"))}),
526
+ };
527
+ let paramsAttr = getAttr("params");
528
+ if (!paramsAttr && el.hasAttribute("name")) {
529
+ const keys = el.getAttribute("name").match(/\w+/g).map(key => `${key}:`).join``;
530
+ paramsAttr = keys + (isCheckbox ? "checked" : "value");
531
+ } else paramsAttr ||= "{}";
532
+ if (!paramsAttr.trim().startsWith("{") && paramsAttr.includes(":")) {
533
+ const props = paramsAttr.split(":").map(s => s.trim());
534
+ paramsAttr = props.reduceRight((acc, key, i) => `{ ${key}: ${i == 1 ? `'${el[acc]}'` : acc} }`);
535
+ }
536
+ const scope = createScope(el, e);
537
+ const paramsCompiled = compile(paramsAttr, true);
538
+ const params = paramsCompiled.execute(scope);
539
+ ajax(value, eventName.toUpperCase(), options, params);
540
+ } else {
541
+ const scope = createScope(el, e);
542
+ const compiled = compile(value, true);
543
+ compiled.execute(scope);
544
+ }
545
+ }
546
+ if (mods.includes("once")) receiver.removeEventListener(event, handler);
547
+ };
548
+ const handler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
549
+ const eventOptions = mods.includes("once") ? { once: true } : undefined;
550
+ receiver.addEventListener(event, handler, eventOptions);
551
+ if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
552
+ HELIUM.listeners.get(el).push({receiver, event, handler});
553
+ }
554
+ }
555
+ });
556
+ deferredBindings.forEach(b => {
557
+ const tracked = b.keys?.includes("*") ? [ALL] : getDependencies(b.compiled, b.el, true).concat(b.keys || []);
558
+ tracked.forEach(key => addBinding(key, b));
559
+ // If no dependencies were found, still apply the binding once (for literals like @text="true")
560
+ if (tracked.length === 0) {
561
+ newBindings.push(b);
562
+ }
563
+ })
564
+ return newBindings;
565
+ }
566
+
567
+ // Disconnect old observer if exists, then create new one
568
+ HELIUM.observer?.disconnect();
569
+ HELIUM.observer = new MutationObserver(async (ms) => {
570
+ for (const m of ms) {
571
+ m.removedNodes.forEach((n) => n.nodeType === 1 && cleanup(n));
572
+
573
+ for (const n of m.addedNodes) {
574
+ if (n.nodeType === 1 && !HELIUM.processed.has(n)) {
575
+ const bindings = await processElements(n);
576
+ bindings?.forEach(applyBinding);
577
+ }
578
+ }
579
+ }
580
+ });
581
+ HELIUM.observer.observe(root, { childList: true, subtree: true });
582
+
583
+ const initialBindings = await processElements(root);
584
+ initialBindings.forEach(applyBinding);
585
+ inits.forEach(({ compiled, el }) => {
586
+ const scope = createScope(el);
587
+ compiled.execute(scope);
588
+ });
589
+
590
+ return state;
591
+ }
592
+
593
+ // Teardown function
594
+ function heliumTeardown() {
595
+ if (HELIUM?.observer) HELIUM.observer.disconnect();
596
+ HELIUM = null;
597
+ }
598
+
599
+ return { helium, heliumTeardown };
600
+ }
601
+
602
+ // Create default instance for backwards compatibility
603
+ const { helium: defaultHelium, heliumTeardown: _defaultTeardown } = createHelium();
604
+
605
+ // Flag to disable default auto-init (used by helium-csp.js and other variants)
606
+ let defaultDisabled = false;
607
+
608
+ // Wrapped teardown that also prevents future auto-init
609
+ function defaultTeardown() {
610
+ defaultDisabled = true;
611
+ _defaultTeardown();
612
+ }
613
+
614
+ // Setup browser globals and auto-initialization
615
+ if (typeof window !== 'undefined') {
616
+ window.helium = defaultHelium;
617
+ window.heliumTeardown = defaultTeardown;
618
+ }
619
+
620
+ // Initialize on load
621
+ if (typeof document !== 'undefined') {
622
+ document.addEventListener("DOMContentLoaded",_ => !defaultDisabled && defaultHelium());
623
+ // Turbo integration
624
+ document.addEventListener("turbo:before-render",_ => defaultTeardown());
625
+ document.addEventListener("turbo:render",_ => !defaultDisabled && defaultHelium());
626
+ }
627
+
628
+ export { defaultTeardown };
629
+ export default defaultHelium;
package/helium-csp.js CHANGED
@@ -221,12 +221,8 @@ const jexprEngine = {
221
221
  }
222
222
  };
223
223
 
224
- // Create helium with jexpr engine and he-* prefixes
225
- const { helium, heliumTeardown } = createHelium({
226
- engine: jexprEngine,
227
- prefix: 'he',
228
- rootAttr: 'helium'
229
- });
224
+ // Create helium with jexpr engine (CSP-safe)
225
+ const { helium, heliumTeardown } = createHelium({ engine: jexprEngine });
230
226
 
231
227
  // Evaluate helper for testing
232
228
  const evaluate = (expr, scope) => {