@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/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;