pulse-js-framework 1.5.2 → 1.6.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/cli/release.js +192 -15
- package/package.json +6 -1
- package/runtime/dom.js +76 -26
- package/runtime/lite.js +42 -0
- package/runtime/logger.js +12 -4
- package/runtime/logger.prod.js +25 -0
- package/runtime/lru-cache.js +53 -1
- package/runtime/pulse.js +27 -8
- package/runtime/router.js +183 -28
- package/runtime/store.js +53 -0
- package/runtime/utils.js +2 -2
- package/types/lite.d.ts +31 -0
package/cli/release.js
CHANGED
|
@@ -15,6 +15,7 @@ import { join, dirname } from 'path';
|
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import { execSync } from 'child_process';
|
|
17
17
|
import { createInterface } from 'readline';
|
|
18
|
+
import https from 'https';
|
|
18
19
|
import { log } from './logger.js';
|
|
19
20
|
|
|
20
21
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -357,6 +358,104 @@ function escapeHtml(str) {
|
|
|
357
358
|
.replace(/"/g, '"');
|
|
358
359
|
}
|
|
359
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Send release notification to Discord webhook
|
|
363
|
+
* @param {string} webhookUrl - Discord webhook URL
|
|
364
|
+
* @param {string} version - Release version
|
|
365
|
+
* @param {string} title - Release title
|
|
366
|
+
* @param {Object} changes - Changelog entries
|
|
367
|
+
* @returns {Promise<void>}
|
|
368
|
+
*/
|
|
369
|
+
function sendDiscordNotification(webhookUrl, version, title, changes) {
|
|
370
|
+
return new Promise((resolve, reject) => {
|
|
371
|
+
// Build embed fields from changes
|
|
372
|
+
const fields = [];
|
|
373
|
+
|
|
374
|
+
if (changes.added?.length > 0) {
|
|
375
|
+
fields.push({
|
|
376
|
+
name: '✨ Added',
|
|
377
|
+
value: changes.added.map(c => `• ${c}`).join('\n').slice(0, 1024),
|
|
378
|
+
inline: false
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (changes.changed?.length > 0) {
|
|
383
|
+
fields.push({
|
|
384
|
+
name: '🔄 Changed',
|
|
385
|
+
value: changes.changed.map(c => `• ${c}`).join('\n').slice(0, 1024),
|
|
386
|
+
inline: false
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (changes.fixed?.length > 0) {
|
|
391
|
+
fields.push({
|
|
392
|
+
name: '🐛 Fixed',
|
|
393
|
+
value: changes.fixed.map(c => `• ${c}`).join('\n').slice(0, 1024),
|
|
394
|
+
inline: false
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (changes.removed?.length > 0) {
|
|
399
|
+
fields.push({
|
|
400
|
+
name: '🗑️ Removed',
|
|
401
|
+
value: changes.removed.map(c => `• ${c}`).join('\n').slice(0, 1024),
|
|
402
|
+
inline: false
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Discord webhook payload with rich embed
|
|
407
|
+
const payload = {
|
|
408
|
+
embeds: [{
|
|
409
|
+
title: `🚀 Pulse Framework v${version}`,
|
|
410
|
+
description: title || 'New release available!',
|
|
411
|
+
color: 0x5865F2, // Discord blurple
|
|
412
|
+
fields,
|
|
413
|
+
footer: {
|
|
414
|
+
text: 'Pulse JS Framework'
|
|
415
|
+
},
|
|
416
|
+
timestamp: new Date().toISOString(),
|
|
417
|
+
url: `https://github.com/vincenthirtz/pulse-js-framework/releases/tag/v${version}`
|
|
418
|
+
}]
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const data = JSON.stringify(payload);
|
|
422
|
+
const url = new URL(webhookUrl);
|
|
423
|
+
|
|
424
|
+
const options = {
|
|
425
|
+
hostname: url.hostname,
|
|
426
|
+
port: 443,
|
|
427
|
+
path: url.pathname + url.search,
|
|
428
|
+
method: 'POST',
|
|
429
|
+
headers: {
|
|
430
|
+
'Content-Type': 'application/json',
|
|
431
|
+
'Content-Length': Buffer.byteLength(data)
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const req = https.request(options, (res) => {
|
|
436
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
437
|
+
log.info(' Discord notification sent successfully');
|
|
438
|
+
resolve();
|
|
439
|
+
} else {
|
|
440
|
+
let body = '';
|
|
441
|
+
res.on('data', chunk => body += chunk);
|
|
442
|
+
res.on('end', () => {
|
|
443
|
+
log.error(` Discord notification failed: ${res.statusCode} - ${body}`);
|
|
444
|
+
reject(new Error(`Discord webhook failed: ${res.statusCode}`));
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
req.on('error', (error) => {
|
|
450
|
+
log.error(` Discord notification error: ${error.message}`);
|
|
451
|
+
reject(error);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
req.write(data);
|
|
455
|
+
req.end();
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
360
459
|
/**
|
|
361
460
|
* Update CLAUDE.md if needed
|
|
362
461
|
*/
|
|
@@ -460,17 +559,26 @@ Types:
|
|
|
460
559
|
major Bump major version (1.0.0 -> 2.0.0)
|
|
461
560
|
|
|
462
561
|
Options:
|
|
463
|
-
--dry-run
|
|
464
|
-
--no-push
|
|
465
|
-
--title <text>
|
|
466
|
-
--skip-prompt
|
|
467
|
-
--from-commits
|
|
562
|
+
--dry-run Show what would be done without making changes
|
|
563
|
+
--no-push Create commit and tag but don't push
|
|
564
|
+
--title <text> Release title (e.g., "Performance Improvements")
|
|
565
|
+
--skip-prompt Use empty changelog (for automated releases)
|
|
566
|
+
--from-commits Auto-extract changelog from git commits since last tag
|
|
567
|
+
--yes, -y Auto-confirm all prompts
|
|
568
|
+
--changes <json> Pass changelog as JSON (e.g., '{"added":["Feature 1"],"fixed":["Bug 1"]}')
|
|
569
|
+
--added <items> Comma-separated list of added features
|
|
570
|
+
--changed <items> Comma-separated list of changes
|
|
571
|
+
--fixed <items> Comma-separated list of fixes
|
|
572
|
+
--discord-webhook <url> Send release notification to Discord channel
|
|
468
573
|
|
|
469
574
|
Examples:
|
|
470
575
|
pulse release patch
|
|
471
|
-
pulse release minor --title "New Features"
|
|
576
|
+
pulse release minor --title "New Features" -y
|
|
472
577
|
pulse release major --dry-run
|
|
473
|
-
pulse release patch --from-commits --title "Bug Fixes"
|
|
578
|
+
pulse release patch --from-commits --title "Bug Fixes" -y
|
|
579
|
+
pulse release patch --title "Security" --fixed "XSS vulnerability,SQL injection" -y
|
|
580
|
+
pulse release patch --title "New API" --added "Feature A,Feature B" --fixed "Bug X" -y
|
|
581
|
+
pulse release patch --discord-webhook "https://discord.com/api/webhooks/..."
|
|
474
582
|
`);
|
|
475
583
|
}
|
|
476
584
|
|
|
@@ -490,6 +598,7 @@ export async function runRelease(args) {
|
|
|
490
598
|
const noPush = args.includes('--no-push');
|
|
491
599
|
const skipPrompt = args.includes('--skip-prompt');
|
|
492
600
|
const fromCommits = args.includes('--from-commits');
|
|
601
|
+
const autoConfirm = args.includes('--yes') || args.includes('-y');
|
|
493
602
|
|
|
494
603
|
let title = '';
|
|
495
604
|
const titleIndex = args.indexOf('--title');
|
|
@@ -497,6 +606,40 @@ export async function runRelease(args) {
|
|
|
497
606
|
title = args[titleIndex + 1];
|
|
498
607
|
}
|
|
499
608
|
|
|
609
|
+
// Parse --discord-webhook option
|
|
610
|
+
let discordWebhook = null;
|
|
611
|
+
const discordIndex = args.indexOf('--discord-webhook');
|
|
612
|
+
if (discordIndex !== -1 && args[discordIndex + 1]) {
|
|
613
|
+
discordWebhook = args[discordIndex + 1];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Parse --changes JSON option
|
|
617
|
+
let changesFromArgs = null;
|
|
618
|
+
const changesIndex = args.indexOf('--changes');
|
|
619
|
+
if (changesIndex !== -1 && args[changesIndex + 1]) {
|
|
620
|
+
try {
|
|
621
|
+
changesFromArgs = JSON.parse(args[changesIndex + 1]);
|
|
622
|
+
} catch (e) {
|
|
623
|
+
log.error('Invalid JSON for --changes option');
|
|
624
|
+
process.exit(1);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Parse individual change type options
|
|
629
|
+
const addedIndex = args.indexOf('--added');
|
|
630
|
+
const changedIndex = args.indexOf('--changed');
|
|
631
|
+
const fixedIndex = args.indexOf('--fixed');
|
|
632
|
+
const removedIndex = args.indexOf('--removed');
|
|
633
|
+
|
|
634
|
+
if (!changesFromArgs && (addedIndex !== -1 || changedIndex !== -1 || fixedIndex !== -1 || removedIndex !== -1)) {
|
|
635
|
+
changesFromArgs = {
|
|
636
|
+
added: addedIndex !== -1 && args[addedIndex + 1] ? args[addedIndex + 1].split(',').map(s => s.trim()) : [],
|
|
637
|
+
changed: changedIndex !== -1 && args[changedIndex + 1] ? args[changedIndex + 1].split(',').map(s => s.trim()) : [],
|
|
638
|
+
fixed: fixedIndex !== -1 && args[fixedIndex + 1] ? args[fixedIndex + 1].split(',').map(s => s.trim()) : [],
|
|
639
|
+
removed: removedIndex !== -1 && args[removedIndex + 1] ? args[removedIndex + 1].split(',').map(s => s.trim()) : []
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
500
643
|
// Read current version
|
|
501
644
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
|
502
645
|
const currentVersion = pkg.version;
|
|
@@ -517,10 +660,14 @@ export async function runRelease(args) {
|
|
|
517
660
|
if (status.trim()) {
|
|
518
661
|
log.warn('You have uncommitted changes:');
|
|
519
662
|
log.info(status);
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
663
|
+
if (!autoConfirm) {
|
|
664
|
+
const proceed = await prompt('Continue anyway? (y/N) ');
|
|
665
|
+
if (proceed.toLowerCase() !== 'y') {
|
|
666
|
+
log.info('Aborted.');
|
|
667
|
+
process.exit(0);
|
|
668
|
+
}
|
|
669
|
+
} else {
|
|
670
|
+
log.info('Auto-confirming with --yes flag');
|
|
524
671
|
}
|
|
525
672
|
}
|
|
526
673
|
} catch (error) {
|
|
@@ -531,7 +678,24 @@ export async function runRelease(args) {
|
|
|
531
678
|
// Collect changelog entries
|
|
532
679
|
let changes = { added: [], changed: [], fixed: [], removed: [] };
|
|
533
680
|
|
|
534
|
-
if
|
|
681
|
+
// Use changes from command-line arguments if provided
|
|
682
|
+
if (changesFromArgs) {
|
|
683
|
+
changes = {
|
|
684
|
+
added: changesFromArgs.added || [],
|
|
685
|
+
changed: changesFromArgs.changed || [],
|
|
686
|
+
fixed: changesFromArgs.fixed || [],
|
|
687
|
+
removed: changesFromArgs.removed || []
|
|
688
|
+
};
|
|
689
|
+
const totalChanges = Object.values(changes).flat().length;
|
|
690
|
+
if (totalChanges > 0) {
|
|
691
|
+
log.info('');
|
|
692
|
+
log.info('Changelog entries from arguments:');
|
|
693
|
+
if (changes.added.length) log.info(` Added: ${changes.added.length} items`);
|
|
694
|
+
if (changes.changed.length) log.info(` Changed: ${changes.changed.length} items`);
|
|
695
|
+
if (changes.fixed.length) log.info(` Fixed: ${changes.fixed.length} items`);
|
|
696
|
+
if (changes.removed.length) log.info(` Removed: ${changes.removed.length} items`);
|
|
697
|
+
}
|
|
698
|
+
} else if (fromCommits) {
|
|
535
699
|
// Auto-extract from git commits since last tag
|
|
536
700
|
const lastTag = getLastTag();
|
|
537
701
|
const commits = getCommitsSinceLastTag();
|
|
@@ -554,10 +718,10 @@ export async function runRelease(args) {
|
|
|
554
718
|
}
|
|
555
719
|
}
|
|
556
720
|
|
|
557
|
-
if (!title) {
|
|
721
|
+
if (!title && !autoConfirm) {
|
|
558
722
|
title = await prompt('Release title (e.g., "Performance Improvements"): ');
|
|
559
723
|
}
|
|
560
|
-
} else if (!skipPrompt) {
|
|
724
|
+
} else if (!skipPrompt && !autoConfirm) {
|
|
561
725
|
if (!title) {
|
|
562
726
|
title = await prompt('Release title (e.g., "Performance Improvements"): ');
|
|
563
727
|
}
|
|
@@ -574,7 +738,7 @@ export async function runRelease(args) {
|
|
|
574
738
|
|
|
575
739
|
const hasChanges = Object.values(changes).some(arr => arr.length > 0);
|
|
576
740
|
|
|
577
|
-
if (!hasChanges && !skipPrompt) {
|
|
741
|
+
if (!hasChanges && !skipPrompt && !autoConfirm) {
|
|
578
742
|
const proceed = await prompt('No changelog entries. Continue? (y/N) ');
|
|
579
743
|
if (proceed.toLowerCase() !== 'y') {
|
|
580
744
|
log.info('Aborted.');
|
|
@@ -641,6 +805,19 @@ export async function runRelease(args) {
|
|
|
641
805
|
log.info(`Release v${newVersion} complete!`);
|
|
642
806
|
log.info('');
|
|
643
807
|
|
|
808
|
+
// Send Discord notification if webhook URL provided
|
|
809
|
+
if (discordWebhook && !dryRun) {
|
|
810
|
+
log.info('Sending Discord notification...');
|
|
811
|
+
try {
|
|
812
|
+
await sendDiscordNotification(discordWebhook, newVersion, title, changes);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
log.warn(`Discord notification failed: ${error.message}`);
|
|
815
|
+
log.warn('Release was successful, but notification could not be sent.');
|
|
816
|
+
}
|
|
817
|
+
} else if (discordWebhook && dryRun) {
|
|
818
|
+
log.info(' [dry-run] Would send Discord notification to webhook');
|
|
819
|
+
}
|
|
820
|
+
|
|
644
821
|
if (!dryRun && !noPush) {
|
|
645
822
|
log.info('Next steps:');
|
|
646
823
|
log.info(` 1. Create GitHub release: https://github.com/vincenthirtz/pulse-js-framework/releases/new?tag=v${newVersion}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"types": "./types/index.d.ts",
|
|
18
18
|
"default": "./runtime/index.js"
|
|
19
19
|
},
|
|
20
|
+
"./runtime/lite": {
|
|
21
|
+
"types": "./types/lite.d.ts",
|
|
22
|
+
"default": "./runtime/lite.js"
|
|
23
|
+
},
|
|
20
24
|
"./runtime/pulse": {
|
|
21
25
|
"types": "./types/pulse.d.ts",
|
|
22
26
|
"default": "./runtime/pulse.js"
|
|
@@ -41,6 +45,7 @@
|
|
|
41
45
|
"types": "./types/logger.d.ts",
|
|
42
46
|
"default": "./runtime/logger.js"
|
|
43
47
|
},
|
|
48
|
+
"./runtime/logger/prod": "./runtime/logger.prod.js",
|
|
44
49
|
"./runtime/hmr": {
|
|
45
50
|
"types": "./types/hmr.d.ts",
|
|
46
51
|
"default": "./runtime/hmr.js"
|
package/runtime/dom.js
CHANGED
|
@@ -28,6 +28,38 @@ const log = loggers.dom;
|
|
|
28
28
|
// Cache hit returns a shallow copy to prevent mutation of cached config
|
|
29
29
|
const selectorCache = new LRUCache(500);
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Safely insert a node before a reference node
|
|
33
|
+
* Returns false if the parent is detached (no parentNode)
|
|
34
|
+
* @private
|
|
35
|
+
* @param {Node} newNode - Node to insert
|
|
36
|
+
* @param {Node} refNode - Reference node (insert before this)
|
|
37
|
+
* @returns {boolean} True if insertion succeeded
|
|
38
|
+
*/
|
|
39
|
+
function safeInsertBefore(newNode, refNode) {
|
|
40
|
+
if (!refNode.parentNode) {
|
|
41
|
+
log.warn('Cannot insert node: reference node has no parent (may be detached)');
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
refNode.parentNode.insertBefore(newNode, refNode);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a selector string or element to a DOM element
|
|
50
|
+
* @private
|
|
51
|
+
* @param {string|HTMLElement} target - CSS selector or DOM element
|
|
52
|
+
* @param {string} context - Context name for error messages
|
|
53
|
+
* @returns {{element: HTMLElement|null, selector: string}} Resolved element and original selector
|
|
54
|
+
*/
|
|
55
|
+
function resolveSelector(target, context = 'target') {
|
|
56
|
+
if (typeof target === 'string') {
|
|
57
|
+
const element = document.querySelector(target);
|
|
58
|
+
return { element, selector: target };
|
|
59
|
+
}
|
|
60
|
+
return { element: target, selector: '(element)' };
|
|
61
|
+
}
|
|
62
|
+
|
|
31
63
|
// Lifecycle tracking
|
|
32
64
|
let mountCallbacks = [];
|
|
33
65
|
let unmountCallbacks = [];
|
|
@@ -58,8 +90,13 @@ export function onUnmount(fn) {
|
|
|
58
90
|
|
|
59
91
|
/**
|
|
60
92
|
* Parse a CSS selector-like string into element configuration
|
|
61
|
-
*
|
|
62
|
-
*
|
|
93
|
+
* Results are cached for performance using LRU cache.
|
|
94
|
+
*
|
|
95
|
+
* Supported syntax:
|
|
96
|
+
* - Tag: `div`, `span`, `custom-element`
|
|
97
|
+
* - ID: `#app`, `#my-id`, `#_private`
|
|
98
|
+
* - Classes: `.class`, `.my-class`, `.-modifier`
|
|
99
|
+
* - Attributes: `[attr]`, `[attr=value]`, `[attr="quoted value"]`, `[attr='single quoted']`
|
|
63
100
|
*
|
|
64
101
|
* Examples:
|
|
65
102
|
* "div" -> { tag: "div" }
|
|
@@ -67,6 +104,10 @@ export function onUnmount(fn) {
|
|
|
67
104
|
* ".container" -> { tag: "div", classes: ["container"] }
|
|
68
105
|
* "button.primary.large" -> { tag: "button", classes: ["primary", "large"] }
|
|
69
106
|
* "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
|
|
107
|
+
* "div[data-id=\"complex-123\"]" -> { tag: "div", attrs: { "data-id": "complex-123" } }
|
|
108
|
+
*
|
|
109
|
+
* @param {string} selector - CSS selector-like string
|
|
110
|
+
* @returns {{tag: string, id: string|null, classes: string[], attrs: Object}} Parsed configuration
|
|
70
111
|
*/
|
|
71
112
|
export function parseSelector(selector) {
|
|
72
113
|
if (!selector || selector === '') {
|
|
@@ -101,32 +142,43 @@ export function parseSelector(selector) {
|
|
|
101
142
|
remaining = remaining.slice(tagMatch[0].length);
|
|
102
143
|
}
|
|
103
144
|
|
|
104
|
-
// Match ID
|
|
105
|
-
const idMatch = remaining.match(/#([a-zA-
|
|
145
|
+
// Match ID (supports starting with letter, underscore, or hyphen followed by valid chars)
|
|
146
|
+
const idMatch = remaining.match(/#([a-zA-Z_-][a-zA-Z0-9-_]*)/);
|
|
106
147
|
if (idMatch) {
|
|
107
148
|
config.id = idMatch[1];
|
|
108
149
|
remaining = remaining.replace(idMatch[0], '');
|
|
109
150
|
}
|
|
110
151
|
|
|
111
|
-
// Match classes
|
|
112
|
-
const classMatches = remaining.matchAll(/\.([a-zA-
|
|
152
|
+
// Match classes (supports starting with letter, underscore, or hyphen)
|
|
153
|
+
const classMatches = remaining.matchAll(/\.([a-zA-Z_-][a-zA-Z0-9-_]*)/g);
|
|
113
154
|
for (const match of classMatches) {
|
|
114
155
|
config.classes.push(match[1]);
|
|
115
156
|
}
|
|
116
157
|
|
|
117
|
-
// Match attributes
|
|
118
|
-
|
|
158
|
+
// Match attributes - improved regex handles quoted values with special characters
|
|
159
|
+
// Matches: [attr], [attr=value], [attr="quoted value"], [attr='quoted value']
|
|
160
|
+
const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/g;
|
|
161
|
+
const attrMatches = remaining.matchAll(attrRegex);
|
|
119
162
|
for (const match of attrMatches) {
|
|
120
163
|
const key = match[1];
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
124
|
-
(value.startsWith("'") && value.endsWith("'"))) {
|
|
125
|
-
value = value.slice(1, -1);
|
|
126
|
-
}
|
|
164
|
+
// Value can be in match[2] (double-quoted), match[3] (single-quoted), or match[4] (unquoted)
|
|
165
|
+
const value = match[2] ?? match[3] ?? match[4] ?? '';
|
|
127
166
|
config.attrs[key] = value;
|
|
128
167
|
}
|
|
129
168
|
|
|
169
|
+
// Validate: check for unparsed content (malformed selector parts)
|
|
170
|
+
// Remove all parsed parts to see if anything remains
|
|
171
|
+
let unparsed = remaining
|
|
172
|
+
.replace(/#[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove IDs
|
|
173
|
+
.replace(/\.[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove classes
|
|
174
|
+
.replace(/\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"[^"]*"|'[^']*'|[^\]]*))?\]/g, '') // Remove attrs
|
|
175
|
+
.trim();
|
|
176
|
+
|
|
177
|
+
if (unparsed) {
|
|
178
|
+
log.warn(`Selector "${selector}" contains unrecognized parts: "${unparsed}". ` +
|
|
179
|
+
'Supported syntax: tag#id.class[attr=value]');
|
|
180
|
+
}
|
|
181
|
+
|
|
130
182
|
// Cache the result (LRU cache handles eviction automatically)
|
|
131
183
|
selectorCache.set(selector, config);
|
|
132
184
|
|
|
@@ -217,7 +269,11 @@ function appendChild(parent, child) {
|
|
|
217
269
|
}
|
|
218
270
|
}
|
|
219
271
|
}
|
|
220
|
-
placeholder.parentNode
|
|
272
|
+
if (placeholder.parentNode) {
|
|
273
|
+
placeholder.parentNode.insertBefore(fragment, placeholder.nextSibling);
|
|
274
|
+
} else {
|
|
275
|
+
log.warn('Cannot insert reactive children: placeholder has no parent node');
|
|
276
|
+
}
|
|
221
277
|
}
|
|
222
278
|
});
|
|
223
279
|
}
|
|
@@ -552,12 +608,8 @@ export function model(element, pulseValue) {
|
|
|
552
608
|
* @throws {Error} If target element is not found
|
|
553
609
|
*/
|
|
554
610
|
export function mount(target, element) {
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
557
|
-
target = document.querySelector(target);
|
|
558
|
-
}
|
|
559
|
-
if (!target) {
|
|
560
|
-
const selector = typeof originalTarget === 'string' ? originalTarget : '(element)';
|
|
611
|
+
const { element: resolved, selector } = resolveSelector(target, 'mount');
|
|
612
|
+
if (!resolved) {
|
|
561
613
|
throw new Error(
|
|
562
614
|
`[Pulse] Mount target not found: "${selector}". ` +
|
|
563
615
|
`Ensure the element exists in the DOM before mounting. ` +
|
|
@@ -565,7 +617,7 @@ export function mount(target, element) {
|
|
|
565
617
|
`or place your script at the end of <body>.`
|
|
566
618
|
);
|
|
567
619
|
}
|
|
568
|
-
|
|
620
|
+
resolved.appendChild(element);
|
|
569
621
|
return () => {
|
|
570
622
|
element.remove();
|
|
571
623
|
};
|
|
@@ -649,12 +701,10 @@ export function show(condition, element) {
|
|
|
649
701
|
* Portal - render children into a different DOM location
|
|
650
702
|
*/
|
|
651
703
|
export function portal(children, target) {
|
|
652
|
-
const resolvedTarget =
|
|
653
|
-
? document.querySelector(target)
|
|
654
|
-
: target;
|
|
704
|
+
const { element: resolvedTarget, selector } = resolveSelector(target, 'portal');
|
|
655
705
|
|
|
656
706
|
if (!resolvedTarget) {
|
|
657
|
-
log.warn(
|
|
707
|
+
log.warn(`Portal target not found: "${selector}"`);
|
|
658
708
|
return document.createComment('portal-target-not-found');
|
|
659
709
|
}
|
|
660
710
|
|
package/runtime/lite.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Framework - Lite Build
|
|
3
|
+
* @module pulse-js-framework/runtime/lite
|
|
4
|
+
*
|
|
5
|
+
* Minimal bundle (~5KB gzipped) with core reactivity and DOM helpers.
|
|
6
|
+
* Use this for simple apps that don't need router or store.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* import { pulse, effect, el, mount } from 'pulse-js-framework/runtime/lite';
|
|
10
|
+
*
|
|
11
|
+
* const count = pulse(0);
|
|
12
|
+
* const app = el('div', [
|
|
13
|
+
* el('h1', () => `Count: ${count.get()}`),
|
|
14
|
+
* el('button', { onclick: () => count.update(n => n + 1) }, 'Increment')
|
|
15
|
+
* ]);
|
|
16
|
+
* mount('#app', app);
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Core reactivity - essential
|
|
20
|
+
export {
|
|
21
|
+
pulse,
|
|
22
|
+
effect,
|
|
23
|
+
computed,
|
|
24
|
+
batch,
|
|
25
|
+
onCleanup,
|
|
26
|
+
untrack
|
|
27
|
+
} from './pulse.js';
|
|
28
|
+
|
|
29
|
+
// DOM helpers - essential
|
|
30
|
+
export {
|
|
31
|
+
el,
|
|
32
|
+
text,
|
|
33
|
+
mount,
|
|
34
|
+
on,
|
|
35
|
+
bind,
|
|
36
|
+
list,
|
|
37
|
+
when,
|
|
38
|
+
model
|
|
39
|
+
} from './dom.js';
|
|
40
|
+
|
|
41
|
+
// Minimal re-exports for common patterns
|
|
42
|
+
export { show, cls, style, prop } from './dom.js';
|
package/runtime/logger.js
CHANGED
|
@@ -39,6 +39,13 @@ let globalLevel = LogLevel.INFO;
|
|
|
39
39
|
/** @type {LogFormatter|null} */
|
|
40
40
|
let globalFormatter = null;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Namespace formatting helpers
|
|
44
|
+
* @private
|
|
45
|
+
*/
|
|
46
|
+
const NAMESPACE_SEPARATOR = ':';
|
|
47
|
+
const formatNamespace = (ns) => `[${ns}]`;
|
|
48
|
+
|
|
42
49
|
/**
|
|
43
50
|
* @callback LogFormatter
|
|
44
51
|
* @param {'error'|'warn'|'info'|'debug'} level - The log level
|
|
@@ -97,13 +104,14 @@ export function setFormatter(formatter) {
|
|
|
97
104
|
function formatArgs(namespace, args) {
|
|
98
105
|
if (!namespace) return args;
|
|
99
106
|
|
|
107
|
+
const prefix = formatNamespace(namespace);
|
|
100
108
|
// If first arg is a string, prepend namespace
|
|
101
109
|
if (typeof args[0] === 'string') {
|
|
102
|
-
return [
|
|
110
|
+
return [`${prefix} ${args[0]}`, ...args.slice(1)];
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
// Otherwise, add namespace as first arg
|
|
106
|
-
return [
|
|
114
|
+
return [prefix, ...args];
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
/**
|
|
@@ -217,7 +225,7 @@ export function createLogger(namespace = null, options = {}) {
|
|
|
217
225
|
*/
|
|
218
226
|
group(label) {
|
|
219
227
|
if (shouldLog(LogLevel.DEBUG)) {
|
|
220
|
-
console.group(namespace ?
|
|
228
|
+
console.group(namespace ? `${formatNamespace(namespace)} ${label}` : label);
|
|
221
229
|
}
|
|
222
230
|
},
|
|
223
231
|
|
|
@@ -264,7 +272,7 @@ export function createLogger(namespace = null, options = {}) {
|
|
|
264
272
|
*/
|
|
265
273
|
child(childNamespace) {
|
|
266
274
|
const combined = namespace
|
|
267
|
-
? `${namespace}
|
|
275
|
+
? `${namespace}${NAMESPACE_SEPARATOR}${childNamespace}`
|
|
268
276
|
: childNamespace;
|
|
269
277
|
return createLogger(combined, options);
|
|
270
278
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Logger - Production Build (minimal/noop)
|
|
3
|
+
* Replace logger.js with this in production builds for smaller bundle.
|
|
4
|
+
* @module pulse-js-framework/runtime/logger.prod
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const LogLevel = { SILENT: 0, ERROR: 1, WARN: 2, INFO: 3, DEBUG: 4 };
|
|
8
|
+
|
|
9
|
+
const noop = () => {};
|
|
10
|
+
const noopLogger = {
|
|
11
|
+
error: noop, warn: noop, info: noop, debug: noop,
|
|
12
|
+
group: noop, groupEnd: noop, log: noop,
|
|
13
|
+
child: () => noopLogger
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const setLogLevel = noop;
|
|
17
|
+
export const getLogLevel = () => LogLevel.SILENT;
|
|
18
|
+
export const setFormatter = noop;
|
|
19
|
+
export const createLogger = () => noopLogger;
|
|
20
|
+
export const logger = noopLogger;
|
|
21
|
+
export const loggers = {
|
|
22
|
+
pulse: noopLogger, dom: noopLogger, router: noopLogger,
|
|
23
|
+
store: noopLogger, native: noopLogger, hmr: noopLogger, cli: noopLogger
|
|
24
|
+
};
|
|
25
|
+
export default logger;
|
package/runtime/lru-cache.js
CHANGED
|
@@ -15,13 +15,21 @@ export class LRUCache {
|
|
|
15
15
|
/**
|
|
16
16
|
* Create an LRU cache
|
|
17
17
|
* @param {number} capacity - Maximum number of items to store
|
|
18
|
+
* @param {Object} [options] - Configuration options
|
|
19
|
+
* @param {boolean} [options.trackMetrics=false] - Enable hit/miss/eviction tracking
|
|
18
20
|
*/
|
|
19
|
-
constructor(capacity) {
|
|
21
|
+
constructor(capacity, options = {}) {
|
|
20
22
|
if (capacity <= 0) {
|
|
21
23
|
throw new Error('LRU cache capacity must be greater than 0');
|
|
22
24
|
}
|
|
23
25
|
this._capacity = capacity;
|
|
24
26
|
this._cache = new Map();
|
|
27
|
+
|
|
28
|
+
// Metrics tracking
|
|
29
|
+
this._trackMetrics = options.trackMetrics || false;
|
|
30
|
+
this._hits = 0;
|
|
31
|
+
this._misses = 0;
|
|
32
|
+
this._evictions = 0;
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
/**
|
|
@@ -32,9 +40,12 @@ export class LRUCache {
|
|
|
32
40
|
*/
|
|
33
41
|
get(key) {
|
|
34
42
|
if (!this._cache.has(key)) {
|
|
43
|
+
if (this._trackMetrics) this._misses++;
|
|
35
44
|
return undefined;
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
if (this._trackMetrics) this._hits++;
|
|
48
|
+
|
|
38
49
|
// Move to end (most recently used) by re-inserting
|
|
39
50
|
const value = this._cache.get(key);
|
|
40
51
|
this._cache.delete(key);
|
|
@@ -57,6 +68,7 @@ export class LRUCache {
|
|
|
57
68
|
// Remove oldest (first item in Map)
|
|
58
69
|
const oldest = this._cache.keys().next().value;
|
|
59
70
|
this._cache.delete(oldest);
|
|
71
|
+
if (this._trackMetrics) this._evictions++;
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
this._cache.set(key, value);
|
|
@@ -136,6 +148,46 @@ export class LRUCache {
|
|
|
136
148
|
forEach(callback) {
|
|
137
149
|
this._cache.forEach((value, key) => callback(value, key, this));
|
|
138
150
|
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get cache performance metrics
|
|
154
|
+
* Only available if trackMetrics option was enabled
|
|
155
|
+
* @returns {{hits: number, misses: number, evictions: number, hitRate: number, size: number, capacity: number}}
|
|
156
|
+
* @example
|
|
157
|
+
* const cache = new LRUCache(100, { trackMetrics: true });
|
|
158
|
+
* // ... use cache ...
|
|
159
|
+
* const stats = cache.getMetrics();
|
|
160
|
+
* console.log(`Hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
|
|
161
|
+
*/
|
|
162
|
+
getMetrics() {
|
|
163
|
+
const total = this._hits + this._misses;
|
|
164
|
+
return {
|
|
165
|
+
hits: this._hits,
|
|
166
|
+
misses: this._misses,
|
|
167
|
+
evictions: this._evictions,
|
|
168
|
+
hitRate: total > 0 ? this._hits / total : 0,
|
|
169
|
+
size: this._cache.size,
|
|
170
|
+
capacity: this._capacity
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reset all metrics counters to zero
|
|
176
|
+
* Useful for measuring metrics over specific time periods
|
|
177
|
+
*/
|
|
178
|
+
resetMetrics() {
|
|
179
|
+
this._hits = 0;
|
|
180
|
+
this._misses = 0;
|
|
181
|
+
this._evictions = 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Enable or disable metrics tracking
|
|
186
|
+
* @param {boolean} enabled - Whether to track metrics
|
|
187
|
+
*/
|
|
188
|
+
setMetricsTracking(enabled) {
|
|
189
|
+
this._trackMetrics = enabled;
|
|
190
|
+
}
|
|
139
191
|
}
|
|
140
192
|
|
|
141
193
|
export default LRUCache;
|
package/runtime/pulse.js
CHANGED
|
@@ -69,6 +69,14 @@ const log = loggers.pulse;
|
|
|
69
69
|
* @property {Map<string, Set<EffectFn>>} effectRegistry - Module ID to effects mapping for HMR
|
|
70
70
|
*/
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Maximum number of effect re-run iterations before aborting.
|
|
74
|
+
* Prevents infinite loops when effects trigger each other cyclically.
|
|
75
|
+
* Set to 100 to allow deep chain reactions while catching most real loops.
|
|
76
|
+
* @type {number}
|
|
77
|
+
*/
|
|
78
|
+
const MAX_EFFECT_ITERATIONS = 100;
|
|
79
|
+
|
|
72
80
|
/**
|
|
73
81
|
* Global reactive context - holds all tracking state.
|
|
74
82
|
* Exported for testing purposes (use resetContext() to reset).
|
|
@@ -515,10 +523,9 @@ function flushEffects() {
|
|
|
515
523
|
|
|
516
524
|
context.isRunningEffects = true;
|
|
517
525
|
let iterations = 0;
|
|
518
|
-
const maxIterations = 100; // Prevent infinite loops
|
|
519
526
|
|
|
520
527
|
try {
|
|
521
|
-
while (context.pendingEffects.size > 0 && iterations <
|
|
528
|
+
while (context.pendingEffects.size > 0 && iterations < MAX_EFFECT_ITERATIONS) {
|
|
522
529
|
iterations++;
|
|
523
530
|
const effects = [...context.pendingEffects];
|
|
524
531
|
context.pendingEffects.clear();
|
|
@@ -528,8 +535,8 @@ function flushEffects() {
|
|
|
528
535
|
}
|
|
529
536
|
}
|
|
530
537
|
|
|
531
|
-
if (iterations >=
|
|
532
|
-
log.warn(
|
|
538
|
+
if (iterations >= MAX_EFFECT_ITERATIONS) {
|
|
539
|
+
log.warn(`Maximum effect iterations (${MAX_EFFECT_ITERATIONS}) reached. Possible infinite loop.`);
|
|
533
540
|
context.pendingEffects.clear();
|
|
534
541
|
}
|
|
535
542
|
} finally {
|
|
@@ -586,6 +593,8 @@ export function computed(fn, options = {}) {
|
|
|
586
593
|
|
|
587
594
|
// Track which pulses this depends on
|
|
588
595
|
let trackedDeps = new Set();
|
|
596
|
+
// Track subscription cleanup functions to prevent memory leaks
|
|
597
|
+
let subscriptionCleanups = [];
|
|
589
598
|
|
|
590
599
|
p.get = function() {
|
|
591
600
|
if (dirty) {
|
|
@@ -603,18 +612,21 @@ export function computed(fn, options = {}) {
|
|
|
603
612
|
dirty = false;
|
|
604
613
|
|
|
605
614
|
// Cleanup old subscriptions
|
|
606
|
-
for (const
|
|
607
|
-
|
|
615
|
+
for (const unsubscribe of subscriptionCleanups) {
|
|
616
|
+
unsubscribe();
|
|
608
617
|
}
|
|
618
|
+
subscriptionCleanups = [];
|
|
619
|
+
trackedDeps.clear();
|
|
609
620
|
|
|
610
621
|
// Set up new subscriptions
|
|
611
622
|
trackedDeps = tempEffect.dependencies;
|
|
612
623
|
for (const dep of trackedDeps) {
|
|
613
|
-
dep.subscribe(() => {
|
|
624
|
+
const unsubscribe = dep.subscribe(() => {
|
|
614
625
|
dirty = true;
|
|
615
626
|
// Notify our own subscribers
|
|
616
627
|
p._triggerNotify();
|
|
617
628
|
});
|
|
629
|
+
subscriptionCleanups.push(unsubscribe);
|
|
618
630
|
}
|
|
619
631
|
|
|
620
632
|
p._init(cachedValue);
|
|
@@ -632,7 +644,14 @@ export function computed(fn, options = {}) {
|
|
|
632
644
|
return cachedValue;
|
|
633
645
|
};
|
|
634
646
|
|
|
635
|
-
|
|
647
|
+
// Cleanup function for lazy computed
|
|
648
|
+
cleanup = () => {
|
|
649
|
+
for (const unsubscribe of subscriptionCleanups) {
|
|
650
|
+
unsubscribe();
|
|
651
|
+
}
|
|
652
|
+
subscriptionCleanups = [];
|
|
653
|
+
trackedDeps.clear();
|
|
654
|
+
};
|
|
636
655
|
} else {
|
|
637
656
|
// Eager computed - updates immediately when dependencies change
|
|
638
657
|
cleanup = effect(() => {
|
package/runtime/router.js
CHANGED
|
@@ -225,8 +225,14 @@ function createMiddlewareRunner(middlewares) {
|
|
|
225
225
|
if (aborted || redirectPath) return;
|
|
226
226
|
if (index >= middlewares.length) return;
|
|
227
227
|
|
|
228
|
+
const middlewareIndex = index;
|
|
228
229
|
const middleware = middlewares[index++];
|
|
229
|
-
|
|
230
|
+
try {
|
|
231
|
+
await middleware(ctx, next);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
log.error(`Middleware error at index ${middlewareIndex}:`, error);
|
|
234
|
+
throw error; // Re-throw to halt navigation
|
|
235
|
+
}
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
await next();
|
|
@@ -417,23 +423,58 @@ function matchRoute(pattern, path) {
|
|
|
417
423
|
return params;
|
|
418
424
|
}
|
|
419
425
|
|
|
426
|
+
// Query string validation limits
|
|
427
|
+
const QUERY_LIMITS = {
|
|
428
|
+
maxTotalLength: 2048, // 2KB max for entire query string
|
|
429
|
+
maxValueLength: 1024, // 1KB max per individual value
|
|
430
|
+
maxParams: 50 // Maximum number of query parameters
|
|
431
|
+
};
|
|
432
|
+
|
|
420
433
|
/**
|
|
421
|
-
* Parse query string into object
|
|
434
|
+
* Parse query string into object with validation
|
|
435
|
+
* @param {string} search - Query string (with or without leading ?)
|
|
436
|
+
* @returns {Object} Parsed query parameters
|
|
422
437
|
*/
|
|
423
438
|
function parseQuery(search) {
|
|
424
|
-
|
|
439
|
+
if (!search) return {};
|
|
440
|
+
|
|
441
|
+
// Remove leading ? if present
|
|
442
|
+
const queryStr = search.startsWith('?') ? search.slice(1) : search;
|
|
443
|
+
|
|
444
|
+
// Validate total length
|
|
445
|
+
if (queryStr.length > QUERY_LIMITS.maxTotalLength) {
|
|
446
|
+
log.warn(`Query string exceeds maximum length (${QUERY_LIMITS.maxTotalLength} chars). Truncating.`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const params = new URLSearchParams(queryStr.slice(0, QUERY_LIMITS.maxTotalLength));
|
|
425
450
|
const query = {};
|
|
451
|
+
let paramCount = 0;
|
|
452
|
+
|
|
426
453
|
for (const [key, value] of params) {
|
|
454
|
+
// Check parameter count limit
|
|
455
|
+
if (paramCount >= QUERY_LIMITS.maxParams) {
|
|
456
|
+
log.warn(`Query string exceeds maximum parameters (${QUERY_LIMITS.maxParams}). Ignoring excess.`);
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Validate and potentially truncate value length
|
|
461
|
+
let safeValue = value;
|
|
462
|
+
if (value.length > QUERY_LIMITS.maxValueLength) {
|
|
463
|
+
log.warn(`Query parameter "${key}" exceeds maximum length. Truncating.`);
|
|
464
|
+
safeValue = value.slice(0, QUERY_LIMITS.maxValueLength);
|
|
465
|
+
}
|
|
466
|
+
|
|
427
467
|
if (key in query) {
|
|
428
468
|
// Multiple values for same key
|
|
429
469
|
if (Array.isArray(query[key])) {
|
|
430
|
-
query[key].push(
|
|
470
|
+
query[key].push(safeValue);
|
|
431
471
|
} else {
|
|
432
|
-
query[key] = [query[key],
|
|
472
|
+
query[key] = [query[key], safeValue];
|
|
433
473
|
}
|
|
434
474
|
} else {
|
|
435
|
-
query[key] =
|
|
475
|
+
query[key] = safeValue;
|
|
436
476
|
}
|
|
477
|
+
paramCount++;
|
|
437
478
|
}
|
|
438
479
|
return query;
|
|
439
480
|
}
|
|
@@ -460,6 +501,10 @@ export function createRouter(options = {}) {
|
|
|
460
501
|
const currentQuery = pulse({});
|
|
461
502
|
const currentMeta = pulse({});
|
|
462
503
|
const isLoading = pulse(false);
|
|
504
|
+
const routeError = pulse(null);
|
|
505
|
+
|
|
506
|
+
// Route error handler (configurable)
|
|
507
|
+
let onRouteError = options.onRouteError || null;
|
|
463
508
|
|
|
464
509
|
// Scroll positions for history
|
|
465
510
|
const scrollPositions = new Map();
|
|
@@ -793,25 +838,65 @@ export function createRouter(options = {}) {
|
|
|
793
838
|
router
|
|
794
839
|
};
|
|
795
840
|
|
|
796
|
-
//
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
:
|
|
841
|
+
// Helper to handle errors
|
|
842
|
+
const handleError = (error) => {
|
|
843
|
+
routeError.set(error);
|
|
844
|
+
log.error('Route component error:', error);
|
|
845
|
+
|
|
846
|
+
if (onRouteError) {
|
|
847
|
+
try {
|
|
848
|
+
const errorView = onRouteError(error, ctx);
|
|
849
|
+
if (errorView instanceof Node) {
|
|
850
|
+
container.replaceChildren(errorView);
|
|
851
|
+
currentView = errorView;
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
} catch (handlerError) {
|
|
855
|
+
log.error('Route error handler threw:', handlerError);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const errorEl = el('div.route-error', [
|
|
860
|
+
el('h2', 'Route Error'),
|
|
861
|
+
el('p', error.message || 'Failed to load route component')
|
|
862
|
+
]);
|
|
863
|
+
container.replaceChildren(errorEl);
|
|
864
|
+
currentView = errorEl;
|
|
865
|
+
return true;
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
// Call handler and render result (with error handling)
|
|
869
|
+
let result;
|
|
870
|
+
try {
|
|
871
|
+
result = typeof route.handler === 'function'
|
|
872
|
+
? route.handler(ctx)
|
|
873
|
+
: route.handler;
|
|
874
|
+
} catch (error) {
|
|
875
|
+
handleError(error);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
800
878
|
|
|
801
879
|
if (result instanceof Node) {
|
|
802
880
|
container.appendChild(result);
|
|
803
881
|
currentView = result;
|
|
882
|
+
routeError.set(null);
|
|
804
883
|
} else if (result && typeof result.then === 'function') {
|
|
805
884
|
// Async component
|
|
806
885
|
isLoading.set(true);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
886
|
+
routeError.set(null);
|
|
887
|
+
result
|
|
888
|
+
.then(component => {
|
|
889
|
+
isLoading.set(false);
|
|
890
|
+
const view = typeof component === 'function' ? component(ctx) : component;
|
|
891
|
+
if (view instanceof Node) {
|
|
892
|
+
container.appendChild(view);
|
|
893
|
+
currentView = view;
|
|
894
|
+
}
|
|
895
|
+
})
|
|
896
|
+
.catch(error => {
|
|
897
|
+
isLoading.set(false);
|
|
898
|
+
handleError(error);
|
|
899
|
+
});
|
|
815
900
|
}
|
|
816
901
|
}
|
|
817
902
|
});
|
|
@@ -868,6 +953,17 @@ export function createRouter(options = {}) {
|
|
|
868
953
|
/**
|
|
869
954
|
* Check if a route matches the given path
|
|
870
955
|
*/
|
|
956
|
+
/**
|
|
957
|
+
* Check if a path matches the current route
|
|
958
|
+
* @param {string} path - Path to check
|
|
959
|
+
* @param {boolean} [exact=false] - If true, requires exact match; if false, matches prefixes
|
|
960
|
+
* @returns {boolean} True if path is active
|
|
961
|
+
* @example
|
|
962
|
+
* // Current path: /users/123
|
|
963
|
+
* router.isActive('/users'); // true (prefix match)
|
|
964
|
+
* router.isActive('/users', true); // false (not exact)
|
|
965
|
+
* router.isActive('/users/123', true); // true (exact match)
|
|
966
|
+
*/
|
|
871
967
|
function isActive(path, exact = false) {
|
|
872
968
|
const current = currentPath.get();
|
|
873
969
|
if (exact) {
|
|
@@ -877,7 +973,12 @@ export function createRouter(options = {}) {
|
|
|
877
973
|
}
|
|
878
974
|
|
|
879
975
|
/**
|
|
880
|
-
* Get all
|
|
976
|
+
* Get all routes that match a given path (useful for nested routes)
|
|
977
|
+
* @param {string} path - Path to match against routes
|
|
978
|
+
* @returns {Array<{route: Object, params: Object}>} Array of matched routes with extracted params
|
|
979
|
+
* @example
|
|
980
|
+
* const matches = router.getMatchedRoutes('/admin/users/123');
|
|
981
|
+
* // Returns: [{route: adminRoute, params: {}}, {route: userRoute, params: {id: '123'}}]
|
|
881
982
|
*/
|
|
882
983
|
function getMatchedRoutes(path) {
|
|
883
984
|
const matches = [];
|
|
@@ -891,53 +992,107 @@ export function createRouter(options = {}) {
|
|
|
891
992
|
}
|
|
892
993
|
|
|
893
994
|
/**
|
|
894
|
-
*
|
|
995
|
+
* Navigate back in browser history
|
|
996
|
+
* Equivalent to browser back button
|
|
997
|
+
* @returns {void}
|
|
998
|
+
* @example
|
|
999
|
+
* router.back(); // Go to previous page
|
|
895
1000
|
*/
|
|
896
1001
|
function back() {
|
|
897
1002
|
window.history.back();
|
|
898
1003
|
}
|
|
899
1004
|
|
|
900
1005
|
/**
|
|
901
|
-
*
|
|
1006
|
+
* Navigate forward in browser history
|
|
1007
|
+
* Equivalent to browser forward button
|
|
1008
|
+
* @returns {void}
|
|
1009
|
+
* @example
|
|
1010
|
+
* router.forward(); // Go to next page (if available)
|
|
902
1011
|
*/
|
|
903
1012
|
function forward() {
|
|
904
1013
|
window.history.forward();
|
|
905
1014
|
}
|
|
906
1015
|
|
|
907
1016
|
/**
|
|
908
|
-
*
|
|
1017
|
+
* Navigate to a specific position in browser history
|
|
1018
|
+
* @param {number} delta - Number of entries to move (negative = back, positive = forward)
|
|
1019
|
+
* @returns {void}
|
|
1020
|
+
* @example
|
|
1021
|
+
* router.go(-2); // Go back 2 pages
|
|
1022
|
+
* router.go(1); // Go forward 1 page
|
|
909
1023
|
*/
|
|
910
1024
|
function go(delta) {
|
|
911
1025
|
window.history.go(delta);
|
|
912
1026
|
}
|
|
913
1027
|
|
|
1028
|
+
/**
|
|
1029
|
+
* Set route error handler
|
|
1030
|
+
* @param {function} handler - Error handler (error, ctx) => Node
|
|
1031
|
+
* @returns {function} Previous handler
|
|
1032
|
+
*/
|
|
1033
|
+
function setErrorHandler(handler) {
|
|
1034
|
+
const prev = onRouteError;
|
|
1035
|
+
onRouteError = handler;
|
|
1036
|
+
return prev;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Router instance with reactive state and navigation methods.
|
|
1041
|
+
*
|
|
1042
|
+
* Reactive properties (use .get() to read value, auto-updates in effects):
|
|
1043
|
+
* - path: Current URL path as string
|
|
1044
|
+
* - route: Current matched route object or null
|
|
1045
|
+
* - params: Route params object, e.g., {id: '123'}
|
|
1046
|
+
* - query: Query params object, e.g., {page: '1'}
|
|
1047
|
+
* - meta: Route meta data object
|
|
1048
|
+
* - loading: Boolean indicating async route loading
|
|
1049
|
+
* - error: Current route error or null
|
|
1050
|
+
*
|
|
1051
|
+
* @example
|
|
1052
|
+
* // Read reactive state
|
|
1053
|
+
* router.path.get(); // '/users/123'
|
|
1054
|
+
* router.params.get(); // {id: '123'}
|
|
1055
|
+
*
|
|
1056
|
+
* // Subscribe to changes
|
|
1057
|
+
* effect(() => {
|
|
1058
|
+
* console.log('Path changed:', router.path.get());
|
|
1059
|
+
* });
|
|
1060
|
+
*
|
|
1061
|
+
* // Navigate
|
|
1062
|
+
* router.navigate('/users/456');
|
|
1063
|
+
* router.back();
|
|
1064
|
+
*/
|
|
914
1065
|
const router = {
|
|
915
|
-
// Reactive state (read-only)
|
|
1066
|
+
// Reactive state (read-only) - use .get() to read, subscribe with effects
|
|
916
1067
|
path: currentPath,
|
|
917
1068
|
route: currentRoute,
|
|
918
1069
|
params: currentParams,
|
|
919
1070
|
query: currentQuery,
|
|
920
1071
|
meta: currentMeta,
|
|
921
1072
|
loading: isLoading,
|
|
1073
|
+
error: routeError,
|
|
922
1074
|
|
|
923
|
-
//
|
|
1075
|
+
// Navigation methods
|
|
924
1076
|
navigate,
|
|
925
1077
|
start,
|
|
926
1078
|
link,
|
|
927
1079
|
outlet,
|
|
1080
|
+
back,
|
|
1081
|
+
forward,
|
|
1082
|
+
go,
|
|
1083
|
+
|
|
1084
|
+
// Guards and middleware
|
|
928
1085
|
use,
|
|
929
1086
|
beforeEach,
|
|
930
1087
|
beforeResolve,
|
|
931
1088
|
afterEach,
|
|
932
|
-
|
|
933
|
-
forward,
|
|
934
|
-
go,
|
|
1089
|
+
setErrorHandler,
|
|
935
1090
|
|
|
936
1091
|
// Route inspection
|
|
937
1092
|
isActive,
|
|
938
1093
|
getMatchedRoutes,
|
|
939
1094
|
|
|
940
|
-
//
|
|
1095
|
+
// Utility functions
|
|
941
1096
|
matchRoute,
|
|
942
1097
|
parseQuery
|
|
943
1098
|
};
|
package/runtime/store.js
CHANGED
|
@@ -58,6 +58,56 @@ const MAX_NESTING_DEPTH = 10;
|
|
|
58
58
|
*/
|
|
59
59
|
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Invalid value types that cannot be stored in state
|
|
63
|
+
* @type {Set<string>}
|
|
64
|
+
*/
|
|
65
|
+
const INVALID_TYPES = new Set(['function', 'symbol']);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate state values, rejecting functions, symbols, and circular references
|
|
69
|
+
* @private
|
|
70
|
+
* @param {*} value - Value to validate
|
|
71
|
+
* @param {string} path - Current path for error messages
|
|
72
|
+
* @param {WeakSet} seen - Set of objects already visited (for circular detection)
|
|
73
|
+
* @throws {TypeError} If value contains invalid types or circular references
|
|
74
|
+
*/
|
|
75
|
+
function validateStateValue(value, path = 'state', seen = new WeakSet()) {
|
|
76
|
+
const valueType = typeof value;
|
|
77
|
+
|
|
78
|
+
// Check for invalid types
|
|
79
|
+
if (INVALID_TYPES.has(valueType)) {
|
|
80
|
+
throw new TypeError(
|
|
81
|
+
`Invalid state value at "${path}": ${valueType}s cannot be stored in state. ` +
|
|
82
|
+
`State values must be primitives, arrays, or plain objects.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check objects for circular references and nested invalid types
|
|
87
|
+
if (value !== null && valueType === 'object') {
|
|
88
|
+
// Check for circular reference
|
|
89
|
+
if (seen.has(value)) {
|
|
90
|
+
throw new TypeError(
|
|
91
|
+
`Circular reference detected at "${path}". ` +
|
|
92
|
+
`State must not contain circular references.`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
seen.add(value);
|
|
96
|
+
|
|
97
|
+
// Validate array elements
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
for (let i = 0; i < value.length; i++) {
|
|
100
|
+
validateStateValue(value[i], `${path}[${i}]`, seen);
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
// Validate object properties
|
|
104
|
+
for (const [key, val] of Object.entries(value)) {
|
|
105
|
+
validateStateValue(val, `${path}.${key}`, seen);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
61
111
|
/**
|
|
62
112
|
* Safely deserialize persisted state, preventing prototype pollution
|
|
63
113
|
* and property injection attacks.
|
|
@@ -122,6 +172,9 @@ function safeDeserialize(savedState, schema) {
|
|
|
122
172
|
export function createStore(initialState = {}, options = {}) {
|
|
123
173
|
const { persist = false, storageKey = 'pulse-store' } = options;
|
|
124
174
|
|
|
175
|
+
// Validate initial state
|
|
176
|
+
validateStateValue(initialState, 'initialState');
|
|
177
|
+
|
|
125
178
|
// Load persisted state if enabled
|
|
126
179
|
let state = initialState;
|
|
127
180
|
if (persist && typeof localStorage !== 'undefined') {
|
package/runtime/utils.js
CHANGED
|
@@ -22,10 +22,10 @@ const HTML_ESCAPES = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* Regex for HTML special characters
|
|
25
|
+
* Regex for HTML special characters (auto-generated from HTML_ESCAPES keys)
|
|
26
26
|
* @private
|
|
27
27
|
*/
|
|
28
|
-
const HTML_ESCAPE_REGEX =
|
|
28
|
+
const HTML_ESCAPE_REGEX = new RegExp(`[${Object.keys(HTML_ESCAPES).join('')}]`, 'g');
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Escape HTML special characters to prevent XSS attacks.
|
package/types/lite.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse Framework - Lite Build Type Definitions
|
|
3
|
+
* Minimal bundle with core reactivity and DOM helpers.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Re-export core reactivity types
|
|
7
|
+
export {
|
|
8
|
+
pulse,
|
|
9
|
+
effect,
|
|
10
|
+
computed,
|
|
11
|
+
batch,
|
|
12
|
+
onCleanup,
|
|
13
|
+
untrack,
|
|
14
|
+
Pulse
|
|
15
|
+
} from './pulse';
|
|
16
|
+
|
|
17
|
+
// Re-export DOM types
|
|
18
|
+
export {
|
|
19
|
+
el,
|
|
20
|
+
text,
|
|
21
|
+
mount,
|
|
22
|
+
on,
|
|
23
|
+
bind,
|
|
24
|
+
list,
|
|
25
|
+
when,
|
|
26
|
+
model,
|
|
27
|
+
show,
|
|
28
|
+
cls,
|
|
29
|
+
style,
|
|
30
|
+
prop
|
|
31
|
+
} from './dom';
|