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/tests/ssr.test.js CHANGED
@@ -1,5 +1,6 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { createSSRApp, renderToString } from '../src/ssr.js';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { createSSRApp, renderToString, escapeHtml } from '../src/ssr.js';
3
+ import { ZQueryError, ErrorCode, onError } from '../src/errors.js';
3
4
 
4
5
 
5
6
  // ---------------------------------------------------------------------------
@@ -13,6 +14,14 @@ describe('createSSRApp', () => {
13
14
  expect(typeof app.component).toBe('function');
14
15
  expect(typeof app.renderToString).toBe('function');
15
16
  expect(typeof app.renderPage).toBe('function');
17
+ expect(typeof app.renderBatch).toBe('function');
18
+ expect(typeof app.has).toBe('function');
19
+ });
20
+
21
+ it('each call returns a fresh instance', () => {
22
+ const a = createSSRApp();
23
+ const b = createSSRApp();
24
+ expect(a).not.toBe(b);
16
25
  });
17
26
  });
18
27
 
@@ -30,10 +39,58 @@ describe('SSRApp — component', () => {
30
39
  expect(result).toBe(app);
31
40
  });
32
41
 
33
- it('throws when rendering unregistered component', async () => {
42
+ it('throws ZQueryError when rendering unregistered component', async () => {
34
43
  const app = createSSRApp();
44
+ await expect(app.renderToString('nonexistent')).rejects.toThrow(ZQueryError);
35
45
  await expect(app.renderToString('nonexistent')).rejects.toThrow('not registered');
36
46
  });
47
+
48
+ it('throws ZQueryError for invalid component name (empty string)', () => {
49
+ const app = createSSRApp();
50
+ expect(() => app.component('', {})).toThrow(ZQueryError);
51
+ });
52
+
53
+ it('throws ZQueryError for non-string component name', () => {
54
+ const app = createSSRApp();
55
+ expect(() => app.component(123, {})).toThrow(ZQueryError);
56
+ });
57
+
58
+ it('throws ZQueryError for invalid definition (null)', () => {
59
+ const app = createSSRApp();
60
+ expect(() => app.component('my-comp', null)).toThrow(ZQueryError);
61
+ });
62
+
63
+ it('throws ZQueryError for non-object definition', () => {
64
+ const app = createSSRApp();
65
+ expect(() => app.component('my-comp', 'not-an-object')).toThrow(ZQueryError);
66
+ });
67
+
68
+ it('allows re-registering a component (override)', async () => {
69
+ const app = createSSRApp();
70
+ app.component('my-comp', { render() { return '<p>v1</p>'; } });
71
+ app.component('my-comp', { render() { return '<p>v2</p>'; } });
72
+ const html = await app.renderToString('my-comp');
73
+ expect(html).toContain('v2');
74
+ expect(html).not.toContain('v1');
75
+ });
76
+ });
77
+
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // SSRApp.has()
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe('SSRApp — has', () => {
84
+ it('returns false for unregistered', () => {
85
+ const app = createSSRApp();
86
+ expect(app.has('nope')).toBe(false);
87
+ });
88
+
89
+ it('returns true for registered', () => {
90
+ const app = createSSRApp();
91
+ app.component('my-comp', { render() { return ''; } });
92
+ expect(app.has('my-comp')).toBe(true);
93
+ });
37
94
  });
38
95
 
39
96
 
@@ -100,6 +157,68 @@ describe('SSRApp — renderToString', () => {
100
157
  const html = await app.renderToString('my-comp');
101
158
  expect(html).not.toContain('@click');
102
159
  });
160
+
161
+ it('strips z-on: event bindings', async () => {
162
+ const app = createSSRApp();
163
+ app.component('my-comp', {
164
+ render() { return '<button z-on:click="handle">Click</button>'; }
165
+ });
166
+ const html = await app.renderToString('my-comp');
167
+ expect(html).not.toContain('z-on:');
168
+ });
169
+
170
+ it('renders empty string when no render function', async () => {
171
+ const app = createSSRApp();
172
+ app.component('empty', { state: () => ({}) });
173
+ const html = await app.renderToString('empty');
174
+ expect(html).toContain('<empty');
175
+ expect(html).toContain('</empty>');
176
+ });
177
+
178
+ it('fragment mode returns inner HTML only', async () => {
179
+ const app = createSSRApp();
180
+ app.component('frag', { render() { return '<p>inner</p>'; } });
181
+ const html = await app.renderToString('frag', {}, { mode: 'fragment' });
182
+ expect(html).toBe('<p>inner</p>');
183
+ expect(html).not.toContain('<frag');
184
+ });
185
+
186
+ it('error in render() produces comment and reports via error system', async () => {
187
+ const handler = vi.fn();
188
+ onError(handler);
189
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
190
+
191
+ const app = createSSRApp();
192
+ app.component('bad', {
193
+ render() { throw new Error('render boom'); }
194
+ });
195
+ const html = await app.renderToString('bad');
196
+ expect(html).toContain('<!-- SSR render error:');
197
+ expect(handler).toHaveBeenCalled();
198
+ const err = handler.mock.calls[0][0];
199
+ expect(err.code).toBe(ErrorCode.SSR_RENDER);
200
+
201
+ spy.mockRestore();
202
+ onError(null);
203
+ });
204
+
205
+ it('error in init() is reported but does not crash', async () => {
206
+ const handler = vi.fn();
207
+ onError(handler);
208
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
209
+
210
+ const app = createSSRApp();
211
+ app.component('bad-init', {
212
+ init() { throw new Error('init boom'); },
213
+ render() { return '<div>ok</div>'; }
214
+ });
215
+ const html = await app.renderToString('bad-init');
216
+ expect(html).toContain('<div>ok</div>');
217
+ expect(handler).toHaveBeenCalled();
218
+
219
+ spy.mockRestore();
220
+ onError(null);
221
+ });
103
222
  });
104
223
 
105
224
 
@@ -120,6 +239,27 @@ describe('renderToString (standalone)', () => {
120
239
  const html = renderToString({ state: {} });
121
240
  expect(html).toBe('');
122
241
  });
242
+
243
+ it('throws ZQueryError for invalid definition', () => {
244
+ expect(() => renderToString(null)).toThrow(ZQueryError);
245
+ expect(() => renderToString('string')).toThrow(ZQueryError);
246
+ });
247
+
248
+ it('works with props', () => {
249
+ const html = renderToString({
250
+ render() { return `<span>${this.props.x}</span>`; }
251
+ }, { x: 42 });
252
+ expect(html).toContain('42');
253
+ });
254
+
255
+ it('props are frozen', () => {
256
+ let frozen = false;
257
+ renderToString({
258
+ init() { frozen = Object.isFrozen(this.props); },
259
+ render() { return ''; }
260
+ }, { a: 1 });
261
+ expect(frozen).toBe(true);
262
+ });
123
263
  });
124
264
 
125
265
 
@@ -149,6 +289,38 @@ describe('SSR — state factory', () => {
149
289
  const html = await app.renderToString('comp');
150
290
  expect(html).toContain('1');
151
291
  });
292
+
293
+ it('each render gets fresh state from factory', async () => {
294
+ const app = createSSRApp();
295
+ app.component('comp', {
296
+ state: () => ({ count: 0 }),
297
+ init() { this.state.count++; },
298
+ render() { return `<p>${this.state.count}</p>`; }
299
+ });
300
+ const html1 = await app.renderToString('comp');
301
+ const html2 = await app.renderToString('comp');
302
+ expect(html1).toContain('<p>1</p>');
303
+ expect(html2).toContain('<p>1</p>');
304
+ });
305
+
306
+ it('has __raw property on state', () => {
307
+ let hasRaw = false;
308
+ renderToString({
309
+ init() { hasRaw = this.state.__raw === this.state; },
310
+ render() { return ''; }
311
+ });
312
+ expect(hasRaw).toBe(true);
313
+ });
314
+
315
+ it('__raw is non-enumerable', () => {
316
+ let keys = [];
317
+ renderToString({
318
+ state: () => ({ a: 1 }),
319
+ init() { keys = Object.keys(this.state); },
320
+ render() { return ''; }
321
+ });
322
+ expect(keys).not.toContain('__raw');
323
+ });
152
324
  });
153
325
 
154
326
 
@@ -169,6 +341,67 @@ describe('SSR — computed', () => {
169
341
  const html = await app.renderToString('comp');
170
342
  expect(html).toContain('Jane Doe');
171
343
  });
344
+
345
+ it('computed has access to this context', async () => {
346
+ const app = createSSRApp();
347
+ app.component('comp', {
348
+ state: () => ({ items: [1, 2, 3] }),
349
+ computed: {
350
+ count() { return this.state.items.length; }
351
+ },
352
+ render() { return `<p>${this.computed.count}</p>`; }
353
+ });
354
+ const html = await app.renderToString('comp');
355
+ expect(html).toContain('<p>3</p>');
356
+ });
357
+
358
+ it('error in computed is reported and returns undefined', async () => {
359
+ const handler = vi.fn();
360
+ onError(handler);
361
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
362
+
363
+ const app = createSSRApp();
364
+ app.component('comp', {
365
+ state: () => ({}),
366
+ computed: {
367
+ broken() { throw new Error('computed boom'); }
368
+ },
369
+ render() { return `<p>${this.computed.broken}</p>`; }
370
+ });
371
+ const html = await app.renderToString('comp');
372
+ expect(html).toContain('undefined');
373
+ expect(handler).toHaveBeenCalled();
374
+
375
+ spy.mockRestore();
376
+ onError(null);
377
+ });
378
+ });
379
+
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // User methods
383
+ // ---------------------------------------------------------------------------
384
+
385
+ describe('SSR — user methods', () => {
386
+ it('binds user methods and they can be called in render', async () => {
387
+ const app = createSSRApp();
388
+ app.component('comp', {
389
+ state: () => ({ items: ['a', 'b'] }),
390
+ getCount() { return this.state.items.length; },
391
+ render() { return `<p>${this.getCount()}</p>`; }
392
+ });
393
+ const html = await app.renderToString('comp');
394
+ expect(html).toContain('<p>2</p>');
395
+ });
396
+
397
+ it('does not bind reserved keys', () => {
398
+ let hasMounted = false;
399
+ renderToString({
400
+ mounted() { hasMounted = true; },
401
+ render() { return ''; }
402
+ });
403
+ expect(hasMounted).toBe(false);
404
+ });
172
405
  });
173
406
 
174
407
 
@@ -185,6 +418,74 @@ describe('SSR — init lifecycle', () => {
185
418
  });
186
419
  expect(initCalled).toBe(true);
187
420
  });
421
+
422
+ it('init can modify state before render', async () => {
423
+ const app = createSSRApp();
424
+ app.component('comp', {
425
+ state: () => ({ msg: 'before' }),
426
+ init() { this.state.msg = 'after'; },
427
+ render() { return `<p>${this.state.msg}</p>`; }
428
+ });
429
+ const html = await app.renderToString('comp');
430
+ expect(html).toContain('after');
431
+ });
432
+
433
+ it('init has access to props', () => {
434
+ let receivedProps = null;
435
+ renderToString({
436
+ init() { receivedProps = this.props; },
437
+ render() { return ''; }
438
+ }, { x: 42 });
439
+ expect(receivedProps.x).toBe(42);
440
+ });
441
+ });
442
+
443
+
444
+ // ---------------------------------------------------------------------------
445
+ // {{expression}} interpolation
446
+ // ---------------------------------------------------------------------------
447
+
448
+ describe('SSR — expression interpolation', () => {
449
+ it('interpolates {{state.key}} patterns', () => {
450
+ const html = renderToString({
451
+ state: () => ({ name: 'World' }),
452
+ render() { return '<p>Hello {{name}}</p>'; }
453
+ });
454
+ expect(html).toContain('Hello World');
455
+ });
456
+
457
+ it('escapes HTML in interpolated values', () => {
458
+ const html = renderToString({
459
+ state: () => ({ xss: '<script>alert(1)</script>' }),
460
+ render() { return '<p>{{xss}}</p>'; }
461
+ });
462
+ expect(html).not.toContain('<script>');
463
+ expect(html).toContain('&lt;script&gt;');
464
+ });
465
+
466
+ it('renders empty string for null/undefined expressions', () => {
467
+ const html = renderToString({
468
+ state: () => ({ x: null }),
469
+ render() { return '<p>{{x}}</p>'; }
470
+ });
471
+ expect(html).toContain('<p></p>');
472
+ });
473
+
474
+ it('expression error is reported and produces empty string', () => {
475
+ const handler = vi.fn();
476
+ onError(handler);
477
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
478
+
479
+ const html = renderToString({
480
+ state: () => ({}),
481
+ render() { return '<p>{{nonexistent.deep.path}}</p>'; }
482
+ });
483
+ // Should still produce valid HTML (empty interpolation)
484
+ expect(html).toContain('<p>');
485
+
486
+ spy.mockRestore();
487
+ onError(null);
488
+ });
188
489
  });
189
490
 
190
491
 
@@ -217,6 +518,20 @@ describe('SSR — XSS sanitization', () => {
217
518
  const html = await app.renderPage({ bodyAttrs: 'data-x="javascript:void(0)"' });
218
519
  expect(html).not.toContain('javascript:');
219
520
  });
521
+
522
+ it('escapes description in meta tag', async () => {
523
+ const app = createSSRApp();
524
+ const html = await app.renderPage({ description: '"><script>xss</script>' });
525
+ expect(html).toContain('&quot;');
526
+ expect(html).not.toContain('"><script>');
527
+ });
528
+
529
+ it('escapes OG tag values', async () => {
530
+ const app = createSSRApp();
531
+ const html = await app.renderPage({ head: { og: { title: '"><script>xss</script>' } } });
532
+ expect(html).toContain('og:title');
533
+ expect(html).not.toContain('"><script>');
534
+ });
220
535
  });
221
536
 
222
537
 
@@ -258,4 +573,121 @@ describe('SSRApp — renderPage', () => {
258
573
  const html = await app.renderPage({ scripts: ['app.js'] });
259
574
  expect(html).toContain('src="app.js"');
260
575
  });
576
+
577
+ it('includes meta description when provided', async () => {
578
+ const app = createSSRApp();
579
+ const html = await app.renderPage({ description: 'A cool page' });
580
+ expect(html).toContain('<meta name="description" content="A cool page">');
581
+ });
582
+
583
+ it('includes canonical URL', async () => {
584
+ const app = createSSRApp();
585
+ const html = await app.renderPage({ head: { canonical: 'https://example.com/' } });
586
+ expect(html).toContain('<link rel="canonical" href="https://example.com/">');
587
+ });
588
+
589
+ it('includes Open Graph tags', async () => {
590
+ const app = createSSRApp();
591
+ const html = await app.renderPage({
592
+ head: { og: { title: 'My Page', image: 'https://example.com/img.png' } }
593
+ });
594
+ expect(html).toContain('property="og:title"');
595
+ expect(html).toContain('content="My Page"');
596
+ expect(html).toContain('property="og:image"');
597
+ });
598
+
599
+ it('gracefully handles render failure in renderPage', async () => {
600
+ const handler = vi.fn();
601
+ onError(handler);
602
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
603
+
604
+ const app = createSSRApp();
605
+ app.component('bad-page', { render() { throw new Error('page boom'); } });
606
+ const html = await app.renderPage({ component: 'bad-page', title: 'Oops' });
607
+ expect(html).toContain('<!DOCTYPE html>');
608
+ expect(html).toContain('<!-- SSR render error:');
609
+ expect(handler).toHaveBeenCalled();
610
+
611
+ spy.mockRestore();
612
+ onError(null);
613
+ });
614
+
615
+ it('has correct structure: doctype, html, head, body', async () => {
616
+ const app = createSSRApp();
617
+ const html = await app.renderPage({});
618
+ expect(html).toMatch(/^<!DOCTYPE html>/);
619
+ expect(html).toContain('<html');
620
+ expect(html).toContain('<head>');
621
+ expect(html).toContain('</head>');
622
+ expect(html).toContain('<body');
623
+ expect(html).toContain('</body>');
624
+ expect(html).toContain('</html>');
625
+ });
626
+
627
+ it('defaults lang to "en"', async () => {
628
+ const app = createSSRApp();
629
+ const html = await app.renderPage({});
630
+ expect(html).toContain('lang="en"');
631
+ });
632
+ });
633
+
634
+
635
+ // ---------------------------------------------------------------------------
636
+ // renderBatch
637
+ // ---------------------------------------------------------------------------
638
+
639
+ describe('SSRApp — renderBatch', () => {
640
+ it('renders multiple components at once', async () => {
641
+ const app = createSSRApp();
642
+ app.component('header-el', { render() { return '<header>Head</header>'; } });
643
+ app.component('footer-el', { render() { return '<footer>Foot</footer>'; } });
644
+
645
+ const results = await app.renderBatch([
646
+ { name: 'header-el' },
647
+ { name: 'footer-el' }
648
+ ]);
649
+ expect(results).toHaveLength(2);
650
+ expect(results[0]).toContain('Head');
651
+ expect(results[1]).toContain('Foot');
652
+ });
653
+
654
+ it('passes props per entry', async () => {
655
+ const app = createSSRApp();
656
+ app.component('greeting', {
657
+ render() { return `<span>${this.props.msg}</span>`; }
658
+ });
659
+ const results = await app.renderBatch([
660
+ { name: 'greeting', props: { msg: 'Hello' } },
661
+ { name: 'greeting', props: { msg: 'Bye' } }
662
+ ]);
663
+ expect(results[0]).toContain('Hello');
664
+ expect(results[1]).toContain('Bye');
665
+ });
666
+
667
+ it('rejects if any component is unregistered', async () => {
668
+ const app = createSSRApp();
669
+ app.component('ok-comp', { render() { return '<p>ok</p>'; } });
670
+ await expect(
671
+ app.renderBatch([{ name: 'ok-comp' }, { name: 'missing' }])
672
+ ).rejects.toThrow('not registered');
673
+ });
674
+ });
675
+
676
+
677
+ // ---------------------------------------------------------------------------
678
+ // escapeHtml (exported utility)
679
+ // ---------------------------------------------------------------------------
680
+
681
+ describe('escapeHtml (exported)', () => {
682
+ it('escapes all dangerous characters', () => {
683
+ expect(escapeHtml('&<>"\'')).toBe('&amp;&lt;&gt;&quot;&#39;');
684
+ });
685
+
686
+ it('leaves safe strings unchanged', () => {
687
+ expect(escapeHtml('hello world')).toBe('hello world');
688
+ });
689
+
690
+ it('coerces numbers to string', () => {
691
+ expect(escapeHtml(42)).toBe('42');
692
+ });
261
693
  });
package/types/errors.d.ts CHANGED
@@ -41,6 +41,12 @@ export declare const ErrorCode: {
41
41
  readonly HTTP_INTERCEPTOR: 'ZQ_HTTP_INTERCEPTOR';
42
42
  readonly HTTP_PARSE: 'ZQ_HTTP_PARSE';
43
43
 
44
+ // SSR
45
+ readonly SSR_RENDER: 'ZQ_SSR_RENDER';
46
+ readonly SSR_COMPONENT: 'ZQ_SSR_COMPONENT';
47
+ readonly SSR_HYDRATION: 'ZQ_SSR_HYDRATION';
48
+ readonly SSR_PAGE: 'ZQ_SSR_PAGE';
49
+
44
50
  // General
45
51
  readonly INVALID_ARGUMENT: 'ZQ_INVALID_ARGUMENT';
46
52
  };
@@ -71,9 +77,10 @@ export type ZQueryErrorHandler = (error: ZQueryError) => void;
71
77
 
72
78
  /**
73
79
  * Register a global error handler. Called whenever zQuery catches an
74
- * error internally. Pass `null` to remove.
80
+ * error internally. Multiple handlers are supported. Pass `null` to clear all.
81
+ * @returns Unsubscribe function to remove this handler.
75
82
  */
76
- export function onError(handler: ZQueryErrorHandler | null): void;
83
+ export function onError(handler: ZQueryErrorHandler | null): () => void;
77
84
 
78
85
  /**
79
86
  * Report an error through the global handler and console.
@@ -101,3 +108,28 @@ export function guardCallback<T extends (...args: any[]) => any>(
101
108
  * Throws `ZQueryError` with `INVALID_ARGUMENT` on failure.
102
109
  */
103
110
  export function validate(value: any, name: string, expectedType?: string): void;
111
+
112
+ /** Formatted error structure for overlays and logging. */
113
+ export interface FormattedError {
114
+ code: string;
115
+ type: string;
116
+ message: string;
117
+ context: Record<string, any>;
118
+ stack: string;
119
+ cause: FormattedError | null;
120
+ }
121
+
122
+ /**
123
+ * Format a ZQueryError into a structured object suitable for overlays/logging.
124
+ */
125
+ export function formatError(err: ZQueryError | Error): FormattedError;
126
+
127
+ /**
128
+ * Async version of guardCallback — wraps an async function so that
129
+ * rejections are caught, reported, and don't crash execution.
130
+ */
131
+ export function guardAsync<T extends (...args: any[]) => Promise<any>>(
132
+ fn: T,
133
+ code: ErrorCodeValue,
134
+ context?: Record<string, any>,
135
+ ): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined>;
package/types/ssr.d.ts CHANGED
@@ -11,6 +11,9 @@ export interface SSRApp {
11
11
  /** Register a component for SSR. */
12
12
  component(name: string, definition: ComponentDefinition): SSRApp;
13
13
 
14
+ /** Check whether a component is registered. */
15
+ has(name: string): boolean;
16
+
14
17
  /**
15
18
  * Render a component to an HTML string.
16
19
  * @param componentName Registered component name.
@@ -20,9 +23,16 @@ export interface SSRApp {
20
23
  renderToString(
21
24
  componentName: string,
22
25
  props?: Record<string, any>,
23
- options?: { hydrate?: boolean },
26
+ options?: { hydrate?: boolean; mode?: 'html' | 'fragment' },
24
27
  ): Promise<string>;
25
28
 
29
+ /**
30
+ * Render multiple components as a batch.
31
+ */
32
+ renderBatch(
33
+ entries: Array<{ name: string; props?: Record<string, any>; options?: { hydrate?: boolean; mode?: 'html' | 'fragment' } }>,
34
+ ): Promise<string[]>;
35
+
26
36
  /**
27
37
  * Render a full HTML page with a component mounted in a shell.
28
38
  */
@@ -30,11 +40,16 @@ export interface SSRApp {
30
40
  component?: string;
31
41
  props?: Record<string, any>;
32
42
  title?: string;
43
+ description?: string;
33
44
  styles?: string[];
34
45
  scripts?: string[];
35
46
  lang?: string;
36
47
  meta?: string;
37
48
  bodyAttrs?: string;
49
+ head?: {
50
+ canonical?: string;
51
+ og?: Record<string, string>;
52
+ };
38
53
  }): Promise<string>;
39
54
  }
40
55
 
@@ -47,3 +62,8 @@ export function createSSRApp(): SSRApp;
47
62
  * @param props Props to pass.
48
63
  */
49
64
  export function renderToString(definition: ComponentDefinition, props?: Record<string, any>): string;
65
+
66
+ /**
67
+ * Escape HTML entities — exposed for use in SSR templates.
68
+ */
69
+ export function escapeHtml(str: string): string;