zero-query 0.9.8 → 0.9.9

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/index.js CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  capitalize, truncate, clamp,
28
28
  memoize, retry, timeout,
29
29
  } from './src/utils.js';
30
- import { ZQueryError, ErrorCode, onError, reportError, guardCallback, validate } from './src/errors.js';
30
+ import { ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError } from './src/errors.js';
31
31
 
32
32
 
33
33
  // ---------------------------------------------------------------------------
@@ -173,7 +173,9 @@ $.onError = onError;
173
173
  $.ZQueryError = ZQueryError;
174
174
  $.ErrorCode = ErrorCode;
175
175
  $.guardCallback = guardCallback;
176
+ $.guardAsync = guardAsync;
176
177
  $.validate = validate;
178
+ $.formatError = formatError;
177
179
 
178
180
  // --- Meta ------------------------------------------------------------------
179
181
  $.version = '__VERSION__';
@@ -213,7 +215,7 @@ export {
213
215
  createRouter, getRouter,
214
216
  createStore, getStore,
215
217
  http,
216
- ZQueryError, ErrorCode, onError, reportError, guardCallback, validate,
218
+ ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError,
217
219
  debounce, throttle, pipe, once, sleep,
218
220
  escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
219
221
  deepClone, deepMerge, isEqual, param, parseQuery,
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "description": "Lightweight modern frontend library — jQuery-like selectors, reactive components, SPA router, and state management with zero dependencies.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./ssr": "./src/ssr.js",
10
+ "./src/*": "./src/*"
11
+ },
7
12
  "bin": {
8
13
  "zquery": "cli/index.js"
9
14
  },
@@ -26,7 +31,8 @@
26
31
  "bundle": "node cli/index.js bundle",
27
32
  "bundle:app": "node cli/index.js bundle zquery-website",
28
33
  "test": "vitest run",
29
- "test:watch": "vitest"
34
+ "test:watch": "vitest",
35
+ "test:ssr": "node scripts/test-ssr.js"
30
36
  },
31
37
  "keywords": [
32
38
  "dom",
package/src/errors.js CHANGED
@@ -49,6 +49,12 @@ export const ErrorCode = Object.freeze({
49
49
  HTTP_INTERCEPTOR: 'ZQ_HTTP_INTERCEPTOR',
50
50
  HTTP_PARSE: 'ZQ_HTTP_PARSE',
51
51
 
52
+ // SSR
53
+ SSR_RENDER: 'ZQ_SSR_RENDER',
54
+ SSR_COMPONENT: 'ZQ_SSR_COMPONENT',
55
+ SSR_HYDRATION: 'ZQ_SSR_HYDRATION',
56
+ SSR_PAGE: 'ZQ_SSR_PAGE',
57
+
52
58
  // General
53
59
  INVALID_ARGUMENT: 'ZQ_INVALID_ARGUMENT',
54
60
  });
@@ -77,16 +83,28 @@ export class ZQueryError extends Error {
77
83
  // ---------------------------------------------------------------------------
78
84
  // Global error handler
79
85
  // ---------------------------------------------------------------------------
80
- let _errorHandler = null;
86
+ let _errorHandlers = [];
81
87
 
82
88
  /**
83
89
  * Register a global error handler.
84
90
  * Called whenever zQuery catches an error internally.
91
+ * Multiple handlers are supported — each receives the error.
92
+ * Pass `null` to clear all handlers.
85
93
  *
86
94
  * @param {Function|null} handler — (error: ZQueryError) => void
95
+ * @returns {Function} unsubscribe function to remove this handler
87
96
  */
88
97
  export function onError(handler) {
89
- _errorHandler = typeof handler === 'function' ? handler : null;
98
+ if (handler === null) {
99
+ _errorHandlers = [];
100
+ return () => {};
101
+ }
102
+ if (typeof handler !== 'function') return () => {};
103
+ _errorHandlers.push(handler);
104
+ return () => {
105
+ const idx = _errorHandlers.indexOf(handler);
106
+ if (idx !== -1) _errorHandlers.splice(idx, 1);
107
+ };
90
108
  }
91
109
 
92
110
  /**
@@ -103,9 +121,9 @@ export function reportError(code, message, context = {}, cause) {
103
121
  ? cause
104
122
  : new ZQueryError(code, message, context, cause);
105
123
 
106
- // User handler gets first crack
107
- if (_errorHandler) {
108
- try { _errorHandler(err); } catch { /* prevent handler from crashing framework */ }
124
+ // Notify all registered handlers
125
+ for (const handler of _errorHandlers) {
126
+ try { handler(err); } catch { /* prevent handler from crashing framework */ }
109
127
  }
110
128
 
111
129
  // Always log for developer visibility
@@ -153,3 +171,39 @@ export function validate(value, name, expectedType) {
153
171
  );
154
172
  }
155
173
  }
174
+
175
+ /**
176
+ * Format a ZQueryError into a structured object suitable for overlays/logging.
177
+ * @param {ZQueryError|Error} err
178
+ * @returns {{ code: string, type: string, message: string, context: object, stack: string }}
179
+ */
180
+ export function formatError(err) {
181
+ const isZQ = err instanceof ZQueryError;
182
+ return {
183
+ code: isZQ ? err.code : '',
184
+ type: isZQ ? 'ZQueryError' : (err.name || 'Error'),
185
+ message: err.message || 'Unknown error',
186
+ context: isZQ ? err.context : {},
187
+ stack: err.stack || '',
188
+ cause: err.cause ? formatError(err.cause) : null,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Async version of guardCallback — wraps an async function so that
194
+ * rejections are caught, reported, and don't crash execution.
195
+ *
196
+ * @param {Function} fn — async function
197
+ * @param {string} code — ErrorCode to use
198
+ * @param {object} [context]
199
+ * @returns {Function}
200
+ */
201
+ export function guardAsync(fn, code, context = {}) {
202
+ return async (...args) => {
203
+ try {
204
+ return await fn(...args);
205
+ } catch (err) {
206
+ reportError(code, err.message || 'Async callback error', context, err);
207
+ }
208
+ };
209
+ }
@@ -0,0 +1 @@
1
+ { "type": "module" }
package/src/ssr.js CHANGED
@@ -21,14 +21,7 @@
21
21
  */
22
22
 
23
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
- }
24
+ import { reportError, ErrorCode, ZQueryError } from './errors.js';
32
25
 
33
26
  // ---------------------------------------------------------------------------
34
27
  // SSR Component renderer
@@ -54,7 +47,14 @@ class SSRComponent {
54
47
  if (definition.computed) {
55
48
  for (const [name, fn] of Object.entries(definition.computed)) {
56
49
  Object.defineProperty(this.computed, name, {
57
- get: () => fn.call(this, this.state),
50
+ get: () => {
51
+ try {
52
+ return fn.call(this, this.state);
53
+ } catch (err) {
54
+ reportError(ErrorCode.SSR_RENDER, `Computed property "${name}" threw during SSR`, { property: name }, err);
55
+ return undefined;
56
+ }
57
+ },
58
58
  enumerable: true
59
59
  });
60
60
  }
@@ -67,15 +67,26 @@ class SSRComponent {
67
67
  }
68
68
  }
69
69
 
70
- // Init
71
- if (definition.init) definition.init.call(this);
70
+ // Init lifecycle — guarded so a broken init doesn't crash the whole render
71
+ if (definition.init) {
72
+ try {
73
+ definition.init.call(this);
74
+ } catch (err) {
75
+ reportError(ErrorCode.SSR_RENDER, 'Component init() threw during SSR', {}, err);
76
+ }
77
+ }
72
78
  }
73
79
 
74
80
  render() {
75
81
  if (this._def.render) {
76
- let html = this._def.render.call(this);
77
- html = this._interpolate(html);
78
- return html;
82
+ try {
83
+ let html = this._def.render.call(this);
84
+ html = this._interpolate(html);
85
+ return html;
86
+ } catch (err) {
87
+ reportError(ErrorCode.SSR_RENDER, 'Component render() threw during SSR', {}, err);
88
+ return `<!-- SSR render error: ${_escapeHtml(err.message)} -->`;
89
+ }
79
90
  }
80
91
  return '';
81
92
  }
@@ -83,11 +94,16 @@ class SSRComponent {
83
94
  // Basic {{expression}} interpolation for SSR
84
95
  _interpolate(html) {
85
96
  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)) : '';
97
+ try {
98
+ const result = safeEval(expr.trim(), [
99
+ this.state,
100
+ { props: this.props, computed: this.computed }
101
+ ]);
102
+ return result != null ? _escapeHtml(String(result)) : '';
103
+ } catch (err) {
104
+ reportError(ErrorCode.SSR_RENDER, `Expression "{{${expr.trim()}}}" failed during SSR`, { expression: expr.trim() }, err);
105
+ return '';
106
+ }
91
107
  });
92
108
  }
93
109
  }
@@ -120,10 +136,25 @@ class SSRApp {
120
136
  * @param {object} definition
121
137
  */
122
138
  component(name, definition) {
139
+ if (typeof name !== 'string' || !name) {
140
+ throw new ZQueryError(ErrorCode.SSR_COMPONENT, 'Component name must be a non-empty string');
141
+ }
142
+ if (!definition || typeof definition !== 'object') {
143
+ throw new ZQueryError(ErrorCode.SSR_COMPONENT, `Invalid definition for component "${name}"`);
144
+ }
123
145
  this._registry.set(name, definition);
124
146
  return this;
125
147
  }
126
148
 
149
+ /**
150
+ * Check whether a component is registered.
151
+ * @param {string} name
152
+ * @returns {boolean}
153
+ */
154
+ has(name) {
155
+ return this._registry.has(name);
156
+ }
157
+
127
158
  /**
128
159
  * Render a component to an HTML string.
129
160
  *
@@ -131,11 +162,18 @@ class SSRApp {
131
162
  * @param {object} [props] — props to pass
132
163
  * @param {object} [options] — rendering options
133
164
  * @param {boolean} [options.hydrate=true] — add hydration marker
165
+ * @param {string} [options.mode='html'] — 'html' (default) or 'fragment' (no wrapper tag)
134
166
  * @returns {Promise<string>} — rendered HTML
135
167
  */
136
168
  async renderToString(componentName, props = {}, options = {}) {
137
169
  const def = this._registry.get(componentName);
138
- if (!def) throw new Error(`SSR: Component "${componentName}" not registered`);
170
+ if (!def) {
171
+ throw new ZQueryError(
172
+ ErrorCode.SSR_COMPONENT,
173
+ `SSR: Component "${componentName}" not registered`,
174
+ { component: componentName }
175
+ );
176
+ }
139
177
 
140
178
  const instance = new SSRComponent(def, props);
141
179
  let html = instance.render();
@@ -147,12 +185,27 @@ class SSRApp {
147
185
  html = html.replace(/\s*@[\w.]+="[^"]*"/g, ''); // Remove event bindings
148
186
  html = html.replace(/\s*z-on:[\w.]+="[^"]*"/g, '');
149
187
 
188
+ // Fragment mode — return inner HTML without wrapper tag
189
+ if (options.mode === 'fragment') return html;
190
+
150
191
  const hydrate = options.hydrate !== false;
151
192
  const marker = hydrate ? ' data-zq-ssr' : '';
152
193
 
153
194
  return `<${componentName}${marker}>${html}</${componentName}>`;
154
195
  }
155
196
 
197
+ /**
198
+ * Render multiple components as a batch.
199
+ *
200
+ * @param {Array<{ name: string, props?: object, options?: object }>} entries
201
+ * @returns {Promise<string[]>} — array of rendered HTML strings
202
+ */
203
+ async renderBatch(entries) {
204
+ return Promise.all(
205
+ entries.map(({ name, props, options }) => this.renderToString(name, props, options))
206
+ );
207
+ }
208
+
156
209
  /**
157
210
  * Render a full HTML page with a component mounted in a shell.
158
211
  *
@@ -160,10 +213,15 @@ class SSRApp {
160
213
  * @param {string} options.component — component name to render
161
214
  * @param {object} [options.props] — props
162
215
  * @param {string} [options.title] — page title
216
+ * @param {string} [options.description] — meta description for SEO
163
217
  * @param {string[]} [options.styles] — CSS file paths
164
218
  * @param {string[]} [options.scripts] — JS file paths
165
219
  * @param {string} [options.lang] — html lang attribute
166
220
  * @param {string} [options.meta] — additional head content
221
+ * @param {string} [options.bodyAttrs] — extra body attributes
222
+ * @param {object} [options.head] — structured head options
223
+ * @param {string} [options.head.canonical] — canonical URL
224
+ * @param {object} [options.head.og] — Open Graph tags
167
225
  * @returns {Promise<string>}
168
226
  */
169
227
  async renderPage(options = {}) {
@@ -171,25 +229,49 @@ class SSRApp {
171
229
  component: comp,
172
230
  props = {},
173
231
  title = '',
232
+ description = '',
174
233
  styles = [],
175
234
  scripts = [],
176
235
  lang = 'en',
177
236
  meta = '',
178
237
  bodyAttrs = '',
238
+ head = {},
179
239
  } = options;
180
240
 
181
- const content = comp ? await this.renderToString(comp, props) : '';
241
+ let content = '';
242
+ if (comp) {
243
+ try {
244
+ content = await this.renderToString(comp, props);
245
+ } catch (err) {
246
+ reportError(ErrorCode.SSR_PAGE, `renderPage failed for component "${comp}"`, { component: comp }, err);
247
+ content = `<!-- SSR error: ${_escapeHtml(err.message)} -->`;
248
+ }
249
+ }
182
250
 
183
251
  const styleLinks = styles.map(s => `<link rel="stylesheet" href="${_escapeHtml(s)}">`).join('\n ');
184
252
  const scriptTags = scripts.map(s => `<script src="${_escapeHtml(s)}"></script>`).join('\n ');
185
253
 
254
+ // Build SEO / structured head tags
255
+ let headExtra = meta;
256
+ if (description) {
257
+ headExtra += `\n <meta name="description" content="${_escapeHtml(description)}">`;
258
+ }
259
+ if (head.canonical) {
260
+ headExtra += `\n <link rel="canonical" href="${_escapeHtml(head.canonical)}">`;
261
+ }
262
+ if (head.og) {
263
+ for (const [key, val] of Object.entries(head.og)) {
264
+ headExtra += `\n <meta property="og:${_escapeHtml(key)}" content="${_escapeHtml(String(val))}">`;
265
+ }
266
+ }
267
+
186
268
  return `<!DOCTYPE html>
187
269
  <html lang="${_escapeHtml(lang)}">
188
270
  <head>
189
271
  <meta charset="UTF-8">
190
272
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
191
273
  <title>${_escapeHtml(title)}</title>
192
- ${meta}
274
+ ${headExtra}
193
275
  ${styleLinks}
194
276
  </head>
195
277
  <body ${bodyAttrs.replace(/on\w+\s*=/gi, '').replace(/javascript\s*:/gi, '')}>
@@ -219,6 +301,18 @@ export function createSSRApp() {
219
301
  * @returns {string}
220
302
  */
221
303
  export function renderToString(definition, props = {}) {
304
+ if (!definition || typeof definition !== 'object') {
305
+ throw new ZQueryError(ErrorCode.SSR_COMPONENT, 'renderToString requires a component definition object');
306
+ }
222
307
  const instance = new SSRComponent(definition, props);
223
308
  return instance.render();
224
309
  }
310
+
311
+ /**
312
+ * Escape HTML entities — exposed for use in SSR templates.
313
+ * @param {string} str
314
+ * @returns {string}
315
+ */
316
+ export function escapeHtml(str) {
317
+ return _escapeHtml(String(str));
318
+ }