@daz4126/helium 0.30.0 → 1.0.0-beta.1

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