zero-query 1.0.2 → 1.0.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.
- package/README.md +3 -3
- package/cli/commands/dev/server.js +2 -1
- package/cli/scaffold/ssr/app/app.js +2 -2
- package/cli/scaffold/ssr/app/components/blog/post.js +8 -0
- package/cli/scaffold/ssr/server/index.js +15 -61
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +67 -17
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +5 -2
- package/index.js +5 -4
- package/package.json +2 -2
- package/src/router.js +61 -12
- package/src/ssr.js +100 -0
- package/tests/router.test.js +65 -1
- package/tests/ssr.test.js +175 -0
- package/types/router.d.ts +25 -0
- package/types/ssr.d.ts +33 -0
package/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Lightweight modern frontend library - jQuery-like selectors, reactive
|
|
5
5
|
* components, SPA router, state management, HTTP client & utilities.
|
|
6
6
|
*
|
|
7
|
-
* @version 1.0.
|
|
7
|
+
* @version 1.0.6
|
|
8
8
|
* @license MIT
|
|
9
9
|
* @see https://z-query.com/docs
|
|
10
10
|
*/
|
|
@@ -46,8 +46,10 @@ export {
|
|
|
46
46
|
NavigationContext,
|
|
47
47
|
RouterConfig,
|
|
48
48
|
RouterInstance,
|
|
49
|
+
RouteMatch,
|
|
49
50
|
createRouter,
|
|
50
51
|
getRouter,
|
|
52
|
+
matchRoute,
|
|
51
53
|
} from './types/router';
|
|
52
54
|
|
|
53
55
|
export {
|
|
@@ -146,7 +148,7 @@ export {
|
|
|
146
148
|
import type { ZQueryCollection } from './types/collection';
|
|
147
149
|
import type { reactive, Signal, signal, computed, effect, batch, untracked } from './types/reactive';
|
|
148
150
|
import type { component, mount, mountAll, getInstance, destroy, getRegistry, prefetch, style } from './types/component';
|
|
149
|
-
import type { createRouter, getRouter } from './types/router';
|
|
151
|
+
import type { createRouter, getRouter, matchRoute } from './types/router';
|
|
150
152
|
import type { createStore, getStore } from './types/store';
|
|
151
153
|
import type { HttpClient } from './types/http';
|
|
152
154
|
import type {
|
|
@@ -264,6 +266,7 @@ interface ZQueryStatic {
|
|
|
264
266
|
// -- Router --------------------------------------------------------------
|
|
265
267
|
router: typeof createRouter;
|
|
266
268
|
getRouter: typeof getRouter;
|
|
269
|
+
matchRoute: typeof matchRoute;
|
|
267
270
|
|
|
268
271
|
// -- Store ---------------------------------------------------------------
|
|
269
272
|
store: typeof createStore;
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { query, queryAll, ZQueryCollection } from './src/core.js';
|
|
13
13
|
import { reactive, Signal, signal, computed, effect, batch, untracked } from './src/reactive.js';
|
|
14
14
|
import { component, mount, mountAll, getInstance, destroy, getRegistry, prefetch, style } from './src/component.js';
|
|
15
|
-
import { createRouter, getRouter } from './src/router.js';
|
|
15
|
+
import { createRouter, getRouter, matchRoute } from './src/router.js';
|
|
16
16
|
import { createStore, getStore } from './src/store.js';
|
|
17
17
|
import { http } from './src/http.js';
|
|
18
18
|
import { morph, morphElement } from './src/diff.js';
|
|
@@ -115,8 +115,9 @@ $.morphElement = morphElement;
|
|
|
115
115
|
$.safeEval = safeEval;
|
|
116
116
|
|
|
117
117
|
// --- Router ----------------------------------------------------------------
|
|
118
|
-
$.router
|
|
119
|
-
$.getRouter
|
|
118
|
+
$.router = createRouter;
|
|
119
|
+
$.getRouter = getRouter;
|
|
120
|
+
$.matchRoute = matchRoute;
|
|
120
121
|
|
|
121
122
|
// --- Store -----------------------------------------------------------------
|
|
122
123
|
$.store = createStore;
|
|
@@ -214,7 +215,7 @@ export {
|
|
|
214
215
|
component, mount, mountAll, getInstance, destroy, getRegistry, prefetch, style,
|
|
215
216
|
morph, morphElement,
|
|
216
217
|
safeEval,
|
|
217
|
-
createRouter, getRouter,
|
|
218
|
+
createRouter, getRouter, matchRoute,
|
|
218
219
|
createStore, getStore,
|
|
219
220
|
http,
|
|
220
221
|
ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zero-query",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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",
|
|
@@ -64,6 +64,6 @@
|
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"jsdom": "^28.1.0",
|
|
66
66
|
"vitest": "^4.0.18",
|
|
67
|
-
"zero-http": "^0.
|
|
67
|
+
"zero-http": "^0.3.1"
|
|
68
68
|
}
|
|
69
69
|
}
|
package/src/router.js
CHANGED
|
@@ -195,24 +195,15 @@ class Router {
|
|
|
195
195
|
|
|
196
196
|
add(route) {
|
|
197
197
|
// Compile path pattern into regex
|
|
198
|
-
const keys =
|
|
199
|
-
const pattern = route.path
|
|
200
|
-
.replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
|
|
201
|
-
.replace(/\*/g, '(.*)');
|
|
202
|
-
const regex = new RegExp(`^${pattern}$`);
|
|
203
|
-
|
|
198
|
+
const { regex, keys } = compilePath(route.path);
|
|
204
199
|
this._routes.push({ ...route, _regex: regex, _keys: keys });
|
|
205
200
|
|
|
206
201
|
// Per-route fallback: register an alias path for the same component.
|
|
207
202
|
// e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
|
|
208
203
|
// When matched via fallback, missing params are undefined.
|
|
209
204
|
if (route.fallback) {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
.replace(/:(\w+)/g, (_, key) => { fbKeys.push(key); return '([^/]+)'; })
|
|
213
|
-
.replace(/\*/g, '(.*)');
|
|
214
|
-
const fbRegex = new RegExp(`^${fbPattern}$`);
|
|
215
|
-
this._routes.push({ ...route, path: route.fallback, _regex: fbRegex, _keys: fbKeys });
|
|
205
|
+
const fb = compilePath(route.fallback);
|
|
206
|
+
this._routes.push({ ...route, path: route.fallback, _regex: fb.regex, _keys: fb.keys });
|
|
216
207
|
}
|
|
217
208
|
|
|
218
209
|
return this;
|
|
@@ -709,6 +700,64 @@ class Router {
|
|
|
709
700
|
}
|
|
710
701
|
|
|
711
702
|
|
|
703
|
+
// ---------------------------------------------------------------------------
|
|
704
|
+
// Path compilation (shared by Router.add and matchRoute)
|
|
705
|
+
// ---------------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Compile a route path pattern into a RegExp and param key list.
|
|
709
|
+
* Supports `:param` segments and `*` wildcard.
|
|
710
|
+
* @param {string} path - e.g. '/user/:id' or '/files/*'
|
|
711
|
+
* @returns {{ regex: RegExp, keys: string[] }}
|
|
712
|
+
*/
|
|
713
|
+
function compilePath(path) {
|
|
714
|
+
const keys = [];
|
|
715
|
+
const pattern = path
|
|
716
|
+
.replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
|
|
717
|
+
.replace(/\*/g, '(.*)');
|
|
718
|
+
return { regex: new RegExp(`^${pattern}$`), keys };
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// Standalone route matcher (DOM-free — usable on server and client)
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Match a pathname against an array of route definitions.
|
|
727
|
+
* Returns `{ component, params }`. If no route matches, falls back to the
|
|
728
|
+
* `fallback` component name (default `'not-found'`).
|
|
729
|
+
*
|
|
730
|
+
* This is the same matching logic the client-side router uses internally,
|
|
731
|
+
* extracted so SSR servers can resolve URLs without the DOM.
|
|
732
|
+
*
|
|
733
|
+
* @param {Array<{ path: string, component: string, fallback?: string }>} routes
|
|
734
|
+
* @param {string} pathname - URL path to match, e.g. '/blog/my-post'
|
|
735
|
+
* @param {string} [fallback='not-found'] - Component name when nothing matches
|
|
736
|
+
* @returns {{ component: string, params: Record<string, string> }}
|
|
737
|
+
*/
|
|
738
|
+
export function matchRoute(routes, pathname, fallback = 'not-found') {
|
|
739
|
+
for (const route of routes) {
|
|
740
|
+
const { regex, keys } = compilePath(route.path);
|
|
741
|
+
const m = pathname.match(regex);
|
|
742
|
+
if (m) {
|
|
743
|
+
const params = {};
|
|
744
|
+
keys.forEach((key, i) => { params[key] = m[i + 1]; });
|
|
745
|
+
return { component: route.component, params };
|
|
746
|
+
}
|
|
747
|
+
// Per-route fallback alias (same as Router.add)
|
|
748
|
+
if (route.fallback) {
|
|
749
|
+
const fb = compilePath(route.fallback);
|
|
750
|
+
const fbm = pathname.match(fb.regex);
|
|
751
|
+
if (fbm) {
|
|
752
|
+
const params = {};
|
|
753
|
+
fb.keys.forEach((key, i) => { params[key] = fbm[i + 1]; });
|
|
754
|
+
return { component: route.component, params };
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return { component: fallback, params: {} };
|
|
759
|
+
}
|
|
760
|
+
|
|
712
761
|
// ---------------------------------------------------------------------------
|
|
713
762
|
// Factory
|
|
714
763
|
// ---------------------------------------------------------------------------
|
package/src/ssr.js
CHANGED
|
@@ -280,6 +280,103 @@ class SSRApp {
|
|
|
280
280
|
</body>
|
|
281
281
|
</html>`;
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Render a component into an existing HTML shell template.
|
|
286
|
+
*
|
|
287
|
+
* Unlike renderPage() which generates a full HTML document from scratch,
|
|
288
|
+
* renderShell() takes your own index.html (with nav, footer, custom markup)
|
|
289
|
+
* and injects the SSR-rendered component body plus metadata into it.
|
|
290
|
+
*
|
|
291
|
+
* Handles:
|
|
292
|
+
* - Component rendering into <z-outlet>
|
|
293
|
+
* - <title> replacement
|
|
294
|
+
* - <meta name="description"> replacement
|
|
295
|
+
* - Open Graph meta tag replacement (og:title, og:description, og:type, etc.)
|
|
296
|
+
* - window.__SSR_DATA__ hydration script injection
|
|
297
|
+
*
|
|
298
|
+
* @param {string} shell - HTML template string (your index.html)
|
|
299
|
+
* @param {object} options
|
|
300
|
+
* @param {string} options.component - registered component name to render
|
|
301
|
+
* @param {object} [options.props] - props passed to the component
|
|
302
|
+
* @param {string} [options.title] - page title (replaces <title>)
|
|
303
|
+
* @param {string} [options.description] - meta description (replaces <meta name="description">)
|
|
304
|
+
* @param {object} [options.og] - Open Graph tags to replace (e.g. { title, description, type, image })
|
|
305
|
+
* @param {any} [options.ssrData] - data to embed as window.__SSR_DATA__ for client hydration
|
|
306
|
+
* @param {object} [options.renderOptions] - options passed to renderToString (hydrate, mode)
|
|
307
|
+
* @returns {Promise<string>} - the shell with SSR content and metadata injected
|
|
308
|
+
*/
|
|
309
|
+
async renderShell(shell, options = {}) {
|
|
310
|
+
const {
|
|
311
|
+
component: comp,
|
|
312
|
+
props = {},
|
|
313
|
+
title,
|
|
314
|
+
description,
|
|
315
|
+
og,
|
|
316
|
+
ssrData,
|
|
317
|
+
renderOptions,
|
|
318
|
+
} = options;
|
|
319
|
+
|
|
320
|
+
// Render the component
|
|
321
|
+
let body = '';
|
|
322
|
+
if (comp) {
|
|
323
|
+
try {
|
|
324
|
+
body = await this.renderToString(comp, props, renderOptions);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
reportError(ErrorCode.SSR_PAGE, `renderShell failed for component "${comp}"`, { component: comp }, err);
|
|
327
|
+
body = `<!-- SSR error: ${_escapeHtml(err.message)} -->`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let html = shell;
|
|
332
|
+
|
|
333
|
+
// Inject SSR body into <z-outlet>
|
|
334
|
+
// Use a replacer function to avoid $ substitution patterns in body
|
|
335
|
+
html = html.replace(/(<z-outlet[^>]*>)([\s\S]*?)(<\/z-outlet>)?/, (_, open) => `${open}${body}</z-outlet>`);
|
|
336
|
+
|
|
337
|
+
// Replace <title>
|
|
338
|
+
if (title != null) {
|
|
339
|
+
const safeTitle = _escapeHtml(title);
|
|
340
|
+
html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${safeTitle}</title>`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Replace <meta name="description">
|
|
344
|
+
if (description != null) {
|
|
345
|
+
const safeDesc = _escapeHtml(description);
|
|
346
|
+
html = html.replace(
|
|
347
|
+
/<meta\s+name="description"\s+content="[^"]*">/,
|
|
348
|
+
() => `<meta name="description" content="${safeDesc}">`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Replace Open Graph meta tags
|
|
353
|
+
if (og) {
|
|
354
|
+
for (const [key, value] of Object.entries(og)) {
|
|
355
|
+
// Sanitize key: allow only safe OG property characters (alphanumeric, hyphens, underscores, colons)
|
|
356
|
+
const safeKey = key.replace(/[^a-zA-Z0-9_:\-]/g, '');
|
|
357
|
+
if (!safeKey) continue;
|
|
358
|
+
const escaped = _escapeHtml(String(value));
|
|
359
|
+
// Escape key for use in RegExp to prevent ReDoS
|
|
360
|
+
const escapedKey = safeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
361
|
+
const pattern = new RegExp(`<meta\\s+property="og:${escapedKey}"\\s+content="[^"]*">`);
|
|
362
|
+
if (pattern.test(html)) {
|
|
363
|
+
html = html.replace(pattern, () => `<meta property="og:${safeKey}" content="${escaped}">`);
|
|
364
|
+
} else {
|
|
365
|
+
// Tag doesn't exist — inject before </head>
|
|
366
|
+
html = html.replace('</head>', () => `<meta property="og:${safeKey}" content="${escaped}">\n</head>`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Inject hydration data as window.__SSR_DATA__
|
|
372
|
+
if (ssrData !== undefined) {
|
|
373
|
+
// Escape </script> and <!-- sequences to prevent breaking out of the script tag
|
|
374
|
+
const json = JSON.stringify(ssrData).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
|
|
375
|
+
html = html.replace('</head>', () => `<script>window.__SSR_DATA__=${json};</script>\n</head>`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return html;
|
|
379
|
+
}
|
|
283
380
|
}
|
|
284
381
|
|
|
285
382
|
// ---------------------------------------------------------------------------
|
|
@@ -316,3 +413,6 @@ export function renderToString(definition, props = {}) {
|
|
|
316
413
|
export function escapeHtml(str) {
|
|
317
414
|
return _escapeHtml(String(str));
|
|
318
415
|
}
|
|
416
|
+
|
|
417
|
+
// Re-export matchRoute so SSR servers can import from 'zero-query/ssr'
|
|
418
|
+
export { matchRoute } from './router.js';
|
package/tests/router.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { createRouter, getRouter } from '../src/router.js';
|
|
2
|
+
import { createRouter, getRouter, matchRoute } from '../src/router.js';
|
|
3
3
|
import { component } from '../src/component.js';
|
|
4
4
|
|
|
5
5
|
// Register stub components used in route definitions so mount() doesn't throw
|
|
@@ -809,6 +809,70 @@ describe('Router - navigation chaining', () => {
|
|
|
809
809
|
});
|
|
810
810
|
|
|
811
811
|
|
|
812
|
+
// ---------------------------------------------------------------------------
|
|
813
|
+
// matchRoute - standalone route matcher (DOM-free)
|
|
814
|
+
// ---------------------------------------------------------------------------
|
|
815
|
+
|
|
816
|
+
describe('matchRoute', () => {
|
|
817
|
+
const routes = [
|
|
818
|
+
{ path: '/', component: 'home-page' },
|
|
819
|
+
{ path: '/blog', component: 'blog-list' },
|
|
820
|
+
{ path: '/blog/:slug', component: 'blog-post' },
|
|
821
|
+
{ path: '/user/:id', component: 'user-page' },
|
|
822
|
+
{ path: '/files/*', component: 'file-browser' },
|
|
823
|
+
];
|
|
824
|
+
|
|
825
|
+
it('matches a static route', () => {
|
|
826
|
+
expect(matchRoute(routes, '/')).toEqual({ component: 'home-page', params: {} });
|
|
827
|
+
expect(matchRoute(routes, '/blog')).toEqual({ component: 'blog-list', params: {} });
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('matches a parameterized route', () => {
|
|
831
|
+
expect(matchRoute(routes, '/blog/hello-world')).toEqual({
|
|
832
|
+
component: 'blog-post',
|
|
833
|
+
params: { slug: 'hello-world' },
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
it('matches multiple params', () => {
|
|
838
|
+
const r = [{ path: '/user/:id/post/:pid', component: 'user-post' }];
|
|
839
|
+
expect(matchRoute(r, '/user/42/post/7')).toEqual({
|
|
840
|
+
component: 'user-post',
|
|
841
|
+
params: { id: '42', pid: '7' },
|
|
842
|
+
});
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
it('matches wildcard routes', () => {
|
|
846
|
+
const result = matchRoute(routes, '/files/docs/readme.md');
|
|
847
|
+
expect(result.component).toBe('file-browser');
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it('returns fallback when nothing matches', () => {
|
|
851
|
+
expect(matchRoute(routes, '/nope')).toEqual({ component: 'not-found', params: {} });
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it('accepts a custom fallback component name', () => {
|
|
855
|
+
expect(matchRoute(routes, '/nope', '404-page')).toEqual({ component: '404-page', params: {} });
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it('matches first route when multiple could match', () => {
|
|
859
|
+
const r = [
|
|
860
|
+
{ path: '/a', component: 'first' },
|
|
861
|
+
{ path: '/a', component: 'second' },
|
|
862
|
+
];
|
|
863
|
+
expect(matchRoute(r, '/a').component).toBe('first');
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('handles per-route fallback aliases', () => {
|
|
867
|
+
const r = [
|
|
868
|
+
{ path: '/docs/:section', component: 'docs-page', fallback: '/docs' },
|
|
869
|
+
];
|
|
870
|
+
expect(matchRoute(r, '/docs/intro')).toEqual({ component: 'docs-page', params: { section: 'intro' } });
|
|
871
|
+
expect(matchRoute(r, '/docs')).toEqual({ component: 'docs-page', params: {} });
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
|
|
812
876
|
// ---------------------------------------------------------------------------
|
|
813
877
|
// Hash mode path parsing
|
|
814
878
|
// ---------------------------------------------------------------------------
|
package/tests/ssr.test.js
CHANGED
|
@@ -693,3 +693,178 @@ describe('escapeHtml (exported)', () => {
|
|
|
693
693
|
expect(escapeHtml(42)).toBe('42');
|
|
694
694
|
});
|
|
695
695
|
});
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
// SSRApp - renderShell
|
|
700
|
+
// ---------------------------------------------------------------------------
|
|
701
|
+
|
|
702
|
+
describe('SSRApp - renderShell', () => {
|
|
703
|
+
const shell = `<!DOCTYPE html>
|
|
704
|
+
<html lang="en">
|
|
705
|
+
<head>
|
|
706
|
+
<title>Default Title</title>
|
|
707
|
+
<meta name="description" content="default desc">
|
|
708
|
+
<meta property="og:title" content="Default OG">
|
|
709
|
+
<meta property="og:description" content="">
|
|
710
|
+
<meta property="og:type" content="website">
|
|
711
|
+
</head>
|
|
712
|
+
<body>
|
|
713
|
+
<z-outlet></z-outlet>
|
|
714
|
+
</body>
|
|
715
|
+
</html>`;
|
|
716
|
+
|
|
717
|
+
let app;
|
|
718
|
+
beforeEach(() => {
|
|
719
|
+
app = createSSRApp();
|
|
720
|
+
app.component('test-page', {
|
|
721
|
+
state: () => ({ greeting: 'Hello' }),
|
|
722
|
+
render() { return `<h1>${this.state.greeting}</h1>`; }
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('renders a component into <z-outlet>', async () => {
|
|
727
|
+
const html = await app.renderShell(shell, { component: 'test-page' });
|
|
728
|
+
expect(html).toContain('<z-outlet><test-page data-zq-ssr><h1>Hello</h1></test-page></z-outlet>');
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
it('replaces the <title> tag', async () => {
|
|
732
|
+
const html = await app.renderShell(shell, { component: 'test-page', title: 'My App — Home' });
|
|
733
|
+
expect(html).toContain('<title>My App — Home</title>');
|
|
734
|
+
expect(html).not.toContain('Default Title');
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it('replaces the meta description', async () => {
|
|
738
|
+
const html = await app.renderShell(shell, { component: 'test-page', description: 'A great page' });
|
|
739
|
+
expect(html).toContain('<meta name="description" content="A great page">');
|
|
740
|
+
expect(html).not.toContain('default desc');
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it('replaces existing Open Graph tags', async () => {
|
|
744
|
+
const html = await app.renderShell(shell, {
|
|
745
|
+
component: 'test-page',
|
|
746
|
+
og: { title: 'OG Title', description: 'OG Desc', type: 'article' },
|
|
747
|
+
});
|
|
748
|
+
expect(html).toContain('<meta property="og:title" content="OG Title">');
|
|
749
|
+
expect(html).toContain('<meta property="og:description" content="OG Desc">');
|
|
750
|
+
expect(html).toContain('<meta property="og:type" content="article">');
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it('injects new OG tags when they do not exist in the shell', async () => {
|
|
754
|
+
const html = await app.renderShell(shell, {
|
|
755
|
+
component: 'test-page',
|
|
756
|
+
og: { image: 'https://example.com/img.png' },
|
|
757
|
+
});
|
|
758
|
+
expect(html).toContain('<meta property="og:image" content="https://example.com/img.png">');
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('injects window.__SSR_DATA__ when ssrData is provided', async () => {
|
|
762
|
+
const data = { component: 'test-page', params: {}, props: {} };
|
|
763
|
+
const html = await app.renderShell(shell, { component: 'test-page', ssrData: data });
|
|
764
|
+
expect(html).toContain('window.__SSR_DATA__=');
|
|
765
|
+
expect(html).toContain('"component":"test-page"');
|
|
766
|
+
// Script is injected before </head>
|
|
767
|
+
expect(html.indexOf('__SSR_DATA__')).toBeLessThan(html.indexOf('</head>'));
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
it('does not inject ssrData when not provided', async () => {
|
|
771
|
+
const html = await app.renderShell(shell, { component: 'test-page' });
|
|
772
|
+
expect(html).not.toContain('__SSR_DATA__');
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it('escapes title and description for XSS safety', async () => {
|
|
776
|
+
const html = await app.renderShell(shell, {
|
|
777
|
+
component: 'test-page',
|
|
778
|
+
title: '<script>alert("xss")</script>',
|
|
779
|
+
description: '"injected" & <dangerous>',
|
|
780
|
+
});
|
|
781
|
+
expect(html).toContain('<script>');
|
|
782
|
+
expect(html).toContain('"injected" & <dangerous>');
|
|
783
|
+
expect(html).not.toContain('<script>alert');
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it('leaves title and description alone when not provided', async () => {
|
|
787
|
+
const html = await app.renderShell(shell, { component: 'test-page' });
|
|
788
|
+
expect(html).toContain('<title>Default Title</title>');
|
|
789
|
+
expect(html).toContain('content="default desc"');
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('passes props to the rendered component', async () => {
|
|
793
|
+
app.component('greeting-page', {
|
|
794
|
+
render() { return `<p>Hi ${this.props.name}</p>`; }
|
|
795
|
+
});
|
|
796
|
+
const html = await app.renderShell(shell, { component: 'greeting-page', props: { name: 'Tony' } });
|
|
797
|
+
expect(html).toContain('<p>Hi Tony</p>');
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('passes renderOptions through to renderToString', async () => {
|
|
801
|
+
const html = await app.renderShell(shell, {
|
|
802
|
+
component: 'test-page',
|
|
803
|
+
renderOptions: { hydrate: false },
|
|
804
|
+
});
|
|
805
|
+
expect(html).toContain('<test-page><h1>Hello</h1></test-page>');
|
|
806
|
+
expect(html).not.toContain('data-zq-ssr');
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('handles a missing component gracefully', async () => {
|
|
810
|
+
const html = await app.renderShell(shell, { component: 'nonexistent' });
|
|
811
|
+
expect(html).toContain('<!-- SSR error:');
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it('returns the shell untouched when no options are provided', async () => {
|
|
815
|
+
const html = await app.renderShell(shell);
|
|
816
|
+
expect(html).toContain('<title>Default Title</title>');
|
|
817
|
+
expect(html).toContain('<z-outlet></z-outlet>');
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it('escapes </script> in ssrData to prevent script injection', async () => {
|
|
821
|
+
const html = await app.renderShell(shell, {
|
|
822
|
+
component: 'test-page',
|
|
823
|
+
ssrData: { payload: '</script><script>alert(1)</script>' },
|
|
824
|
+
});
|
|
825
|
+
expect(html).not.toContain('</script><script>alert(1)');
|
|
826
|
+
expect(html).toContain('<\\/script>');
|
|
827
|
+
// The JSON should still be parseable when unescaped
|
|
828
|
+
const match = html.match(/window\.__SSR_DATA__=(.+?);/);
|
|
829
|
+
expect(match).toBeTruthy();
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
it('escapes <!-- in ssrData to prevent HTML comment injection', async () => {
|
|
833
|
+
const html = await app.renderShell(shell, {
|
|
834
|
+
component: 'test-page',
|
|
835
|
+
ssrData: { payload: '<!-- injected -->' },
|
|
836
|
+
});
|
|
837
|
+
expect(html).not.toContain('<!-- injected');
|
|
838
|
+
expect(html).toContain('<\\!--');
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('sanitizes OG keys to prevent ReDoS and attribute injection', async () => {
|
|
842
|
+
const html = await app.renderShell(shell, {
|
|
843
|
+
component: 'test-page',
|
|
844
|
+
og: {
|
|
845
|
+
'title" onload="alert(1)': 'attack', // attribute breakout attempt
|
|
846
|
+
'valid-key': 'safe value',
|
|
847
|
+
'': 'empty key should be skipped', // empty after sanitization
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
// Attribute injection should be neutralized (quotes stripped from key)
|
|
851
|
+
expect(html).not.toContain('onload=');
|
|
852
|
+
expect(html).toContain('og:titleonloadalert1');
|
|
853
|
+
// Valid key should work fine
|
|
854
|
+
expect(html).toContain('og:valid-key');
|
|
855
|
+
expect(html).toContain('safe value');
|
|
856
|
+
// Empty key should be skipped
|
|
857
|
+
const emptyOgCount = (html.match(/og:""/g) || []).length;
|
|
858
|
+
expect(emptyOgCount).toBe(0);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('handles $ substitution patterns in component output safely', async () => {
|
|
862
|
+
app.component('dollar-page', {
|
|
863
|
+
render() { return "<p>Price: $1.00 and $' and $` tricks</p>"; }
|
|
864
|
+
});
|
|
865
|
+
const html = await app.renderShell(shell, { component: 'dollar-page' });
|
|
866
|
+
expect(html).toContain("$1.00");
|
|
867
|
+
expect(html).toContain("$'");
|
|
868
|
+
expect(html).toContain("$`");
|
|
869
|
+
});
|
|
870
|
+
});
|
package/types/router.d.ts
CHANGED
|
@@ -163,3 +163,28 @@ export function createRouter(config: RouterConfig): RouterInstance;
|
|
|
163
163
|
|
|
164
164
|
/** Get the currently active router instance. */
|
|
165
165
|
export function getRouter(): RouterInstance | null;
|
|
166
|
+
|
|
167
|
+
/** Result of matching a URL path against route definitions. */
|
|
168
|
+
export interface RouteMatch {
|
|
169
|
+
/** The matched component name, or the fallback if nothing matched. */
|
|
170
|
+
component: string;
|
|
171
|
+
/** Parsed `:param` values from the URL. */
|
|
172
|
+
params: Record<string, string>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Match a pathname against an array of route definitions.
|
|
177
|
+
* Returns `{ component, params }`. If no route matches, returns the
|
|
178
|
+
* fallback component (default `'not-found'`).
|
|
179
|
+
*
|
|
180
|
+
* DOM-free — works on both server and client.
|
|
181
|
+
*
|
|
182
|
+
* @param routes - Array of route definitions with `path` and `component`.
|
|
183
|
+
* @param pathname - URL path to match, e.g. `'/blog/my-post'`.
|
|
184
|
+
* @param fallback - Component name when nothing matches (default `'not-found'`).
|
|
185
|
+
*/
|
|
186
|
+
export function matchRoute(
|
|
187
|
+
routes: RouteDefinition[],
|
|
188
|
+
pathname: string,
|
|
189
|
+
fallback?: string,
|
|
190
|
+
): RouteMatch;
|
package/types/ssr.d.ts
CHANGED
|
@@ -51,6 +51,36 @@ export interface SSRApp {
|
|
|
51
51
|
og?: Record<string, string>;
|
|
52
52
|
};
|
|
53
53
|
}): Promise<string>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Render a component into an existing HTML shell template.
|
|
57
|
+
*
|
|
58
|
+
* Unlike `renderPage()` which generates a full document from scratch,
|
|
59
|
+
* `renderShell()` takes your own `index.html` (with nav, footer, custom
|
|
60
|
+
* markup) and injects the SSR-rendered component body plus metadata.
|
|
61
|
+
*
|
|
62
|
+
* Handles `<z-outlet>` injection, `<title>` replacement, meta description,
|
|
63
|
+
* Open Graph tags, and `window.__SSR_DATA__` hydration data.
|
|
64
|
+
*
|
|
65
|
+
* @param shell - HTML template string (your index.html content).
|
|
66
|
+
* @param options - Rendering and metadata options.
|
|
67
|
+
*/
|
|
68
|
+
renderShell(shell: string, options?: {
|
|
69
|
+
/** Registered component name to render into `<z-outlet>`. */
|
|
70
|
+
component?: string;
|
|
71
|
+
/** Props passed to the component. */
|
|
72
|
+
props?: Record<string, any>;
|
|
73
|
+
/** Page title — replaces `<title>`. */
|
|
74
|
+
title?: string;
|
|
75
|
+
/** Meta description — replaces `<meta name="description">`. */
|
|
76
|
+
description?: string;
|
|
77
|
+
/** Open Graph tags to replace or inject (e.g. `{ title, description, type, image }`). */
|
|
78
|
+
og?: Record<string, string>;
|
|
79
|
+
/** Data embedded as `window.__SSR_DATA__` for client-side hydration. */
|
|
80
|
+
ssrData?: any;
|
|
81
|
+
/** Options passed through to `renderToString()` (hydrate, mode). */
|
|
82
|
+
renderOptions?: { hydrate?: boolean; mode?: 'html' | 'fragment' };
|
|
83
|
+
}): Promise<string>;
|
|
54
84
|
}
|
|
55
85
|
|
|
56
86
|
/** Create an SSR application instance. */
|
|
@@ -67,3 +97,6 @@ export function renderToString(definition: ComponentDefinition, props?: Record<s
|
|
|
67
97
|
* Escape HTML entities - exposed for use in SSR templates.
|
|
68
98
|
*/
|
|
69
99
|
export function escapeHtml(str: string): string;
|
|
100
|
+
|
|
101
|
+
// Re-exported from router for SSR server convenience
|
|
102
|
+
export { matchRoute, RouteMatch } from './router';
|