@daz4126/helium 1.0.0-beta.3 → 1.0.0-beta.5

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.
@@ -11,7 +11,8 @@
11
11
  "Bash(npm install)",
12
12
  "Bash(npm link helium)",
13
13
  "Bash(ls:*)",
14
- "Bash(wc:*)"
14
+ "Bash(wc:*)",
15
+ "WebFetch(domain:codepen.io)"
15
16
  ]
16
17
  }
17
18
  }
package/README.md CHANGED
@@ -12,7 +12,7 @@ Here's a simple example of a button that counts the number of times it has been
12
12
 
13
13
  It's really simple to use - just sprinkle the magic @attributes into your HTML and watch it come alive!
14
14
 
15
- [See more examples here](https://codepen.io/daz4126/pen/ogxGMX)
15
+ [See more examples here](https://codepen.io/daz4126/pen/ogxGMX) | [TodoMVC Example](https://codepen.io/daz4126/pen/gbMjdZO)
16
16
 
17
17
  ## Why Helium?
18
18
 
@@ -20,8 +20,68 @@ Helium is designed for developers who want:
20
20
 
21
21
  - **Lightweight** - Just over 3KB minified and gzipped
22
22
  - **Powerful** - Declarative JavaScript in your HTML
23
- - **Zero build step** Works directly in the browser with no compiling
24
- - **Easy to learn** If you know HTML and basic JavaScript, you're ready
23
+ - **Zero build step** - Works directly in the browser with no compiling
24
+ - **Easy to learn** - If you know HTML and basic JavaScript, you're ready
25
+
26
+ ## Versions
27
+
28
+ Helium comes in three versions so you can pick the right balance of features and size for your project:
29
+
30
+ ### Helium (Standard)
31
+
32
+ The full-featured version with everything included.
33
+
34
+ - All directives (`@text`, `@html`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`, etc.)
35
+ - Event handlers with modifiers
36
+ - HTTP requests (`@get`, `@post`, `@put`, `@patch`, `@delete`)
37
+ - Module imports (`@import`)
38
+ - Server-Sent Events (SSE)
39
+ - Turbo/Hotwire integration
40
+
41
+ **Best for:** Most projects where you want the full power of Helium without worrying about CSP restrictions.
42
+
43
+ ```javascript
44
+ import helium from "@daz4126/helium"
45
+ ```
46
+
47
+ ### Helium Lite
48
+
49
+ A slimmed-down version with just the core reactivity features.
50
+
51
+ - Core directives (`@text`, `@html`, `@bind`, `@data`, `@ref`, `@calculate`, `@effect`, `@init`)
52
+ - Event handlers with modifiers
53
+ - No HTTP requests, imports, SSE, or Turbo integration
54
+
55
+ **Best for:** Simple interactive pages where you only need reactivity and don't need server communication or module imports.
56
+
57
+ ```javascript
58
+ import helium from "@daz4126/helium/lite"
59
+ ```
60
+
61
+ ### Helium CSP
62
+
63
+ A Content Security Policy safe version that doesn't require `unsafe-eval`. It uses [jexpr](https://github.com/nicolo-ribaudo/jexpr) as its expression engine instead of `new Function()`, while keeping the full feature set.
64
+
65
+ - All the same features as the standard version
66
+ - CSP-compliant — no `unsafe-eval` needed
67
+
68
+ **Best for:** Projects with strict Content Security Policies that prohibit `unsafe-eval`.
69
+
70
+ ```javascript
71
+ import helium from "@daz4126/helium/csp"
72
+ ```
73
+
74
+ ### Comparison
75
+
76
+ | Feature | Standard | Lite | CSP |
77
+ |---------|----------|------|-----|
78
+ | Core directives | ✅ | ✅ | ✅ |
79
+ | Event handlers | ✅ | ✅ | ✅ |
80
+ | HTTP requests | ✅ | ❌ | ✅ |
81
+ | `@import` | ✅ | ❌ | ✅ |
82
+ | SSE | ✅ | ❌ | ✅ |
83
+ | Turbo integration | ✅ | ❌ | ✅ |
84
+ | CSP safe | ❌ | ❌ | ✅ |
25
85
 
26
86
  ## Installation
27
87
 
@@ -30,9 +90,20 @@ Helium is designed for developers who want:
30
90
  Just import from the CDN in a script tag directly in your HTML page:
31
91
 
32
92
  ```html
93
+ <!-- Standard -->
33
94
  <script type="module">
34
95
  import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium.js';
35
96
  </script>
97
+
98
+ <!-- Lite -->
99
+ <script type="module">
100
+ import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium-lite.js';
101
+ </script>
102
+
103
+ <!-- CSP -->
104
+ <script type="module">
105
+ import helium from 'https://cdn.jsdelivr.net/gh/daz-codes/helium/helium-csp.js';
106
+ </script>
36
107
  ```
37
108
 
38
109
  ### NPM
@@ -41,10 +112,12 @@ Just import from the CDN in a script tag directly in your HTML page:
41
112
  npm install @daz4126/helium
42
113
  ```
43
114
 
44
- Then include it in your JavaScript file and call the helium() function:
115
+ Then import the version you need:
45
116
 
46
117
  ```javascript
47
- import helium from "@daz4126/helium"
118
+ import helium from "@daz4126/helium" // Standard
119
+ import helium from "@daz4126/helium/lite" // Lite
120
+ import helium from "@daz4126/helium/csp" // CSP
48
121
  ```
49
122
 
50
123
  ### Automatic Initialization
@@ -220,17 +220,11 @@
220
220
  </head>
221
221
  <body>
222
222
 
223
- <div class="page" data-helium @data="{ count: 0 }">
223
+ <div class="page" data-helium>
224
224
  <h1>Helium Examples</h1>
225
225
 
226
226
  <div class="examples">
227
227
 
228
- <!-- @data Test -->
229
- <div class="card" data-he="{ word: 'Hello',number: 42 }">
230
- <h2 data-he-text="word"></h2>
231
- <p>Number: <b @text="number"></b></p>
232
- </div>
233
-
234
228
  <!-- Counter -->
235
229
  <div class="card">
236
230
  <h2>Counter</h2>
@@ -328,7 +322,7 @@
328
322
  </div>
329
323
 
330
324
  <!-- Debounced Input -->
331
- <div class="card" @data="{ search: '', debouncedSearch: '' }">
325
+ <div class="card">
332
326
  <h2>Debounced Input</h2>
333
327
  <p>Type and wait 500ms to see the debounced value update.</p>
334
328
  <input placeholder="Search..." @bind="search" @input.debounce:500="debouncedSearch = search">
@@ -340,13 +334,13 @@
340
334
  <div class="card" @data="{ tab: 'one' }">
341
335
  <h2>Tabs</h2>
342
336
  <div class="tabs">
343
- <button class="tab" :class="{ active: tab === 'one' }" @click="tab = 'one'">Tab 1</button>
344
- <button class="tab" :class="{ active: tab === 'two' }" @click="tab = 'two'">Tab 2</button>
345
- <button class="tab" :class="{ active: tab === 'three' }" @click="tab = 'three'">Tab 3</button>
337
+ <button class="tab" :class="{ active: tab == 'one' }" @click="tab = 'one'">Tab 1</button>
338
+ <button class="tab" :class="{ active: tab == 'two' }" @click="tab = 'two'">Tab 2</button>
339
+ <button class="tab" :class="{ active: tab == 'three' }" @click="tab = 'three'">Tab 3</button>
346
340
  </div>
347
- <div class="tab-content" @visible="tab === 'one'">Content for Tab 1</div>
348
- <div class="tab-content" @visible="tab === 'two'">Content for Tab 2</div>
349
- <div class="tab-content" @visible="tab === 'three'">Content for Tab 3</div>
341
+ <div class="tab-content" @visible="tab == 'one'">Content for Tab 1</div>
342
+ <div class="tab-content" @visible="tab == 'two'">Content for Tab 2</div>
343
+ <div class="tab-content" @visible="tab == 'three'">Content for Tab 3</div>
350
344
  </div>
351
345
 
352
346
  <!-- Element References -->
@@ -395,7 +389,7 @@
395
389
  </div>
396
390
 
397
391
  <!-- Character Counter -->
398
- <div class="card" @data="{ text: '', maxLength: 100 }">
392
+ <div class="card" @data="{ maxLength: 100 }">
399
393
  <h2>Character Counter</h2>
400
394
  <textarea @bind="text" placeholder="Type something..." rows="3"></textarea>
401
395
  <span @calculate:remaining="maxLength - text.length"></span>
@@ -405,7 +399,7 @@
405
399
  </div>
406
400
 
407
401
  <!-- Password Strength (uses @import) -->
408
- <div class="card" @data="{ password: '' }" @import="password-utils">
402
+ <div class="card" @import="https://codepen.io/daz4126/pen/xbOzGNO.js">
409
403
  <h2>Password Strength</h2>
410
404
  <input type="password" @bind="password" placeholder="Enter password">
411
405
  <span @calculate:result="checkStrength(password)"></span>
@@ -418,7 +412,7 @@
418
412
  </div>
419
413
 
420
414
  <!-- Init Example -->
421
- <div class="card" @data="{ loadTime: '' }">
415
+ <div class="card">
422
416
  <h2>Init Example</h2>
423
417
  <p @init="loadTime = new Date().toLocaleTimeString()">
424
418
  Page loaded at: <b @text="loadTime"></b>
@@ -427,7 +421,7 @@
427
421
  </div>
428
422
 
429
423
  <!-- Effect Example -->
430
- <div class="card" @data="{ clicks: 0, history: [] }">
424
+ <div class="card" @data="{ history: [] }">
431
425
  <h2>Effect (Side Effects)</h2>
432
426
  <p>Click the button and watch the history build up.</p>
433
427
  <button @click="clicks++">Click Me</button>
@@ -436,21 +430,12 @@
436
430
  <p>History: <span @text="history.join(', ') || 'None yet'">None yet</span></p>
437
431
  </div>
438
432
 
439
- <!-- Clipboard Copy -->
440
- <div class="card" @data="{ copied: false, textToCopy: 'Hello from Helium!' }">
441
- <h2>Copy to Clipboard</h2>
442
- <input @bind="textToCopy" placeholder="Text to copy">
443
- <button @click="navigator.clipboard.writeText(textToCopy).then(() => { copied = true; setTimeout(() => copied = false, 2000) })">
444
- <span @text="copied ? 'Copied!' : 'Copy'"></span>
445
- </button>
446
- </div>
447
-
448
433
  </div>
449
434
 
450
435
  <!-- AJAX Examples Section -->
451
436
  <div class="page" style="margin-top: 2rem;" @data="{
452
437
  templates: {
453
- post: data => `<section class='post'><h1>${data && data.title || 'Loading...'}</h1><p>${data && data.body || ''}</p></section>`
438
+ post: data => `<section class='post'><h1>${data?.title || 'Loading...'}</h1><p>${data?.body || ''}</p></section>`
454
439
  }
455
440
  }">
456
441
  <h1>AJAX Examples</h1>
@@ -0,0 +1,109 @@
1
+ * { box-sizing: border-box; margin: 0; padding: 0; }
2
+ body {
3
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
4
+ background: #f5f5f5;
5
+ min-height: 100vh;
6
+ }
7
+ .todoapp {
8
+ max-width: 550px;
9
+ margin: 40px auto;
10
+ background: #fff;
11
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2), 0 25px 50px rgba(0,0,0,0.1);
12
+ }
13
+ .todoapp h1 {
14
+ font-size: 80px;
15
+ font-weight: 200;
16
+ text-align: center;
17
+ color: #b83f45;
18
+ padding: 10px 0;
19
+ }
20
+ .new-todo {
21
+ width: 100%;
22
+ padding: 16px 16px 16px 20px;
23
+ font-size: 24px;
24
+ border: none;
25
+ border-bottom: 1px solid #ededed;
26
+ font-family: inherit;
27
+ outline: none;
28
+ }
29
+ .new-todo::placeholder { color: #d9d9d9; font-style: italic; }
30
+ .toggle-all-container {
31
+ display: flex;
32
+ align-items: center;
33
+ padding: 12px 15px;
34
+ border-bottom: 1px solid #ededed;
35
+ color: #777;
36
+ cursor: pointer;
37
+ }
38
+ .toggle-all-container input { margin-right: 10px; }
39
+ .todo-list { list-style: none; }
40
+ .todo-item {
41
+ display: flex;
42
+ align-items: center;
43
+ padding: 15px;
44
+ border-bottom: 1px solid #ededed;
45
+ font-size: 24px;
46
+ }
47
+ .todo-item.completed .todo-text {
48
+ color: #949494;
49
+ text-decoration: line-through;
50
+ }
51
+ .toggle {
52
+ width: 24px;
53
+ height: 24px;
54
+ margin-right: 15px;
55
+ cursor: pointer;
56
+ flex-shrink: 0;
57
+ accent-color: #85cda8;
58
+ }
59
+ .todo-text { flex: 1; word-break: break-all; }
60
+ .destroy {
61
+ display: none;
62
+ background: none;
63
+ border: none;
64
+ color: #cc9a9a;
65
+ font-size: 28px;
66
+ cursor: pointer;
67
+ padding: 0 10px;
68
+ line-height: 1;
69
+ }
70
+ .destroy:hover { color: #af5b5e; }
71
+ .todo-item:hover .destroy { display: block; }
72
+ .footer {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ padding: 10px 15px;
77
+ color: #777;
78
+ font-size: 14px;
79
+ border-top: 1px solid #e6e6e6;
80
+ }
81
+ .filters { display: flex; gap: 3px; }
82
+ .filters button {
83
+ background: none;
84
+ border: 1px solid transparent;
85
+ padding: 3px 7px;
86
+ border-radius: 3px;
87
+ cursor: pointer;
88
+ font-size: 14px;
89
+ color: inherit;
90
+ font-family: inherit;
91
+ }
92
+ .filters button:hover { border-color: #db7676; }
93
+ .filters button.selected { border-color: #ce4646; }
94
+ .clear-completed {
95
+ background: none;
96
+ border: none;
97
+ cursor: pointer;
98
+ color: inherit;
99
+ font-size: 14px;
100
+ font-family: inherit;
101
+ }
102
+ .clear-completed:hover { text-decoration: underline; }
103
+ .info {
104
+ text-align: center;
105
+ margin: 30px 0;
106
+ color: #bbb;
107
+ font-size: 12px;
108
+ }
109
+ .info a { color: inherit; }
@@ -0,0 +1,46 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Helium TodoMVC</title>
7
+ <link rel="stylesheet" href="todo.css">
8
+ </head>
9
+ <body>
10
+
11
+ <section class="todoapp" data-helium @local-storage="helium-todos" @import="todo"
12
+ @data="{ todos: [], newTodo: '', filter: 'all', nextId: 1 }">
13
+
14
+ <span hidden @calculate:remaining="todos.filter(t => !t.completed).length"></span>
15
+ <span hidden @calculate:filtered="filter === 'all' ? todos : filter === 'active' ? todos.filter(t => !t.completed) : todos.filter(t => t.completed)"></span>
16
+
17
+ <header>
18
+ <h1>todos</h1>
19
+ <input class="new-todo" @bind="newTodo" @keydown.enter="addTodo($data)" placeholder="What needs to be done?" autofocus>
20
+ </header>
21
+
22
+ <section @visible="todos.length > 0">
23
+ <label class="toggle-all-container">
24
+ <input type="checkbox" @click="toggleAll($data)" :checked="todos.length > 0 && todos.every(t => t.completed)">
25
+ Mark all as complete
26
+ </label>
27
+ <ul class="todo-list" @html="renderTodos(filtered)"></ul>
28
+ </section>
29
+
30
+ <footer class="footer" @visible="todos.length > 0">
31
+ <span @text="remaining + (remaining === 1 ? ' item' : ' items') + ' left'"></span>
32
+ <nav class="filters">
33
+ <button :class="{ selected: filter === 'all' }" @click="filter = 'all'">All</button>
34
+ <button :class="{ selected: filter === 'active' }" @click="filter = 'active'">Active</button>
35
+ <button :class="{ selected: filter === 'completed' }" @click="filter = 'completed'">Completed</button>
36
+ </nav>
37
+ <button class="clear-completed" @click="clearCompleted($data)" @visible="todos.some(t => t.completed)">Clear completed</button>
38
+ </footer>
39
+
40
+ </section>
41
+
42
+ <p class="info">Built with <a href="https://github.com/daz-codes/helium">Helium</a></p>
43
+
44
+ <script type="module" src="../helium.js"></script>
45
+ </body>
46
+ </html>
@@ -0,0 +1,36 @@
1
+ function esc(s) {
2
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
3
+ }
4
+
5
+ export function renderTodos(list) {
6
+ return list.map(t => `<li class='todo-item${t.completed ? ' completed' : ''}'>
7
+ <input type='checkbox' class='toggle'${t.completed ? ' checked' : ''} @click='toggleTodo($data, ${t.id})'>
8
+ <span class='todo-text'>${esc(t.text)}</span>
9
+ <button class='destroy' @click='removeTodo($data, ${t.id})'>&#215;</button>
10
+ </li>`).join('');
11
+ }
12
+
13
+ export function addTodo($data) {
14
+ const text = $data.newTodo.trim();
15
+ if (text) {
16
+ $data.todos = [...$data.todos, { id: $data.nextId++, text, completed: false }];
17
+ $data.newTodo = '';
18
+ }
19
+ }
20
+
21
+ export function toggleTodo($data, id) {
22
+ $data.todos = $data.todos.map(t => t.id === id ? { ...t, completed: !t.completed } : t);
23
+ }
24
+
25
+ export function removeTodo($data, id) {
26
+ $data.todos = $data.todos.filter(t => t.id !== id);
27
+ }
28
+
29
+ export function toggleAll($data) {
30
+ const allDone = $data.todos.every(t => t.completed);
31
+ $data.todos = $data.todos.map(t => ({ ...t, completed: !allDone }));
32
+ }
33
+
34
+ export function clearCompleted($data) {
35
+ $data.todos = $data.todos.filter(t => !t.completed);
36
+ }
package/helium-lite.js CHANGED
@@ -4,19 +4,47 @@
4
4
  * No AJAX, no @import, no SSE, no Turbo
5
5
  */
6
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)) };
7
+ const parseEx = (v) => {
8
+ try {
9
+ return Function(`return(${v})`)();
10
+ } catch {
11
+ return v;
12
+ }
13
+ };
14
+ const tryNum = (v) => {
15
+ const t = String(v).trim(),
16
+ n = +t;
17
+ return t && !isNaN(n) ? n : v;
18
+ };
19
+ const isValidIdentifier = (v) => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v);
20
+ const RESERVED = new Set([
21
+ "undefined",
22
+ "null",
23
+ "true",
24
+ "false",
25
+ "NaN",
26
+ "Infinity",
27
+ "this",
28
+ "arguments",
29
+ ]);
30
+ const KEY_MODS = {
31
+ shift: "shiftKey",
32
+ ctrl: "ctrlKey",
33
+ alt: "altKey",
34
+ meta: "metaKey",
35
+ };
36
+ const debounce = (f, d) => {
37
+ let t;
38
+ return (...a) => (clearTimeout(t), (t = setTimeout(f, d, ...a)));
39
+ };
13
40
 
14
41
  const ATTR_REGEX = /^(@|:|data-he)/;
15
42
 
16
43
  const match = (name, ...attrs) => {
17
44
  const p = name.split(/[.:]/)[0];
18
- if (p === ":" || p === "") return false;
19
- return attrs.some(a => p === `@${a}` || p === `data-he-${a}`);
45
+ return p == ":" || p == ""
46
+ ? false
47
+ : attrs.some((a) => p === `@${a}` || p === `data-he-${a}`);
20
48
  };
21
49
 
22
50
  let HELIUM = null;
@@ -24,8 +52,13 @@ let HELIUM = null;
24
52
  async function helium(initialState = {}) {
25
53
  const inits = [];
26
54
  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');
55
+ const root =
56
+ document.querySelector("[\\@helium]") ||
57
+ document.querySelector("[data-helium]") ||
58
+ document.body;
59
+ const storageKey =
60
+ root.getAttribute("@local-storage") ||
61
+ root.getAttribute("data-he-local-storage");
29
62
 
30
63
  if (!HELIUM) {
31
64
  HELIUM = {
@@ -36,12 +69,14 @@ async function helium(initialState = {}) {
36
69
  processed: new WeakSet(),
37
70
  parentKeys: new WeakMap(),
38
71
  fnCache: new Map(),
39
- proxyCache: new WeakMap()
72
+ proxyCache: new WeakMap(),
40
73
  };
41
74
  }
42
75
 
43
- const $ = s => document.querySelector(s);
44
- const html = s => Object.assign(document.createElement("template"), { innerHTML: s.trim() }).content.firstChild;
76
+ const $ = (s) => document.querySelector(s);
77
+ const html = (s) =>
78
+ Object.assign(document.createElement("template"), { innerHTML: s.trim() })
79
+ .content.firstChild;
45
80
 
46
81
  const applyingBindings = new Set();
47
82
 
@@ -57,9 +92,9 @@ async function helium(initialState = {}) {
57
92
 
58
93
  const handler = {
59
94
  has(t, p) {
60
- if (typeof p === 'string' && p.startsWith('$')) return false;
95
+ if (typeof p === "string" && p.startsWith("$")) return false;
61
96
  if (Reflect.has(t, p)) return true;
62
- if (typeof globalThis[p] !== 'undefined') return false;
97
+ if (typeof globalThis[p] !== "undefined") return false;
63
98
  return true;
64
99
  },
65
100
  get(t, p, r) {
@@ -81,21 +116,12 @@ async function helium(initialState = {}) {
81
116
  HELIUM.bindings.get(ALL)?.forEach(safeApplyBinding);
82
117
  if (storageKey) localStorage.setItem(storageKey, JSON.stringify(state));
83
118
  return res;
84
- }
119
+ },
85
120
  };
86
121
 
87
122
  const state = new Proxy({}, handler);
88
- if (storageKey) try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey))); } catch {}
89
123
  Object.assign(state, initialState);
90
124
 
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
125
  const createScope = (el, event = {}) => {
100
126
  return {
101
127
  $,
@@ -103,7 +129,7 @@ async function helium(initialState = {}) {
103
129
  $event: event,
104
130
  $el: el,
105
131
  $html: html,
106
- ...getRefsObject()
132
+ ...Object.fromEntries(HELIUM.refs),
107
133
  };
108
134
  };
109
135
 
@@ -115,7 +141,7 @@ async function helium(initialState = {}) {
115
141
  "$scope",
116
142
  withReturn
117
143
  ? `with($scope){with($scope.$data){return(${expr.trim()})}}`
118
- : `with($scope){with($scope.$data){${expr.trim()}}}`
144
+ : `with($scope){with($scope.$data){${expr.trim()}}}`,
119
145
  );
120
146
  const compiled = { execute: (scope) => fn(scope), getIds: null };
121
147
  HELIUM.fnCache.set(key, compiled);
@@ -136,18 +162,22 @@ async function helium(initialState = {}) {
136
162
  if (prop === "innerHTML") {
137
163
  const content = Array.isArray(r) ? r.join`` : r;
138
164
  return typeof Idiomorph === "object"
139
- ? Idiomorph.morph(el, content, { morphStyle: 'innerHTML' })
140
- : el.innerHTML = content;
165
+ ? Idiomorph.morph(el, content, { morphStyle: "innerHTML" })
166
+ : (el.innerHTML = content);
141
167
  }
142
168
  if (prop === "class" && r && typeof r === "object")
143
169
  return Object.entries(r).forEach(([k, v]) =>
144
- k.split(/\s+/).forEach(c => el.classList.toggle(c, v)));
170
+ k.split(/\s+/).forEach((c) => el.classList.toggle(c, v)),
171
+ );
145
172
 
146
173
  if (prop === "style" && r && typeof r === "object")
147
- return el.style.cssText = Object.entries(r)
174
+ return (el.style.cssText = Object.entries(r)
148
175
  .filter(([_, v]) => v != null && v !== false)
149
- .map(([k, v]) => `${k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())}:${v}`)
150
- .join("; ");
176
+ .map(
177
+ ([k, v]) =>
178
+ `${k.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())}:${v}`,
179
+ )
180
+ .join("; "));
151
181
 
152
182
  if (prop === "hidden") {
153
183
  el.hidden = isHiddenAttr ? !!r : !r;
@@ -156,8 +186,9 @@ async function helium(initialState = {}) {
156
186
 
157
187
  if (prop in el) {
158
188
  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);
189
+ else if (el.type === "radio" && prop !== "checked")
190
+ el.checked = el.value === r;
191
+ else el[prop] = prop === "textContent" ? (r ?? "") : parseEx(r);
161
192
  return;
162
193
  }
163
194
 
@@ -171,12 +202,16 @@ async function helium(initialState = {}) {
171
202
  return true;
172
203
  },
173
204
  get(target, prop) {
174
- if (typeof prop == 'string') {
175
- excludeChanged ? !accessed.has(prop) && accessed.set(prop, target[prop]) : accessed.add(prop);
205
+ if (typeof prop == "string") {
206
+ excludeChanged
207
+ ? !accessed.has(prop) && accessed.set(prop, target[prop])
208
+ : accessed.add(prop);
176
209
  }
177
210
  const val = target[prop];
178
- return typeof val == "object" && val != null ? new Proxy(val, this) : val;
179
- }
211
+ return typeof val == "object" && val != null
212
+ ? new Proxy(val, this)
213
+ : val;
214
+ },
180
215
  });
181
216
 
182
217
  try {
@@ -186,21 +221,29 @@ async function helium(initialState = {}) {
186
221
  $event: {},
187
222
  $el: el,
188
223
  $html: html,
189
- ...getRefsObject()
224
+ ...Object.fromEntries(HELIUM.refs),
190
225
  };
191
226
  compiled.execute(scope);
192
227
  } catch {}
193
228
 
194
- return excludeChanged ? [...accessed.keys()].filter(prop => state[prop] === accessed.get(prop)) : [...accessed];
229
+ return excludeChanged
230
+ ? [...accessed.keys()].filter(
231
+ (prop) => state[prop] === accessed.get(prop),
232
+ )
233
+ : [...accessed];
195
234
  };
196
235
 
197
236
  const getDependencies = (compiled, el, excludeChanged = false) => {
198
237
  return trackDependenciesProxy(compiled, el, excludeChanged);
199
238
  };
200
239
 
201
- const cleanup = el => {
202
- [el, ...el.querySelectorAll('*')].forEach(e => {
203
- HELIUM.listeners.get(e)?.forEach(({ receiver, event, handler }) => receiver.removeEventListener(event, handler));
240
+ const cleanup = (el) => {
241
+ [el, ...el.querySelectorAll("*")].forEach((e) => {
242
+ HELIUM.listeners
243
+ .get(e)
244
+ ?.forEach(({ receiver, event, handler }) =>
245
+ receiver.removeEventListener(event, handler),
246
+ );
204
247
  HELIUM.listeners.delete(e);
205
248
  });
206
249
  };
@@ -209,91 +252,154 @@ async function helium(initialState = {}) {
209
252
  const newBindings = [];
210
253
  const deferredBindings = [];
211
254
 
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
- });
255
+ const heElements = [element, ...element.querySelectorAll("*")].filter(
256
+ (e) => {
257
+ if (HELIUM.processed.has(e)) return false;
258
+ for (let i = 0; i < e.attributes.length; i++) {
259
+ if (ATTR_REGEX.test(e.attributes[i].name)) return true;
260
+ }
261
+ return false;
262
+ },
263
+ );
219
264
 
220
265
  const addBinding = (val, b) => {
221
- HELIUM.bindings.set(val, b.calc ? [b, ...(HELIUM.bindings.get(val) || [])] : [...(HELIUM.bindings.get(val) || []), b]);
266
+ HELIUM.bindings.set(
267
+ val,
268
+ b.calc
269
+ ? [b, ...(HELIUM.bindings.get(val) || [])]
270
+ : [...(HELIUM.bindings.get(val) || []), b],
271
+ );
222
272
  b.calc ? newBindings.unshift(b) : newBindings.push(b);
223
273
  };
224
274
 
225
- heElements.forEach(el => {
275
+ heElements.forEach((el) => {
226
276
  HELIUM.processed.add(el);
227
277
 
228
278
  const attrs = el.attributes;
229
279
  const inputType = el.type?.toLowerCase();
230
- const isCheckbox = inputType == "checkbox", isRadio = inputType == "radio", isSelect = el.tagName == "SELECT";
280
+ const isCheckbox = inputType == "checkbox",
281
+ isRadio = inputType == "radio",
282
+ isSelect = el.tagName == "SELECT";
231
283
 
232
284
  for (let i = 0; i < attrs.length; i++) {
233
285
  const { name, value } = attrs[i];
234
286
 
235
- if (!name.startsWith('@') && !name.startsWith(':') && !name.startsWith('data-he')) continue;
287
+ if (
288
+ !name.startsWith("@") &&
289
+ !name.startsWith(":") &&
290
+ !name.startsWith("data-he")
291
+ )
292
+ continue;
236
293
 
237
294
  // 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);
295
+ if (
296
+ match(name, "text", "html", "bind") &&
297
+ isValidIdentifier(value) &&
298
+ !RESERVED.has(value) &&
299
+ state[value] == null
300
+ ) {
301
+ state[value] = match(name, "bind")
302
+ ? el.type == "checkbox"
303
+ ? el.checked
304
+ : tryNum(el.value)
305
+ : tryNum(el.textContent);
240
306
  }
241
307
 
242
308
  // Process the attribute
243
309
  if (name === "@data" || name === "data-he") {
244
310
  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")) {
311
+ } else if (name.startsWith(":") || name.startsWith("data-he-attr:")) {
312
+ const propName = name.startsWith(":")
313
+ ? name.slice(1)
314
+ : name.slice(13);
315
+ deferredBindings.push({
316
+ el,
317
+ prop: propName,
318
+ compiled: compile(value, true),
319
+ });
320
+ } else if (match(name, "ref")) {
251
321
  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";
322
+ } else if (match(name, "text", "html")) {
323
+ deferredBindings.push({
324
+ el,
325
+ prop: match(name, "text") ? "textContent" : "innerHTML",
326
+ compiled: compile(value, true),
327
+ });
328
+ } else if (match(name, "bind")) {
329
+ const event = isCheckbox || isRadio || isSelect ? "change" : "input";
258
330
  const prop = isCheckbox ? "checked" : "value";
259
- const inputHandler = e => state[value] = isCheckbox ? e.target.checked : e.target.value;
331
+ const inputHandler = (e) =>
332
+ (state[value] = isCheckbox ? e.target.checked : e.target.value);
260
333
  el.addEventListener(event, inputHandler);
261
334
  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 });
335
+ HELIUM.listeners
336
+ .get(el)
337
+ .push({ receiver: el, event, handler: inputHandler });
338
+ deferredBindings.push({
339
+ el,
340
+ prop,
341
+ compiled: compile(value, true),
342
+ isRadio,
343
+ });
264
344
  if (isCheckbox) el.checked = !!state[value];
265
345
  else if (isRadio) el.checked = el.value == state[value];
266
346
  else el.value = state[value] ?? "";
267
- }
268
- else if (match(name, "hidden", "visible")) {
347
+ } else if (match(name, "hidden", "visible")) {
269
348
  const isHidden = match(name, "hidden");
270
- deferredBindings.push({ el, prop: "hidden", compiled: compile(value, true), isHiddenAttr: isHidden });
271
- }
272
- else if (match(name, "calculate")) {
349
+ deferredBindings.push({
350
+ el,
351
+ prop: "hidden",
352
+ compiled: compile(value, true),
353
+ isHiddenAttr: isHidden,
354
+ });
355
+ } else if (match(name, "calculate")) {
273
356
  const calcName = name.split(":")[1];
274
357
  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")) {
358
+ deferredBindings.push({
359
+ el,
360
+ calc: calcName,
361
+ prop: null,
362
+ compiled: compile(value, true),
363
+ });
364
+ } else if (match(name, "effect")) {
365
+ deferredBindings.push({
366
+ el,
367
+ prop: null,
368
+ compiled: compile(value, true),
369
+ keys: name.split(":").slice(1),
370
+ });
371
+ } else if (match(name, "init")) {
281
372
  inits.push({ compiled: compile(value, true), el });
282
- }
283
- else if (name.startsWith("@") || name.startsWith("data-he")) {
373
+ } else if (name.startsWith("@") || name.startsWith("data-he")) {
284
374
  const fullName = name.startsWith("@") ? name.slice(1) : name.slice(8);
285
375
  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 => {
376
+ const receiver =
377
+ mods.includes("outside") || mods.includes("document")
378
+ ? document
379
+ : el;
380
+ const debounceMod = mods.find((m) => m.startsWith("debounce"));
381
+ const debounceDelay = debounceMod
382
+ ? ((t) => (t && !isNaN(t) ? Number(t) : 300))(
383
+ debounceMod.split(":")[1],
384
+ )
385
+ : 0;
386
+ const _handler = (e) => {
290
387
  if (mods.includes("prevent")) e.preventDefault();
291
388
  if (mods.includes("stop")) e.stopPropagation();
292
- for (const [mod, prop] of Object.entries(KEY_MODS)) if (mods.includes(mod) && !e[prop]) return;
389
+ for (const [mod, prop] of Object.entries(KEY_MODS))
390
+ if (mods.includes(mod) && !e[prop]) return;
293
391
  if (["keydown", "keyup", "keypress"].includes(event)) {
294
392
  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;
393
+ if (
394
+ last &&
395
+ !["prevent", "once", "outside", "document", "stop"].includes(
396
+ last,
397
+ ) &&
398
+ !last.startsWith("debounce") &&
399
+ !Object.keys(KEY_MODS).includes(last)
400
+ ) {
401
+ const keyName =
402
+ e.key == " " ? "Space" : e.key == "Escape" ? "Esc" : e.key;
297
403
  if (keyName.toLowerCase() !== last.toLowerCase()) return;
298
404
  }
299
405
  }
@@ -302,10 +408,14 @@ async function helium(initialState = {}) {
302
408
  const compiled = compile(value, true);
303
409
  compiled.execute(scope);
304
410
  }
305
- if (mods.includes("once")) receiver.removeEventListener(event, handler);
411
+ if (mods.includes("once"))
412
+ receiver.removeEventListener(event, handler);
306
413
  };
307
- const handler = debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
308
- const eventOptions = mods.includes("once") ? { once: true } : undefined;
414
+ const handler =
415
+ debounceDelay > 0 ? debounce(_handler, debounceDelay) : _handler;
416
+ const eventOptions = mods.includes("once")
417
+ ? { once: true }
418
+ : undefined;
309
419
  receiver.addEventListener(event, handler, eventOptions);
310
420
  if (!HELIUM.listeners.has(el)) HELIUM.listeners.set(el, []);
311
421
  HELIUM.listeners.get(el).push({ receiver, event, handler });
@@ -313,9 +423,11 @@ async function helium(initialState = {}) {
313
423
  }
314
424
  });
315
425
 
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));
426
+ deferredBindings.forEach((b) => {
427
+ const tracked = b.keys?.includes("*")
428
+ ? [ALL]
429
+ : getDependencies(b.compiled, b.el, true).concat(b.keys || []);
430
+ tracked.forEach((key) => addBinding(key, b));
319
431
  if (tracked.length === 0) {
320
432
  newBindings.push(b);
321
433
  }
@@ -339,7 +451,11 @@ async function helium(initialState = {}) {
339
451
  });
340
452
  HELIUM.observer.observe(root, { childList: true, subtree: true });
341
453
 
454
+ // Read localStorage before processElements (which sets @data defaults and triggers saves via proxy)
455
+ const savedState = storageKey ? (() => { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } })() : null;
342
456
  const initialBindings = await processElements(root);
457
+ // Merge saved values after @data defaults, so localStorage takes priority
458
+ if (savedState) Object.assign(state, savedState);
343
459
  initialBindings.forEach(applyBinding);
344
460
  inits.forEach(({ compiled, el }) => {
345
461
  const scope = createScope(el);
@@ -355,13 +471,13 @@ function heliumTeardown() {
355
471
  }
356
472
 
357
473
  // Browser globals
358
- if (typeof window !== 'undefined') {
474
+ if (typeof window !== "undefined") {
359
475
  window.helium = helium;
360
476
  window.heliumTeardown = heliumTeardown;
361
477
  }
362
478
 
363
479
  // Auto-initialize
364
- if (typeof document !== 'undefined') {
480
+ if (typeof document !== "undefined") {
365
481
  document.addEventListener("DOMContentLoaded", () => helium());
366
482
  }
367
483
 
package/helium.js CHANGED
@@ -238,21 +238,9 @@ export function createHelium(options = {}) {
238
238
  // Initialize state if it doesn't exist
239
239
  const state = new Proxy({}, handler);
240
240
 
241
- // Load from localStorage if key exists
242
- if (storageKey) try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey))); } catch {}
243
-
244
241
  // Merge initial state
245
242
  Object.assign(state, initialState);
246
243
 
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
244
  // Create scope for expression execution
257
245
  const createScope = (el, event = {}) => {
258
246
  return engine.createScope({
@@ -266,7 +254,7 @@ export function createHelium(options = {}) {
266
254
  put,
267
255
  patch,
268
256
  del,
269
- refs: getRefsObject()
257
+ refs: Object.fromEntries(HELIUM.refs)
270
258
  });
271
259
  };
272
260
 
@@ -349,7 +337,7 @@ export function createHelium(options = {}) {
349
337
  put,
350
338
  patch,
351
339
  del,
352
- refs: getRefsObject()
340
+ refs: Object.fromEntries(HELIUM.refs)
353
341
  });
354
342
  compiled.execute(scope);
355
343
  } catch {}
@@ -404,9 +392,7 @@ export function createHelium(options = {}) {
404
392
  const hasExtension = moduleName.endsWith(".js");
405
393
  const hasPathPrefix = moduleName.startsWith("/") || moduleName.startsWith("./") || moduleName.startsWith("../");
406
394
  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;
395
+ const path = isUrl ? moduleName : new URL(relativePath, location.href).href;
410
396
  importPromises.push(
411
397
  import(path)
412
398
  .then((module) => {
@@ -580,7 +566,11 @@ export function createHelium(options = {}) {
580
566
  });
581
567
  HELIUM.observer.observe(root, { childList: true, subtree: true });
582
568
 
569
+ // Read localStorage before processElements (which sets @data defaults and triggers saves via proxy)
570
+ const savedState = storageKey ? (() => { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } })() : null;
583
571
  const initialBindings = await processElements(root);
572
+ // Merge saved values after @data defaults, so localStorage takes priority
573
+ if (savedState) Object.assign(state, savedState);
584
574
  initialBindings.forEach(applyBinding);
585
575
  inits.forEach(({ compiled, el }) => {
586
576
  const scope = createScope(el);
package/helium.test.js CHANGED
@@ -788,4 +788,83 @@ describe('@calculate', () => {
788
788
  await new Promise(r => setTimeout(r, 10));
789
789
  expect(container.querySelector('#result').textContent).toBe('Strong');
790
790
  });
791
+
792
+ describe('@local-storage', () => {
793
+ afterEach(() => {
794
+ localStorage.clear();
795
+ });
796
+
797
+ it('should not overwrite localStorage values with @data defaults', async () => {
798
+ // Simulate saved state from a previous session
799
+ localStorage.setItem('test-todos', JSON.stringify({ todos: ['buy milk', 'walk dog'], filter: 'active', nextId: 3 }));
800
+
801
+ container.setAttribute('data-he-local-storage', 'test-todos');
802
+ container.innerHTML = `
803
+ <div @data="{ todos: [], filter: 'all', nextId: 1 }">
804
+ <span id="count" @text="todos.length"></span>
805
+ <span id="filter" @text="filter"></span>
806
+ <span id="nextId" @text="nextId"></span>
807
+ </div>
808
+ `;
809
+ await helium();
810
+ await new Promise(r => setTimeout(r, 10));
811
+
812
+ // localStorage values should be preserved, not overwritten by @data defaults
813
+ expect(container.querySelector('#count').textContent).toBe('2');
814
+ expect(container.querySelector('#filter').textContent).toBe('active');
815
+ expect(container.querySelector('#nextId').textContent).toBe('3');
816
+ });
817
+
818
+ it('should set @data values that are not in localStorage', async () => {
819
+ // Saved state only has some keys
820
+ localStorage.setItem('test-partial', JSON.stringify({ count: 42 }));
821
+
822
+ container.setAttribute('data-he-local-storage', 'test-partial');
823
+ container.innerHTML = `
824
+ <div @data="{ count: 0, name: 'default' }">
825
+ <span id="count" @text="count"></span>
826
+ <span id="name" @text="name"></span>
827
+ </div>
828
+ `;
829
+ await helium();
830
+ await new Promise(r => setTimeout(r, 10));
831
+
832
+ // count should come from localStorage, name should come from @data
833
+ expect(container.querySelector('#count').textContent).toBe('42');
834
+ expect(container.querySelector('#name').textContent).toBe('default');
835
+ });
836
+
837
+ it('should save state changes to localStorage', async () => {
838
+ container.setAttribute('data-he-local-storage', 'test-save');
839
+ container.innerHTML = `
840
+ <div @data="{ count: 0 }">
841
+ <button @click="count++">Inc</button>
842
+ <span @text="count"></span>
843
+ </div>
844
+ `;
845
+ await helium();
846
+
847
+ container.querySelector('button').click();
848
+ await new Promise(r => setTimeout(r, 10));
849
+
850
+ const saved = JSON.parse(localStorage.getItem('test-save'));
851
+ expect(saved.count).toBe(1);
852
+ });
853
+
854
+ it('should preserve functions from @data when localStorage is active', async () => {
855
+ localStorage.setItem('test-funcs', JSON.stringify({ count: 5 }));
856
+
857
+ container.setAttribute('data-he-local-storage', 'test-funcs');
858
+ container.innerHTML = `
859
+ <div @data="{ count: 0, double(n) { return n * 2 } }">
860
+ <span id="result" @text="double(count)"></span>
861
+ </div>
862
+ `;
863
+ await helium();
864
+ await new Promise(r => setTimeout(r, 10));
865
+
866
+ // count from localStorage (5), function from @data
867
+ expect(container.querySelector('#result').textContent).toBe('10');
868
+ });
869
+ });
791
870
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daz4126/helium",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "main": "helium.js",
5
5
  "type": "module",
6
6
  "exports": {