@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/.claude/settings.local.json +17 -0
- package/CLAUDE.md +137 -0
- package/README.md +46 -18
- package/examples/index-lite.html +387 -0
- package/examples/index.html +486 -0
- package/examples/password-utils.js +80 -0
- package/examples/sse-demo.html +125 -0
- package/examples/sse-server.js +138 -0
- package/examples/sse-simple.html +49 -0
- package/examples/test-password.html +251 -0
- package/helium-csp.js +240 -0
- package/helium-csp.test.js +776 -0
- package/helium-lite.js +366 -0
- package/helium-lite.test.js +120 -0
- package/helium.js +580 -337
- package/helium.test.js +562 -212
- package/jexpr.js +666 -0
- package/jexpr.test.js +551 -0
- package/package.json +11 -2
- package/test-csp.html +50 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PORT = process.env.PORT || 3456;
|
|
8
|
+
|
|
9
|
+
// Helper to send SSE events
|
|
10
|
+
const sendSSE = (res, event, data, id = null) => {
|
|
11
|
+
if (id) res.write(`id: ${id}\n`);
|
|
12
|
+
if (event) res.write(`event: ${event}\n`);
|
|
13
|
+
res.write(`data: ${data}\n\n`);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Delay helper
|
|
17
|
+
const delay = ms => new Promise(r => setTimeout(r, ms));
|
|
18
|
+
|
|
19
|
+
const server = http.createServer(async (req, res) => {
|
|
20
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
21
|
+
|
|
22
|
+
// Serve static files
|
|
23
|
+
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
24
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
25
|
+
res.end(fs.readFileSync(path.join(__dirname, 'sse-simple.html')));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (url.pathname === '/demo') {
|
|
30
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
31
|
+
res.end(fs.readFileSync(path.join(__dirname, 'sse-demo.html')));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (url.pathname === '/helium.js' || url.pathname === '/../helium.js') {
|
|
36
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
|
37
|
+
res.end(fs.readFileSync(path.join(__dirname, '..', 'helium.js')));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// SSE Endpoints
|
|
42
|
+
if (url.pathname === '/api/hello-world') {
|
|
43
|
+
res.writeHead(200, {
|
|
44
|
+
'Content-Type': 'text/event-stream',
|
|
45
|
+
'Cache-Control': 'no-cache',
|
|
46
|
+
'Connection': 'keep-alive'
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Stream letters one at a time with 500ms delay
|
|
50
|
+
// Just send data - target comes from @target attribute on the button
|
|
51
|
+
const message = 'Hello from Helium SSE!';
|
|
52
|
+
let output = '';
|
|
53
|
+
for (const char of message) {
|
|
54
|
+
output += char;
|
|
55
|
+
sendSSE(res, null, `<span>${output}<span style="opacity:0.5">|</span></span>`);
|
|
56
|
+
await delay(500);
|
|
57
|
+
}
|
|
58
|
+
// Final message without cursor
|
|
59
|
+
sendSSE(res, null, `<span style="color: green; font-weight: bold;">${message}</span>`);
|
|
60
|
+
res.end();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (url.pathname === '/api/count') {
|
|
65
|
+
res.writeHead(200, {
|
|
66
|
+
'Content-Type': 'text/event-stream',
|
|
67
|
+
'Cache-Control': 'no-cache',
|
|
68
|
+
'Connection': 'keep-alive'
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
for (let i = 1; i <= 10; i++) {
|
|
72
|
+
sendSSE(res, '#counter', `<span>${i}</span>`, `count-${i}`);
|
|
73
|
+
await delay(300);
|
|
74
|
+
}
|
|
75
|
+
sendSSE(res, '#counter', `<span style="color: green;">Done!</span>`);
|
|
76
|
+
res.end();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url.pathname === '/api/dashboard') {
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
'Content-Type': 'text/event-stream',
|
|
83
|
+
'Cache-Control': 'no-cache',
|
|
84
|
+
'Connection': 'keep-alive'
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Send to different targets using event: field
|
|
88
|
+
await delay(200);
|
|
89
|
+
sendSSE(res, '#header', '<h3>Dashboard Loaded</h3>');
|
|
90
|
+
await delay(300);
|
|
91
|
+
sendSSE(res, '#stats', `<div>Users: 1,234</div><div>Revenue: $5,678</div>`);
|
|
92
|
+
await delay(200);
|
|
93
|
+
sendSSE(res, '#header', '<h3>Dashboard Loaded <span style="color:green;">✓</span></h3>');
|
|
94
|
+
res.end();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (url.pathname === '/api/time') {
|
|
99
|
+
res.writeHead(200, {
|
|
100
|
+
'Content-Type': 'text/event-stream',
|
|
101
|
+
'Cache-Control': 'no-cache',
|
|
102
|
+
'Connection': 'keep-alive'
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// This updates state.currentTime (not a DOM element)
|
|
106
|
+
sendSSE(res, 'currentTime', new Date().toLocaleTimeString());
|
|
107
|
+
res.end();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (url.pathname === '/api/events') {
|
|
112
|
+
res.writeHead(200, {
|
|
113
|
+
'Content-Type': 'text/event-stream',
|
|
114
|
+
'Cache-Control': 'no-cache',
|
|
115
|
+
'Connection': 'keep-alive'
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const events = ['User logged in', 'File uploaded', 'Comment added', 'Order placed', 'Payment received'];
|
|
119
|
+
|
|
120
|
+
for (let i = 0; i < events.length; i++) {
|
|
121
|
+
const time = new Date().toLocaleTimeString();
|
|
122
|
+
sendSSE(res, '#events', `<div class="event-item">[${time}] ${events[i]}</div>`, `event-${i}`);
|
|
123
|
+
await delay(800);
|
|
124
|
+
}
|
|
125
|
+
sendSSE(res, '#events', `<div class="event-item" style="color: gray;">[Stream ended]</div>`);
|
|
126
|
+
res.end();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 404
|
|
131
|
+
res.writeHead(404);
|
|
132
|
+
res.end('Not found');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
server.listen(PORT, () => {
|
|
136
|
+
console.log(`SSE Demo server running at http://localhost:${PORT}`);
|
|
137
|
+
console.log('Press Ctrl+C to stop');
|
|
138
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
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 SSE - Simple Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: system-ui, sans-serif;
|
|
10
|
+
max-width: 600px;
|
|
11
|
+
margin: 4rem auto;
|
|
12
|
+
padding: 0 1rem;
|
|
13
|
+
text-align: center;
|
|
14
|
+
}
|
|
15
|
+
button {
|
|
16
|
+
padding: 1rem 2rem;
|
|
17
|
+
font-size: 1.2rem;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
background: #4a90d9;
|
|
20
|
+
color: white;
|
|
21
|
+
border: none;
|
|
22
|
+
border-radius: 8px;
|
|
23
|
+
margin-bottom: 2rem;
|
|
24
|
+
}
|
|
25
|
+
button:hover { background: #357abd; }
|
|
26
|
+
button:active { background: #2a5f8f; }
|
|
27
|
+
#message {
|
|
28
|
+
padding: 2rem;
|
|
29
|
+
background: #f5f5f5;
|
|
30
|
+
border-radius: 8px;
|
|
31
|
+
min-height: 3rem;
|
|
32
|
+
font-size: 1.5rem;
|
|
33
|
+
border: 2px solid #ddd;
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body data-helium>
|
|
38
|
+
<h1>Helium SSE Demo</h1>
|
|
39
|
+
|
|
40
|
+
<button @get="/api/hello-world" @target="#message">Start</button>
|
|
41
|
+
|
|
42
|
+
<div id="message"> </div>
|
|
43
|
+
|
|
44
|
+
<script type="module">
|
|
45
|
+
import helium from '../helium.js';
|
|
46
|
+
helium();
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Test Password Strength</title>
|
|
5
|
+
<style>
|
|
6
|
+
body { font-family: sans-serif; padding: 2rem; }
|
|
7
|
+
.progress-bar { width: 100%; height: 20px; background: #eee; border-radius: 10px; overflow: hidden; }
|
|
8
|
+
.progress-fill { height: 100%; transition: width 0.3s; }
|
|
9
|
+
</style>
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
|
|
13
|
+
<div data-helium @data="{ password: '' }">
|
|
14
|
+
<h2>Password Strength Test</h2>
|
|
15
|
+
|
|
16
|
+
<input type="text" @bind="password" placeholder="Enter password">
|
|
17
|
+
|
|
18
|
+
<p>Password value: "<span @text="password"></span>"</p>
|
|
19
|
+
<p>Password length: <span @text="password.length"></span></p>
|
|
20
|
+
|
|
21
|
+
<span @calculate:strength="password ? (password.length < 4 ? 'Weak' : password.length < 8 ? 'Medium' : 'Strong') : 'Enter a password'"></span>
|
|
22
|
+
|
|
23
|
+
<p>Strength: <b @text="strength">???</b></p>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<script>
|
|
27
|
+
// Unminified helium for debugging
|
|
28
|
+
const parseEx=v=>{try{return Function(`return(${v})`)()}catch{return v}};
|
|
29
|
+
const tryNum=v=>{const t=String(v).trim(),n=+t;return t&&!isNaN(n)?n:v};
|
|
30
|
+
const isValidIdentifier=v=>/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(v);
|
|
31
|
+
const RESERVED=new Set(['undefined','null','true','false','NaN','Infinity','this','arguments']);
|
|
32
|
+
const INPUT_EVENTS={form:"submit",input:"input",textarea:"input",select:"change"};
|
|
33
|
+
const debounce=(f,d)=>{let t;return(...a)=>(clearTimeout(t),t=setTimeout(f,d,...a))};
|
|
34
|
+
|
|
35
|
+
const defaultEngine={
|
|
36
|
+
compile(expr,withReturn=true){
|
|
37
|
+
try{
|
|
38
|
+
const fn=new Function("$scope",withReturn?`with($scope){with($scope.$data){return(${expr.trim()})}}`:`with($scope){with($scope.$data){${expr.trim()}}}`);
|
|
39
|
+
return{execute:scope=>fn(scope),getIds:null};
|
|
40
|
+
}catch(e){
|
|
41
|
+
console.error('Compile error:', expr, e);
|
|
42
|
+
return{execute:()=>expr,getIds:null};
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
createScope(ctx){
|
|
46
|
+
return{$:ctx.$,$data:ctx.state,$event:ctx.event,$el:ctx.el,...ctx.refs};
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function createHelium(options={}){
|
|
51
|
+
const engine=options.engine||defaultEngine;
|
|
52
|
+
const prefix=options.prefix||'he';
|
|
53
|
+
const rootAttr=options.rootAttr||'helium';
|
|
54
|
+
const ATTR_REGEX=new RegExp(`^(@|:|data-${prefix})`);
|
|
55
|
+
const match=(name,...attrs)=>{
|
|
56
|
+
const p=name.split(/[.:]/)[0];
|
|
57
|
+
if(p===":"||p==="")return false;
|
|
58
|
+
return attrs.some(a=>p===`@${a}`||p===`data-${prefix}-${a}`);
|
|
59
|
+
};
|
|
60
|
+
let HELIUM=null;
|
|
61
|
+
|
|
62
|
+
async function helium(initialState={}){
|
|
63
|
+
console.log('[HELIUM] Starting...');
|
|
64
|
+
const ALL=Symbol("all");
|
|
65
|
+
const root=document.querySelector(`[\\@${rootAttr}]`)||document.querySelector(`[data-${rootAttr}]`)||document.body;
|
|
66
|
+
console.log('[HELIUM] Root:', root);
|
|
67
|
+
|
|
68
|
+
if(!HELIUM){
|
|
69
|
+
HELIUM={bindings:new Map(),refs:new Map(),listeners:new WeakMap(),processed:new WeakSet(),fnCache:new Map(),proxyCache:new WeakMap()};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const $=s=>document.querySelector(s);
|
|
73
|
+
const applyingBindings=new Set();
|
|
74
|
+
const safeApplyBinding=(b)=>{
|
|
75
|
+
if(applyingBindings.has(b))return;
|
|
76
|
+
applyingBindings.add(b);
|
|
77
|
+
try{applyBinding(b);}finally{applyingBindings.delete(b);}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const handler={
|
|
81
|
+
get(t,p,r){
|
|
82
|
+
const v=Reflect.get(t,p,r);
|
|
83
|
+
if(v&&typeof v==="object"){
|
|
84
|
+
if(HELIUM.proxyCache.has(v))return HELIUM.proxyCache.get(v);
|
|
85
|
+
const proxy=new Proxy(v,handler);
|
|
86
|
+
HELIUM.proxyCache.set(v,proxy);
|
|
87
|
+
return proxy;
|
|
88
|
+
}
|
|
89
|
+
return v;
|
|
90
|
+
},
|
|
91
|
+
set:(t,p,v)=>{
|
|
92
|
+
console.log('[PROXY SET]', p, '=', v);
|
|
93
|
+
const res=Reflect.set(t,p,v);
|
|
94
|
+
const bindings = HELIUM.bindings.get(p);
|
|
95
|
+
console.log('[PROXY SET] Bindings for', p, ':', bindings?.length || 0);
|
|
96
|
+
bindings?.forEach(safeApplyBinding);
|
|
97
|
+
HELIUM.bindings.get(ALL)?.forEach(safeApplyBinding);
|
|
98
|
+
return res;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const state=new Proxy({},handler);
|
|
103
|
+
Object.assign(state,initialState);
|
|
104
|
+
|
|
105
|
+
const getRefsObject=()=>{const refs={};for(const[name,element]of HELIUM.refs){refs[name]=element;}return refs;};
|
|
106
|
+
const createScope=(el,event={})=>engine.createScope({$,state,event,el,refs:getRefsObject()});
|
|
107
|
+
|
|
108
|
+
const compile=(expr,withReturn=true)=>{
|
|
109
|
+
const key=`${withReturn}:${expr}`;
|
|
110
|
+
if(HELIUM.fnCache.has(key))return HELIUM.fnCache.get(key);
|
|
111
|
+
const compiled=engine.compile(expr,withReturn);
|
|
112
|
+
HELIUM.fnCache.set(key,compiled);
|
|
113
|
+
return compiled;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function applyBinding(b){
|
|
117
|
+
const{el,prop,compiled,calc}=b;
|
|
118
|
+
const scope=createScope(el);
|
|
119
|
+
const r=compiled.execute(scope);
|
|
120
|
+
console.log('[APPLY]', 'calc=', calc, 'prop=', prop, 'result=', r);
|
|
121
|
+
if(calc){
|
|
122
|
+
console.log('[APPLY] Setting state.' + calc + ' =', r);
|
|
123
|
+
state[calc]=r;
|
|
124
|
+
}
|
|
125
|
+
if(prop==="textContent")el.textContent=r??'';
|
|
126
|
+
else if(prop==="style"&&r&&typeof r==="object"){
|
|
127
|
+
el.style.cssText=Object.entries(r).filter(([_,v])=>v!=null&&v!==false).map(([k,v])=>`${k}:${v}`).join("; ");
|
|
128
|
+
}
|
|
129
|
+
else if(prop&&prop in el)el[prop]=r;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const trackDependenciesProxy=(compiled,el,excludeChanged=false)=>{
|
|
133
|
+
const accessed=excludeChanged?new Map():new Set();
|
|
134
|
+
const trackProxy=new Proxy(state,{
|
|
135
|
+
has(target,prop){return true;},
|
|
136
|
+
get(target,prop){
|
|
137
|
+
if(typeof prop=='string'){
|
|
138
|
+
if(excludeChanged){
|
|
139
|
+
if(!accessed.has(prop))accessed.set(prop,target[prop]);
|
|
140
|
+
}else{
|
|
141
|
+
accessed.add(prop);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const val=target[prop];
|
|
145
|
+
return typeof val=="object"&&val!=null?new Proxy(val,this):val;
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
try{
|
|
149
|
+
const scope=engine.createScope({$,state:trackProxy,event:{},el,refs:getRefsObject()});
|
|
150
|
+
compiled.execute(scope);
|
|
151
|
+
}catch(e){console.error('[TRACK ERROR]', e);}
|
|
152
|
+
const result = excludeChanged?[...accessed.keys()].filter(prop=>state[prop]===accessed.get(prop)):[...accessed];
|
|
153
|
+
console.log('[TRACK]', 'excludeChanged=', excludeChanged, 'result=', result);
|
|
154
|
+
return result;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const getDependencies=(compiled,el,excludeChanged=false)=>{
|
|
158
|
+
return trackDependenciesProxy(compiled,el,excludeChanged);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const newBindings=[];
|
|
162
|
+
const deferredBindings=[];
|
|
163
|
+
|
|
164
|
+
const heElements=[root,...root.querySelectorAll("*")].filter(e=>{
|
|
165
|
+
if(HELIUM.processed.has(e))return false;
|
|
166
|
+
for(let i=0;i<e.attributes.length;i++){
|
|
167
|
+
if(ATTR_REGEX.test(e.attributes[i].name))return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
console.log('[HELIUM] Found', heElements.length, 'elements to process');
|
|
173
|
+
|
|
174
|
+
const addBinding=(val,b)=>{
|
|
175
|
+
console.log('[ADD BINDING]', 'key=', String(val), 'calc=', b.calc, 'prop=', b.prop);
|
|
176
|
+
HELIUM.bindings.set(val,b.calc?[b,...(HELIUM.bindings.get(val)||[])]:[...(HELIUM.bindings.get(val)||[]),b]);
|
|
177
|
+
b.calc?newBindings.unshift(b):newBindings.push(b);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
heElements.forEach(el=>{
|
|
181
|
+
HELIUM.processed.add(el);
|
|
182
|
+
const attrs=el.attributes;
|
|
183
|
+
const inputType=el.type?.toLowerCase();
|
|
184
|
+
const isCheckbox=inputType=="checkbox";
|
|
185
|
+
|
|
186
|
+
for(let i=0;i<attrs.length;i++){
|
|
187
|
+
const{name,value}=attrs[i];
|
|
188
|
+
if(!name.startsWith('@')&&!name.startsWith(':')&&!name.startsWith(`data-${prefix}`))continue;
|
|
189
|
+
|
|
190
|
+
console.log('[PROCESS]', el.tagName, name, '=', value);
|
|
191
|
+
|
|
192
|
+
if(match(name,"text","bind")&&isValidIdentifier(value)&&!RESERVED.has(value)&&!(value in state)){
|
|
193
|
+
state[value]=match(name,"bind")?tryNum(el.value):tryNum(el.textContent);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if(name==="@data"||name===`data-${prefix}`){
|
|
197
|
+
Object.assign(state,parseEx(value));
|
|
198
|
+
console.log('[DATA] State is now:', {...state});
|
|
199
|
+
}
|
|
200
|
+
else if(name.startsWith(":")){
|
|
201
|
+
const propName=name.slice(1);
|
|
202
|
+
deferredBindings.push({el,prop:propName,compiled:compile(value,true)});
|
|
203
|
+
}
|
|
204
|
+
else if(match(name,"text")){
|
|
205
|
+
deferredBindings.push({el,prop:"textContent",compiled:compile(value,true)});
|
|
206
|
+
}
|
|
207
|
+
else if(match(name,"bind")){
|
|
208
|
+
const event="input";
|
|
209
|
+
const inputHandler=e=>{
|
|
210
|
+
console.log('[INPUT]', value, '=', e.target.value);
|
|
211
|
+
state[value]=e.target.value;
|
|
212
|
+
};
|
|
213
|
+
el.addEventListener(event,inputHandler);
|
|
214
|
+
deferredBindings.push({el,prop:"value",compiled:compile(value,true)});
|
|
215
|
+
el.value=state[value]??"";
|
|
216
|
+
}
|
|
217
|
+
else if(match(name,"calculate")){
|
|
218
|
+
const calcName = name.split(":")[1];
|
|
219
|
+
console.log('[CALCULATE] Found @calculate:' + calcName);
|
|
220
|
+
deferredBindings.push({el,calc:calcName,prop:null,compiled:compile(value,true)});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
console.log('[HELIUM] Processing', deferredBindings.length, 'deferred bindings');
|
|
226
|
+
|
|
227
|
+
deferredBindings.forEach(b=>{
|
|
228
|
+
const tracked=getDependencies(b.compiled,b.el,true);
|
|
229
|
+
console.log('[DEFERRED]', 'calc=', b.calc, 'prop=', b.prop, 'tracked=', tracked);
|
|
230
|
+
tracked.forEach(key=>addBinding(key,b));
|
|
231
|
+
if(tracked.length===0){
|
|
232
|
+
newBindings.push(b);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
console.log('[HELIUM] Applying', newBindings.length, 'initial bindings');
|
|
237
|
+
newBindings.forEach(applyBinding);
|
|
238
|
+
|
|
239
|
+
console.log('[HELIUM] Done. State:', {...state});
|
|
240
|
+
return state;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return{helium};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const{helium}=createHelium();
|
|
247
|
+
document.addEventListener("DOMContentLoaded",()=>helium());
|
|
248
|
+
</script>
|
|
249
|
+
|
|
250
|
+
</body>
|
|
251
|
+
</html>
|
package/helium-csp.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helium CSP - CSP-safe reactive library
|
|
3
|
+
* A variant of Helium that works without unsafe-eval
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createHelium, defaultTeardown } from './helium.js';
|
|
7
|
+
import { parse, EvalAstFactory } from './jexpr.js';
|
|
8
|
+
|
|
9
|
+
// Disable the default helium instance from helium.js to prevent double-processing
|
|
10
|
+
defaultTeardown();
|
|
11
|
+
|
|
12
|
+
const astFactory = new EvalAstFactory();
|
|
13
|
+
|
|
14
|
+
// Transform template literals: `Hello ${name}!` → 'Hello ' + (name) + '!'
|
|
15
|
+
const preprocessTemplateLiterals = (expr) => {
|
|
16
|
+
let result = '';
|
|
17
|
+
let i = 0;
|
|
18
|
+
|
|
19
|
+
while (i < expr.length) {
|
|
20
|
+
// Look for backtick
|
|
21
|
+
if (expr[i] === '`') {
|
|
22
|
+
i++; // skip opening backtick
|
|
23
|
+
let parts = [];
|
|
24
|
+
let currentStr = '';
|
|
25
|
+
|
|
26
|
+
while (i < expr.length && expr[i] !== '`') {
|
|
27
|
+
if (expr[i] === '$' && expr[i + 1] === '{') {
|
|
28
|
+
// Save accumulated string part
|
|
29
|
+
if (currentStr || parts.length === 0) {
|
|
30
|
+
parts.push(`'${currentStr}'`);
|
|
31
|
+
}
|
|
32
|
+
currentStr = '';
|
|
33
|
+
i += 2; // skip ${
|
|
34
|
+
|
|
35
|
+
// Find matching closing brace (handle nested braces)
|
|
36
|
+
let braceDepth = 1;
|
|
37
|
+
let exprContent = '';
|
|
38
|
+
while (i < expr.length && braceDepth > 0) {
|
|
39
|
+
if (expr[i] === '{') braceDepth++;
|
|
40
|
+
else if (expr[i] === '}') braceDepth--;
|
|
41
|
+
if (braceDepth > 0) exprContent += expr[i];
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
parts.push(`(${exprContent})`);
|
|
45
|
+
} else if (expr[i] === '\\' && i + 1 < expr.length) {
|
|
46
|
+
// Handle escape sequences
|
|
47
|
+
const next = expr[i + 1];
|
|
48
|
+
if (next === 'n') currentStr += '\\n';
|
|
49
|
+
else if (next === 't') currentStr += '\\t';
|
|
50
|
+
else if (next === '`') currentStr += '`';
|
|
51
|
+
else if (next === '$') currentStr += '$';
|
|
52
|
+
else if (next === '\\') currentStr += '\\\\';
|
|
53
|
+
else currentStr += expr[i + 1];
|
|
54
|
+
i += 2;
|
|
55
|
+
} else {
|
|
56
|
+
// Escape single quotes for the output string
|
|
57
|
+
if (expr[i] === "'") currentStr += "\\'";
|
|
58
|
+
else currentStr += expr[i];
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Add final string part
|
|
64
|
+
if (currentStr || parts.length === 0) {
|
|
65
|
+
parts.push(`'${currentStr}'`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
i++; // skip closing backtick
|
|
69
|
+
result += parts.join(' + ');
|
|
70
|
+
} else {
|
|
71
|
+
result += expr[i];
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Transform comma operator: `a = 1, b = 2` → `[a = 1, b = 2].pop()`
|
|
80
|
+
// Only transforms top-level commas (not inside parens, brackets, braces, or strings)
|
|
81
|
+
const preprocessCommaOperator = (expr) => {
|
|
82
|
+
const commaPositions = [];
|
|
83
|
+
let depth = 0;
|
|
84
|
+
let inString = null;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < expr.length; i++) {
|
|
87
|
+
const char = expr[i];
|
|
88
|
+
const prev = expr[i - 1];
|
|
89
|
+
|
|
90
|
+
// Handle string boundaries
|
|
91
|
+
if ((char === '"' || char === "'") && prev !== '\\') {
|
|
92
|
+
if (inString === char) inString = null;
|
|
93
|
+
else if (!inString) inString = char;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (inString) continue;
|
|
97
|
+
|
|
98
|
+
// Track nesting depth
|
|
99
|
+
if (char === '(' || char === '[' || char === '{') depth++;
|
|
100
|
+
else if (char === ')' || char === ']' || char === '}') depth--;
|
|
101
|
+
else if (char === ',' && depth === 0) commaPositions.push(i);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// No top-level commas, return as-is
|
|
105
|
+
if (commaPositions.length === 0) return expr;
|
|
106
|
+
|
|
107
|
+
// Split by top-level commas and wrap in array with .pop()
|
|
108
|
+
const parts = [];
|
|
109
|
+
let lastIndex = 0;
|
|
110
|
+
for (const pos of commaPositions) {
|
|
111
|
+
parts.push(expr.slice(lastIndex, pos).trim());
|
|
112
|
+
lastIndex = pos + 1;
|
|
113
|
+
}
|
|
114
|
+
parts.push(expr.slice(lastIndex).trim());
|
|
115
|
+
|
|
116
|
+
return `[${parts.join(', ')}].pop()`;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Pre-process expressions to handle ++ and -- operators
|
|
120
|
+
// Transforms: count++ -> (count = count + 1) - 1 (returns old value)
|
|
121
|
+
// Transforms: ++count -> (count = count + 1) (returns new value)
|
|
122
|
+
const preprocess = (expr) => {
|
|
123
|
+
// If expression looks like raw HTML, wrap it in quotes to make it a string
|
|
124
|
+
const trimmed = expr.trim();
|
|
125
|
+
if (trimmed.startsWith('<') && trimmed.endsWith('>')) {
|
|
126
|
+
return "'" + trimmed.replace(/'/g, "\\'") + "'";
|
|
127
|
+
}
|
|
128
|
+
// First handle template literals
|
|
129
|
+
expr = preprocessTemplateLiterals(expr);
|
|
130
|
+
// Handle comma operator
|
|
131
|
+
expr = preprocessCommaOperator(expr);
|
|
132
|
+
// Handle 'new' keyword for common constructors (jexpr doesn't support 'new')
|
|
133
|
+
expr = expr.replace(/\bnew\s+Date\b/g, '$Date');
|
|
134
|
+
expr = expr.replace(/\bnew\s+FormData\b/g, '$FormData');
|
|
135
|
+
// Then handle ++ and -- operators
|
|
136
|
+
return expr
|
|
137
|
+
.replace(/(\$?\w+(?:\.\w+|\[\w+\])*)(\+\+)/g, '($1 = $1 + 1) - 1')
|
|
138
|
+
.replace(/(\$?\w+(?:\.\w+|\[\w+\])*)(\-\-)/g, '($1 = $1 - 1) + 1')
|
|
139
|
+
.replace(/\+\+(\$?\w+(?:\.\w+|\[\w+\])*)/g, '($1 = $1 + 1)')
|
|
140
|
+
.replace(/\-\-(\$?\w+(?:\.\w+|\[\w+\])*)/g, '($1 = $1 - 1)');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// Compile expression cache
|
|
144
|
+
const cache = new Map();
|
|
145
|
+
|
|
146
|
+
// Reserved words that should not be auto-initialized as state
|
|
147
|
+
const RESERVED_WORDS = new Set([
|
|
148
|
+
'null', 'undefined', 'true', 'false', 'this',
|
|
149
|
+
'NaN', 'Infinity', 'Math', 'console', 'Object', 'Array',
|
|
150
|
+
'String', 'Number', 'Boolean', 'Date', 'JSON', 'Map', 'Set'
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
// jexpr-based expression engine (CSP-safe)
|
|
154
|
+
const jexprEngine = {
|
|
155
|
+
compile(expr, withReturn = true) {
|
|
156
|
+
const processed = preprocess(expr.trim());
|
|
157
|
+
const cacheKey = `${withReturn}:${processed}`;
|
|
158
|
+
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const ast = parse(processed, astFactory);
|
|
162
|
+
const compiled = {
|
|
163
|
+
execute: (scope) => ast ? ast.evaluate(scope) : undefined,
|
|
164
|
+
getIds: () => ast ? ast.getIds([]).filter(id => !RESERVED_WORDS.has(id)) : []
|
|
165
|
+
};
|
|
166
|
+
cache.set(cacheKey, compiled);
|
|
167
|
+
return compiled;
|
|
168
|
+
} catch (e) {
|
|
169
|
+
console.error(`Failed to parse: "${expr}"`, e.message);
|
|
170
|
+
const compiled = {
|
|
171
|
+
execute: () => undefined,
|
|
172
|
+
getIds: () => []
|
|
173
|
+
};
|
|
174
|
+
cache.set(cacheKey, compiled);
|
|
175
|
+
return compiled;
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
createScope(ctx) {
|
|
180
|
+
const locals = {
|
|
181
|
+
console,
|
|
182
|
+
Math,
|
|
183
|
+
Date,
|
|
184
|
+
$: ctx.$,
|
|
185
|
+
$el: ctx.el,
|
|
186
|
+
$event: ctx.event,
|
|
187
|
+
$data: ctx.state,
|
|
188
|
+
$html: ctx.html,
|
|
189
|
+
$Date: (...args) => new Date(...args),
|
|
190
|
+
$FormData: el => new FormData(el),
|
|
191
|
+
$get: ctx.get,
|
|
192
|
+
$post: ctx.post,
|
|
193
|
+
$put: ctx.put,
|
|
194
|
+
$patch: ctx.patch,
|
|
195
|
+
$delete: ctx.del,
|
|
196
|
+
...ctx.refs
|
|
197
|
+
};
|
|
198
|
+
// Proxy to allow state access and assignment without $data prefix
|
|
199
|
+
return new Proxy(locals, {
|
|
200
|
+
get(t, p) {
|
|
201
|
+
return p in t ? t[p] : ctx.state[p];
|
|
202
|
+
},
|
|
203
|
+
set(t, p, v) {
|
|
204
|
+
if (p in t) t[p] = v;
|
|
205
|
+
else ctx.state[p] = v;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Create helium with jexpr engine and he-* prefixes
|
|
213
|
+
const { helium, heliumTeardown } = createHelium({
|
|
214
|
+
engine: jexprEngine,
|
|
215
|
+
prefix: 'he',
|
|
216
|
+
rootAttr: 'helium'
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Evaluate helper for testing
|
|
220
|
+
const evaluate = (expr, scope) => {
|
|
221
|
+
const compiled = jexprEngine.compile(expr);
|
|
222
|
+
return compiled.execute(scope);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Expose globally
|
|
226
|
+
if (typeof window !== 'undefined') {
|
|
227
|
+
window.helium = helium;
|
|
228
|
+
window.heliumTeardown = heliumTeardown;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Auto-initialize
|
|
232
|
+
if (typeof document !== 'undefined') {
|
|
233
|
+
document.addEventListener("DOMContentLoaded", () => helium());
|
|
234
|
+
// Turbo integration
|
|
235
|
+
document.addEventListener("turbo:before-render", () => heliumTeardown());
|
|
236
|
+
document.addEventListener("turbo:render", () => helium());
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export { helium, heliumTeardown, evaluate, preprocess };
|
|
240
|
+
export default helium;
|