pulse-js-framework 1.7.5 → 1.7.8
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/README.md +78 -392
- package/cli/dev.js +14 -0
- package/cli/docs-test.js +633 -0
- package/cli/index.js +313 -31
- package/cli/lint.js +13 -4
- package/cli/logger.js +32 -4
- package/cli/release.js +50 -20
- package/compiler/parser.js +1 -1
- package/package.json +11 -4
- package/runtime/dom-advanced.js +357 -0
- package/runtime/dom-binding.js +230 -0
- package/runtime/dom-conditional.js +133 -0
- package/runtime/dom-element.js +142 -0
- package/runtime/dom-lifecycle.js +178 -0
- package/runtime/dom-list.js +267 -0
- package/runtime/dom-selector.js +267 -0
- package/runtime/dom.js +119 -1279
- package/runtime/form.js +417 -22
- package/runtime/native.js +398 -52
- package/runtime/pulse.js +1 -1
- package/runtime/router.js +6 -5
- package/runtime/store.js +81 -6
- package/types/async.d.ts +310 -0
- package/types/form.d.ts +378 -0
- package/types/index.d.ts +44 -0
- /package/{core → runtime}/errors.js +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.8",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -56,8 +56,14 @@
|
|
|
56
56
|
"./runtime/lru-cache": "./runtime/lru-cache.js",
|
|
57
57
|
"./runtime/utils": "./runtime/utils.js",
|
|
58
58
|
"./runtime/dom-adapter": "./runtime/dom-adapter.js",
|
|
59
|
-
"./runtime/async":
|
|
60
|
-
|
|
59
|
+
"./runtime/async": {
|
|
60
|
+
"types": "./types/async.d.ts",
|
|
61
|
+
"default": "./runtime/async.js"
|
|
62
|
+
},
|
|
63
|
+
"./runtime/form": {
|
|
64
|
+
"types": "./types/form.d.ts",
|
|
65
|
+
"default": "./runtime/form.js"
|
|
66
|
+
},
|
|
61
67
|
"./runtime/devtools": "./runtime/devtools.js",
|
|
62
68
|
"./compiler": {
|
|
63
69
|
"types": "./types/index.d.ts",
|
|
@@ -87,7 +93,7 @@
|
|
|
87
93
|
"LICENSE"
|
|
88
94
|
],
|
|
89
95
|
"scripts": {
|
|
90
|
-
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:dom-adapter && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:async && npm run test:form && npm run test:devtools",
|
|
96
|
+
"test": "npm run test:compiler && npm run test:sourcemap && npm run test:pulse && npm run test:dom && npm run test:dom-adapter && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:lru-cache && npm run test:utils && npm run test:docs && npm run test:async && npm run test:form && npm run test:devtools && npm run test:native",
|
|
91
97
|
"test:compiler": "node test/compiler.test.js",
|
|
92
98
|
"test:sourcemap": "node test/sourcemap.test.js",
|
|
93
99
|
"test:pulse": "node test/pulse.test.js",
|
|
@@ -105,6 +111,7 @@
|
|
|
105
111
|
"test:async": "node test/async.test.js",
|
|
106
112
|
"test:form": "node test/form.test.js",
|
|
107
113
|
"test:devtools": "node test/devtools.test.js",
|
|
114
|
+
"test:native": "node test/native.test.js",
|
|
108
115
|
"build:netlify": "node scripts/build-netlify.js",
|
|
109
116
|
"version": "node scripts/sync-version.js",
|
|
110
117
|
"docs": "node cli/index.js dev docs"
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DOM Advanced Module
|
|
3
|
+
* Advanced features: portal, error boundary, transitions
|
|
4
|
+
*
|
|
5
|
+
* @module dom-advanced
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { effect, pulse, onCleanup } from './pulse.js';
|
|
9
|
+
import { loggers } from './logger.js';
|
|
10
|
+
import { getAdapter } from './dom-adapter.js';
|
|
11
|
+
import { resolveSelector } from './dom-selector.js';
|
|
12
|
+
|
|
13
|
+
const log = loggers.dom;
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// PORTAL
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Portal - render children into a different DOM location
|
|
21
|
+
*
|
|
22
|
+
* @param {*|Function} children - Children to render (static or reactive)
|
|
23
|
+
* @param {string|HTMLElement} target - Target selector or element
|
|
24
|
+
* @returns {Comment} Marker node for position tracking
|
|
25
|
+
*/
|
|
26
|
+
export function portal(children, target) {
|
|
27
|
+
const dom = getAdapter();
|
|
28
|
+
const { element: resolvedTarget, selector } = resolveSelector(target, 'portal');
|
|
29
|
+
|
|
30
|
+
if (!resolvedTarget) {
|
|
31
|
+
log.warn(`Portal target not found: "${selector}"`);
|
|
32
|
+
return dom.createComment('portal-target-not-found');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const marker = dom.createComment('portal');
|
|
36
|
+
let mountedNodes = [];
|
|
37
|
+
|
|
38
|
+
// Handle reactive children
|
|
39
|
+
if (typeof children === 'function') {
|
|
40
|
+
effect(() => {
|
|
41
|
+
// Cleanup previous nodes
|
|
42
|
+
for (const node of mountedNodes) {
|
|
43
|
+
dom.removeNode(node);
|
|
44
|
+
if (node._pulseUnmount) {
|
|
45
|
+
for (const cb of node._pulseUnmount) cb();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
mountedNodes = [];
|
|
49
|
+
|
|
50
|
+
const result = children();
|
|
51
|
+
if (result) {
|
|
52
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
53
|
+
for (const node of nodes) {
|
|
54
|
+
if (dom.isNode(node)) {
|
|
55
|
+
dom.appendChild(resolvedTarget, node);
|
|
56
|
+
mountedNodes.push(node);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
} else {
|
|
62
|
+
// Static children
|
|
63
|
+
const nodes = Array.isArray(children) ? children : [children];
|
|
64
|
+
for (const node of nodes) {
|
|
65
|
+
if (dom.isNode(node)) {
|
|
66
|
+
dom.appendChild(resolvedTarget, node);
|
|
67
|
+
mountedNodes.push(node);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Return marker for position tracking, attach cleanup
|
|
73
|
+
marker._pulseUnmount = [() => {
|
|
74
|
+
for (const node of mountedNodes) {
|
|
75
|
+
dom.removeNode(node);
|
|
76
|
+
if (node._pulseUnmount) {
|
|
77
|
+
for (const cb of node._pulseUnmount) cb();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}];
|
|
81
|
+
|
|
82
|
+
return marker;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// =============================================================================
|
|
86
|
+
// ERROR BOUNDARY
|
|
87
|
+
// =============================================================================
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Error boundary - catch errors in child components
|
|
91
|
+
*
|
|
92
|
+
* @param {*|Function} children - Children to render (static or reactive)
|
|
93
|
+
* @param {*|Function} fallback - Fallback to render on error (receives error)
|
|
94
|
+
* @returns {DocumentFragment} Container with error-protected content
|
|
95
|
+
*/
|
|
96
|
+
export function errorBoundary(children, fallback) {
|
|
97
|
+
const dom = getAdapter();
|
|
98
|
+
const container = dom.createDocumentFragment();
|
|
99
|
+
const marker = dom.createComment('error-boundary');
|
|
100
|
+
dom.appendChild(container, marker);
|
|
101
|
+
|
|
102
|
+
const error = pulse(null);
|
|
103
|
+
let currentNodes = [];
|
|
104
|
+
|
|
105
|
+
const renderContent = () => {
|
|
106
|
+
// Cleanup previous
|
|
107
|
+
for (const node of currentNodes) {
|
|
108
|
+
dom.removeNode(node);
|
|
109
|
+
}
|
|
110
|
+
currentNodes = [];
|
|
111
|
+
|
|
112
|
+
const hasError = error.peek();
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
let result;
|
|
116
|
+
if (hasError && fallback) {
|
|
117
|
+
result = typeof fallback === 'function' ? fallback(hasError) : fallback;
|
|
118
|
+
} else {
|
|
119
|
+
result = typeof children === 'function' ? children() : children;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (result) {
|
|
123
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
124
|
+
const fragment = dom.createDocumentFragment();
|
|
125
|
+
for (const node of nodes) {
|
|
126
|
+
if (dom.isNode(node)) {
|
|
127
|
+
dom.appendChild(fragment, node);
|
|
128
|
+
currentNodes.push(node);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const markerParent = dom.getParentNode(marker);
|
|
132
|
+
if (markerParent) {
|
|
133
|
+
dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {
|
|
137
|
+
log.error('Error in component:', e);
|
|
138
|
+
error.set(e);
|
|
139
|
+
// Re-render with error
|
|
140
|
+
if (!hasError) {
|
|
141
|
+
dom.queueMicrotask(renderContent);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
effect(renderContent);
|
|
147
|
+
|
|
148
|
+
// Expose reset method on marker
|
|
149
|
+
marker.resetError = () => error.set(null);
|
|
150
|
+
|
|
151
|
+
return container;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// TRANSITIONS
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Transition helper - animate element enter/exit
|
|
160
|
+
*
|
|
161
|
+
* MEMORY SAFETY: All timers are tracked and cleared on cleanup
|
|
162
|
+
* to prevent callbacks executing on removed elements.
|
|
163
|
+
*
|
|
164
|
+
* @param {HTMLElement} element - Element to animate
|
|
165
|
+
* @param {Object} options - Transition options
|
|
166
|
+
* @param {string} [options.enter='fade-in'] - Enter animation class
|
|
167
|
+
* @param {string} [options.exit='fade-out'] - Exit animation class
|
|
168
|
+
* @param {number} [options.duration=300] - Animation duration in ms
|
|
169
|
+
* @param {Function} [options.onEnter] - Callback on enter start
|
|
170
|
+
* @param {Function} [options.onExit] - Callback on exit start
|
|
171
|
+
* @returns {HTMLElement} The element with transition attached
|
|
172
|
+
*/
|
|
173
|
+
export function transition(element, options = {}) {
|
|
174
|
+
const dom = getAdapter();
|
|
175
|
+
const {
|
|
176
|
+
enter = 'fade-in',
|
|
177
|
+
exit = 'fade-out',
|
|
178
|
+
duration = 300,
|
|
179
|
+
onEnter,
|
|
180
|
+
onExit
|
|
181
|
+
} = options;
|
|
182
|
+
|
|
183
|
+
// Track active timers for cleanup
|
|
184
|
+
const activeTimers = new Set();
|
|
185
|
+
|
|
186
|
+
const safeTimeout = (fn, delay) => {
|
|
187
|
+
const timerId = dom.setTimeout(() => {
|
|
188
|
+
activeTimers.delete(timerId);
|
|
189
|
+
fn();
|
|
190
|
+
}, delay);
|
|
191
|
+
activeTimers.add(timerId);
|
|
192
|
+
return timerId;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const clearAllTimers = () => {
|
|
196
|
+
for (const timerId of activeTimers) {
|
|
197
|
+
dom.clearTimeout(timerId);
|
|
198
|
+
}
|
|
199
|
+
activeTimers.clear();
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Apply enter animation
|
|
203
|
+
const applyEnter = () => {
|
|
204
|
+
dom.addClass(element, enter);
|
|
205
|
+
if (onEnter) onEnter(element);
|
|
206
|
+
safeTimeout(() => {
|
|
207
|
+
dom.removeClass(element, enter);
|
|
208
|
+
}, duration);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Apply exit animation and return promise
|
|
212
|
+
const applyExit = () => {
|
|
213
|
+
return new Promise(resolve => {
|
|
214
|
+
dom.addClass(element, exit);
|
|
215
|
+
if (onExit) onExit(element);
|
|
216
|
+
safeTimeout(() => {
|
|
217
|
+
dom.removeClass(element, exit);
|
|
218
|
+
resolve();
|
|
219
|
+
}, duration);
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Apply enter on mount
|
|
224
|
+
dom.queueMicrotask(applyEnter);
|
|
225
|
+
|
|
226
|
+
// Attach exit method
|
|
227
|
+
element._pulseTransitionExit = applyExit;
|
|
228
|
+
|
|
229
|
+
// Register cleanup for all timers
|
|
230
|
+
onCleanup(clearAllTimers);
|
|
231
|
+
|
|
232
|
+
return element;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Conditional rendering with transitions
|
|
237
|
+
*
|
|
238
|
+
* MEMORY SAFETY: All timers are tracked and cleared on cleanup
|
|
239
|
+
* to prevent callbacks executing on removed elements.
|
|
240
|
+
*
|
|
241
|
+
* @param {Function|Pulse} condition - Condition source (reactive)
|
|
242
|
+
* @param {Function|Node} thenTemplate - Template to render when true
|
|
243
|
+
* @param {Function|Node|null} elseTemplate - Template to render when false
|
|
244
|
+
* @param {Object} options - Transition options
|
|
245
|
+
* @param {number} [options.duration=300] - Animation duration in ms
|
|
246
|
+
* @param {string} [options.enterClass='fade-in'] - Enter animation class
|
|
247
|
+
* @param {string} [options.exitClass='fade-out'] - Exit animation class
|
|
248
|
+
* @returns {DocumentFragment} Container with transitioning content
|
|
249
|
+
*/
|
|
250
|
+
export function whenTransition(condition, thenTemplate, elseTemplate = null, options = {}) {
|
|
251
|
+
const dom = getAdapter();
|
|
252
|
+
const container = dom.createDocumentFragment();
|
|
253
|
+
const marker = dom.createComment('when-transition');
|
|
254
|
+
dom.appendChild(container, marker);
|
|
255
|
+
|
|
256
|
+
const { duration = 300, enterClass = 'fade-in', exitClass = 'fade-out' } = options;
|
|
257
|
+
|
|
258
|
+
let currentNodes = [];
|
|
259
|
+
let isTransitioning = false;
|
|
260
|
+
|
|
261
|
+
// Track active timers for cleanup
|
|
262
|
+
const activeTimers = new Set();
|
|
263
|
+
|
|
264
|
+
const safeTimeout = (fn, delay) => {
|
|
265
|
+
const timerId = dom.setTimeout(() => {
|
|
266
|
+
activeTimers.delete(timerId);
|
|
267
|
+
fn();
|
|
268
|
+
}, delay);
|
|
269
|
+
activeTimers.add(timerId);
|
|
270
|
+
return timerId;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const clearAllTimers = () => {
|
|
274
|
+
for (const timerId of activeTimers) {
|
|
275
|
+
dom.clearTimeout(timerId);
|
|
276
|
+
}
|
|
277
|
+
activeTimers.clear();
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Register cleanup for all timers
|
|
281
|
+
onCleanup(clearAllTimers);
|
|
282
|
+
|
|
283
|
+
effect(() => {
|
|
284
|
+
const show = typeof condition === 'function' ? condition() : condition.get();
|
|
285
|
+
|
|
286
|
+
if (isTransitioning) return;
|
|
287
|
+
|
|
288
|
+
const template = show ? thenTemplate : elseTemplate;
|
|
289
|
+
|
|
290
|
+
// Exit animation for current nodes
|
|
291
|
+
if (currentNodes.length > 0) {
|
|
292
|
+
isTransitioning = true;
|
|
293
|
+
const nodesToRemove = [...currentNodes];
|
|
294
|
+
currentNodes = [];
|
|
295
|
+
|
|
296
|
+
for (const node of nodesToRemove) {
|
|
297
|
+
dom.addClass(node, exitClass);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
safeTimeout(() => {
|
|
301
|
+
for (const node of nodesToRemove) {
|
|
302
|
+
dom.removeNode(node);
|
|
303
|
+
}
|
|
304
|
+
isTransitioning = false;
|
|
305
|
+
|
|
306
|
+
// Render new content
|
|
307
|
+
if (template) {
|
|
308
|
+
const result = typeof template === 'function' ? template() : template;
|
|
309
|
+
if (result) {
|
|
310
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
311
|
+
const fragment = dom.createDocumentFragment();
|
|
312
|
+
for (const node of nodes) {
|
|
313
|
+
if (dom.isNode(node)) {
|
|
314
|
+
dom.addClass(node, enterClass);
|
|
315
|
+
dom.appendChild(fragment, node);
|
|
316
|
+
currentNodes.push(node);
|
|
317
|
+
safeTimeout(() => dom.removeClass(node, enterClass), duration);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const markerParent = dom.getParentNode(marker);
|
|
321
|
+
if (markerParent) {
|
|
322
|
+
dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}, duration);
|
|
327
|
+
} else if (template) {
|
|
328
|
+
// No previous content, just render with enter animation
|
|
329
|
+
const result = typeof template === 'function' ? template() : template;
|
|
330
|
+
if (result) {
|
|
331
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
332
|
+
const fragment = dom.createDocumentFragment();
|
|
333
|
+
for (const node of nodes) {
|
|
334
|
+
if (dom.isNode(node)) {
|
|
335
|
+
dom.addClass(node, enterClass);
|
|
336
|
+
dom.appendChild(fragment, node);
|
|
337
|
+
currentNodes.push(node);
|
|
338
|
+
safeTimeout(() => dom.removeClass(node, enterClass), duration);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const markerParent = dom.getParentNode(marker);
|
|
342
|
+
if (markerParent) {
|
|
343
|
+
dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
return container;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export default {
|
|
353
|
+
portal,
|
|
354
|
+
errorBoundary,
|
|
355
|
+
transition,
|
|
356
|
+
whenTransition
|
|
357
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DOM Binding Module
|
|
3
|
+
* Reactive attribute, property, class, style, and event bindings
|
|
4
|
+
*
|
|
5
|
+
* @module dom-binding
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { effect, onCleanup } from './pulse.js';
|
|
9
|
+
import { sanitizeUrl, safeSetStyle } from './utils.js';
|
|
10
|
+
import { getAdapter } from './dom-adapter.js';
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// URL ATTRIBUTES (XSS Protection)
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* URL attributes that need sanitization in bind()
|
|
18
|
+
* @private
|
|
19
|
+
*/
|
|
20
|
+
const BIND_URL_ATTRIBUTES = new Set([
|
|
21
|
+
'href', 'src', 'action', 'formaction', 'data', 'poster',
|
|
22
|
+
'cite', 'codebase', 'background', 'profile', 'usemap', 'longdesc'
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// REACTIVE BINDINGS
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bind an attribute reactively with XSS protection
|
|
31
|
+
*
|
|
32
|
+
* Security: URL attributes (href, src, etc.) are sanitized to prevent javascript: XSS
|
|
33
|
+
*
|
|
34
|
+
* @param {HTMLElement} element - Target element
|
|
35
|
+
* @param {string} attr - Attribute name
|
|
36
|
+
* @param {*|Function} getValue - Value or function returning value
|
|
37
|
+
* @returns {HTMLElement} The element for chaining
|
|
38
|
+
*/
|
|
39
|
+
export function bind(element, attr, getValue) {
|
|
40
|
+
const dom = getAdapter();
|
|
41
|
+
const lowerAttr = attr.toLowerCase();
|
|
42
|
+
const isUrlAttr = BIND_URL_ATTRIBUTES.has(lowerAttr);
|
|
43
|
+
|
|
44
|
+
if (typeof getValue === 'function') {
|
|
45
|
+
effect(() => {
|
|
46
|
+
const value = getValue();
|
|
47
|
+
if (value == null || value === false) {
|
|
48
|
+
dom.removeAttribute(element, attr);
|
|
49
|
+
} else if (value === true) {
|
|
50
|
+
dom.setAttribute(element, attr, '');
|
|
51
|
+
} else {
|
|
52
|
+
// Sanitize URL attributes to prevent javascript: XSS
|
|
53
|
+
if (isUrlAttr) {
|
|
54
|
+
const sanitized = sanitizeUrl(String(value));
|
|
55
|
+
if (sanitized === null) {
|
|
56
|
+
console.warn(
|
|
57
|
+
`[Pulse Security] Dangerous URL blocked in bind() for ${attr}: "${String(value).slice(0, 50)}"`
|
|
58
|
+
);
|
|
59
|
+
dom.removeAttribute(element, attr);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
dom.setAttribute(element, attr, sanitized);
|
|
63
|
+
} else {
|
|
64
|
+
dom.setAttribute(element, attr, String(value));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
} else {
|
|
69
|
+
// Sanitize URL attributes for static values too
|
|
70
|
+
if (isUrlAttr) {
|
|
71
|
+
const sanitized = sanitizeUrl(String(getValue));
|
|
72
|
+
if (sanitized === null) {
|
|
73
|
+
console.warn(
|
|
74
|
+
`[Pulse Security] Dangerous URL blocked in bind() for ${attr}: "${String(getValue).slice(0, 50)}"`
|
|
75
|
+
);
|
|
76
|
+
return element;
|
|
77
|
+
}
|
|
78
|
+
dom.setAttribute(element, attr, sanitized);
|
|
79
|
+
} else {
|
|
80
|
+
dom.setAttribute(element, attr, String(getValue));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return element;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Bind a property reactively
|
|
88
|
+
*
|
|
89
|
+
* @param {HTMLElement} element - Target element
|
|
90
|
+
* @param {string} propName - Property name
|
|
91
|
+
* @param {*|Function} getValue - Value or function returning value
|
|
92
|
+
* @returns {HTMLElement} The element for chaining
|
|
93
|
+
*/
|
|
94
|
+
export function prop(element, propName, getValue) {
|
|
95
|
+
const dom = getAdapter();
|
|
96
|
+
if (typeof getValue === 'function') {
|
|
97
|
+
effect(() => {
|
|
98
|
+
dom.setProperty(element, propName, getValue());
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
dom.setProperty(element, propName, getValue);
|
|
102
|
+
}
|
|
103
|
+
return element;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Bind CSS class reactively
|
|
108
|
+
*
|
|
109
|
+
* @param {HTMLElement} element - Target element
|
|
110
|
+
* @param {string} className - Class name to toggle
|
|
111
|
+
* @param {boolean|Function} condition - Condition or function returning condition
|
|
112
|
+
* @returns {HTMLElement} The element for chaining
|
|
113
|
+
*/
|
|
114
|
+
export function cls(element, className, condition) {
|
|
115
|
+
const dom = getAdapter();
|
|
116
|
+
if (typeof condition === 'function') {
|
|
117
|
+
effect(() => {
|
|
118
|
+
if (condition()) {
|
|
119
|
+
dom.addClass(element, className);
|
|
120
|
+
} else {
|
|
121
|
+
dom.removeClass(element, className);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
} else if (condition) {
|
|
125
|
+
dom.addClass(element, className);
|
|
126
|
+
}
|
|
127
|
+
return element;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Bind style property reactively with CSS injection protection
|
|
132
|
+
*
|
|
133
|
+
* Security: CSS values are sanitized to prevent injection attacks via:
|
|
134
|
+
* - Semicolons (property injection: 'red; position: fixed')
|
|
135
|
+
* - url() (data exfiltration)
|
|
136
|
+
* - expression() (IE script execution)
|
|
137
|
+
*
|
|
138
|
+
* @param {HTMLElement} element - Target element
|
|
139
|
+
* @param {string} prop - CSS property name
|
|
140
|
+
* @param {*} getValue - Value or function returning value
|
|
141
|
+
* @param {Object} [options] - Options passed to safeSetStyle
|
|
142
|
+
* @returns {HTMLElement} The element for chaining
|
|
143
|
+
*/
|
|
144
|
+
export function style(element, prop, getValue, options = {}) {
|
|
145
|
+
const dom = getAdapter();
|
|
146
|
+
if (typeof getValue === 'function') {
|
|
147
|
+
effect(() => {
|
|
148
|
+
safeSetStyle(element, prop, getValue(), options, dom);
|
|
149
|
+
});
|
|
150
|
+
} else {
|
|
151
|
+
safeSetStyle(element, prop, getValue, options, dom);
|
|
152
|
+
}
|
|
153
|
+
return element;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Attach an event listener with automatic cleanup
|
|
158
|
+
*
|
|
159
|
+
* @param {HTMLElement} element - Target element
|
|
160
|
+
* @param {string} event - Event name
|
|
161
|
+
* @param {Function} handler - Event handler
|
|
162
|
+
* @param {Object} [options] - addEventListener options
|
|
163
|
+
* @returns {HTMLElement} The element for chaining
|
|
164
|
+
*/
|
|
165
|
+
export function on(element, event, handler, options) {
|
|
166
|
+
const dom = getAdapter();
|
|
167
|
+
dom.addEventListener(element, event, handler, options);
|
|
168
|
+
|
|
169
|
+
// Auto-cleanup: remove listener when effect is disposed (HMR support)
|
|
170
|
+
onCleanup(() => {
|
|
171
|
+
dom.removeEventListener(element, event, handler, options);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return element;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Two-way binding for form inputs
|
|
179
|
+
*
|
|
180
|
+
* MEMORY SAFETY: All event listeners are registered with onCleanup()
|
|
181
|
+
* to prevent memory leaks when the element is removed from the DOM.
|
|
182
|
+
*
|
|
183
|
+
* @param {HTMLElement} element - Form element (input, select, textarea)
|
|
184
|
+
* @param {Pulse} pulseValue - Pulse signal for two-way binding
|
|
185
|
+
* @returns {HTMLElement} The element for chaining
|
|
186
|
+
*/
|
|
187
|
+
export function model(element, pulseValue) {
|
|
188
|
+
const dom = getAdapter();
|
|
189
|
+
const tagName = dom.getTagName(element);
|
|
190
|
+
const type = dom.getInputType(element);
|
|
191
|
+
|
|
192
|
+
if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) {
|
|
193
|
+
// Checkbox/Radio
|
|
194
|
+
effect(() => {
|
|
195
|
+
dom.setProperty(element, 'checked', pulseValue.get());
|
|
196
|
+
});
|
|
197
|
+
const handler = () => pulseValue.set(dom.getProperty(element, 'checked'));
|
|
198
|
+
dom.addEventListener(element, 'change', handler);
|
|
199
|
+
onCleanup(() => dom.removeEventListener(element, 'change', handler));
|
|
200
|
+
} else if (tagName === 'select') {
|
|
201
|
+
// Select
|
|
202
|
+
effect(() => {
|
|
203
|
+
dom.setProperty(element, 'value', pulseValue.get());
|
|
204
|
+
});
|
|
205
|
+
const handler = () => pulseValue.set(dom.getProperty(element, 'value'));
|
|
206
|
+
dom.addEventListener(element, 'change', handler);
|
|
207
|
+
onCleanup(() => dom.removeEventListener(element, 'change', handler));
|
|
208
|
+
} else {
|
|
209
|
+
// Text input, textarea, etc.
|
|
210
|
+
effect(() => {
|
|
211
|
+
if (dom.getProperty(element, 'value') !== pulseValue.get()) {
|
|
212
|
+
dom.setProperty(element, 'value', pulseValue.get());
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
const handler = () => pulseValue.set(dom.getProperty(element, 'value'));
|
|
216
|
+
dom.addEventListener(element, 'input', handler);
|
|
217
|
+
onCleanup(() => dom.removeEventListener(element, 'input', handler));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return element;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export default {
|
|
224
|
+
bind,
|
|
225
|
+
prop,
|
|
226
|
+
cls,
|
|
227
|
+
style,
|
|
228
|
+
on,
|
|
229
|
+
model
|
|
230
|
+
};
|