pulse-js-framework 1.5.1 → 1.5.3
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/index.js +2 -0
- package/cli/release.js +245 -33
- package/package.json +1 -1
- package/runtime/dom.js +76 -26
- package/runtime/logger.js +12 -4
- package/runtime/lru-cache.js +53 -1
- package/runtime/pulse.js +158 -13
- package/runtime/router.js +183 -28
- package/runtime/store.js +128 -3
- package/runtime/utils.js +2 -2
package/cli/index.js
CHANGED
|
@@ -87,6 +87,7 @@ Release Options:
|
|
|
87
87
|
--no-push Create commit and tag but don't push
|
|
88
88
|
--title <text> Release title for changelog
|
|
89
89
|
--skip-prompt Use empty changelog (for automation)
|
|
90
|
+
--from-commits Auto-extract changelog from git commits since last tag
|
|
90
91
|
|
|
91
92
|
Examples:
|
|
92
93
|
pulse create my-app
|
|
@@ -107,6 +108,7 @@ Examples:
|
|
|
107
108
|
pulse release patch
|
|
108
109
|
pulse release minor --title "New Features"
|
|
109
110
|
pulse release major --dry-run
|
|
111
|
+
pulse release patch --from-commits
|
|
110
112
|
|
|
111
113
|
Documentation: https://github.com/vincenthirtz/pulse-js-framework
|
|
112
114
|
`);
|
package/cli/release.js
CHANGED
|
@@ -113,6 +113,85 @@ function getCurrentMonthYear() {
|
|
|
113
113
|
return `${months[now.getMonth()]} ${now.getFullYear()}`;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Get the last git tag
|
|
118
|
+
*/
|
|
119
|
+
function getLastTag() {
|
|
120
|
+
try {
|
|
121
|
+
return execSync('git describe --tags --abbrev=0', { cwd: root, encoding: 'utf-8' }).trim();
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get commits since the last tag (or all commits if no tag exists)
|
|
129
|
+
*/
|
|
130
|
+
function getCommitsSinceLastTag() {
|
|
131
|
+
const lastTag = getLastTag();
|
|
132
|
+
const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const output = execSync(`git log ${range} --pretty=format:"%s"`, {
|
|
136
|
+
cwd: root,
|
|
137
|
+
encoding: 'utf-8'
|
|
138
|
+
});
|
|
139
|
+
return output.split('\n').filter(line => line.trim());
|
|
140
|
+
} catch {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse commit messages into changelog categories
|
|
147
|
+
* Supports conventional commits: feat:, fix:, docs:, chore:, refactor:, perf:, test:, style:
|
|
148
|
+
*/
|
|
149
|
+
function parseCommitMessages(commits) {
|
|
150
|
+
const changes = { added: [], changed: [], fixed: [], removed: [] };
|
|
151
|
+
|
|
152
|
+
for (const commit of commits) {
|
|
153
|
+
const lowerCommit = commit.toLowerCase();
|
|
154
|
+
|
|
155
|
+
// Skip version commits (e.g., "v1.5.0")
|
|
156
|
+
if (/^v?\d+\.\d+\.\d+/.test(commit)) continue;
|
|
157
|
+
|
|
158
|
+
// Skip merge commits
|
|
159
|
+
if (lowerCommit.startsWith('merge ')) continue;
|
|
160
|
+
|
|
161
|
+
// Parse conventional commits
|
|
162
|
+
if (lowerCommit.startsWith('feat:') || lowerCommit.startsWith('feat(')) {
|
|
163
|
+
changes.added.push(cleanCommitMessage(commit, 'feat'));
|
|
164
|
+
} else if (lowerCommit.startsWith('fix:') || lowerCommit.startsWith('fix(')) {
|
|
165
|
+
changes.fixed.push(cleanCommitMessage(commit, 'fix'));
|
|
166
|
+
} else if (lowerCommit.startsWith('remove') || lowerCommit.startsWith('deprecate')) {
|
|
167
|
+
changes.removed.push(commit);
|
|
168
|
+
} else if (
|
|
169
|
+
lowerCommit.startsWith('refactor:') ||
|
|
170
|
+
lowerCommit.startsWith('perf:') ||
|
|
171
|
+
lowerCommit.startsWith('chore:') ||
|
|
172
|
+
lowerCommit.startsWith('docs:') ||
|
|
173
|
+
lowerCommit.startsWith('style:') ||
|
|
174
|
+
lowerCommit.startsWith('test:')
|
|
175
|
+
) {
|
|
176
|
+
changes.changed.push(cleanCommitMessage(commit, commit.split(':')[0]));
|
|
177
|
+
} else {
|
|
178
|
+
// Default: treat as a change
|
|
179
|
+
changes.changed.push(commit);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return changes;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Clean commit message by removing the conventional commit prefix
|
|
188
|
+
*/
|
|
189
|
+
function cleanCommitMessage(message, prefix) {
|
|
190
|
+
// Remove "prefix:" or "prefix(scope):"
|
|
191
|
+
const regex = new RegExp(`^${prefix}(\\([^)]+\\))?:\\s*`, 'i');
|
|
192
|
+
return message.replace(regex, '').trim();
|
|
193
|
+
}
|
|
194
|
+
|
|
116
195
|
/**
|
|
117
196
|
* Update package.json version
|
|
118
197
|
*/
|
|
@@ -296,30 +375,76 @@ function updateReadme(newVersion) {
|
|
|
296
375
|
}
|
|
297
376
|
|
|
298
377
|
/**
|
|
299
|
-
*
|
|
378
|
+
* Build commit message from version, title, and changes
|
|
300
379
|
*/
|
|
301
|
-
function
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
380
|
+
function buildCommitMessage(newVersion, title, changes) {
|
|
381
|
+
// Build header: "v1.5.2 - Title" or just "v1.5.2"
|
|
382
|
+
let message = `v${newVersion}`;
|
|
383
|
+
if (title) {
|
|
384
|
+
message += ` - ${title}`;
|
|
385
|
+
}
|
|
386
|
+
message += '\n\n';
|
|
387
|
+
|
|
388
|
+
// Add change items as bullet points
|
|
389
|
+
const allChanges = [
|
|
390
|
+
...(changes.added || []),
|
|
391
|
+
...(changes.changed || []),
|
|
392
|
+
...(changes.fixed || []),
|
|
393
|
+
...(changes.removed || [])
|
|
308
394
|
];
|
|
309
395
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
} else {
|
|
314
|
-
log.info(` Running: ${cmd.split('\n')[0]}...`);
|
|
315
|
-
try {
|
|
316
|
-
execSync(cmd, { cwd: root, stdio: 'inherit', shell: '/bin/bash' });
|
|
317
|
-
} catch (error) {
|
|
318
|
-
log.error(` Failed: ${error.message}`);
|
|
319
|
-
throw error;
|
|
320
|
-
}
|
|
396
|
+
if (allChanges.length > 0) {
|
|
397
|
+
for (const change of allChanges) {
|
|
398
|
+
message += `- ${change}\n`;
|
|
321
399
|
}
|
|
400
|
+
message += '\n';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Add co-author
|
|
404
|
+
message += 'Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>';
|
|
405
|
+
|
|
406
|
+
return message;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Execute git commands
|
|
411
|
+
*/
|
|
412
|
+
function gitCommitTagPush(newVersion, title, changes, dryRun = false) {
|
|
413
|
+
const commitMessage = buildCommitMessage(newVersion, title, changes);
|
|
414
|
+
|
|
415
|
+
if (dryRun) {
|
|
416
|
+
log.info(' [dry-run] git add -A');
|
|
417
|
+
log.info(' [dry-run] git commit with message:');
|
|
418
|
+
log.info(' ' + commitMessage.split('\n').join('\n '));
|
|
419
|
+
log.info(` [dry-run] git tag -a v${newVersion} -m "Release v${newVersion}"`);
|
|
420
|
+
log.info(' [dry-run] git push');
|
|
421
|
+
log.info(' [dry-run] git push --tags');
|
|
422
|
+
return;
|
|
322
423
|
}
|
|
424
|
+
|
|
425
|
+
// git add
|
|
426
|
+
log.info(' Running: git add -A...');
|
|
427
|
+
execSync('git add -A', { cwd: root, stdio: 'inherit' });
|
|
428
|
+
|
|
429
|
+
// git commit with heredoc for proper message formatting
|
|
430
|
+
log.info(' Running: git commit...');
|
|
431
|
+
const escapedMessage = commitMessage.replace(/'/g, "'\\''");
|
|
432
|
+
execSync(`git commit -m $'${escapedMessage.replace(/\n/g, '\\n')}'`, {
|
|
433
|
+
cwd: root,
|
|
434
|
+
stdio: 'inherit',
|
|
435
|
+
shell: '/bin/bash'
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// git tag
|
|
439
|
+
log.info(` Running: git tag v${newVersion}...`);
|
|
440
|
+
execSync(`git tag -a v${newVersion} -m "Release v${newVersion}"`, { cwd: root, stdio: 'inherit' });
|
|
441
|
+
|
|
442
|
+
// git push
|
|
443
|
+
log.info(' Running: git push...');
|
|
444
|
+
execSync('git push', { cwd: root, stdio: 'inherit' });
|
|
445
|
+
|
|
446
|
+
log.info(' Running: git push --tags...');
|
|
447
|
+
execSync('git push --tags', { cwd: root, stdio: 'inherit' });
|
|
323
448
|
}
|
|
324
449
|
|
|
325
450
|
/**
|
|
@@ -335,15 +460,24 @@ Types:
|
|
|
335
460
|
major Bump major version (1.0.0 -> 2.0.0)
|
|
336
461
|
|
|
337
462
|
Options:
|
|
338
|
-
--dry-run
|
|
339
|
-
--no-push
|
|
340
|
-
--title <text>
|
|
341
|
-
--skip-prompt
|
|
463
|
+
--dry-run Show what would be done without making changes
|
|
464
|
+
--no-push Create commit and tag but don't push
|
|
465
|
+
--title <text> Release title (e.g., "Performance Improvements")
|
|
466
|
+
--skip-prompt Use empty changelog (for automated releases)
|
|
467
|
+
--from-commits Auto-extract changelog from git commits since last tag
|
|
468
|
+
--yes, -y Auto-confirm all prompts
|
|
469
|
+
--changes <json> Pass changelog as JSON (e.g., '{"added":["Feature 1"],"fixed":["Bug 1"]}')
|
|
470
|
+
--added <items> Comma-separated list of added features
|
|
471
|
+
--changed <items> Comma-separated list of changes
|
|
472
|
+
--fixed <items> Comma-separated list of fixes
|
|
342
473
|
|
|
343
474
|
Examples:
|
|
344
475
|
pulse release patch
|
|
345
|
-
pulse release minor --title "New Features"
|
|
476
|
+
pulse release minor --title "New Features" -y
|
|
346
477
|
pulse release major --dry-run
|
|
478
|
+
pulse release patch --from-commits --title "Bug Fixes" -y
|
|
479
|
+
pulse release patch --title "Security" --fixed "XSS vulnerability,SQL injection" -y
|
|
480
|
+
pulse release patch --title "New API" --added "Feature A,Feature B" --fixed "Bug X" -y
|
|
347
481
|
`);
|
|
348
482
|
}
|
|
349
483
|
|
|
@@ -362,6 +496,8 @@ export async function runRelease(args) {
|
|
|
362
496
|
const dryRun = args.includes('--dry-run');
|
|
363
497
|
const noPush = args.includes('--no-push');
|
|
364
498
|
const skipPrompt = args.includes('--skip-prompt');
|
|
499
|
+
const fromCommits = args.includes('--from-commits');
|
|
500
|
+
const autoConfirm = args.includes('--yes') || args.includes('-y');
|
|
365
501
|
|
|
366
502
|
let title = '';
|
|
367
503
|
const titleIndex = args.indexOf('--title');
|
|
@@ -369,6 +505,33 @@ export async function runRelease(args) {
|
|
|
369
505
|
title = args[titleIndex + 1];
|
|
370
506
|
}
|
|
371
507
|
|
|
508
|
+
// Parse --changes JSON option
|
|
509
|
+
let changesFromArgs = null;
|
|
510
|
+
const changesIndex = args.indexOf('--changes');
|
|
511
|
+
if (changesIndex !== -1 && args[changesIndex + 1]) {
|
|
512
|
+
try {
|
|
513
|
+
changesFromArgs = JSON.parse(args[changesIndex + 1]);
|
|
514
|
+
} catch (e) {
|
|
515
|
+
log.error('Invalid JSON for --changes option');
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Parse individual change type options
|
|
521
|
+
const addedIndex = args.indexOf('--added');
|
|
522
|
+
const changedIndex = args.indexOf('--changed');
|
|
523
|
+
const fixedIndex = args.indexOf('--fixed');
|
|
524
|
+
const removedIndex = args.indexOf('--removed');
|
|
525
|
+
|
|
526
|
+
if (!changesFromArgs && (addedIndex !== -1 || changedIndex !== -1 || fixedIndex !== -1 || removedIndex !== -1)) {
|
|
527
|
+
changesFromArgs = {
|
|
528
|
+
added: addedIndex !== -1 && args[addedIndex + 1] ? args[addedIndex + 1].split(',').map(s => s.trim()) : [],
|
|
529
|
+
changed: changedIndex !== -1 && args[changedIndex + 1] ? args[changedIndex + 1].split(',').map(s => s.trim()) : [],
|
|
530
|
+
fixed: fixedIndex !== -1 && args[fixedIndex + 1] ? args[fixedIndex + 1].split(',').map(s => s.trim()) : [],
|
|
531
|
+
removed: removedIndex !== -1 && args[removedIndex + 1] ? args[removedIndex + 1].split(',').map(s => s.trim()) : []
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
372
535
|
// Read current version
|
|
373
536
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
|
|
374
537
|
const currentVersion = pkg.version;
|
|
@@ -389,10 +552,14 @@ export async function runRelease(args) {
|
|
|
389
552
|
if (status.trim()) {
|
|
390
553
|
log.warn('You have uncommitted changes:');
|
|
391
554
|
log.info(status);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
555
|
+
if (!autoConfirm) {
|
|
556
|
+
const proceed = await prompt('Continue anyway? (y/N) ');
|
|
557
|
+
if (proceed.toLowerCase() !== 'y') {
|
|
558
|
+
log.info('Aborted.');
|
|
559
|
+
process.exit(0);
|
|
560
|
+
}
|
|
561
|
+
} else {
|
|
562
|
+
log.info('Auto-confirming with --yes flag');
|
|
396
563
|
}
|
|
397
564
|
}
|
|
398
565
|
} catch (error) {
|
|
@@ -403,7 +570,50 @@ export async function runRelease(args) {
|
|
|
403
570
|
// Collect changelog entries
|
|
404
571
|
let changes = { added: [], changed: [], fixed: [], removed: [] };
|
|
405
572
|
|
|
406
|
-
if
|
|
573
|
+
// Use changes from command-line arguments if provided
|
|
574
|
+
if (changesFromArgs) {
|
|
575
|
+
changes = {
|
|
576
|
+
added: changesFromArgs.added || [],
|
|
577
|
+
changed: changesFromArgs.changed || [],
|
|
578
|
+
fixed: changesFromArgs.fixed || [],
|
|
579
|
+
removed: changesFromArgs.removed || []
|
|
580
|
+
};
|
|
581
|
+
const totalChanges = Object.values(changes).flat().length;
|
|
582
|
+
if (totalChanges > 0) {
|
|
583
|
+
log.info('');
|
|
584
|
+
log.info('Changelog entries from arguments:');
|
|
585
|
+
if (changes.added.length) log.info(` Added: ${changes.added.length} items`);
|
|
586
|
+
if (changes.changed.length) log.info(` Changed: ${changes.changed.length} items`);
|
|
587
|
+
if (changes.fixed.length) log.info(` Fixed: ${changes.fixed.length} items`);
|
|
588
|
+
if (changes.removed.length) log.info(` Removed: ${changes.removed.length} items`);
|
|
589
|
+
}
|
|
590
|
+
} else if (fromCommits) {
|
|
591
|
+
// Auto-extract from git commits since last tag
|
|
592
|
+
const lastTag = getLastTag();
|
|
593
|
+
const commits = getCommitsSinceLastTag();
|
|
594
|
+
|
|
595
|
+
if (commits.length === 0) {
|
|
596
|
+
log.warn('No commits found since last tag');
|
|
597
|
+
} else {
|
|
598
|
+
log.info(`Found ${commits.length} commits since ${lastTag || 'beginning'}`);
|
|
599
|
+
changes = parseCommitMessages(commits);
|
|
600
|
+
|
|
601
|
+
// Show extracted changes
|
|
602
|
+
const totalChanges = Object.values(changes).flat().length;
|
|
603
|
+
if (totalChanges > 0) {
|
|
604
|
+
log.info('');
|
|
605
|
+
log.info('Extracted changelog entries:');
|
|
606
|
+
if (changes.added.length) log.info(` Added: ${changes.added.length} items`);
|
|
607
|
+
if (changes.changed.length) log.info(` Changed: ${changes.changed.length} items`);
|
|
608
|
+
if (changes.fixed.length) log.info(` Fixed: ${changes.fixed.length} items`);
|
|
609
|
+
if (changes.removed.length) log.info(` Removed: ${changes.removed.length} items`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (!title && !autoConfirm) {
|
|
614
|
+
title = await prompt('Release title (e.g., "Performance Improvements"): ');
|
|
615
|
+
}
|
|
616
|
+
} else if (!skipPrompt && !autoConfirm) {
|
|
407
617
|
if (!title) {
|
|
408
618
|
title = await prompt('Release title (e.g., "Performance Improvements"): ');
|
|
409
619
|
}
|
|
@@ -420,7 +630,7 @@ export async function runRelease(args) {
|
|
|
420
630
|
|
|
421
631
|
const hasChanges = Object.values(changes).some(arr => arr.length > 0);
|
|
422
632
|
|
|
423
|
-
if (!hasChanges && !skipPrompt) {
|
|
633
|
+
if (!hasChanges && !skipPrompt && !autoConfirm) {
|
|
424
634
|
const proceed = await prompt('No changelog entries. Continue? (y/N) ');
|
|
425
635
|
if (proceed.toLowerCase() !== 'y') {
|
|
426
636
|
log.info('Aborted.');
|
|
@@ -466,8 +676,10 @@ export async function runRelease(args) {
|
|
|
466
676
|
if (!dryRun) {
|
|
467
677
|
if (noPush) {
|
|
468
678
|
// Only commit and tag, no push
|
|
679
|
+
const commitMessage = buildCommitMessage(newVersion, title, changes);
|
|
469
680
|
execSync('git add -A', { cwd: root, stdio: 'inherit' });
|
|
470
|
-
|
|
681
|
+
const escapedMessage = commitMessage.replace(/'/g, "'\\''");
|
|
682
|
+
execSync(`git commit -m $'${escapedMessage.replace(/\n/g, '\\n')}'`, {
|
|
471
683
|
cwd: root,
|
|
472
684
|
stdio: 'inherit',
|
|
473
685
|
shell: '/bin/bash'
|
|
@@ -475,10 +687,10 @@ export async function runRelease(args) {
|
|
|
475
687
|
execSync(`git tag -a v${newVersion} -m "Release v${newVersion}"`, { cwd: root, stdio: 'inherit' });
|
|
476
688
|
log.info(' Created commit and tag (--no-push specified)');
|
|
477
689
|
} else {
|
|
478
|
-
gitCommitTagPush(newVersion, false);
|
|
690
|
+
gitCommitTagPush(newVersion, title, changes, false);
|
|
479
691
|
}
|
|
480
692
|
} else {
|
|
481
|
-
gitCommitTagPush(newVersion, true);
|
|
693
|
+
gitCommitTagPush(newVersion, title, changes, true);
|
|
482
694
|
}
|
|
483
695
|
|
|
484
696
|
log.info('');
|
package/package.json
CHANGED
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/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
|
}
|