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 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
- * Execute git commands
378
+ * Build commit message from version, title, and changes
300
379
  */
301
- function gitCommitTagPush(newVersion, dryRun = false) {
302
- const commands = [
303
- 'git add -A',
304
- `git commit -m "$(cat <<'EOF'\nv${newVersion}\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)"`,
305
- `git tag -a v${newVersion} -m "Release v${newVersion}"`,
306
- 'git push',
307
- 'git push --tags'
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
- for (const cmd of commands) {
311
- if (dryRun) {
312
- log.info(` [dry-run] ${cmd}`);
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 Show what would be done without making changes
339
- --no-push Create commit and tag but don't push
340
- --title <text> Release title (e.g., "Performance Improvements")
341
- --skip-prompt Use empty changelog (for automated releases)
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
- const proceed = await prompt('Continue anyway? (y/N) ');
393
- if (proceed.toLowerCase() !== 'y') {
394
- log.info('Aborted.');
395
- process.exit(0);
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 (!skipPrompt) {
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
- execSync(`git commit -m "v${newVersion}\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"`, {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "main": "index.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
- * Supports: tag, #id, .class, [attr=value]
62
- * Results are cached for performance.
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-Z][a-zA-Z0-9-_]*)/);
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-Z][a-zA-Z0-9-_]*)/g);
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
- const attrMatches = remaining.matchAll(/\[([a-zA-Z][a-zA-Z0-9-_]*)(?:=([^\]]+))?\]/g);
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
- let value = match[2] || '';
122
- // Remove quotes if present
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.insertBefore(fragment, placeholder.nextSibling);
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 originalTarget = target;
556
- if (typeof target === 'string') {
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
- target.appendChild(element);
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 = typeof target === 'string'
653
- ? document.querySelector(target)
654
- : target;
704
+ const { element: resolvedTarget, selector } = resolveSelector(target, 'portal');
655
705
 
656
706
  if (!resolvedTarget) {
657
- log.warn('Portal target not found:', target);
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 [`[${namespace}] ${args[0]}`, ...args.slice(1)];
110
+ return [`${prefix} ${args[0]}`, ...args.slice(1)];
103
111
  }
104
112
 
105
113
  // Otherwise, add namespace as first arg
106
- return [`[${namespace}]`, ...args];
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 ? `[${namespace}] ${label}` : label);
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}:${childNamespace}`
275
+ ? `${namespace}${NAMESPACE_SEPARATOR}${childNamespace}`
268
276
  : childNamespace;
269
277
  return createLogger(combined, options);
270
278
  }