pulse-js-framework 1.0.0 → 1.4.0
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 +414 -182
- package/cli/analyze.js +499 -0
- package/cli/build.js +341 -199
- package/cli/format.js +704 -0
- package/cli/index.js +398 -324
- package/cli/lint.js +642 -0
- package/cli/mobile.js +1473 -0
- package/cli/utils/file-utils.js +298 -0
- package/compiler/lexer.js +766 -581
- package/compiler/parser.js +1797 -900
- package/compiler/transformer.js +1332 -552
- package/index.js +1 -1
- package/mobile/bridge/pulse-native.js +420 -0
- package/package.json +68 -58
- package/runtime/dom.js +363 -33
- package/runtime/index.js +2 -0
- package/runtime/native.js +368 -0
- package/runtime/pulse.js +247 -13
- package/runtime/router.js +596 -392
package/runtime/dom.js
CHANGED
|
@@ -5,7 +5,35 @@
|
|
|
5
5
|
* and provides reactive bindings
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { effect, pulse, batch } from './pulse.js';
|
|
8
|
+
import { effect, pulse, batch, onCleanup } from './pulse.js';
|
|
9
|
+
|
|
10
|
+
// Lifecycle tracking
|
|
11
|
+
let mountCallbacks = [];
|
|
12
|
+
let unmountCallbacks = [];
|
|
13
|
+
let currentMountContext = null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register a callback to run when component mounts
|
|
17
|
+
*/
|
|
18
|
+
export function onMount(fn) {
|
|
19
|
+
if (currentMountContext) {
|
|
20
|
+
currentMountContext.mountCallbacks.push(fn);
|
|
21
|
+
} else {
|
|
22
|
+
// Defer to next microtask if no context
|
|
23
|
+
queueMicrotask(fn);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Register a callback to run when component unmounts
|
|
29
|
+
*/
|
|
30
|
+
export function onUnmount(fn) {
|
|
31
|
+
if (currentMountContext) {
|
|
32
|
+
currentMountContext.unmountCallbacks.push(fn);
|
|
33
|
+
}
|
|
34
|
+
// Also register with effect cleanup if in an effect
|
|
35
|
+
onCleanup(fn);
|
|
36
|
+
}
|
|
9
37
|
|
|
10
38
|
/**
|
|
11
39
|
* Parse a CSS selector-like string into element configuration
|
|
@@ -18,7 +46,7 @@ import { effect, pulse, batch } from './pulse.js';
|
|
|
18
46
|
* "button.primary.large" -> { tag: "button", classes: ["primary", "large"] }
|
|
19
47
|
* "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
|
|
20
48
|
*/
|
|
21
|
-
function parseSelector(selector) {
|
|
49
|
+
export function parseSelector(selector) {
|
|
22
50
|
const config = {
|
|
23
51
|
tag: 'div',
|
|
24
52
|
id: null,
|
|
@@ -238,7 +266,7 @@ export function on(element, event, handler, options) {
|
|
|
238
266
|
}
|
|
239
267
|
|
|
240
268
|
/**
|
|
241
|
-
* Create a reactive list
|
|
269
|
+
* Create a reactive list with efficient keyed diffing
|
|
242
270
|
*/
|
|
243
271
|
export function list(getItems, template, keyFn = (item, i) => i) {
|
|
244
272
|
const container = document.createDocumentFragment();
|
|
@@ -248,41 +276,36 @@ export function list(getItems, template, keyFn = (item, i) => i) {
|
|
|
248
276
|
container.appendChild(startMarker);
|
|
249
277
|
container.appendChild(endMarker);
|
|
250
278
|
|
|
251
|
-
|
|
279
|
+
// Map: key -> { nodes: Node[], cleanup: Function, item: any }
|
|
280
|
+
let itemNodes = new Map();
|
|
281
|
+
let keyOrder = []; // Track order of keys for diffing
|
|
252
282
|
|
|
253
283
|
effect(() => {
|
|
254
284
|
const items = typeof getItems === 'function' ? getItems() : getItems.get();
|
|
255
|
-
const
|
|
285
|
+
const itemsArray = Array.isArray(items) ? items : Array.from(items);
|
|
256
286
|
|
|
257
|
-
|
|
258
|
-
const fragment = document.createDocumentFragment();
|
|
287
|
+
const newKeys = [];
|
|
259
288
|
const newItemNodes = new Map();
|
|
260
289
|
|
|
261
|
-
items
|
|
290
|
+
// Build map of new items by key
|
|
291
|
+
itemsArray.forEach((item, index) => {
|
|
262
292
|
const key = keyFn(item, index);
|
|
263
|
-
newKeys.
|
|
293
|
+
newKeys.push(key);
|
|
264
294
|
|
|
265
295
|
if (itemNodes.has(key)) {
|
|
266
|
-
// Reuse existing
|
|
267
|
-
|
|
268
|
-
for (const node of existing.nodes) {
|
|
269
|
-
fragment.appendChild(node);
|
|
270
|
-
}
|
|
271
|
-
newItemNodes.set(key, existing);
|
|
296
|
+
// Reuse existing entry
|
|
297
|
+
newItemNodes.set(key, itemNodes.get(key));
|
|
272
298
|
} else {
|
|
273
299
|
// Create new nodes
|
|
274
300
|
const result = template(item, index);
|
|
275
301
|
const nodes = Array.isArray(result) ? result : [result];
|
|
276
|
-
|
|
277
|
-
fragment.appendChild(node);
|
|
278
|
-
}
|
|
279
|
-
newItemNodes.set(key, { nodes, cleanup: null });
|
|
302
|
+
newItemNodes.set(key, { nodes, cleanup: null, item });
|
|
280
303
|
}
|
|
281
304
|
});
|
|
282
305
|
|
|
283
|
-
// Remove
|
|
306
|
+
// Remove items that are no longer present
|
|
284
307
|
for (const [key, entry] of itemNodes) {
|
|
285
|
-
if (!
|
|
308
|
+
if (!newItemNodes.has(key)) {
|
|
286
309
|
for (const node of entry.nodes) {
|
|
287
310
|
node.remove();
|
|
288
311
|
}
|
|
@@ -290,17 +313,30 @@ export function list(getItems, template, keyFn = (item, i) => i) {
|
|
|
290
313
|
}
|
|
291
314
|
}
|
|
292
315
|
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
316
|
+
// Efficient reordering using minimal DOM operations
|
|
317
|
+
// Use a simple diff algorithm: iterate through new order and move/insert as needed
|
|
318
|
+
let prevNode = startMarker;
|
|
319
|
+
|
|
320
|
+
for (let i = 0; i < newKeys.length; i++) {
|
|
321
|
+
const key = newKeys[i];
|
|
322
|
+
const entry = newItemNodes.get(key);
|
|
323
|
+
const firstNode = entry.nodes[0];
|
|
324
|
+
|
|
325
|
+
// Check if node is already in correct position
|
|
326
|
+
if (prevNode.nextSibling !== firstNode) {
|
|
327
|
+
// Need to move/insert
|
|
328
|
+
for (const node of entry.nodes) {
|
|
329
|
+
prevNode.parentNode?.insertBefore(node, prevNode.nextSibling);
|
|
330
|
+
prevNode = node;
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
// Already in position, just advance prevNode
|
|
334
|
+
prevNode = entry.nodes[entry.nodes.length - 1];
|
|
335
|
+
}
|
|
299
336
|
}
|
|
300
337
|
|
|
301
|
-
// Insert new fragment
|
|
302
|
-
endMarker.parentNode?.insertBefore(fragment, endMarker);
|
|
303
338
|
itemNodes = newItemNodes;
|
|
339
|
+
keyOrder = newKeys;
|
|
304
340
|
});
|
|
305
341
|
|
|
306
342
|
return container;
|
|
@@ -441,13 +477,22 @@ export function mount(target, element) {
|
|
|
441
477
|
}
|
|
442
478
|
|
|
443
479
|
/**
|
|
444
|
-
* Create a component factory
|
|
480
|
+
* Create a component factory with lifecycle support
|
|
445
481
|
*/
|
|
446
482
|
export function component(setup) {
|
|
447
483
|
return (props = {}) => {
|
|
448
484
|
const state = {};
|
|
449
485
|
const methods = {};
|
|
450
486
|
|
|
487
|
+
// Create mount context for lifecycle hooks
|
|
488
|
+
const mountContext = {
|
|
489
|
+
mountCallbacks: [],
|
|
490
|
+
unmountCallbacks: []
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
const prevContext = currentMountContext;
|
|
494
|
+
currentMountContext = mountContext;
|
|
495
|
+
|
|
451
496
|
const ctx = {
|
|
452
497
|
state,
|
|
453
498
|
methods,
|
|
@@ -459,13 +504,290 @@ export function component(setup) {
|
|
|
459
504
|
when,
|
|
460
505
|
on,
|
|
461
506
|
bind,
|
|
462
|
-
model
|
|
507
|
+
model,
|
|
508
|
+
onMount,
|
|
509
|
+
onUnmount
|
|
463
510
|
};
|
|
464
511
|
|
|
465
|
-
|
|
512
|
+
let result;
|
|
513
|
+
try {
|
|
514
|
+
result = setup(ctx);
|
|
515
|
+
} finally {
|
|
516
|
+
currentMountContext = prevContext;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Schedule mount callbacks after DOM insertion
|
|
520
|
+
if (mountContext.mountCallbacks.length > 0) {
|
|
521
|
+
queueMicrotask(() => {
|
|
522
|
+
for (const cb of mountContext.mountCallbacks) {
|
|
523
|
+
try {
|
|
524
|
+
cb();
|
|
525
|
+
} catch (e) {
|
|
526
|
+
console.error('Mount callback error:', e);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Store unmount callbacks on the element for later cleanup
|
|
533
|
+
if (result instanceof Node && mountContext.unmountCallbacks.length > 0) {
|
|
534
|
+
result._pulseUnmount = mountContext.unmountCallbacks;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return result;
|
|
466
538
|
};
|
|
467
539
|
}
|
|
468
540
|
|
|
541
|
+
/**
|
|
542
|
+
* Toggle element visibility without removing from DOM
|
|
543
|
+
* Unlike when(), this keeps the element in the DOM but hides it
|
|
544
|
+
*/
|
|
545
|
+
export function show(condition, element) {
|
|
546
|
+
effect(() => {
|
|
547
|
+
const shouldShow = typeof condition === 'function' ? condition() : condition.get();
|
|
548
|
+
element.style.display = shouldShow ? '' : 'none';
|
|
549
|
+
});
|
|
550
|
+
return element;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Portal - render children into a different DOM location
|
|
555
|
+
*/
|
|
556
|
+
export function portal(children, target) {
|
|
557
|
+
const resolvedTarget = typeof target === 'string'
|
|
558
|
+
? document.querySelector(target)
|
|
559
|
+
: target;
|
|
560
|
+
|
|
561
|
+
if (!resolvedTarget) {
|
|
562
|
+
console.warn('Portal target not found:', target);
|
|
563
|
+
return document.createComment('portal-target-not-found');
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const marker = document.createComment('portal');
|
|
567
|
+
let mountedNodes = [];
|
|
568
|
+
|
|
569
|
+
// Handle reactive children
|
|
570
|
+
if (typeof children === 'function') {
|
|
571
|
+
effect(() => {
|
|
572
|
+
// Cleanup previous nodes
|
|
573
|
+
for (const node of mountedNodes) {
|
|
574
|
+
node.remove();
|
|
575
|
+
if (node._pulseUnmount) {
|
|
576
|
+
for (const cb of node._pulseUnmount) cb();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
mountedNodes = [];
|
|
580
|
+
|
|
581
|
+
const result = children();
|
|
582
|
+
if (result) {
|
|
583
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
584
|
+
for (const node of nodes) {
|
|
585
|
+
if (node instanceof Node) {
|
|
586
|
+
resolvedTarget.appendChild(node);
|
|
587
|
+
mountedNodes.push(node);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
} else {
|
|
593
|
+
// Static children
|
|
594
|
+
const nodes = Array.isArray(children) ? children : [children];
|
|
595
|
+
for (const node of nodes) {
|
|
596
|
+
if (node instanceof Node) {
|
|
597
|
+
resolvedTarget.appendChild(node);
|
|
598
|
+
mountedNodes.push(node);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Return marker for position tracking, attach cleanup
|
|
604
|
+
marker._pulseUnmount = [() => {
|
|
605
|
+
for (const node of mountedNodes) {
|
|
606
|
+
node.remove();
|
|
607
|
+
if (node._pulseUnmount) {
|
|
608
|
+
for (const cb of node._pulseUnmount) cb();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}];
|
|
612
|
+
|
|
613
|
+
return marker;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Error boundary - catch errors in child components
|
|
618
|
+
*/
|
|
619
|
+
export function errorBoundary(children, fallback) {
|
|
620
|
+
const container = document.createDocumentFragment();
|
|
621
|
+
const marker = document.createComment('error-boundary');
|
|
622
|
+
container.appendChild(marker);
|
|
623
|
+
|
|
624
|
+
const error = pulse(null);
|
|
625
|
+
let currentNodes = [];
|
|
626
|
+
|
|
627
|
+
const renderContent = () => {
|
|
628
|
+
// Cleanup previous
|
|
629
|
+
for (const node of currentNodes) {
|
|
630
|
+
node.remove();
|
|
631
|
+
}
|
|
632
|
+
currentNodes = [];
|
|
633
|
+
|
|
634
|
+
const hasError = error.peek();
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
let result;
|
|
638
|
+
if (hasError && fallback) {
|
|
639
|
+
result = typeof fallback === 'function' ? fallback(hasError) : fallback;
|
|
640
|
+
} else {
|
|
641
|
+
result = typeof children === 'function' ? children() : children;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (result) {
|
|
645
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
646
|
+
const fragment = document.createDocumentFragment();
|
|
647
|
+
for (const node of nodes) {
|
|
648
|
+
if (node instanceof Node) {
|
|
649
|
+
fragment.appendChild(node);
|
|
650
|
+
currentNodes.push(node);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
marker.parentNode?.insertBefore(fragment, marker.nextSibling);
|
|
654
|
+
}
|
|
655
|
+
} catch (e) {
|
|
656
|
+
console.error('Error in component:', e);
|
|
657
|
+
error.set(e);
|
|
658
|
+
// Re-render with error
|
|
659
|
+
if (!hasError) {
|
|
660
|
+
queueMicrotask(renderContent);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
effect(renderContent);
|
|
666
|
+
|
|
667
|
+
// Expose reset method on marker
|
|
668
|
+
marker.resetError = () => error.set(null);
|
|
669
|
+
|
|
670
|
+
return container;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Transition helper - animate element enter/exit
|
|
675
|
+
*/
|
|
676
|
+
export function transition(element, options = {}) {
|
|
677
|
+
const {
|
|
678
|
+
enter = 'fade-in',
|
|
679
|
+
exit = 'fade-out',
|
|
680
|
+
duration = 300,
|
|
681
|
+
onEnter,
|
|
682
|
+
onExit
|
|
683
|
+
} = options;
|
|
684
|
+
|
|
685
|
+
// Apply enter animation
|
|
686
|
+
const applyEnter = () => {
|
|
687
|
+
element.classList.add(enter);
|
|
688
|
+
if (onEnter) onEnter(element);
|
|
689
|
+
setTimeout(() => {
|
|
690
|
+
element.classList.remove(enter);
|
|
691
|
+
}, duration);
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
// Apply exit animation and return promise
|
|
695
|
+
const applyExit = () => {
|
|
696
|
+
return new Promise(resolve => {
|
|
697
|
+
element.classList.add(exit);
|
|
698
|
+
if (onExit) onExit(element);
|
|
699
|
+
setTimeout(() => {
|
|
700
|
+
element.classList.remove(exit);
|
|
701
|
+
resolve();
|
|
702
|
+
}, duration);
|
|
703
|
+
});
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// Apply enter on mount
|
|
707
|
+
queueMicrotask(applyEnter);
|
|
708
|
+
|
|
709
|
+
// Attach exit method
|
|
710
|
+
element._pulseTransitionExit = applyExit;
|
|
711
|
+
|
|
712
|
+
return element;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Conditional rendering with transitions
|
|
717
|
+
*/
|
|
718
|
+
export function whenTransition(condition, thenTemplate, elseTemplate = null, options = {}) {
|
|
719
|
+
const container = document.createDocumentFragment();
|
|
720
|
+
const marker = document.createComment('when-transition');
|
|
721
|
+
container.appendChild(marker);
|
|
722
|
+
|
|
723
|
+
const { duration = 300, enterClass = 'fade-in', exitClass = 'fade-out' } = options;
|
|
724
|
+
|
|
725
|
+
let currentNodes = [];
|
|
726
|
+
let isTransitioning = false;
|
|
727
|
+
|
|
728
|
+
effect(() => {
|
|
729
|
+
const show = typeof condition === 'function' ? condition() : condition.get();
|
|
730
|
+
|
|
731
|
+
if (isTransitioning) return;
|
|
732
|
+
|
|
733
|
+
const template = show ? thenTemplate : elseTemplate;
|
|
734
|
+
|
|
735
|
+
// Exit animation for current nodes
|
|
736
|
+
if (currentNodes.length > 0) {
|
|
737
|
+
isTransitioning = true;
|
|
738
|
+
const nodesToRemove = [...currentNodes];
|
|
739
|
+
currentNodes = [];
|
|
740
|
+
|
|
741
|
+
for (const node of nodesToRemove) {
|
|
742
|
+
node.classList.add(exitClass);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
setTimeout(() => {
|
|
746
|
+
for (const node of nodesToRemove) {
|
|
747
|
+
node.remove();
|
|
748
|
+
}
|
|
749
|
+
isTransitioning = false;
|
|
750
|
+
|
|
751
|
+
// Render new content
|
|
752
|
+
if (template) {
|
|
753
|
+
const result = typeof template === 'function' ? template() : template;
|
|
754
|
+
if (result) {
|
|
755
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
756
|
+
const fragment = document.createDocumentFragment();
|
|
757
|
+
for (const node of nodes) {
|
|
758
|
+
if (node instanceof Node) {
|
|
759
|
+
node.classList.add(enterClass);
|
|
760
|
+
fragment.appendChild(node);
|
|
761
|
+
currentNodes.push(node);
|
|
762
|
+
setTimeout(() => node.classList.remove(enterClass), duration);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
marker.parentNode?.insertBefore(fragment, marker.nextSibling);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}, duration);
|
|
769
|
+
} else if (template) {
|
|
770
|
+
// No previous content, just render with enter animation
|
|
771
|
+
const result = typeof template === 'function' ? template() : template;
|
|
772
|
+
if (result) {
|
|
773
|
+
const nodes = Array.isArray(result) ? result : [result];
|
|
774
|
+
const fragment = document.createDocumentFragment();
|
|
775
|
+
for (const node of nodes) {
|
|
776
|
+
if (node instanceof Node) {
|
|
777
|
+
node.classList.add(enterClass);
|
|
778
|
+
fragment.appendChild(node);
|
|
779
|
+
currentNodes.push(node);
|
|
780
|
+
setTimeout(() => node.classList.remove(enterClass), duration);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
marker.parentNode?.insertBefore(fragment, marker.nextSibling);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
return container;
|
|
789
|
+
}
|
|
790
|
+
|
|
469
791
|
export default {
|
|
470
792
|
el,
|
|
471
793
|
text,
|
|
@@ -480,5 +802,13 @@ export default {
|
|
|
480
802
|
model,
|
|
481
803
|
mount,
|
|
482
804
|
component,
|
|
483
|
-
parseSelector
|
|
805
|
+
parseSelector,
|
|
806
|
+
// New features
|
|
807
|
+
onMount,
|
|
808
|
+
onUnmount,
|
|
809
|
+
show,
|
|
810
|
+
portal,
|
|
811
|
+
errorBoundary,
|
|
812
|
+
transition,
|
|
813
|
+
whenTransition
|
|
484
814
|
};
|
package/runtime/index.js
CHANGED
|
@@ -6,8 +6,10 @@ export * from './pulse.js';
|
|
|
6
6
|
export * from './dom.js';
|
|
7
7
|
export * from './router.js';
|
|
8
8
|
export * from './store.js';
|
|
9
|
+
export * from './native.js';
|
|
9
10
|
|
|
10
11
|
export { default as PulseCore } from './pulse.js';
|
|
11
12
|
export { default as PulseDOM } from './dom.js';
|
|
12
13
|
export { default as PulseRouter } from './router.js';
|
|
13
14
|
export { default as PulseStore } from './store.js';
|
|
15
|
+
export { default as PulseNative } from './native.js';
|