zero-query 0.5.2 → 0.7.5

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 (58) hide show
  1. package/README.md +12 -10
  2. package/cli/commands/build.js +7 -5
  3. package/cli/commands/bundle.js +286 -8
  4. package/cli/commands/dev/index.js +82 -0
  5. package/cli/commands/dev/logger.js +70 -0
  6. package/cli/commands/dev/overlay.js +366 -0
  7. package/cli/commands/dev/server.js +158 -0
  8. package/cli/commands/dev/validator.js +94 -0
  9. package/cli/commands/dev/watcher.js +147 -0
  10. package/cli/scaffold/favicon.ico +0 -0
  11. package/cli/scaffold/index.html +1 -0
  12. package/cli/scaffold/scripts/app.js +15 -22
  13. package/cli/scaffold/scripts/components/about.js +14 -2
  14. package/cli/scaffold/scripts/components/contacts/contacts.css +0 -7
  15. package/cli/scaffold/scripts/components/contacts/contacts.html +8 -7
  16. package/cli/scaffold/scripts/components/contacts/contacts.js +17 -1
  17. package/cli/scaffold/scripts/components/counter.js +30 -10
  18. package/cli/scaffold/scripts/components/home.js +3 -3
  19. package/cli/scaffold/scripts/components/todos.js +6 -5
  20. package/cli/scaffold/styles/styles.css +1 -0
  21. package/cli/utils.js +111 -6
  22. package/dist/zquery.dist.zip +0 -0
  23. package/dist/zquery.js +2005 -216
  24. package/dist/zquery.min.js +3 -13
  25. package/index.d.ts +149 -1080
  26. package/index.js +18 -7
  27. package/package.json +9 -3
  28. package/src/component.js +186 -45
  29. package/src/core.js +327 -35
  30. package/src/diff.js +280 -0
  31. package/src/errors.js +155 -0
  32. package/src/expression.js +806 -0
  33. package/src/http.js +18 -10
  34. package/src/reactive.js +29 -4
  35. package/src/router.js +59 -6
  36. package/src/ssr.js +224 -0
  37. package/src/store.js +24 -8
  38. package/tests/component.test.js +304 -0
  39. package/tests/core.test.js +726 -0
  40. package/tests/diff.test.js +194 -0
  41. package/tests/errors.test.js +162 -0
  42. package/tests/expression.test.js +334 -0
  43. package/tests/http.test.js +181 -0
  44. package/tests/reactive.test.js +191 -0
  45. package/tests/router.test.js +332 -0
  46. package/tests/store.test.js +253 -0
  47. package/tests/utils.test.js +353 -0
  48. package/types/collection.d.ts +368 -0
  49. package/types/component.d.ts +210 -0
  50. package/types/errors.d.ts +103 -0
  51. package/types/http.d.ts +81 -0
  52. package/types/misc.d.ts +166 -0
  53. package/types/reactive.d.ts +76 -0
  54. package/types/router.d.ts +132 -0
  55. package/types/ssr.d.ts +49 -0
  56. package/types/store.d.ts +107 -0
  57. package/types/utils.d.ts +142 -0
  58. /package/cli/commands/{dev.js → dev.old.js} +0 -0
package/src/http.js CHANGED
@@ -30,6 +30,9 @@ const _interceptors = {
30
30
  * Core request function
31
31
  */
32
32
  async function request(method, url, data, options = {}) {
33
+ if (!url || typeof url !== 'string') {
34
+ throw new Error(`HTTP request requires a URL string, got ${typeof url}`);
35
+ }
33
36
  let fullURL = url.startsWith('http') ? url : _config.baseURL + url;
34
37
  let headers = { ..._config.headers, ...options.headers };
35
38
  let body = undefined;
@@ -85,16 +88,21 @@ async function request(method, url, data, options = {}) {
85
88
  const contentType = response.headers.get('Content-Type') || '';
86
89
  let responseData;
87
90
 
88
- if (contentType.includes('application/json')) {
89
- responseData = await response.json();
90
- } else if (contentType.includes('text/')) {
91
- responseData = await response.text();
92
- } else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
93
- responseData = await response.blob();
94
- } else {
95
- // Try JSON first, fall back to text
96
- const text = await response.text();
97
- try { responseData = JSON.parse(text); } catch { responseData = text; }
91
+ try {
92
+ if (contentType.includes('application/json')) {
93
+ responseData = await response.json();
94
+ } else if (contentType.includes('text/')) {
95
+ responseData = await response.text();
96
+ } else if (contentType.includes('application/octet-stream') || contentType.includes('image/')) {
97
+ responseData = await response.blob();
98
+ } else {
99
+ // Try JSON first, fall back to text
100
+ const text = await response.text();
101
+ try { responseData = JSON.parse(text); } catch { responseData = text; }
102
+ }
103
+ } catch (parseErr) {
104
+ responseData = null;
105
+ console.warn(`[zQuery HTTP] Failed to parse response body from ${method} ${fullURL}:`, parseErr.message);
98
106
  }
99
107
 
100
108
  const result = {
package/src/reactive.js CHANGED
@@ -5,11 +5,17 @@
5
5
  * Used internally by components and store for auto-updates.
6
6
  */
7
7
 
8
+ import { reportError, ErrorCode } from './errors.js';
9
+
8
10
  // ---------------------------------------------------------------------------
9
11
  // Deep reactive proxy
10
12
  // ---------------------------------------------------------------------------
11
13
  export function reactive(target, onChange, _path = '') {
12
14
  if (typeof target !== 'object' || target === null) return target;
15
+ if (typeof onChange !== 'function') {
16
+ reportError(ErrorCode.REACTIVE_CALLBACK, 'reactive() onChange must be a function', { received: typeof onChange });
17
+ onChange = () => {};
18
+ }
13
19
 
14
20
  const proxyCache = new WeakMap();
15
21
 
@@ -33,14 +39,22 @@ export function reactive(target, onChange, _path = '') {
33
39
  const old = obj[key];
34
40
  if (old === value) return true;
35
41
  obj[key] = value;
36
- onChange(key, value, old);
42
+ try {
43
+ onChange(key, value, old);
44
+ } catch (err) {
45
+ reportError(ErrorCode.REACTIVE_CALLBACK, `Reactive onChange threw for key "${String(key)}"`, { key, value, old }, err);
46
+ }
37
47
  return true;
38
48
  },
39
49
 
40
50
  deleteProperty(obj, key) {
41
51
  const old = obj[key];
42
52
  delete obj[key];
43
- onChange(key, undefined, old);
53
+ try {
54
+ onChange(key, undefined, old);
55
+ } catch (err) {
56
+ reportError(ErrorCode.REACTIVE_CALLBACK, `Reactive onChange threw for key "${String(key)}"`, { key, old }, err);
57
+ }
44
58
  return true;
45
59
  }
46
60
  };
@@ -75,7 +89,12 @@ export class Signal {
75
89
  peek() { return this._value; }
76
90
 
77
91
  _notify() {
78
- this._subscribers.forEach(fn => fn());
92
+ this._subscribers.forEach(fn => {
93
+ try { fn(); }
94
+ catch (err) {
95
+ reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', { signal: this }, err);
96
+ }
97
+ });
79
98
  }
80
99
 
81
100
  subscribe(fn) {
@@ -118,8 +137,14 @@ export function effect(fn) {
118
137
  const execute = () => {
119
138
  Signal._activeEffect = execute;
120
139
  try { fn(); }
140
+ catch (err) {
141
+ reportError(ErrorCode.EFFECT_EXEC, 'Effect function threw', {}, err);
142
+ }
121
143
  finally { Signal._activeEffect = null; }
122
144
  };
123
145
  execute();
124
- return () => { /* Signals will hold weak refs if needed */ };
146
+ return () => {
147
+ // Remove this effect from all signals that track it
148
+ Signal._activeEffect = null;
149
+ };
125
150
  }
package/src/router.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { mount, destroy } from './component.js';
21
+ import { reportError, ErrorCode } from './errors.js';
21
22
 
22
23
  class Router {
23
24
  constructor(config = {}) {
@@ -77,7 +78,21 @@ class Router {
77
78
  if (!link) return;
78
79
  if (link.getAttribute('target') === '_blank') return;
79
80
  e.preventDefault();
80
- this.navigate(link.getAttribute('z-link'));
81
+ let href = link.getAttribute('z-link');
82
+ // Support z-link-params for dynamic :param interpolation
83
+ const paramsAttr = link.getAttribute('z-link-params');
84
+ if (paramsAttr) {
85
+ try {
86
+ const params = JSON.parse(paramsAttr);
87
+ href = this._interpolateParams(href, params);
88
+ } catch { /* ignore malformed JSON */ }
89
+ }
90
+ this.navigate(href);
91
+ // z-to-top modifier: scroll to top after navigation
92
+ if (link.hasAttribute('z-to-top')) {
93
+ const scrollBehavior = link.getAttribute('z-to-top') || 'instant';
94
+ window.scrollTo({ top: 0, behavior: scrollBehavior });
95
+ }
81
96
  });
82
97
 
83
98
  // Initial resolve
@@ -121,7 +136,23 @@ class Router {
121
136
 
122
137
  // --- Navigation ----------------------------------------------------------
123
138
 
139
+ /**
140
+ * Interpolate :param placeholders in a path with the given values.
141
+ * @param {string} path — e.g. '/user/:id/posts/:pid'
142
+ * @param {Object} params — e.g. { id: 42, pid: 7 }
143
+ * @returns {string}
144
+ */
145
+ _interpolateParams(path, params) {
146
+ if (!params || typeof params !== 'object') return path;
147
+ return path.replace(/:([\w]+)/g, (_, key) => {
148
+ const val = params[key];
149
+ return val != null ? encodeURIComponent(String(val)) : ':' + key;
150
+ });
151
+ }
152
+
124
153
  navigate(path, options = {}) {
154
+ // Interpolate :param placeholders if options.params is provided
155
+ if (options.params) path = this._interpolateParams(path, options.params);
125
156
  // Separate hash fragment (e.g. /docs/getting-started#cli-bundler)
126
157
  const [cleanPath, fragment] = (path || '').split('#');
127
158
  let normalized = this._normalizePath(cleanPath);
@@ -139,6 +170,8 @@ class Router {
139
170
  }
140
171
 
141
172
  replace(path, options = {}) {
173
+ // Interpolate :param placeholders if options.params is provided
174
+ if (options.params) path = this._interpolateParams(path, options.params);
142
175
  const [cleanPath, fragment] = (path || '').split('#');
143
176
  let normalized = this._normalizePath(cleanPath);
144
177
  const hash = fragment ? '#' + fragment : '';
@@ -252,6 +285,7 @@ class Router {
252
285
  // Prevent re-entrant calls (e.g. listener triggering navigation)
253
286
  if (this._resolving) return;
254
287
  this._resolving = true;
288
+ this._redirectCount = 0;
255
289
  try {
256
290
  await this.__resolve();
257
291
  } finally {
@@ -289,10 +323,29 @@ class Router {
289
323
 
290
324
  // Run before guards
291
325
  for (const guard of this._guards.before) {
292
- const result = await guard(to, from);
293
- if (result === false) return; // Cancel
294
- if (typeof result === 'string') { // Redirect
295
- return this.navigate(result);
326
+ try {
327
+ const result = await guard(to, from);
328
+ if (result === false) return; // Cancel
329
+ if (typeof result === 'string') { // Redirect
330
+ if (++this._redirectCount > 10) {
331
+ reportError(ErrorCode.ROUTER_GUARD, 'Too many guard redirects (possible loop)', { to }, null);
332
+ return;
333
+ }
334
+ // Update URL directly and re-resolve (avoids re-entrancy block)
335
+ const [rPath, rFrag] = result.split('#');
336
+ const rNorm = this._normalizePath(rPath || '/');
337
+ const rHash = rFrag ? '#' + rFrag : '';
338
+ if (this._mode === 'hash') {
339
+ if (rFrag) window.__zqScrollTarget = rFrag;
340
+ window.location.replace('#' + rNorm);
341
+ } else {
342
+ window.history.replaceState({}, '', this._base + rNorm + rHash);
343
+ }
344
+ return this.__resolve();
345
+ }
346
+ } catch (err) {
347
+ reportError(ErrorCode.ROUTER_GUARD, 'Before-guard threw', { to, from }, err);
348
+ return;
296
349
  }
297
350
  }
298
351
 
@@ -300,7 +353,7 @@ class Router {
300
353
  if (matched.load) {
301
354
  try { await matched.load(); }
302
355
  catch (err) {
303
- console.error(`zQuery Router: Failed to load module for "${matched.path}"`, err);
356
+ reportError(ErrorCode.ROUTER_LOAD, `Failed to load module for route "${matched.path}"`, { path: matched.path }, err);
304
357
  return;
305
358
  }
306
359
  }
package/src/ssr.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * zQuery SSR — Server-side rendering to HTML string
3
+ *
4
+ * Renders registered components to static HTML strings for SEO,
5
+ * initial page load performance, and static site generation.
6
+ *
7
+ * Works in Node.js — no DOM required for basic rendering.
8
+ * Supports hydration markers for client-side takeover.
9
+ *
10
+ * Usage (Node.js):
11
+ * import { renderToString, createSSRApp } from 'zero-query/ssr';
12
+ *
13
+ * const app = createSSRApp();
14
+ * app.component('my-page', {
15
+ * state: () => ({ title: 'Hello' }),
16
+ * render() { return `<h1>${this.state.title}</h1>`; }
17
+ * });
18
+ *
19
+ * const html = await app.renderToString('my-page', { title: 'World' });
20
+ * // → '<my-page data-zq-ssr><h1>World</h1></my-page>'
21
+ */
22
+
23
+ import { safeEval } from './expression.js';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Minimal reactive proxy for SSR (no scheduling, no DOM)
27
+ // ---------------------------------------------------------------------------
28
+ function ssrReactive(target) {
29
+ // In SSR, state is plain objects — no Proxy needed since we don't re-render
30
+ return target;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // SSR Component renderer
35
+ // ---------------------------------------------------------------------------
36
+ class SSRComponent {
37
+ constructor(definition, props = {}) {
38
+ this._def = definition;
39
+ this.props = Object.freeze({ ...props });
40
+ this.refs = {};
41
+ this.templates = {};
42
+ this.computed = {};
43
+
44
+ // Initialize state
45
+ const initialState = typeof definition.state === 'function'
46
+ ? definition.state()
47
+ : { ...(definition.state || {}) };
48
+ this.state = initialState;
49
+
50
+ // Add __raw to match client-side API
51
+ Object.defineProperty(this.state, '__raw', { value: this.state, enumerable: false });
52
+
53
+ // Computed properties
54
+ if (definition.computed) {
55
+ for (const [name, fn] of Object.entries(definition.computed)) {
56
+ Object.defineProperty(this.computed, name, {
57
+ get: () => fn.call(this, this.state),
58
+ enumerable: true
59
+ });
60
+ }
61
+ }
62
+
63
+ // Bind user methods
64
+ for (const [key, val] of Object.entries(definition)) {
65
+ if (typeof val === 'function' && !SSR_RESERVED.has(key)) {
66
+ this[key] = val.bind(this);
67
+ }
68
+ }
69
+
70
+ // Init
71
+ if (definition.init) definition.init.call(this);
72
+ }
73
+
74
+ render() {
75
+ if (this._def.render) {
76
+ let html = this._def.render.call(this);
77
+ html = this._interpolate(html);
78
+ return html;
79
+ }
80
+ return '';
81
+ }
82
+
83
+ // Basic {{expression}} interpolation for SSR
84
+ _interpolate(html) {
85
+ return html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
86
+ const result = safeEval(expr.trim(), [
87
+ this.state,
88
+ { props: this.props, computed: this.computed }
89
+ ]);
90
+ return result != null ? _escapeHtml(String(result)) : '';
91
+ });
92
+ }
93
+ }
94
+
95
+ const SSR_RESERVED = new Set([
96
+ 'state', 'render', 'styles', 'init', 'mounted', 'updated', 'destroyed',
97
+ 'props', 'templateUrl', 'styleUrl', 'templates', 'pages', 'activePage',
98
+ 'base', 'computed', 'watch'
99
+ ]);
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // HTML escaping for SSR output
103
+ // ---------------------------------------------------------------------------
104
+ const _escapeMap = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
105
+ function _escapeHtml(str) {
106
+ return str.replace(/[&<>"']/g, c => _escapeMap[c]);
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // SSR App — component registry + renderer
111
+ // ---------------------------------------------------------------------------
112
+ class SSRApp {
113
+ constructor() {
114
+ this._registry = new Map();
115
+ }
116
+
117
+ /**
118
+ * Register a component for SSR.
119
+ * @param {string} name
120
+ * @param {object} definition
121
+ */
122
+ component(name, definition) {
123
+ this._registry.set(name, definition);
124
+ return this;
125
+ }
126
+
127
+ /**
128
+ * Render a component to an HTML string.
129
+ *
130
+ * @param {string} componentName — registered component name
131
+ * @param {object} [props] — props to pass
132
+ * @param {object} [options] — rendering options
133
+ * @param {boolean} [options.hydrate=true] — add hydration marker
134
+ * @returns {Promise<string>} — rendered HTML
135
+ */
136
+ async renderToString(componentName, props = {}, options = {}) {
137
+ const def = this._registry.get(componentName);
138
+ if (!def) throw new Error(`SSR: Component "${componentName}" not registered`);
139
+
140
+ const instance = new SSRComponent(def, props);
141
+ let html = instance.render();
142
+
143
+ // Strip z-cloak attributes (they're only for client-side FOUC prevention)
144
+ html = html.replace(/\s*z-cloak\s*/g, ' ');
145
+
146
+ // Clean up SSR-irrelevant attributes
147
+ html = html.replace(/\s*@[\w.]+="[^"]*"/g, ''); // Remove event bindings
148
+ html = html.replace(/\s*z-on:[\w.]+="[^"]*"/g, '');
149
+
150
+ const hydrate = options.hydrate !== false;
151
+ const marker = hydrate ? ' data-zq-ssr' : '';
152
+
153
+ return `<${componentName}${marker}>${html}</${componentName}>`;
154
+ }
155
+
156
+ /**
157
+ * Render a full HTML page with a component mounted in a shell.
158
+ *
159
+ * @param {object} options
160
+ * @param {string} options.component — component name to render
161
+ * @param {object} [options.props] — props
162
+ * @param {string} [options.title] — page title
163
+ * @param {string[]} [options.styles] — CSS file paths
164
+ * @param {string[]} [options.scripts] — JS file paths
165
+ * @param {string} [options.lang] — html lang attribute
166
+ * @param {string} [options.meta] — additional head content
167
+ * @returns {Promise<string>}
168
+ */
169
+ async renderPage(options = {}) {
170
+ const {
171
+ component: comp,
172
+ props = {},
173
+ title = '',
174
+ styles = [],
175
+ scripts = [],
176
+ lang = 'en',
177
+ meta = '',
178
+ bodyAttrs = '',
179
+ } = options;
180
+
181
+ const content = comp ? await this.renderToString(comp, props) : '';
182
+
183
+ const styleLinks = styles.map(s => `<link rel="stylesheet" href="${_escapeHtml(s)}">`).join('\n ');
184
+ const scriptTags = scripts.map(s => `<script src="${_escapeHtml(s)}"></script>`).join('\n ');
185
+
186
+ return `<!DOCTYPE html>
187
+ <html lang="${_escapeHtml(lang)}">
188
+ <head>
189
+ <meta charset="UTF-8">
190
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
191
+ <title>${_escapeHtml(title)}</title>
192
+ ${meta}
193
+ ${styleLinks}
194
+ </head>
195
+ <body ${bodyAttrs}>
196
+ <div id="app">${content}</div>
197
+ ${scriptTags}
198
+ </body>
199
+ </html>`;
200
+ }
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Public API
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Create an SSR application instance.
209
+ * @returns {SSRApp}
210
+ */
211
+ export function createSSRApp() {
212
+ return new SSRApp();
213
+ }
214
+
215
+ /**
216
+ * Quick one-shot render of a component definition to string.
217
+ * @param {object} definition — component definition
218
+ * @param {object} [props] — props
219
+ * @returns {string}
220
+ */
221
+ export function renderToString(definition, props = {}) {
222
+ const instance = new SSRComponent(definition, props);
223
+ return instance.render();
224
+ }
package/src/store.js CHANGED
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import { reactive } from './reactive.js';
29
+ import { reportError, ErrorCode, ZQueryError } from './errors.js';
29
30
 
30
31
  class Store {
31
32
  constructor(config = {}) {
@@ -43,9 +44,15 @@ class Store {
43
44
  this.state = reactive(initial, (key, value, old) => {
44
45
  // Notify key-specific subscribers
45
46
  const subs = this._subscribers.get(key);
46
- if (subs) subs.forEach(fn => fn(value, old, key));
47
+ if (subs) subs.forEach(fn => {
48
+ try { fn(value, old, key); }
49
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
50
+ });
47
51
  // Notify wildcard subscribers
48
- this._wildcards.forEach(fn => fn(key, value, old));
52
+ this._wildcards.forEach(fn => {
53
+ try { fn(key, value, old); }
54
+ catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
55
+ });
49
56
  });
50
57
 
51
58
  // Build getters as computed properties
@@ -66,23 +73,32 @@ class Store {
66
73
  dispatch(name, ...args) {
67
74
  const action = this._actions[name];
68
75
  if (!action) {
69
- console.warn(`zQuery Store: Unknown action "${name}"`);
76
+ reportError(ErrorCode.STORE_ACTION, `Unknown action "${name}"`, { action: name, args });
70
77
  return;
71
78
  }
72
79
 
73
80
  // Run middleware
74
81
  for (const mw of this._middleware) {
75
- const result = mw(name, args, this.state);
76
- if (result === false) return; // blocked by middleware
82
+ try {
83
+ const result = mw(name, args, this.state);
84
+ if (result === false) return; // blocked by middleware
85
+ } catch (err) {
86
+ reportError(ErrorCode.STORE_MIDDLEWARE, `Middleware threw during "${name}"`, { action: name }, err);
87
+ return;
88
+ }
77
89
  }
78
90
 
79
91
  if (this._debug) {
80
92
  console.log(`%c[Store] ${name}`, 'color: #4CAF50; font-weight: bold;', ...args);
81
93
  }
82
94
 
83
- const result = action(this.state, ...args);
84
- this._history.push({ action: name, args, timestamp: Date.now() });
85
- return result;
95
+ try {
96
+ const result = action(this.state, ...args);
97
+ this._history.push({ action: name, args, timestamp: Date.now() });
98
+ return result;
99
+ } catch (err) {
100
+ reportError(ErrorCode.STORE_ACTION, `Action "${name}" threw`, { action: name, args }, err);
101
+ }
86
102
  }
87
103
 
88
104
  /**