zero-query 0.9.7 → 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/README.md +55 -4
- package/cli/commands/build.js +50 -3
- package/cli/commands/create.js +58 -11
- package/cli/help.js +4 -0
- package/cli/scaffold/default/app/app.js +211 -0
- package/cli/scaffold/default/app/components/about.js +201 -0
- package/cli/scaffold/default/app/components/api-demo.js +143 -0
- package/cli/scaffold/default/app/components/contact-card.js +231 -0
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -0
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -0
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -0
- package/cli/scaffold/default/app/components/counter.js +127 -0
- package/cli/scaffold/default/app/components/home.js +249 -0
- package/cli/scaffold/{app → default/app}/components/not-found.js +2 -2
- package/cli/scaffold/default/app/components/playground/playground.css +116 -0
- package/cli/scaffold/default/app/components/playground/playground.html +162 -0
- package/cli/scaffold/default/app/components/playground/playground.js +117 -0
- package/cli/scaffold/default/app/components/todos.js +225 -0
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -0
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -0
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -0
- package/cli/scaffold/default/app/routes.js +15 -0
- package/cli/scaffold/{app → default/app}/store.js +15 -10
- package/cli/scaffold/{global.css → default/global.css} +238 -252
- package/cli/scaffold/{index.html → default/index.html} +35 -0
- package/cli/scaffold/{app → minimal/app}/app.js +37 -39
- package/cli/scaffold/minimal/app/components/about.js +68 -0
- package/cli/scaffold/minimal/app/components/counter.js +122 -0
- package/cli/scaffold/minimal/app/components/home.js +68 -0
- package/cli/scaffold/minimal/app/components/not-found.js +16 -0
- package/cli/scaffold/minimal/app/routes.js +9 -0
- package/cli/scaffold/minimal/app/store.js +36 -0
- package/cli/scaffold/minimal/assets/.gitkeep +0 -0
- package/cli/scaffold/minimal/global.css +291 -0
- package/cli/scaffold/minimal/index.html +44 -0
- package/cli/scaffold/ssr/app/app.js +30 -0
- package/cli/scaffold/ssr/app/components/about.js +28 -0
- package/cli/scaffold/ssr/app/components/home.js +37 -0
- package/cli/scaffold/ssr/app/components/not-found.js +15 -0
- package/cli/scaffold/ssr/app/routes.js +6 -0
- package/cli/scaffold/ssr/global.css +113 -0
- package/cli/scaffold/ssr/index.html +31 -0
- package/cli/scaffold/ssr/package.json +8 -0
- package/cli/scaffold/ssr/server/index.js +118 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +2006 -1933
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +20 -1
- package/index.js +8 -5
- package/package.json +8 -2
- package/src/component.js +6 -3
- package/src/diff.js +15 -2
- package/src/errors.js +59 -5
- package/src/package.json +1 -0
- package/src/ssr.js +116 -22
- package/tests/cli.test.js +304 -0
- package/tests/errors.test.js +423 -145
- package/tests/ssr.test.js +435 -3
- package/types/errors.d.ts +34 -2
- package/types/ssr.d.ts +21 -1
- package/cli/scaffold/app/components/about.js +0 -131
- package/cli/scaffold/app/components/api-demo.js +0 -103
- package/cli/scaffold/app/components/contacts/contacts.css +0 -246
- package/cli/scaffold/app/components/contacts/contacts.html +0 -140
- package/cli/scaffold/app/components/contacts/contacts.js +0 -153
- package/cli/scaffold/app/components/counter.js +0 -85
- package/cli/scaffold/app/components/home.js +0 -137
- package/cli/scaffold/app/components/todos.js +0 -131
- package/cli/scaffold/app/routes.js +0 -13
- /package/cli/scaffold/{LICENSE → default/LICENSE} +0 -0
- /package/cli/scaffold/{assets → default/assets}/.gitkeep +0 -0
- /package/cli/scaffold/{favicon.ico → default/favicon.ico} +0 -0
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,12 +173,15 @@ $.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
|
-
$.version
|
|
180
|
-
$.libSize
|
|
181
|
-
$.
|
|
181
|
+
$.version = '__VERSION__';
|
|
182
|
+
$.libSize = '__LIB_SIZE__';
|
|
183
|
+
$.unitTests = '__UNIT_TESTS__';
|
|
184
|
+
$.meta = {}; // populated at build time by CLI bundler
|
|
182
185
|
|
|
183
186
|
$.noConflict = () => {
|
|
184
187
|
if (typeof window !== 'undefined' && window.$ === $) {
|
|
@@ -212,7 +215,7 @@ export {
|
|
|
212
215
|
createRouter, getRouter,
|
|
213
216
|
createStore, getStore,
|
|
214
217
|
http,
|
|
215
|
-
ZQueryError, ErrorCode, onError, reportError, guardCallback, validate,
|
|
218
|
+
ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError,
|
|
216
219
|
debounce, throttle, pipe, once, sleep,
|
|
217
220
|
escapeHtml, stripHtml, html, trust, TrustedHTML, uuid, camelCase, kebabCase,
|
|
218
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.
|
|
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/component.js
CHANGED
|
@@ -502,11 +502,14 @@ class Component {
|
|
|
502
502
|
this._bindRefs();
|
|
503
503
|
this._bindModels();
|
|
504
504
|
|
|
505
|
-
// Restore focus if the morph replaced the focused element
|
|
505
|
+
// Restore focus if the morph replaced the focused element.
|
|
506
|
+
// Always restore selectionRange — even when the element is still
|
|
507
|
+
// the activeElement — because _bindModels or morph attribute syncing
|
|
508
|
+
// can alter the value and move the cursor.
|
|
506
509
|
if (_focusInfo) {
|
|
507
510
|
const el = this._el.querySelector(_focusInfo.selector);
|
|
508
|
-
if (el
|
|
509
|
-
el.focus();
|
|
511
|
+
if (el) {
|
|
512
|
+
if (el !== document.activeElement) el.focus();
|
|
510
513
|
try {
|
|
511
514
|
if (_focusInfo.start !== null && _focusInfo.start !== undefined) {
|
|
512
515
|
el.setSelectionRange(_focusInfo.start, _focusInfo.end, _focusInfo.dir);
|
package/src/diff.js
CHANGED
|
@@ -89,6 +89,11 @@ export function morphElement(oldEl, newHTML) {
|
|
|
89
89
|
return clone;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// Aliases for the concat build — core.js imports these as _morph / _morphElement,
|
|
93
|
+
// but the build strips `import … as` lines, so the aliases must exist at runtime.
|
|
94
|
+
const _morph = morph;
|
|
95
|
+
const _morphElement = morphElement;
|
|
96
|
+
|
|
92
97
|
/**
|
|
93
98
|
* Reconcile children of `oldParent` to match `newParent`.
|
|
94
99
|
*
|
|
@@ -434,6 +439,13 @@ function _morphAttributes(oldEl, newEl) {
|
|
|
434
439
|
|
|
435
440
|
/**
|
|
436
441
|
* Sync input element value, checked, disabled states.
|
|
442
|
+
*
|
|
443
|
+
* Only updates the value when the new HTML explicitly carries a `value`
|
|
444
|
+
* attribute. Templates that use z-model manage values through reactive
|
|
445
|
+
* state + _bindModels — the morph engine should not interfere by wiping
|
|
446
|
+
* a live input's content to '' just because the template has no `value`
|
|
447
|
+
* attr. This prevents the wipe-then-restore cycle that resets cursor
|
|
448
|
+
* position on every keystroke.
|
|
437
449
|
*/
|
|
438
450
|
function _syncInputValue(oldEl, newEl) {
|
|
439
451
|
const type = (oldEl.type || '').toLowerCase();
|
|
@@ -441,8 +453,9 @@ function _syncInputValue(oldEl, newEl) {
|
|
|
441
453
|
if (type === 'checkbox' || type === 'radio') {
|
|
442
454
|
if (oldEl.checked !== newEl.checked) oldEl.checked = newEl.checked;
|
|
443
455
|
} else {
|
|
444
|
-
|
|
445
|
-
|
|
456
|
+
const newVal = newEl.getAttribute('value');
|
|
457
|
+
if (newVal !== null && oldEl.value !== newVal) {
|
|
458
|
+
oldEl.value = newVal;
|
|
446
459
|
}
|
|
447
460
|
}
|
|
448
461
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
107
|
-
|
|
108
|
-
try {
|
|
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
|
+
}
|
package/src/package.json
ADDED
|
@@ -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: () =>
|
|
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)
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
+
}
|