@daz4126/helium 0.31.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 +3 -1
- package/CLAUDE.md +1 -1
- package/examples/index-lite.html +387 -0
- package/examples/index.html +11 -10
- package/examples/password-utils.js +80 -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 +19 -7
- package/jexpr.js +666 -0
- package/jexpr.test.js +551 -0
- package/package.json +6 -1
- package/test-csp.html +50 -0
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;
|