zero-query 0.6.3 → 0.8.6

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.
Files changed (72) hide show
  1. package/README.md +39 -29
  2. package/cli/commands/build.js +113 -4
  3. package/cli/commands/bundle.js +392 -29
  4. package/cli/commands/create.js +1 -1
  5. package/cli/commands/dev/devtools/index.js +56 -0
  6. package/cli/commands/dev/devtools/js/components.js +49 -0
  7. package/cli/commands/dev/devtools/js/core.js +409 -0
  8. package/cli/commands/dev/devtools/js/elements.js +413 -0
  9. package/cli/commands/dev/devtools/js/network.js +166 -0
  10. package/cli/commands/dev/devtools/js/performance.js +73 -0
  11. package/cli/commands/dev/devtools/js/router.js +105 -0
  12. package/cli/commands/dev/devtools/js/source.js +132 -0
  13. package/cli/commands/dev/devtools/js/stats.js +35 -0
  14. package/cli/commands/dev/devtools/js/tabs.js +79 -0
  15. package/cli/commands/dev/devtools/panel.html +95 -0
  16. package/cli/commands/dev/devtools/styles.css +244 -0
  17. package/cli/commands/dev/index.js +29 -4
  18. package/cli/commands/dev/logger.js +6 -1
  19. package/cli/commands/dev/overlay.js +428 -2
  20. package/cli/commands/dev/server.js +42 -5
  21. package/cli/commands/dev/watcher.js +59 -1
  22. package/cli/help.js +8 -5
  23. package/cli/scaffold/{scripts → app}/app.js +16 -23
  24. package/cli/scaffold/{scripts → app}/components/about.js +4 -4
  25. package/cli/scaffold/{scripts → app}/components/api-demo.js +1 -1
  26. package/cli/scaffold/{scripts → app}/components/contacts/contacts.css +0 -7
  27. package/cli/scaffold/{scripts → app}/components/contacts/contacts.html +3 -3
  28. package/cli/scaffold/app/components/home.js +137 -0
  29. package/cli/scaffold/{scripts → app}/routes.js +1 -1
  30. package/cli/scaffold/{scripts → app}/store.js +6 -6
  31. package/cli/scaffold/assets/.gitkeep +0 -0
  32. package/cli/scaffold/{styles/styles.css → global.css} +4 -2
  33. package/cli/scaffold/index.html +12 -11
  34. package/cli/utils.js +111 -6
  35. package/dist/zquery.dist.zip +0 -0
  36. package/dist/zquery.js +1122 -158
  37. package/dist/zquery.min.js +3 -16
  38. package/index.d.ts +129 -1290
  39. package/index.js +15 -10
  40. package/package.json +7 -6
  41. package/src/component.js +172 -49
  42. package/src/core.js +359 -18
  43. package/src/diff.js +256 -58
  44. package/src/expression.js +33 -3
  45. package/src/reactive.js +37 -5
  46. package/src/router.js +243 -7
  47. package/tests/component.test.js +886 -0
  48. package/tests/core.test.js +977 -0
  49. package/tests/diff.test.js +525 -0
  50. package/tests/errors.test.js +162 -0
  51. package/tests/expression.test.js +482 -0
  52. package/tests/http.test.js +289 -0
  53. package/tests/reactive.test.js +339 -0
  54. package/tests/router.test.js +649 -0
  55. package/tests/store.test.js +379 -0
  56. package/tests/utils.test.js +512 -0
  57. package/types/collection.d.ts +383 -0
  58. package/types/component.d.ts +217 -0
  59. package/types/errors.d.ts +103 -0
  60. package/types/http.d.ts +81 -0
  61. package/types/misc.d.ts +179 -0
  62. package/types/reactive.d.ts +76 -0
  63. package/types/router.d.ts +161 -0
  64. package/types/ssr.d.ts +49 -0
  65. package/types/store.d.ts +107 -0
  66. package/types/utils.d.ts +142 -0
  67. package/cli/commands/dev.old.js +0 -520
  68. package/cli/scaffold/scripts/components/home.js +0 -137
  69. /package/cli/scaffold/{scripts → app}/components/contacts/contacts.js +0 -0
  70. /package/cli/scaffold/{scripts → app}/components/counter.js +0 -0
  71. /package/cli/scaffold/{scripts → app}/components/not-found.js +0 -0
  72. /package/cli/scaffold/{scripts → app}/components/todos.js +0 -0
@@ -281,14 +281,63 @@ const OVERLAY_SCRIPT = `<script>
281
281
  location.reload();
282
282
  });
283
283
 
284
- es.addEventListener('css', function() {
284
+ es.addEventListener('css', function(e) {
285
+ var changedPath = (e.data || '').replace(/^\\/+/, '');
286
+ var matched = false;
287
+
288
+ // 1) Try cache-busting matching <link rel="stylesheet"> tags
285
289
  var sheets = document.querySelectorAll('link[rel="stylesheet"]');
286
290
  sheets.forEach(function(l) {
287
291
  var href = l.getAttribute('href');
288
292
  if (!href) return;
293
+ var clean = href.replace(/[?&]_zqr=\\d+/, '').replace(/^\\/+/, '');
294
+ if (changedPath && clean.indexOf(changedPath) === -1) return;
295
+ matched = true;
289
296
  var sep = href.indexOf('?') >= 0 ? '&' : '?';
290
- l.setAttribute('href', href.replace(/[?&]_zqr=\\\\d+/, '') + sep + '_zqr=' + Date.now());
297
+ l.setAttribute('href', href.replace(/[?&]_zqr=\\d+/, '') + sep + '_zqr=' + Date.now());
291
298
  });
299
+
300
+ // 2) Try hot-swapping scoped <style data-zq-style-urls> elements
301
+ // These come from component styleUrl — the CSS was fetched, scoped,
302
+ // and injected as an inline <style>. We re-fetch and re-scope it.
303
+ if (!matched) {
304
+ var scopedEls = document.querySelectorAll('style[data-zq-style-urls]');
305
+ scopedEls.forEach(function(el) {
306
+ var urls = el.getAttribute('data-zq-style-urls') || '';
307
+ var hit = urls.split(' ').some(function(u) {
308
+ return u && u.replace(/^\\/+/, '').indexOf(changedPath) !== -1;
309
+ });
310
+ if (!hit) return;
311
+ matched = true;
312
+
313
+ var scopeAttr = el.getAttribute('data-zq-scope') || '';
314
+ var inlineStyles = el.getAttribute('data-zq-inline') || '';
315
+
316
+ // Re-fetch all style URLs (cache-busted)
317
+ var urlList = urls.split(' ').filter(Boolean);
318
+ Promise.all(urlList.map(function(u) {
319
+ return fetch(u + (u.indexOf('?') >= 0 ? '&' : '?') + '_zqr=' + Date.now())
320
+ .then(function(r) { return r.text(); });
321
+ })).then(function(results) {
322
+ var raw = (inlineStyles ? inlineStyles + '\\n' : '') + results.join('\\n');
323
+ // Re-scope CSS with the same scope attribute
324
+ if (scopeAttr) {
325
+ var inAt = 0;
326
+ raw = raw.replace(/([^{}]+)\\{|\\}/g, function(m, sel) {
327
+ if (m === '}') { if (inAt > 0) inAt--; return m; }
328
+ var t = sel.trim();
329
+ if (t.charAt(0) === '@') { inAt++; return m; }
330
+ if (inAt > 0 && /^[\\d%\\s,fromto]+$/.test(t.replace(/\\s/g, ''))) return m;
331
+ return sel.split(',').map(function(s) { return '[' + scopeAttr + '] ' + s.trim(); }).join(', ') + ' {';
332
+ });
333
+ }
334
+ el.textContent = raw;
335
+ });
336
+ });
337
+ }
338
+
339
+ // 3) Nothing matched — fall back to full reload
340
+ if (!matched) { location.reload(); }
292
341
  });
293
342
 
294
343
  es.addEventListener('error:syntax', function(e) {
@@ -311,6 +360,383 @@ const OVERLAY_SCRIPT = `<script>
311
360
  }
312
361
 
313
362
  connect();
363
+
364
+ // =====================================================================
365
+ // Fetch / $.http Interceptor — pretty console logging
366
+ // =====================================================================
367
+ var __zqChannel;
368
+ try { __zqChannel = new BroadcastChannel('__zq_devtools'); } catch(e) {}
369
+
370
+ var __zqRequests = [];
371
+ var __zqMorphEvents = [];
372
+ var __zqMorphCount = 0;
373
+ var __zqRenderCount = 0;
374
+ var __zqReqId = 0;
375
+ var _origFetch = window.fetch;
376
+
377
+ window.fetch = function(input, init) {
378
+ var url = typeof input === 'string' ? input : (input && input.url ? input.url : String(input));
379
+ var method = ((init && init.method) || (input && input.method) || 'GET').toUpperCase();
380
+ var id = ++__zqReqId;
381
+ var start = performance.now();
382
+
383
+ // Skip internal dev-server requests
384
+ if (url.indexOf('__zq_') !== -1 || url.indexOf('/_devtools') !== -1) {
385
+ return _origFetch.apply(this, arguments);
386
+ }
387
+
388
+ return _origFetch.apply(this, arguments).then(function(response) {
389
+ var elapsed = Math.round(performance.now() - start);
390
+ var status = response.status;
391
+ var cloned = response.clone();
392
+
393
+ cloned.text().then(function(bodyText) {
394
+ var entry = {
395
+ id: id, method: method, url: url, status: status,
396
+ elapsed: elapsed, bodyPreview: bodyText.slice(0, 5000),
397
+ timestamp: Date.now()
398
+ };
399
+ __zqRequests.push(entry);
400
+ if (__zqRequests.length > 500) __zqRequests.shift();
401
+ updateDevBar();
402
+
403
+ // Pretty console log
404
+ var isOk = status >= 200 && status < 300;
405
+ var color = isOk ? '#2ecc71' : status < 400 ? '#f39c12' : '#e74c3c';
406
+
407
+ console.groupCollapsed(
408
+ '%c ' + method + ' %c' + status + '%c ' + url + ' %c' + elapsed + 'ms',
409
+ 'background:' + color + ';color:#fff;padding:2px 6px;border-radius:3px;font-weight:700;font-size:11px',
410
+ 'color:' + color + ';font-weight:700;margin-left:8px',
411
+ 'color:inherit;margin-left:4px',
412
+ 'color:#888;margin-left:8px;font-size:11px'
413
+ );
414
+
415
+ // Response body
416
+ try {
417
+ var parsed = JSON.parse(bodyText);
418
+ console.log('%c Response ', 'background:#1e1e2e;color:#8be9fd;padding:2px 6px;border-radius:2px;font-weight:600', parsed);
419
+ } catch(pe) {
420
+ if (bodyText.length > 0) {
421
+ console.log('%c Response ', 'background:#1e1e2e;color:#8be9fd;padding:2px 6px;border-radius:2px;font-weight:600',
422
+ bodyText.length > 500 ? bodyText.slice(0, 500) + '... (' + bodyText.length + ' chars)' : bodyText);
423
+ }
424
+ }
425
+
426
+ // Headers
427
+ try {
428
+ console.log('%c Headers ', 'background:#1e1e2e;color:#bd93f9;padding:2px 6px;border-radius:2px;font-weight:600',
429
+ Object.fromEntries(response.headers.entries()));
430
+ } catch(he) {}
431
+
432
+ // Request body (if sent)
433
+ if (init && init.body) {
434
+ try {
435
+ console.log('%c Request ', 'background:#1e1e2e;color:#f1fa8c;padding:2px 6px;border-radius:2px;font-weight:600',
436
+ JSON.parse(init.body));
437
+ } catch(re) {
438
+ console.log('%c Request ', 'background:#1e1e2e;color:#f1fa8c;padding:2px 6px;border-radius:2px;font-weight:600',
439
+ String(init.body).slice(0, 500));
440
+ }
441
+ }
442
+
443
+ console.groupEnd();
444
+
445
+ // Broadcast to devtools
446
+ if (__zqChannel) {
447
+ try { __zqChannel.postMessage({ type: 'http', data: entry }); } catch(ce) {}
448
+ }
449
+ }).catch(function() {});
450
+
451
+ return response;
452
+ }, function(err) {
453
+ var elapsed = Math.round(performance.now() - start);
454
+ console.groupCollapsed(
455
+ '%c ' + method + ' %cERR%c ' + url + ' %c' + elapsed + 'ms',
456
+ 'background:#e74c3c;color:#fff;padding:2px 6px;border-radius:3px;font-weight:700;font-size:11px',
457
+ 'color:#e74c3c;font-weight:700;margin-left:8px',
458
+ 'color:inherit;margin-left:4px',
459
+ 'color:#888;margin-left:8px;font-size:11px'
460
+ );
461
+ console.error(err);
462
+ console.groupEnd();
463
+
464
+ var entry = { id: id, method: method, url: url, status: 0, elapsed: elapsed, bodyPreview: err.message, timestamp: Date.now() };
465
+ __zqRequests.push(entry);
466
+ updateDevBar();
467
+ if (__zqChannel) {
468
+ try { __zqChannel.postMessage({ type: 'http', data: entry }); } catch(ce) {}
469
+ }
470
+
471
+ throw err;
472
+ });
473
+ };
474
+
475
+ // =====================================================================
476
+ // Morph instrumentation — hook via window.__zqMorphHook (set by diff.js)
477
+ // =====================================================================
478
+ window.__zqMorphHook = function(el, elapsed) {
479
+ __zqMorphCount++;
480
+ updateDevBar();
481
+
482
+ var evt = { target: el.id || el.tagName.toLowerCase(), elapsed: elapsed, kind: 'morph', timestamp: Date.now() };
483
+ __zqMorphEvents.push(evt);
484
+ if (__zqMorphEvents.length > 200) __zqMorphEvents.shift();
485
+
486
+ // Console timing for slow morphs (> 4ms)
487
+ if (elapsed > 4) {
488
+ console.log(
489
+ '%c morph %c' + elapsed.toFixed(2) + 'ms%c ' + (el.id || el.tagName.toLowerCase()),
490
+ 'background:#9b59b6;color:#fff;padding:2px 6px;border-radius:3px;font-weight:700;font-size:11px',
491
+ 'color:' + (elapsed > 16 ? '#e74c3c' : '#f39c12') + ';font-weight:700;margin-left:8px',
492
+ 'color:#888;margin-left:4px'
493
+ );
494
+ }
495
+
496
+ // Broadcast to devtools
497
+ if (__zqChannel) {
498
+ try {
499
+ __zqChannel.postMessage({
500
+ type: 'morph-detail',
501
+ data: evt
502
+ });
503
+ } catch(ce) {}
504
+ }
505
+ };
506
+
507
+ // =====================================================================
508
+ // Render instrumentation — hook for first-renders & route swaps
509
+ // =====================================================================
510
+ window.__zqRenderHook = function(el, elapsed, kind, name) {
511
+ __zqRenderCount++;
512
+ __zqMorphCount++; // count renders in the morph total for the toolbar
513
+ updateDevBar();
514
+
515
+ var evt = { target: name || el.id || el.tagName.toLowerCase(), elapsed: elapsed, kind: kind, timestamp: Date.now() };
516
+ __zqMorphEvents.push(evt);
517
+ if (__zqMorphEvents.length > 200) __zqMorphEvents.shift();
518
+
519
+ // Console log for route/mount renders
520
+ var label = kind === 'route' ? ' route ' : ' mount ';
521
+ var bg = kind === 'route' ? '#d29922' : '#3fb950';
522
+ console.log(
523
+ '%c' + label + '%c' + elapsed.toFixed(2) + 'ms%c ' + (name || el.id || el.tagName.toLowerCase()),
524
+ 'background:' + bg + ';color:#fff;padding:2px 6px;border-radius:3px;font-weight:700;font-size:11px',
525
+ 'color:' + (elapsed > 16 ? '#e74c3c' : '#888') + ';font-weight:700;margin-left:8px',
526
+ 'color:#888;margin-left:4px'
527
+ );
528
+
529
+ // Broadcast to devtools
530
+ if (__zqChannel) {
531
+ try {
532
+ __zqChannel.postMessage({
533
+ type: 'render-detail',
534
+ data: evt
535
+ });
536
+ } catch(ce) {}
537
+ }
538
+ };
539
+
540
+ // =====================================================================
541
+ // Router instrumentation — history state tracking for devtools
542
+ // =====================================================================
543
+ var __zqRouterEvents = [];
544
+
545
+ var _origPushState = history.pushState;
546
+ history.pushState = function(state, title, url) {
547
+ _origPushState.apply(this, arguments);
548
+ var isSubstate = state && state.__zq === 'substate';
549
+ var evt = {
550
+ action: isSubstate ? 'substate' : 'navigate',
551
+ url: String(url || location.href).replace(location.origin, ''),
552
+ key: isSubstate ? state.key : null,
553
+ data: isSubstate ? state.data : null,
554
+ timestamp: Date.now()
555
+ };
556
+ __zqRouterEvents.push(evt);
557
+ if (__zqRouterEvents.length > 200) __zqRouterEvents.shift();
558
+ if (__zqChannel) {
559
+ try { __zqChannel.postMessage({ type: 'router', data: evt }); } catch(e) {}
560
+ }
561
+ };
562
+
563
+ var _origReplaceState = history.replaceState;
564
+ history.replaceState = function(state, title, url) {
565
+ _origReplaceState.apply(this, arguments);
566
+ var isSubstate = state && state.__zq === 'substate';
567
+ var evt = {
568
+ action: 'replace',
569
+ url: String(url || location.href).replace(location.origin, ''),
570
+ key: isSubstate ? state.key : null,
571
+ data: isSubstate ? state.data : null,
572
+ timestamp: Date.now()
573
+ };
574
+ __zqRouterEvents.push(evt);
575
+ if (__zqRouterEvents.length > 200) __zqRouterEvents.shift();
576
+ if (__zqChannel) {
577
+ try { __zqChannel.postMessage({ type: 'router', data: evt }); } catch(e) {}
578
+ }
579
+ };
580
+
581
+ window.addEventListener('popstate', function(e) {
582
+ var state = e.state;
583
+ var isSubstate = state && state.__zq === 'substate';
584
+ var evt = {
585
+ action: isSubstate ? 'pop-substate' : 'pop',
586
+ url: location.pathname + location.hash,
587
+ key: isSubstate ? state.key : null,
588
+ data: isSubstate ? state.data : null,
589
+ timestamp: Date.now()
590
+ };
591
+ __zqRouterEvents.push(evt);
592
+ if (__zqRouterEvents.length > 200) __zqRouterEvents.shift();
593
+ if (__zqChannel) {
594
+ try { __zqChannel.postMessage({ type: 'router', data: evt }); } catch(e) {}
595
+ }
596
+ });
597
+
598
+ window.addEventListener('hashchange', function() {
599
+ var evt = {
600
+ action: 'hashchange',
601
+ url: location.hash,
602
+ timestamp: Date.now()
603
+ };
604
+ __zqRouterEvents.push(evt);
605
+ if (__zqRouterEvents.length > 200) __zqRouterEvents.shift();
606
+ if (__zqChannel) {
607
+ try { __zqChannel.postMessage({ type: 'router', data: evt }); } catch(e) {}
608
+ }
609
+ });
610
+
611
+ // =====================================================================
612
+ // Dev Toolbar — floating bar with DOM viewer button & request counter
613
+ // =====================================================================
614
+ var devBar;
615
+
616
+ function createDevBar() {
617
+ devBar = document.createElement('div');
618
+ devBar.id = '__zq_devbar';
619
+ devBar.setAttribute('style',
620
+ 'position:fixed;bottom:12px;right:12px;z-index:2147483646;' +
621
+ 'display:flex;align-items:center;gap:6px;' +
622
+ 'background:rgba(22,27,34,0.92);border:1px solid rgba(48,54,61,0.8);' +
623
+ 'border-radius:8px;padding:4px 6px;backdrop-filter:blur(8px);' +
624
+ 'font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;' +
625
+ 'font-size:11px;color:#8b949e;user-select:none;cursor:default;' +
626
+ 'box-shadow:0 4px 12px rgba(0,0,0,0.4);'
627
+ );
628
+ devBar.innerHTML =
629
+ '<span style="color:#58a6ff;font-weight:700;padding:0 4px;font-size:10px;letter-spacing:.5px">zQ</span>' +
630
+ '<span id="__zq_bar_reqs" title="Network requests" style="padding:2px 6px;border-radius:4px;' +
631
+ 'background:rgba(88,166,255,0.1);color:#58a6ff;cursor:pointer;font-size:10px;font-weight:600;">0 req</span>' +
632
+ '<span id="__zq_bar_morphs" title="Render operations" style="padding:2px 6px;border-radius:4px;' +
633
+ 'background:rgba(188,140,255,0.1);color:#bc8cff;cursor:pointer;font-size:10px;font-weight:600;">0 render</span>' +
634
+ '<button id="__zq_bar_dom" title="Open DevTools (/_devtools)" style="' +
635
+ 'padding:3px 8px;border-radius:4px;font-size:10px;font-weight:700;' +
636
+ 'background:rgba(63,185,80,0.15);color:#3fb950;border:1px solid rgba(63,185,80,0.3);' +
637
+ 'cursor:pointer;font-family:inherit;transition:all .15s;' +
638
+ '">DOM</button>' +
639
+ '<button id="__zq_bar_close" title="Close toolbar" style="' +
640
+ 'padding:0 4px;color:#484f58;cursor:pointer;font-size:14px;border:none;' +
641
+ 'background:none;font-family:inherit;line-height:1;' +
642
+ '">&times;</button>';
643
+
644
+ document.body.appendChild(devBar);
645
+ updateDevBar();
646
+
647
+ // Check if we're inside a devtools split-view iframe
648
+ function isInSplitFrame() {
649
+ try { return window.parent !== window && window.parent.document.getElementById('app-frame'); }
650
+ catch(e) { return false; }
651
+ }
652
+
653
+ // Switch tab in devtools (works for both split iframe and popup)
654
+ function switchDevTab(tab) {
655
+ if (__zqChannel) {
656
+ __zqChannel.postMessage({ type: 'switch-tab', tab: tab });
657
+ }
658
+ }
659
+
660
+ // Req counter → Network tab
661
+ document.getElementById('__zq_bar_reqs').addEventListener('click', function() {
662
+ if (isInSplitFrame()) {
663
+ switchDevTab('network');
664
+ } else {
665
+ openDevToolsPopup('network');
666
+ }
667
+ });
668
+
669
+ // Render counter → Performance tab
670
+ document.getElementById('__zq_bar_morphs').addEventListener('click', function() {
671
+ if (isInSplitFrame()) {
672
+ switchDevTab('perf');
673
+ } else {
674
+ openDevToolsPopup('perf');
675
+ }
676
+ });
677
+
678
+ // DOM button → Elements tab (in split) or open popup
679
+ var __zqPopup = null;
680
+ function openDevToolsPopup(tab) {
681
+ // If popup is already open, just switch the tab via BroadcastChannel
682
+ if (__zqPopup && !__zqPopup.closed) {
683
+ switchDevTab(tab);
684
+ __zqPopup.focus();
685
+ return;
686
+ }
687
+ var w = 1080, h = 800;
688
+ var left = window.screenX + window.outerWidth - w - 20;
689
+ var top = window.screenY + 60;
690
+ var url = '/_devtools' + (tab ? '#' + tab : '');
691
+ __zqPopup = window.open(url, '__zq_devtools',
692
+ 'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top +
693
+ ',resizable=yes,scrollbars=yes');
694
+ }
695
+
696
+ document.getElementById('__zq_bar_dom').addEventListener('click', function() {
697
+ if (isInSplitFrame()) {
698
+ switchDevTab('dom');
699
+ } else {
700
+ openDevToolsPopup('dom');
701
+ }
702
+ });
703
+
704
+ // Close button
705
+ document.getElementById('__zq_bar_close').addEventListener('click', function() {
706
+ devBar.style.display = 'none';
707
+ });
708
+
709
+ // Hover effects
710
+ document.getElementById('__zq_bar_dom').addEventListener('mouseover', function() {
711
+ this.style.background = 'rgba(63,185,80,0.3)';
712
+ });
713
+ document.getElementById('__zq_bar_dom').addEventListener('mouseout', function() {
714
+ this.style.background = 'rgba(63,185,80,0.15)';
715
+ });
716
+ }
717
+
718
+ function updateDevBar() {
719
+ if (!devBar) return;
720
+ var reqEl = document.getElementById('__zq_bar_reqs');
721
+ var morphEl = document.getElementById('__zq_bar_morphs');
722
+ if (reqEl) reqEl.textContent = __zqRequests.length + ' req';
723
+ if (morphEl) morphEl.textContent = __zqMorphCount + ' render';
724
+ }
725
+
726
+ // Expose for devtools popup
727
+ window.__zqDevTools = {
728
+ get requests() { return __zqRequests; },
729
+ get morphEvents() { return __zqMorphEvents; },
730
+ get morphCount() { return __zqMorphCount; },
731
+ get renderCount() { return __zqRenderCount; },
732
+ get routerEvents() { return __zqRouterEvents; }
733
+ };
734
+
735
+ if (document.readyState === 'loading') {
736
+ document.addEventListener('DOMContentLoaded', createDevBar);
737
+ } else {
738
+ createDevBar();
739
+ }
314
740
  })();
315
741
  </script>`;
316
742
 
@@ -11,6 +11,7 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const OVERLAY_SCRIPT = require('./overlay');
14
+ const DEVTOOLS_HTML = require('./devtools');
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // SSE client pool
@@ -44,22 +45,51 @@ class SSEPool {
44
45
  // Server factory
45
46
  // ---------------------------------------------------------------------------
46
47
 
48
+ /**
49
+ * Prompt the user to auto-install zero-http when it isn't found.
50
+ * Resolves `true` if the user accepts, `false` otherwise.
51
+ */
52
+ function promptInstall() {
53
+ const rl = require('readline').createInterface({
54
+ input: process.stdin,
55
+ output: process.stdout,
56
+ });
57
+ return new Promise((resolve) => {
58
+ rl.question(
59
+ '\n The local dev server requires zero-http, which is not installed.\n' +
60
+ ' This package is only used during development and is not needed\n' +
61
+ ' for building, bundling, or production.\n' +
62
+ ' Install it now? (y/n): ',
63
+ (answer) => {
64
+ rl.close();
65
+ resolve(answer.trim().toLowerCase() === 'y');
66
+ }
67
+ );
68
+ });
69
+ }
70
+
47
71
  /**
48
72
  * @param {object} opts
49
73
  * @param {string} opts.root — absolute path to project root
50
74
  * @param {string} opts.htmlEntry — e.g. 'index.html'
51
75
  * @param {number} opts.port
52
76
  * @param {boolean} opts.noIntercept — skip zquery.min.js auto-resolve
53
- * @returns {{ app, pool: SSEPool, listen: Function }}
77
+ * @returns {Promise<{ app, pool: SSEPool, listen: Function }>}
54
78
  */
55
- function createServer({ root, htmlEntry, port, noIntercept }) {
79
+ async function createServer({ root, htmlEntry, port, noIntercept }) {
56
80
  let zeroHttp;
57
81
  try {
58
82
  zeroHttp = require('zero-http');
59
83
  } catch {
60
- console.error(`\n \u2717 zero-http is required for the dev server.`);
61
- console.error(` Install it: npm install zero-http --save-dev\n`);
62
- process.exit(1);
84
+ const ok = await promptInstall();
85
+ if (!ok) {
86
+ console.error('\n ✖ Cannot start dev server without zero-http.\n');
87
+ process.exit(1);
88
+ }
89
+ const { execSync } = require('child_process');
90
+ console.log('\n Installing zero-http...\n');
91
+ execSync('npm install zero-http --save-dev', { stdio: 'inherit' });
92
+ zeroHttp = require('zero-http');
63
93
  }
64
94
 
65
95
  const { createApp, static: serveStatic } = zeroHttp;
@@ -73,6 +103,13 @@ function createServer({ root, htmlEntry, port, noIntercept }) {
73
103
  pool.add(sse);
74
104
  });
75
105
 
106
+ // ---- DevTools panel ----
107
+ app.get('/_devtools', (req, res) => {
108
+ res.set('Content-Type', 'text/html; charset=utf-8');
109
+ res.set('Cache-Control', 'no-cache');
110
+ res.send(DEVTOOLS_HTML);
111
+ });
112
+
76
113
  // ---- Auto-resolve zquery.min.js ----
77
114
  const pkgRoot = path.resolve(__dirname, '..', '..', '..');
78
115
 
@@ -28,6 +28,14 @@ function isIgnored(filepath) {
28
28
  return filepath.split(path.sep).some(p => IGNORE_DIRS.has(p));
29
29
  }
30
30
 
31
+ /**
32
+ * Return the file's mtime as a millisecond timestamp, or 0 if unreadable.
33
+ * Used to ignore spurious fs.watch events (Windows fires on reads too).
34
+ */
35
+ function mtime(filepath) {
36
+ try { return fs.statSync(filepath).mtimeMs; } catch { return 0; }
37
+ }
38
+
31
39
  /** Recursively collect every directory under `dir` (excluding ignored). */
32
40
  function collectWatchDirs(dir) {
33
41
  const dirs = [dir];
@@ -52,12 +60,31 @@ function collectWatchDirs(dir) {
52
60
  * @param {SSEPool} opts.pool — SSE broadcast pool
53
61
  * @returns {{ dirs: string[], destroy: Function }}
54
62
  */
55
- function startWatcher({ root, pool }) {
63
+ function startWatcher({ root, pool, bundleMode, serveRoot }) {
56
64
  const watchDirs = collectWatchDirs(root);
57
65
  const watchers = [];
58
66
 
59
67
  let debounceTimer;
60
68
  let currentError = null; // track which file has an active error
69
+ let bundleTimer = null; // debounce for bundle rebuilds
70
+
71
+ // Track file mtimes so we only react to genuine writes.
72
+ // On Windows, fs.watch fires on reads/access too, which causes
73
+ // spurious reloads the first time the server serves a file.
74
+ // We seed the cache with current mtimes so the first real save
75
+ // (which changes the mtime) is always detected.
76
+ const mtimeCache = new Map();
77
+ for (const d of watchDirs) {
78
+ try {
79
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
80
+ if (entry.isFile()) {
81
+ const fp = path.join(d, entry.name);
82
+ const mt = mtime(fp);
83
+ if (mt) mtimeCache.set(fp, mt);
84
+ }
85
+ }
86
+ } catch { /* skip */ }
87
+ }
61
88
 
62
89
  for (const dir of watchDirs) {
63
90
  try {
@@ -68,6 +95,13 @@ function startWatcher({ root, pool }) {
68
95
 
69
96
  clearTimeout(debounceTimer);
70
97
  debounceTimer = setTimeout(() => {
98
+ // Skip if the file hasn't actually been modified
99
+ const mt = mtime(fullPath);
100
+ if (mt === 0) return; // deleted or unreadable
101
+ const prev = mtimeCache.get(fullPath);
102
+ mtimeCache.set(fullPath, mt);
103
+ if (prev !== undefined && mt === prev) return; // unchanged
104
+
71
105
  const rel = path.relative(root, fullPath).replace(/\\/g, '/');
72
106
  const ext = path.extname(filename).toLowerCase();
73
107
 
@@ -95,6 +129,30 @@ function startWatcher({ root, pool }) {
95
129
  }
96
130
 
97
131
  // ---- Full reload ----
132
+ if (bundleMode) {
133
+ // Debounce bundle rebuilds (500ms) so rapid saves don't spam
134
+ clearTimeout(bundleTimer);
135
+ bundleTimer = setTimeout(() => {
136
+ try {
137
+ const bundleFn = require('../bundle');
138
+ const { args: cliArgs } = require('../../args');
139
+ const savedArgs = cliArgs.slice();
140
+ cliArgs.length = 0;
141
+ cliArgs.push('bundle');
142
+ const prevCwd = process.cwd();
143
+ process.chdir(root);
144
+ bundleFn();
145
+ process.chdir(prevCwd);
146
+ cliArgs.length = 0;
147
+ savedArgs.forEach(a => cliArgs.push(a));
148
+ logReload(rel + ' (rebuilt)');
149
+ pool.broadcast('reload', rel);
150
+ } catch (err) {
151
+ console.error(' Bundle rebuild failed:', err.message);
152
+ }
153
+ }, 500);
154
+ return;
155
+ }
98
156
  logReload(rel);
99
157
  pool.broadcast('reload', rel);
100
158
  }, 100);
package/cli/help.js CHANGED
@@ -7,7 +7,7 @@ function showHelp() {
7
7
  COMMANDS
8
8
 
9
9
  create [dir] Scaffold a new zQuery project
10
- Creates index.html, scripts/, styles/ in the target directory
10
+ Creates index.html, global.css, app/, assets/ in the target directory
11
11
  (defaults to the current directory)
12
12
 
13
13
  dev [root] Start a dev server with live-reload
@@ -15,6 +15,8 @@ function showHelp() {
15
15
  --index, -i <file> Index HTML file (default: index.html)
16
16
  --no-intercept Disable auto-resolution of zquery.min.js
17
17
  (serve the on-disk vendor copy instead)
18
+ --bundle, -b Serve the bundled build (runs bundler first,
19
+ serves from dist/server/, auto-rebuilds on save)
18
20
 
19
21
  Includes error overlay: syntax errors are
20
22
  caught on save and shown as a full-screen
@@ -24,7 +26,8 @@ function showHelp() {
24
26
  bundle [dir|file] Bundle app ES modules into a single file
25
27
  --out, -o <path> Output directory (default: dist/ next to HTML file)
26
28
  --index, -i <file> Index HTML file (default: auto-detected)
27
- --minimal, -m Only output HTML + bundled JS (skip static assets)
29
+ --minimal, -m Only output HTML, bundled JS, and global CSS (skip static assets)
30
+ --global-css <path> Override global CSS input (default: first <link> in HTML)
28
31
 
29
32
  build Build the zQuery library \u2192 dist/ --watch, -w Watch src/ and rebuild on changes (must be run from the project root where src/ lives)
30
33
 
@@ -35,7 +38,7 @@ function showHelp() {
35
38
  1. index.html first, then other .html files
36
39
  2. Within HTML: module script pointing to app.js, else first module script
37
40
  3. JS scan: $.router( first (entry point), then $.mount( / $.store(
38
- 4. Convention fallbacks (scripts/app.js, app.js, etc.)
41
+ 4. Convention fallbacks (app/app.js, scripts/app.js, app.js, etc.)
39
42
  • Passing a directory auto-detects the entry; passing a file uses it directly
40
43
  • zquery.min.js is always embedded (auto-built from source if not found)
41
44
  • HTML file is auto-detected (any .html, not just index.html)
@@ -83,12 +86,12 @@ function showHelp() {
83
86
  zquery bundle my-app/
84
87
 
85
88
  # Pass a direct entry file (skip auto-detection)
86
- zquery bundle my-app/scripts/main.js
89
+ zquery bundle my-app/app/main.js
87
90
 
88
91
  # Custom output directory
89
92
  zquery bundle my-app/ -o build/
90
93
 
91
- # Minimal build (only HTML + bundled JS, no static assets)
94
+ # Minimal build (HTML + JS + global CSS, no static asset copying)
92
95
  zquery bundle my-app/ --minimal
93
96
 
94
97
  # Dev server with a custom index page