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
package/src/router.js CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Supports hash mode (#/path) and history mode (/path).
5
5
  * Route params, query strings, navigation guards, and lazy loading.
6
+ * Sub-route history substates for in-page UI changes (modals, tabs, etc.).
6
7
  *
7
8
  * Usage:
8
9
  * $.router({
@@ -17,9 +18,12 @@
17
18
  * });
18
19
  */
19
20
 
20
- import { mount, destroy } from './component.js';
21
+ import { mount, destroy, prefetch } from './component.js';
21
22
  import { reportError, ErrorCode } from './errors.js';
22
23
 
24
+ // Unique marker on history.state to identify zQuery-managed entries
25
+ const _ZQ_STATE_KEY = '__zq';
26
+
23
27
  class Router {
24
28
  constructor(config = {}) {
25
29
  this._el = null;
@@ -53,6 +57,10 @@ class Router {
53
57
  this._instance = null; // current mounted component
54
58
  this._resolving = false; // re-entrancy guard
55
59
 
60
+ // Sub-route history substates
61
+ this._substateListeners = []; // [(key, data) => bool|void]
62
+ this._inSubstate = false; // true while substate entries are in the history stack
63
+
56
64
  // Set outlet element
57
65
  if (config.el) {
58
66
  this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
@@ -67,7 +75,21 @@ class Router {
67
75
  if (this._mode === 'hash') {
68
76
  window.addEventListener('hashchange', () => this._resolve());
69
77
  } else {
70
- window.addEventListener('popstate', () => this._resolve());
78
+ window.addEventListener('popstate', (e) => {
79
+ // Check for substate pop first — if a listener handles it, don't route
80
+ const st = e.state;
81
+ if (st && st[_ZQ_STATE_KEY] === 'substate') {
82
+ const handled = this._fireSubstate(st.key, st.data, 'pop');
83
+ if (handled) return;
84
+ // Unhandled substate — fall through to route resolve
85
+ // _inSubstate stays true so the next non-substate pop triggers reset
86
+ } else if (this._inSubstate) {
87
+ // Popped past all substates — notify listeners to reset to defaults
88
+ this._inSubstate = false;
89
+ this._fireSubstate(null, null, 'reset');
90
+ }
91
+ this._resolve();
92
+ });
71
93
  }
72
94
 
73
95
  // Intercept link clicks for SPA navigation
@@ -78,7 +100,21 @@ class Router {
78
100
  if (!link) return;
79
101
  if (link.getAttribute('target') === '_blank') return;
80
102
  e.preventDefault();
81
- this.navigate(link.getAttribute('z-link'));
103
+ let href = link.getAttribute('z-link');
104
+ // Support z-link-params for dynamic :param interpolation
105
+ const paramsAttr = link.getAttribute('z-link-params');
106
+ if (paramsAttr) {
107
+ try {
108
+ const params = JSON.parse(paramsAttr);
109
+ href = this._interpolateParams(href, params);
110
+ } catch { /* ignore malformed JSON */ }
111
+ }
112
+ this.navigate(href);
113
+ // z-to-top modifier: scroll to top after navigation
114
+ if (link.hasAttribute('z-to-top')) {
115
+ const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
116
+ window.scrollTo({ top: 0, behavior: scrollBehavior });
117
+ }
82
118
  });
83
119
 
84
120
  // Initial resolve
@@ -122,7 +158,36 @@ class Router {
122
158
 
123
159
  // --- Navigation ----------------------------------------------------------
124
160
 
161
+ /**
162
+ * Interpolate :param placeholders in a path with the given values.
163
+ * @param {string} path — e.g. '/user/:id/posts/:pid'
164
+ * @param {Object} params — e.g. { id: 42, pid: 7 }
165
+ * @returns {string}
166
+ */
167
+ _interpolateParams(path, params) {
168
+ if (!params || typeof params !== 'object') return path;
169
+ return path.replace(/:([\w]+)/g, (_, key) => {
170
+ const val = params[key];
171
+ return val != null ? encodeURIComponent(String(val)) : ':' + key;
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Get the full current URL (path + hash) for same-URL detection.
177
+ * @returns {string}
178
+ */
179
+ _currentURL() {
180
+ if (this._mode === 'hash') {
181
+ return window.location.hash.slice(1) || '/';
182
+ }
183
+ const pathname = window.location.pathname || '/';
184
+ const hash = window.location.hash || '';
185
+ return pathname + hash;
186
+ }
187
+
125
188
  navigate(path, options = {}) {
189
+ // Interpolate :param placeholders if options.params is provided
190
+ if (options.params) path = this._interpolateParams(path, options.params);
126
191
  // Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
127
192
  const [cleanPath, fragment] = (path || '').split('#');
128
193
  let normalized = this._normalizePath(cleanPath);
@@ -131,15 +196,56 @@ class Router {
131
196
  // Hash mode uses the URL hash for routing, so a #fragment can't live
132
197
  // in the URL. Store it as a scroll target for the destination component.
133
198
  if (fragment) window.__zqScrollTarget = fragment;
134
- window.location.hash = '#' + normalized;
199
+ const targetHash = '#' + normalized;
200
+ // Skip if already at this exact hash (prevents duplicate entries)
201
+ if (window.location.hash === targetHash && !options.force) return this;
202
+ window.location.hash = targetHash;
135
203
  } else {
136
- window.history.pushState(options.state || {}, '', this._base + normalized + hash);
204
+ const targetURL = this._base + normalized + hash;
205
+ const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
206
+
207
+ if (targetURL === currentURL && !options.force) {
208
+ // Same full URL (path + hash) — don't push duplicate entry.
209
+ // If only the hash changed to a fragment target, scroll to it.
210
+ if (fragment) {
211
+ const el = document.getElementById(fragment);
212
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
213
+ }
214
+ return this;
215
+ }
216
+
217
+ // Same route path but different hash fragment — use replaceState
218
+ // so back goes to the previous *route*, not the previous scroll position.
219
+ const targetPathOnly = this._base + normalized;
220
+ const currentPathOnly = window.location.pathname || '/';
221
+ if (targetPathOnly === currentPathOnly && hash && !options.force) {
222
+ window.history.replaceState(
223
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
224
+ '',
225
+ targetURL
226
+ );
227
+ // Scroll to the fragment target
228
+ if (fragment) {
229
+ const el = document.getElementById(fragment);
230
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
231
+ }
232
+ // Don't re-resolve — same route, just a hash change
233
+ return this;
234
+ }
235
+
236
+ window.history.pushState(
237
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
238
+ '',
239
+ targetURL
240
+ );
137
241
  this._resolve();
138
242
  }
139
243
  return this;
140
244
  }
141
245
 
142
246
  replace(path, options = {}) {
247
+ // Interpolate :param placeholders if options.params is provided
248
+ if (options.params) path = this._interpolateParams(path, options.params);
143
249
  const [cleanPath, fragment] = (path || '').split('#');
144
250
  let normalized = this._normalizePath(cleanPath);
145
251
  const hash = fragment ? '#' + fragment : '';
@@ -147,7 +253,11 @@ class Router {
147
253
  if (fragment) window.__zqScrollTarget = fragment;
148
254
  window.location.replace('#' + normalized);
149
255
  } else {
150
- window.history.replaceState(options.state || {}, '', this._base + normalized + hash);
256
+ window.history.replaceState(
257
+ { ...options.state, [_ZQ_STATE_KEY]: 'route' },
258
+ '',
259
+ this._base + normalized + hash
260
+ );
151
261
  this._resolve();
152
262
  }
153
263
  return this;
@@ -202,6 +312,80 @@ class Router {
202
312
  return () => this._listeners.delete(fn);
203
313
  }
204
314
 
315
+ // --- Sub-route history substates -----------------------------------------
316
+
317
+ /**
318
+ * Push a lightweight history entry for in-component UI state.
319
+ * The URL path does NOT change — only a history entry is added so the
320
+ * back button can undo the UI change (close modal, revert tab, etc.)
321
+ * before navigating away.
322
+ *
323
+ * @param {string} key — identifier (e.g. 'modal', 'tab', 'panel')
324
+ * @param {*} data — arbitrary state (serializable)
325
+ * @returns {Router}
326
+ *
327
+ * @example
328
+ * // Open a modal and push a substate
329
+ * router.pushSubstate('modal', { id: 'confirm-delete' });
330
+ * // User hits back → onSubstate fires → close the modal
331
+ */
332
+ pushSubstate(key, data) {
333
+ this._inSubstate = true;
334
+ if (this._mode === 'hash') {
335
+ // Hash mode: stash the substate in a global — hashchange will check.
336
+ // We still push a history entry via a sentinel hash suffix.
337
+ const current = window.location.hash || '#/';
338
+ window.history.pushState(
339
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
340
+ '',
341
+ window.location.href
342
+ );
343
+ } else {
344
+ window.history.pushState(
345
+ { [_ZQ_STATE_KEY]: 'substate', key, data },
346
+ '',
347
+ window.location.href // keep same URL
348
+ );
349
+ }
350
+ return this;
351
+ }
352
+
353
+ /**
354
+ * Register a listener for substate pops (back button on a substate entry).
355
+ * The callback receives `(key, data)` and should return `true` if it
356
+ * handled the pop (prevents route resolution). If no listener returns
357
+ * `true`, normal route resolution proceeds.
358
+ *
359
+ * @param {(key: string, data: any, action: string) => boolean|void} fn
360
+ * @returns {() => void} unsubscribe function
361
+ *
362
+ * @example
363
+ * const unsub = router.onSubstate((key, data) => {
364
+ * if (key === 'modal') { closeModal(); return true; }
365
+ * });
366
+ */
367
+ onSubstate(fn) {
368
+ this._substateListeners.push(fn);
369
+ return () => {
370
+ this._substateListeners = this._substateListeners.filter(f => f !== fn);
371
+ };
372
+ }
373
+
374
+ /**
375
+ * Fire substate listeners. Returns true if any listener handled it.
376
+ * @private
377
+ */
378
+ _fireSubstate(key, data, action) {
379
+ for (const fn of this._substateListeners) {
380
+ try {
381
+ if (fn(key, data, action) === true) return true;
382
+ } catch (err) {
383
+ reportError(ErrorCode.ROUTER_GUARD, 'onSubstate listener threw', { key, data }, err);
384
+ }
385
+ }
386
+ return false;
387
+ }
388
+
205
389
  // --- Current state -------------------------------------------------------
206
390
 
207
391
  get current() { return this._current; }
@@ -253,6 +437,7 @@ class Router {
253
437
  // Prevent re-entrant calls (e.g. listener triggering navigation)
254
438
  if (this._resolving) return;
255
439
  this._resolving = true;
440
+ this._redirectCount = 0;
256
441
  try {
257
442
  await this.__resolve();
258
443
  } finally {
@@ -261,6 +446,16 @@ class Router {
261
446
  }
262
447
 
263
448
  async __resolve() {
449
+ // Check if we're landing on a substate entry (e.g. page refresh on a
450
+ // substate bookmark, or hash-mode popstate). Fire listeners and bail
451
+ // if handled — the URL hasn't changed so there's no route to resolve.
452
+ const histState = window.history.state;
453
+ if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
454
+ const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
455
+ if (handled) return;
456
+ // No listener handled it — fall through to normal routing
457
+ }
458
+
264
459
  const fullPath = this.path;
265
460
  const [pathPart, queryString] = fullPath.split('?');
266
461
  const path = pathPart || '/';
@@ -288,13 +483,43 @@ class Router {
288
483
  const to = { route: matched, params, query, path };
289
484
  const from = this._current;
290
485
 
486
+ // Same-route optimization: if the resolved route is the same component
487
+ // with the same params, skip the full destroy/mount cycle and just
488
+ // update props. This prevents flashing and unnecessary DOM churn.
489
+ if (from && this._instance && matched.component === from.route.component) {
490
+ const sameParams = JSON.stringify(params) === JSON.stringify(from.params);
491
+ const sameQuery = JSON.stringify(query) === JSON.stringify(from.query);
492
+ if (sameParams && sameQuery) {
493
+ // Identical navigation — nothing to do
494
+ return;
495
+ }
496
+ }
497
+
291
498
  // Run before guards
292
499
  for (const guard of this._guards.before) {
293
500
  try {
294
501
  const result = await guard(to, from);
295
502
  if (result === false) return; // Cancel
296
503
  if (typeof result === 'string') { // Redirect
297
- return this.navigate(result);
504
+ if (++this._redirectCount > 10) {
505
+ reportError(ErrorCode.ROUTER_GUARD, 'Too many guard redirects (possible loop)', { to }, null);
506
+ return;
507
+ }
508
+ // Update URL directly and re-resolve (avoids re-entrancy block)
509
+ const [rPath, rFrag] = result.split('#');
510
+ const rNorm = this._normalizePath(rPath || '/');
511
+ const rHash = rFrag ? '#' + rFrag : '';
512
+ if (this._mode === 'hash') {
513
+ if (rFrag) window.__zqScrollTarget = rFrag;
514
+ window.location.replace('#' + rNorm);
515
+ } else {
516
+ window.history.replaceState(
517
+ { [_ZQ_STATE_KEY]: 'route' },
518
+ '',
519
+ this._base + rNorm + rHash
520
+ );
521
+ }
522
+ return this.__resolve();
298
523
  }
299
524
  } catch (err) {
300
525
  reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
@@ -315,6 +540,12 @@ class Router {
315
540
 
316
541
  // Mount component into outlet
317
542
  if (this._el && matched.component) {
543
+ // Pre-load external templates/styles so the mount renders synchronously
544
+ // (keeps old content visible during the fetch instead of showing blank)
545
+ if (typeof matched.component === 'string') {
546
+ await prefetch(matched.component);
547
+ }
548
+
318
549
  // Destroy previous
319
550
  if (this._instance) {
320
551
  this._instance.destroy();
@@ -322,6 +553,7 @@ class Router {
322
553
  }
323
554
 
324
555
  // Create container
556
+ const _routeStart = typeof window !== 'undefined' && window.__zqRenderHook ? performance.now() : 0;
325
557
  this._el.innerHTML = '';
326
558
 
327
559
  // Pass route params and query as props
@@ -332,10 +564,12 @@ class Router {
332
564
  const container = document.createElement(matched.component);
333
565
  this._el.appendChild(container);
334
566
  this._instance = mount(container, matched.component, props);
567
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', matched.component);
335
568
  }
336
569
  // If component is a render function
337
570
  else if (typeof matched.component === 'function') {
338
571
  this._el.innerHTML = matched.component(to);
572
+ if (_routeStart) window.__zqRenderHook(this._el, performance.now() - _routeStart, 'route', to);
339
573
  }
340
574
  }
341
575
 
@@ -353,6 +587,8 @@ class Router {
353
587
  destroy() {
354
588
  if (this._instance) this._instance.destroy();
355
589
  this._listeners.clear();
590
+ this._substateListeners = [];
591
+ this._inSubstate = false;
356
592
  this._routes = [];
357
593
  this._guards = { before: [], after: [] };
358
594
  }