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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.7.5",
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": "./runtime/async.js",
60
- "./runtime/form": "./runtime/form.js",
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
+ };