@bquery/bquery 1.7.0 → 1.8.1
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 +760 -716
- package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
- package/dist/a11y-DVBCy09c.js.map +1 -0
- package/dist/a11y.es.mjs +1 -1
- package/dist/component/library.d.ts.map +1 -1
- package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
- package/dist/component-L3-JfOFz.js.map +1 -0
- package/dist/component.es.mjs +1 -1
- package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
- package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
- package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
- package/dist/constraints-D5RHQLmP.js.map +1 -0
- package/dist/core/collection.d.ts +86 -0
- package/dist/core/collection.d.ts.map +1 -1
- package/dist/core/element.d.ts +28 -0
- package/dist/core/element.d.ts.map +1 -1
- package/dist/core/shared.d.ts +6 -0
- package/dist/core/shared.d.ts.map +1 -1
- package/dist/core-DdtZHzsS.js +168 -0
- package/dist/core-DdtZHzsS.js.map +1 -0
- package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
- package/dist/core-EMYSLzaT.js.map +1 -0
- package/dist/core.es.mjs +48 -47
- package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
- package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
- package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
- package/dist/devtools-BhB2iDPT.js.map +1 -0
- package/dist/devtools.es.mjs +1 -1
- package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
- package/dist/dnd-NwZBYh4l.js.map +1 -0
- package/dist/dnd.es.mjs +1 -1
- package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
- package/dist/env-CTdvLaH2.js.map +1 -0
- package/dist/forms/create-form.d.ts.map +1 -1
- package/dist/forms/index.d.ts +3 -2
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/types.d.ts +46 -0
- package/dist/forms/types.d.ts.map +1 -1
- package/dist/forms/use-field.d.ts +34 -0
- package/dist/forms/use-field.d.ts.map +1 -0
- package/dist/forms/validators.d.ts +25 -0
- package/dist/forms/validators.d.ts.map +1 -1
- package/dist/forms-UcRHsYxC.js +227 -0
- package/dist/forms-UcRHsYxC.js.map +1 -0
- package/dist/forms.es.mjs +14 -12
- package/dist/full.d.ts +17 -26
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +206 -181
- package/dist/full.iife.js +33 -33
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +33 -33
- package/dist/full.umd.js.map +1 -1
- package/dist/function-Cybd57JV.js +33 -0
- package/dist/function-Cybd57JV.js.map +1 -0
- package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
- package/dist/i18n-kuF6Ekj6.js.map +1 -0
- package/dist/i18n.es.mjs +1 -1
- package/dist/index.es.mjs +251 -228
- package/dist/media/breakpoints.d.ts.map +1 -1
- package/dist/media/types.d.ts +2 -2
- package/dist/media/types.d.ts.map +1 -1
- package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
- package/dist/media-i-fB5WxI.js.map +1 -0
- package/dist/media.es.mjs +1 -1
- package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
- package/dist/motion-BJsAuULb.js.map +1 -0
- package/dist/motion.es.mjs +1 -1
- package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
- package/dist/mount-B4Y8bk8Z.js.map +1 -0
- package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
- package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
- package/dist/platform.es.mjs +2 -2
- package/dist/plugin/registry.d.ts.map +1 -1
- package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
- package/dist/plugin-C2WuC8SF.js.map +1 -0
- package/dist/plugin.es.mjs +1 -1
- package/dist/reactive/async-data.d.ts +28 -3
- package/dist/reactive/async-data.d.ts.map +1 -1
- package/dist/reactive/computed.d.ts +3 -0
- package/dist/reactive/computed.d.ts.map +1 -1
- package/dist/reactive/effect.d.ts +3 -0
- package/dist/reactive/effect.d.ts.map +1 -1
- package/dist/reactive/http.d.ts +194 -0
- package/dist/reactive/http.d.ts.map +1 -0
- package/dist/reactive/index.d.ts +2 -2
- package/dist/reactive/index.d.ts.map +1 -1
- package/dist/reactive/pagination.d.ts +126 -0
- package/dist/reactive/pagination.d.ts.map +1 -0
- package/dist/reactive/polling.d.ts +55 -0
- package/dist/reactive/polling.d.ts.map +1 -0
- package/dist/reactive/readonly.d.ts +20 -1
- package/dist/reactive/readonly.d.ts.map +1 -1
- package/dist/reactive/rest.d.ts +293 -0
- package/dist/reactive/rest.d.ts.map +1 -0
- package/dist/reactive/scope.d.ts +140 -0
- package/dist/reactive/scope.d.ts.map +1 -0
- package/dist/reactive/signal.d.ts +16 -2
- package/dist/reactive/signal.d.ts.map +1 -1
- package/dist/reactive/to-value.d.ts +57 -0
- package/dist/reactive/to-value.d.ts.map +1 -0
- package/dist/reactive/websocket.d.ts +285 -0
- package/dist/reactive/websocket.d.ts.map +1 -0
- package/dist/reactive-DwkhUJfP.js +1148 -0
- package/dist/reactive-DwkhUJfP.js.map +1 -0
- package/dist/reactive.es.mjs +38 -19
- package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
- package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
- package/dist/router/constraints.d.ts.map +1 -1
- package/dist/router/index.d.ts +1 -1
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/router.d.ts.map +1 -1
- package/dist/router/state.d.ts +25 -2
- package/dist/router/state.d.ts.map +1 -1
- package/dist/router-CQikC9Ed.js +492 -0
- package/dist/router-CQikC9Ed.js.map +1 -0
- package/dist/router.es.mjs +9 -8
- package/dist/ssr/hydrate.d.ts.map +1 -1
- package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
- package/dist/ssr-_dAcGdzu.js.map +1 -0
- package/dist/ssr.es.mjs +1 -1
- package/dist/store/persisted.d.ts.map +1 -1
- package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
- package/dist/store-Cb3gPRve.js.map +1 -0
- package/dist/store.es.mjs +2 -2
- package/dist/storybook.es.mjs.map +1 -1
- package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
- package/dist/testing-C5Sjfsna.js.map +1 -0
- package/dist/testing.es.mjs +1 -1
- package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
- package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
- package/dist/untrack-D0fnO5k2.js +36 -0
- package/dist/untrack-D0fnO5k2.js.map +1 -0
- package/dist/view/custom-directives.d.ts.map +1 -1
- package/dist/view.es.mjs +4 -4
- package/package.json +177 -177
- package/src/a11y/announce.ts +131 -131
- package/src/a11y/audit.ts +314 -314
- package/src/a11y/index.ts +68 -68
- package/src/a11y/media-preferences.ts +255 -255
- package/src/a11y/roving-tab-index.ts +164 -164
- package/src/a11y/skip-link.ts +255 -255
- package/src/a11y/trap-focus.ts +184 -184
- package/src/a11y/types.ts +183 -183
- package/src/component/component.ts +599 -599
- package/src/component/html.ts +153 -153
- package/src/component/index.ts +52 -52
- package/src/component/library.ts +540 -542
- package/src/component/scope.ts +212 -212
- package/src/component/types.ts +310 -310
- package/src/core/collection.ts +876 -707
- package/src/core/element.ts +1015 -981
- package/src/core/env.ts +60 -60
- package/src/core/index.ts +49 -49
- package/src/core/shared.ts +77 -62
- package/src/core/utils/index.ts +148 -148
- package/src/devtools/devtools.ts +410 -410
- package/src/devtools/index.ts +48 -48
- package/src/devtools/types.ts +104 -104
- package/src/dnd/draggable.ts +296 -296
- package/src/dnd/droppable.ts +228 -228
- package/src/dnd/index.ts +62 -62
- package/src/dnd/sortable.ts +307 -307
- package/src/dnd/types.ts +293 -293
- package/src/forms/create-form.ts +320 -278
- package/src/forms/index.ts +70 -65
- package/src/forms/types.ts +203 -154
- package/src/forms/use-field.ts +231 -0
- package/src/forms/validators.ts +294 -265
- package/src/full.ts +554 -480
- package/src/i18n/formatting.ts +67 -67
- package/src/i18n/i18n.ts +200 -200
- package/src/i18n/index.ts +67 -67
- package/src/i18n/translate.ts +182 -182
- package/src/i18n/types.ts +171 -171
- package/src/index.ts +108 -108
- package/src/media/battery.ts +116 -116
- package/src/media/breakpoints.ts +129 -131
- package/src/media/clipboard.ts +80 -80
- package/src/media/device-sensors.ts +158 -158
- package/src/media/geolocation.ts +119 -119
- package/src/media/index.ts +76 -76
- package/src/media/media-query.ts +92 -92
- package/src/media/network.ts +115 -115
- package/src/media/types.ts +177 -177
- package/src/media/viewport.ts +84 -84
- package/src/motion/index.ts +57 -57
- package/src/motion/morph.ts +151 -151
- package/src/motion/parallax.ts +120 -120
- package/src/motion/reduced-motion.ts +66 -66
- package/src/motion/types.ts +271 -271
- package/src/motion/typewriter.ts +164 -164
- package/src/plugin/index.ts +37 -37
- package/src/plugin/registry.ts +284 -269
- package/src/plugin/types.ts +137 -137
- package/src/reactive/async-data.ts +250 -29
- package/src/reactive/computed.ts +144 -130
- package/src/reactive/effect.ts +29 -6
- package/src/reactive/http.ts +790 -0
- package/src/reactive/index.ts +60 -0
- package/src/reactive/pagination.ts +317 -0
- package/src/reactive/polling.ts +179 -0
- package/src/reactive/readonly.ts +52 -8
- package/src/reactive/rest.ts +859 -0
- package/src/reactive/scope.ts +276 -0
- package/src/reactive/signal.ts +61 -1
- package/src/reactive/to-value.ts +71 -0
- package/src/reactive/websocket.ts +849 -0
- package/src/router/bq-link.ts +279 -279
- package/src/router/constraints.ts +204 -201
- package/src/router/index.ts +49 -49
- package/src/router/match.ts +312 -312
- package/src/router/path-pattern.ts +52 -52
- package/src/router/query.ts +38 -38
- package/src/router/router.ts +421 -402
- package/src/router/state.ts +51 -3
- package/src/router/types.ts +139 -139
- package/src/router/use-route.ts +68 -68
- package/src/router/utils.ts +157 -157
- package/src/security/index.ts +12 -12
- package/src/ssr/hydrate.ts +84 -82
- package/src/ssr/index.ts +70 -70
- package/src/ssr/render.ts +508 -508
- package/src/ssr/serialize.ts +296 -296
- package/src/ssr/types.ts +81 -81
- package/src/store/create-store.ts +467 -467
- package/src/store/index.ts +27 -27
- package/src/store/persisted.ts +245 -249
- package/src/store/types.ts +247 -247
- package/src/store/utils.ts +135 -135
- package/src/storybook/index.ts +480 -480
- package/src/testing/index.ts +42 -42
- package/src/testing/testing.ts +593 -593
- package/src/testing/types.ts +170 -170
- package/src/view/custom-directives.ts +28 -30
- package/src/view/evaluate.ts +292 -292
- package/src/view/process.ts +108 -108
- package/dist/a11y-C5QOVvRn.js.map +0 -1
- package/dist/component-CuuTijA6.js.map +0 -1
- package/dist/constraints-3lV9yyBw.js.map +0 -1
- package/dist/core-Cjl7GUu8.js.map +0 -1
- package/dist/core-DnlyjbF2.js +0 -112
- package/dist/core-DnlyjbF2.js.map +0 -1
- package/dist/custom-directives-7wAShnnd.js.map +0 -1
- package/dist/devtools-D2fQLhDN.js.map +0 -1
- package/dist/dnd-B8EgyzaI.js.map +0 -1
- package/dist/env-NeVmr4Gf.js.map +0 -1
- package/dist/forms-C3yovgH9.js +0 -141
- package/dist/forms-C3yovgH9.js.map +0 -1
- package/dist/i18n-BnnhTFOS.js.map +0 -1
- package/dist/media-Di2Ta22s.js.map +0 -1
- package/dist/motion-qPj_TYGv.js.map +0 -1
- package/dist/mount-SM07RUa6.js.map +0 -1
- package/dist/plugin-cPoOHFLY.js.map +0 -1
- package/dist/reactive-Cfv0RK6x.js +0 -233
- package/dist/reactive-Cfv0RK6x.js.map +0 -1
- package/dist/router-BrthaP_z.js +0 -473
- package/dist/router-BrthaP_z.js.map +0 -1
- package/dist/ssr-B2qd_WBB.js.map +0 -1
- package/dist/store-DWpyH6p5.js.map +0 -1
- package/dist/testing-CsqjNUyy.js.map +0 -1
- package/dist/untrack-DJVQQ2WM.js +0 -33
- package/dist/untrack-DJVQQ2WM.js.map +0 -1
package/src/ssr/render.ts
CHANGED
|
@@ -1,508 +1,508 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SSR rendering utilities.
|
|
3
|
-
*
|
|
4
|
-
* Server-side renders bQuery templates to HTML strings by evaluating
|
|
5
|
-
* directive attributes against a plain data context. Uses a lightweight
|
|
6
|
-
* DOM implementation to process templates without a browser.
|
|
7
|
-
*
|
|
8
|
-
* @module bquery/ssr
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { isComputed, isSignal, type Signal } from '../reactive/index';
|
|
12
|
-
import { DANGEROUS_PROTOCOLS } from '../security/constants';
|
|
13
|
-
import { sanitizeHtml } from '../security/sanitize';
|
|
14
|
-
import type { BindingContext } from '../view/types';
|
|
15
|
-
import type { RenderOptions, SSRResult } from './types';
|
|
16
|
-
import { serializeStoreState } from './serialize';
|
|
17
|
-
|
|
18
|
-
const VOID_ELEMENTS = new Set([
|
|
19
|
-
'area',
|
|
20
|
-
'base',
|
|
21
|
-
'br',
|
|
22
|
-
'col',
|
|
23
|
-
'embed',
|
|
24
|
-
'hr',
|
|
25
|
-
'img',
|
|
26
|
-
'input',
|
|
27
|
-
'link',
|
|
28
|
-
'meta',
|
|
29
|
-
'param',
|
|
30
|
-
'source',
|
|
31
|
-
'track',
|
|
32
|
-
'wbr',
|
|
33
|
-
]);
|
|
34
|
-
|
|
35
|
-
const escapeHtmlText = (value: string): string =>
|
|
36
|
-
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
37
|
-
|
|
38
|
-
const escapeHtmlAttribute = (value: string): string =>
|
|
39
|
-
escapeHtmlText(value).replace(/"/g, '"');
|
|
40
|
-
|
|
41
|
-
const isUnsafeUrlAttribute = (name: string): boolean => {
|
|
42
|
-
const normalized = name.toLowerCase();
|
|
43
|
-
return (
|
|
44
|
-
normalized === 'href' ||
|
|
45
|
-
normalized === 'src' ||
|
|
46
|
-
normalized === 'xlink:href' ||
|
|
47
|
-
normalized === 'formaction' ||
|
|
48
|
-
normalized === 'action' ||
|
|
49
|
-
normalized === 'poster' ||
|
|
50
|
-
normalized === 'background' ||
|
|
51
|
-
normalized === 'cite' ||
|
|
52
|
-
normalized === 'data'
|
|
53
|
-
);
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const sanitizeUrlForProtocolCheck = (value: string): string =>
|
|
57
|
-
value
|
|
58
|
-
.trim()
|
|
59
|
-
.replace(/[\u0000-\u001F\u007F]+/g, '')
|
|
60
|
-
.replace(/[\u200B-\u200D\uFEFF\u2028\u2029]+/g, '')
|
|
61
|
-
.replace(/\\u[\da-fA-F]{4}/g, '')
|
|
62
|
-
.replace(/\s+/g, '')
|
|
63
|
-
.toLowerCase();
|
|
64
|
-
|
|
65
|
-
const isUnsafeUrlValue = (value: string): boolean => {
|
|
66
|
-
const normalized = sanitizeUrlForProtocolCheck(value);
|
|
67
|
-
return DANGEROUS_PROTOCOLS.some((protocol) => normalized.startsWith(protocol));
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
const serializeSSRNode = (node: Node): string => {
|
|
71
|
-
if (node.nodeType === Node.TEXT_NODE) {
|
|
72
|
-
return escapeHtmlText(node.textContent ?? '');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
76
|
-
return '';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const el = node as Element;
|
|
80
|
-
const tagName = el.tagName.toLowerCase();
|
|
81
|
-
|
|
82
|
-
if (tagName === 'script') {
|
|
83
|
-
return '';
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
let attrs = '';
|
|
87
|
-
for (const attr of el.attributes) {
|
|
88
|
-
const attrName = attr.name.toLowerCase();
|
|
89
|
-
if (attrName.startsWith('on')) {
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (isUnsafeUrlAttribute(attrName) && isUnsafeUrlValue(attr.value)) {
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
attrs += ` ${attr.name}="${escapeHtmlAttribute(attr.value)}"`;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (VOID_ELEMENTS.has(tagName)) {
|
|
99
|
-
return `<${tagName}${attrs}>`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
let childrenHtml = '';
|
|
103
|
-
for (const child of el.childNodes) {
|
|
104
|
-
childrenHtml += serializeSSRNode(child);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return `<${tagName}${attrs}>${childrenHtml}</${tagName}>`;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Unwraps a value — if it's a signal/computed, returns `.value`, otherwise returns as-is.
|
|
112
|
-
* @internal
|
|
113
|
-
*/
|
|
114
|
-
const unwrap = (value: unknown): unknown => {
|
|
115
|
-
if (isSignal(value) || isComputed(value)) {
|
|
116
|
-
return (value as Signal<unknown>).value;
|
|
117
|
-
}
|
|
118
|
-
return value;
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Evaluates a simple expression against a context.
|
|
123
|
-
* Supports dot-notation property access, negation, ternary, and basic comparisons.
|
|
124
|
-
* Unlike the view module's `evaluate()`, this does NOT use `new Function()` —
|
|
125
|
-
* it uses a safe subset for SSR to avoid `unsafe-eval` in server environments.
|
|
126
|
-
*
|
|
127
|
-
* Falls back to `new Function()` for complex expressions.
|
|
128
|
-
*
|
|
129
|
-
* @internal
|
|
130
|
-
*/
|
|
131
|
-
const evaluateSSR = <T = unknown>(expression: string, context: BindingContext): T => {
|
|
132
|
-
const trimmed = expression.trim();
|
|
133
|
-
|
|
134
|
-
// Handle negation: !expr
|
|
135
|
-
if (trimmed.startsWith('!')) {
|
|
136
|
-
return !evaluateSSR(trimmed.slice(1).trim(), context) as T;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Handle string literals
|
|
140
|
-
if (
|
|
141
|
-
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
142
|
-
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
143
|
-
) {
|
|
144
|
-
return trimmed.slice(1, -1) as T;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Handle numeric literals
|
|
148
|
-
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
149
|
-
return Number(trimmed) as T;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Handle boolean literals
|
|
153
|
-
if (trimmed === 'true') return true as T;
|
|
154
|
-
if (trimmed === 'false') return false as T;
|
|
155
|
-
if (trimmed === 'null') return null as T;
|
|
156
|
-
if (trimmed === 'undefined') return undefined as T;
|
|
157
|
-
|
|
158
|
-
// Handle dot-notation property access: a.b.c
|
|
159
|
-
if (/^[\w$]+(?:\.[\w$]+)*$/.test(trimmed)) {
|
|
160
|
-
const parts = trimmed.split('.');
|
|
161
|
-
let current: unknown = context;
|
|
162
|
-
for (const part of parts) {
|
|
163
|
-
if (current == null) return undefined as T;
|
|
164
|
-
// First level: unwrap signals
|
|
165
|
-
if (current === context) {
|
|
166
|
-
current = unwrap((current as Record<string, unknown>)[part]);
|
|
167
|
-
} else {
|
|
168
|
-
current = (current as Record<string, unknown>)[part];
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return current as T;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// For complex expressions, fall back to Function-based evaluation
|
|
175
|
-
try {
|
|
176
|
-
const keys = Object.keys(context);
|
|
177
|
-
const values = keys.map((k) => unwrap(context[k]));
|
|
178
|
-
const fn = new Function(...keys, `return (${trimmed});`);
|
|
179
|
-
return fn(...values) as T;
|
|
180
|
-
} catch {
|
|
181
|
-
return undefined as T;
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Parses a `bq-for` expression like `item in items` or `(item, index) in items`.
|
|
187
|
-
* @internal
|
|
188
|
-
*/
|
|
189
|
-
const parseForExpression = (
|
|
190
|
-
expression: string
|
|
191
|
-
): { itemName: string; indexName?: string; listExpr: string } | null => {
|
|
192
|
-
const match = expression.match(/^\(?(\w+)(?:\s*,\s*(\w+))?\)?\s+in\s+(\S.*)$/);
|
|
193
|
-
if (!match) return null;
|
|
194
|
-
return {
|
|
195
|
-
itemName: match[1],
|
|
196
|
-
indexName: match[2] || undefined,
|
|
197
|
-
listExpr: match[3].trim(),
|
|
198
|
-
};
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Processes an element's SSR directives, modifying it in place.
|
|
203
|
-
* Returns `false` if the element should be removed from output (bq-if = false).
|
|
204
|
-
* @internal
|
|
205
|
-
*/
|
|
206
|
-
const processSSRElement = (
|
|
207
|
-
el: Element,
|
|
208
|
-
context: BindingContext,
|
|
209
|
-
prefix: string,
|
|
210
|
-
doc: Document
|
|
211
|
-
): boolean => {
|
|
212
|
-
// Handle bq-if: remove element if condition is falsy
|
|
213
|
-
const ifExpr = el.getAttribute(`${prefix}-if`);
|
|
214
|
-
if (ifExpr !== null) {
|
|
215
|
-
const condition = evaluateSSR<boolean>(ifExpr, context);
|
|
216
|
-
if (!condition) {
|
|
217
|
-
return false; // Signal to remove this element
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Handle bq-show: set display:none if falsy
|
|
222
|
-
const showExpr = el.getAttribute(`${prefix}-show`);
|
|
223
|
-
if (showExpr !== null) {
|
|
224
|
-
const condition = evaluateSSR<boolean>(showExpr, context);
|
|
225
|
-
if (!condition) {
|
|
226
|
-
const htmlEl = el as unknown as { style?: { display?: string } };
|
|
227
|
-
if (htmlEl.style) {
|
|
228
|
-
htmlEl.style.display = 'none';
|
|
229
|
-
} else {
|
|
230
|
-
el.setAttribute('style', 'display: none;');
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Handle bq-text: set text content
|
|
236
|
-
const textExpr = el.getAttribute(`${prefix}-text`);
|
|
237
|
-
if (textExpr !== null) {
|
|
238
|
-
const value = evaluateSSR(textExpr, context);
|
|
239
|
-
el.textContent = String(value ?? '');
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Handle bq-html: sanitize to match client-side default behavior
|
|
243
|
-
const htmlExpr = el.getAttribute(`${prefix}-html`);
|
|
244
|
-
if (htmlExpr !== null) {
|
|
245
|
-
const value = evaluateSSR(htmlExpr, context);
|
|
246
|
-
el.innerHTML = String(sanitizeHtml(String(value ?? '')));
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Handle bq-class: add classes
|
|
250
|
-
const classExpr = el.getAttribute(`${prefix}-class`);
|
|
251
|
-
if (classExpr !== null) {
|
|
252
|
-
const trimmedClass = classExpr.trim();
|
|
253
|
-
if (trimmedClass.startsWith('{')) {
|
|
254
|
-
// Object syntax: { active: isActive, disabled: !enabled }
|
|
255
|
-
const inner = trimmedClass.slice(1, -1).trim();
|
|
256
|
-
const pairs = inner.split(',');
|
|
257
|
-
for (const pair of pairs) {
|
|
258
|
-
const colonIdx = pair.indexOf(':');
|
|
259
|
-
if (colonIdx > -1) {
|
|
260
|
-
const className = pair
|
|
261
|
-
.slice(0, colonIdx)
|
|
262
|
-
.trim()
|
|
263
|
-
.replace(/^['"]|['"]$/g, '');
|
|
264
|
-
const condExpr = pair.slice(colonIdx + 1).trim();
|
|
265
|
-
const condition = evaluateSSR<boolean>(condExpr, context);
|
|
266
|
-
if (condition) {
|
|
267
|
-
el.classList.add(className);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
} else {
|
|
272
|
-
const result = evaluateSSR<string | string[]>(classExpr, context);
|
|
273
|
-
if (typeof result === 'string') {
|
|
274
|
-
result
|
|
275
|
-
.split(/\s+/)
|
|
276
|
-
.filter(Boolean)
|
|
277
|
-
.forEach((cls) => el.classList.add(cls));
|
|
278
|
-
} else if (Array.isArray(result)) {
|
|
279
|
-
result.filter(Boolean).forEach((cls) => el.classList.add(cls));
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Handle bq-style: set inline styles
|
|
285
|
-
const styleExpr = el.getAttribute(`${prefix}-style`);
|
|
286
|
-
if (styleExpr !== null) {
|
|
287
|
-
const result = evaluateSSR<Record<string, string>>(styleExpr, context);
|
|
288
|
-
if (result && typeof result === 'object') {
|
|
289
|
-
const htmlEl = el as HTMLElement;
|
|
290
|
-
for (const [prop, val] of Object.entries(result)) {
|
|
291
|
-
// Convert camelCase to kebab-case
|
|
292
|
-
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
293
|
-
htmlEl.style.setProperty(cssProp, String(val));
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// Handle bq-bind:attr — set arbitrary attributes
|
|
299
|
-
const attrs = Array.from(el.attributes);
|
|
300
|
-
for (const attr of attrs) {
|
|
301
|
-
if (attr.name.startsWith(`${prefix}-bind:`)) {
|
|
302
|
-
const attrName = attr.name.slice(`${prefix}-bind:`.length);
|
|
303
|
-
const value = evaluateSSR(attr.value, context);
|
|
304
|
-
if (value === false || value === null || value === undefined) {
|
|
305
|
-
el.removeAttribute(attrName);
|
|
306
|
-
} else if (value === true) {
|
|
307
|
-
el.setAttribute(attrName, '');
|
|
308
|
-
} else {
|
|
309
|
-
el.setAttribute(attrName, String(value));
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// Handle bq-for: list rendering
|
|
315
|
-
const forExpr = el.getAttribute(`${prefix}-for`);
|
|
316
|
-
if (forExpr !== null) {
|
|
317
|
-
const parsed = parseForExpression(forExpr);
|
|
318
|
-
if (parsed) {
|
|
319
|
-
const list = evaluateSSR<unknown[]>(parsed.listExpr, context);
|
|
320
|
-
if (Array.isArray(list) && el.parentNode) {
|
|
321
|
-
const parent = el.parentNode;
|
|
322
|
-
for (let i = 0; i < list.length; i++) {
|
|
323
|
-
const item = list[i];
|
|
324
|
-
const clone = el.cloneNode(true) as Element;
|
|
325
|
-
|
|
326
|
-
// Remove the bq-for attribute from clones
|
|
327
|
-
clone.removeAttribute(`${prefix}-for`);
|
|
328
|
-
clone.removeAttribute(':key');
|
|
329
|
-
clone.removeAttribute(`${prefix}-key`);
|
|
330
|
-
|
|
331
|
-
// Create item context
|
|
332
|
-
const itemContext: BindingContext = {
|
|
333
|
-
...context,
|
|
334
|
-
[parsed.itemName]: item,
|
|
335
|
-
};
|
|
336
|
-
if (parsed.indexName) {
|
|
337
|
-
itemContext[parsed.indexName] = i;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// Recursively process the clone
|
|
341
|
-
processSSRElement(clone, itemContext, prefix, doc);
|
|
342
|
-
processSSRChildren(clone, itemContext, prefix, doc);
|
|
343
|
-
|
|
344
|
-
parent.insertBefore(clone, el);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Remove the original template element
|
|
348
|
-
parent.removeChild(el);
|
|
349
|
-
return true; // Already handled children
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
return true;
|
|
355
|
-
};
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Recursively processes children of an element for SSR.
|
|
359
|
-
* @internal
|
|
360
|
-
*/
|
|
361
|
-
const processSSRChildren = (
|
|
362
|
-
parent: Element,
|
|
363
|
-
context: BindingContext,
|
|
364
|
-
prefix: string,
|
|
365
|
-
doc: Document
|
|
366
|
-
): void => {
|
|
367
|
-
// Process children in reverse to handle removals safely
|
|
368
|
-
const children = Array.from(parent.children);
|
|
369
|
-
for (const child of children) {
|
|
370
|
-
// Skip bq-for elements — they're handled by parent
|
|
371
|
-
if (child.hasAttribute(`${prefix}-for`)) {
|
|
372
|
-
// Process the for directive on this element
|
|
373
|
-
const keep = processSSRElement(child, context, prefix, doc);
|
|
374
|
-
if (!keep) {
|
|
375
|
-
child.remove();
|
|
376
|
-
}
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const keep = processSSRElement(child, context, prefix, doc);
|
|
381
|
-
if (!keep) {
|
|
382
|
-
child.remove();
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
// Recurse into children
|
|
387
|
-
processSSRChildren(child, context, prefix, doc);
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Strips all directive attributes (bq-*) from an element and its descendants.
|
|
393
|
-
* @internal
|
|
394
|
-
*/
|
|
395
|
-
const stripDirectiveAttributes = (el: Element, prefix: string): void => {
|
|
396
|
-
// Remove directive attributes from this element
|
|
397
|
-
const attrs = Array.from(el.attributes);
|
|
398
|
-
for (const attr of attrs) {
|
|
399
|
-
if (attr.name.startsWith(`${prefix}-`) || attr.name.startsWith(':') || attr.name === ':key') {
|
|
400
|
-
el.removeAttribute(attr.name);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Recurse into children
|
|
405
|
-
for (const child of Array.from(el.children)) {
|
|
406
|
-
stripDirectiveAttributes(child, prefix);
|
|
407
|
-
}
|
|
408
|
-
};
|
|
409
|
-
|
|
410
|
-
/**
|
|
411
|
-
* Server-side renders a bQuery template to an HTML string.
|
|
412
|
-
*
|
|
413
|
-
* Takes an HTML template with bQuery directives (bq-text, bq-if, bq-for, etc.)
|
|
414
|
-
* and a data context, then evaluates the directives to produce a static HTML string.
|
|
415
|
-
* This HTML can be sent to the client and later hydrated with `mount()` using
|
|
416
|
-
* `{ hydrate: true }`.
|
|
417
|
-
*
|
|
418
|
-
* Supported directives:
|
|
419
|
-
* - `bq-text` — Sets text content
|
|
420
|
-
* - `bq-html` — Sets innerHTML
|
|
421
|
-
* - `bq-if` — Conditional rendering (removes element if falsy)
|
|
422
|
-
* - `bq-show` — Toggle visibility via `display: none`
|
|
423
|
-
* - `bq-class` — Dynamic class binding (object or expression syntax)
|
|
424
|
-
* - `bq-style` — Dynamic inline styles
|
|
425
|
-
* - `bq-for` — List rendering
|
|
426
|
-
* - `bq-bind:attr` — Dynamic attribute binding
|
|
427
|
-
*
|
|
428
|
-
* @param template - HTML template string with bq-* directives
|
|
429
|
-
* @param data - Plain data object (signals will be unwrapped automatically)
|
|
430
|
-
* @param options - Rendering options
|
|
431
|
-
* @returns SSR result with HTML string and optional store state
|
|
432
|
-
*
|
|
433
|
-
* @example
|
|
434
|
-
* ```ts
|
|
435
|
-
* import { renderToString } from '@bquery/bquery/ssr';
|
|
436
|
-
* import { signal } from '@bquery/bquery/reactive';
|
|
437
|
-
*
|
|
438
|
-
* const result = renderToString(
|
|
439
|
-
* '<div><h1 bq-text="title"></h1><p bq-if="showBody">Hello!</p></div>',
|
|
440
|
-
* { title: 'Welcome', showBody: true }
|
|
441
|
-
* );
|
|
442
|
-
*
|
|
443
|
-
* console.log(result.html);
|
|
444
|
-
* // '<div><h1>Welcome</h1><p>Hello!</p></div>'
|
|
445
|
-
* ```
|
|
446
|
-
*
|
|
447
|
-
* @example
|
|
448
|
-
* ```ts
|
|
449
|
-
* // With bq-for list rendering
|
|
450
|
-
* const result = renderToString(
|
|
451
|
-
* '<ul><li bq-for="item in items" bq-text="item.name"></li></ul>',
|
|
452
|
-
* { items: [{ name: 'Alice' }, { name: 'Bob' }] }
|
|
453
|
-
* );
|
|
454
|
-
*
|
|
455
|
-
* console.log(result.html);
|
|
456
|
-
* // '<ul><li>Alice</li><li>Bob</li></ul>'
|
|
457
|
-
* ```
|
|
458
|
-
*/
|
|
459
|
-
export const renderToString = (
|
|
460
|
-
template: string,
|
|
461
|
-
data: BindingContext,
|
|
462
|
-
options: RenderOptions = {}
|
|
463
|
-
): SSRResult => {
|
|
464
|
-
const { prefix = 'bq', stripDirectives = false, includeStoreState = false } = options;
|
|
465
|
-
|
|
466
|
-
if (!template || typeof template !== 'string') {
|
|
467
|
-
throw new Error('bQuery SSR: template must be a non-empty string.');
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (typeof DOMParser === 'undefined') {
|
|
471
|
-
throw new Error(
|
|
472
|
-
'bQuery SSR: DOMParser is not available in this environment. Provide a DOMParser-compatible implementation before calling renderToString().'
|
|
473
|
-
);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Create a DOM document for processing
|
|
477
|
-
const parser = new DOMParser();
|
|
478
|
-
const doc = parser.parseFromString(template.trim(), 'text/html');
|
|
479
|
-
const body = doc.body || doc.documentElement;
|
|
480
|
-
|
|
481
|
-
if (!body) {
|
|
482
|
-
throw new Error('bQuery SSR: Failed to parse template.');
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Process all children of the body
|
|
486
|
-
processSSRChildren(body, data, prefix, doc);
|
|
487
|
-
|
|
488
|
-
// Strip directive attributes if requested
|
|
489
|
-
if (stripDirectives) {
|
|
490
|
-
for (const child of Array.from(body.children)) {
|
|
491
|
-
stripDirectiveAttributes(child, prefix);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
let html = '';
|
|
496
|
-
for (const child of body.childNodes) {
|
|
497
|
-
html += serializeSSRNode(child);
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Handle store state serialization
|
|
501
|
-
let storeState: string | undefined;
|
|
502
|
-
if (includeStoreState) {
|
|
503
|
-
const storeIds = Array.isArray(includeStoreState) ? includeStoreState : undefined;
|
|
504
|
-
storeState = serializeStoreState({ storeIds }).stateJson;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
return { html, storeState };
|
|
508
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* SSR rendering utilities.
|
|
3
|
+
*
|
|
4
|
+
* Server-side renders bQuery templates to HTML strings by evaluating
|
|
5
|
+
* directive attributes against a plain data context. Uses a lightweight
|
|
6
|
+
* DOM implementation to process templates without a browser.
|
|
7
|
+
*
|
|
8
|
+
* @module bquery/ssr
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isComputed, isSignal, type Signal } from '../reactive/index';
|
|
12
|
+
import { DANGEROUS_PROTOCOLS } from '../security/constants';
|
|
13
|
+
import { sanitizeHtml } from '../security/sanitize';
|
|
14
|
+
import type { BindingContext } from '../view/types';
|
|
15
|
+
import type { RenderOptions, SSRResult } from './types';
|
|
16
|
+
import { serializeStoreState } from './serialize';
|
|
17
|
+
|
|
18
|
+
const VOID_ELEMENTS = new Set([
|
|
19
|
+
'area',
|
|
20
|
+
'base',
|
|
21
|
+
'br',
|
|
22
|
+
'col',
|
|
23
|
+
'embed',
|
|
24
|
+
'hr',
|
|
25
|
+
'img',
|
|
26
|
+
'input',
|
|
27
|
+
'link',
|
|
28
|
+
'meta',
|
|
29
|
+
'param',
|
|
30
|
+
'source',
|
|
31
|
+
'track',
|
|
32
|
+
'wbr',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const escapeHtmlText = (value: string): string =>
|
|
36
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
37
|
+
|
|
38
|
+
const escapeHtmlAttribute = (value: string): string =>
|
|
39
|
+
escapeHtmlText(value).replace(/"/g, '"');
|
|
40
|
+
|
|
41
|
+
const isUnsafeUrlAttribute = (name: string): boolean => {
|
|
42
|
+
const normalized = name.toLowerCase();
|
|
43
|
+
return (
|
|
44
|
+
normalized === 'href' ||
|
|
45
|
+
normalized === 'src' ||
|
|
46
|
+
normalized === 'xlink:href' ||
|
|
47
|
+
normalized === 'formaction' ||
|
|
48
|
+
normalized === 'action' ||
|
|
49
|
+
normalized === 'poster' ||
|
|
50
|
+
normalized === 'background' ||
|
|
51
|
+
normalized === 'cite' ||
|
|
52
|
+
normalized === 'data'
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const sanitizeUrlForProtocolCheck = (value: string): string =>
|
|
57
|
+
value
|
|
58
|
+
.trim()
|
|
59
|
+
.replace(/[\u0000-\u001F\u007F]+/g, '')
|
|
60
|
+
.replace(/[\u200B-\u200D\uFEFF\u2028\u2029]+/g, '')
|
|
61
|
+
.replace(/\\u[\da-fA-F]{4}/g, '')
|
|
62
|
+
.replace(/\s+/g, '')
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
|
|
65
|
+
const isUnsafeUrlValue = (value: string): boolean => {
|
|
66
|
+
const normalized = sanitizeUrlForProtocolCheck(value);
|
|
67
|
+
return DANGEROUS_PROTOCOLS.some((protocol) => normalized.startsWith(protocol));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const serializeSSRNode = (node: Node): string => {
|
|
71
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
72
|
+
return escapeHtmlText(node.textContent ?? '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (node.nodeType !== Node.ELEMENT_NODE) {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const el = node as Element;
|
|
80
|
+
const tagName = el.tagName.toLowerCase();
|
|
81
|
+
|
|
82
|
+
if (tagName === 'script') {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let attrs = '';
|
|
87
|
+
for (const attr of el.attributes) {
|
|
88
|
+
const attrName = attr.name.toLowerCase();
|
|
89
|
+
if (attrName.startsWith('on')) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (isUnsafeUrlAttribute(attrName) && isUnsafeUrlValue(attr.value)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
attrs += ` ${attr.name}="${escapeHtmlAttribute(attr.value)}"`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (VOID_ELEMENTS.has(tagName)) {
|
|
99
|
+
return `<${tagName}${attrs}>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let childrenHtml = '';
|
|
103
|
+
for (const child of el.childNodes) {
|
|
104
|
+
childrenHtml += serializeSSRNode(child);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return `<${tagName}${attrs}>${childrenHtml}</${tagName}>`;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Unwraps a value — if it's a signal/computed, returns `.value`, otherwise returns as-is.
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
const unwrap = (value: unknown): unknown => {
|
|
115
|
+
if (isSignal(value) || isComputed(value)) {
|
|
116
|
+
return (value as Signal<unknown>).value;
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Evaluates a simple expression against a context.
|
|
123
|
+
* Supports dot-notation property access, negation, ternary, and basic comparisons.
|
|
124
|
+
* Unlike the view module's `evaluate()`, this does NOT use `new Function()` —
|
|
125
|
+
* it uses a safe subset for SSR to avoid `unsafe-eval` in server environments.
|
|
126
|
+
*
|
|
127
|
+
* Falls back to `new Function()` for complex expressions.
|
|
128
|
+
*
|
|
129
|
+
* @internal
|
|
130
|
+
*/
|
|
131
|
+
const evaluateSSR = <T = unknown>(expression: string, context: BindingContext): T => {
|
|
132
|
+
const trimmed = expression.trim();
|
|
133
|
+
|
|
134
|
+
// Handle negation: !expr
|
|
135
|
+
if (trimmed.startsWith('!')) {
|
|
136
|
+
return !evaluateSSR(trimmed.slice(1).trim(), context) as T;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Handle string literals
|
|
140
|
+
if (
|
|
141
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
142
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
143
|
+
) {
|
|
144
|
+
return trimmed.slice(1, -1) as T;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Handle numeric literals
|
|
148
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
|
|
149
|
+
return Number(trimmed) as T;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Handle boolean literals
|
|
153
|
+
if (trimmed === 'true') return true as T;
|
|
154
|
+
if (trimmed === 'false') return false as T;
|
|
155
|
+
if (trimmed === 'null') return null as T;
|
|
156
|
+
if (trimmed === 'undefined') return undefined as T;
|
|
157
|
+
|
|
158
|
+
// Handle dot-notation property access: a.b.c
|
|
159
|
+
if (/^[\w$]+(?:\.[\w$]+)*$/.test(trimmed)) {
|
|
160
|
+
const parts = trimmed.split('.');
|
|
161
|
+
let current: unknown = context;
|
|
162
|
+
for (const part of parts) {
|
|
163
|
+
if (current == null) return undefined as T;
|
|
164
|
+
// First level: unwrap signals
|
|
165
|
+
if (current === context) {
|
|
166
|
+
current = unwrap((current as Record<string, unknown>)[part]);
|
|
167
|
+
} else {
|
|
168
|
+
current = (current as Record<string, unknown>)[part];
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return current as T;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// For complex expressions, fall back to Function-based evaluation
|
|
175
|
+
try {
|
|
176
|
+
const keys = Object.keys(context);
|
|
177
|
+
const values = keys.map((k) => unwrap(context[k]));
|
|
178
|
+
const fn = new Function(...keys, `return (${trimmed});`);
|
|
179
|
+
return fn(...values) as T;
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined as T;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Parses a `bq-for` expression like `item in items` or `(item, index) in items`.
|
|
187
|
+
* @internal
|
|
188
|
+
*/
|
|
189
|
+
const parseForExpression = (
|
|
190
|
+
expression: string
|
|
191
|
+
): { itemName: string; indexName?: string; listExpr: string } | null => {
|
|
192
|
+
const match = expression.match(/^\(?(\w+)(?:\s*,\s*(\w+))?\)?\s+in\s+(\S.*)$/);
|
|
193
|
+
if (!match) return null;
|
|
194
|
+
return {
|
|
195
|
+
itemName: match[1],
|
|
196
|
+
indexName: match[2] || undefined,
|
|
197
|
+
listExpr: match[3].trim(),
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Processes an element's SSR directives, modifying it in place.
|
|
203
|
+
* Returns `false` if the element should be removed from output (bq-if = false).
|
|
204
|
+
* @internal
|
|
205
|
+
*/
|
|
206
|
+
const processSSRElement = (
|
|
207
|
+
el: Element,
|
|
208
|
+
context: BindingContext,
|
|
209
|
+
prefix: string,
|
|
210
|
+
doc: Document
|
|
211
|
+
): boolean => {
|
|
212
|
+
// Handle bq-if: remove element if condition is falsy
|
|
213
|
+
const ifExpr = el.getAttribute(`${prefix}-if`);
|
|
214
|
+
if (ifExpr !== null) {
|
|
215
|
+
const condition = evaluateSSR<boolean>(ifExpr, context);
|
|
216
|
+
if (!condition) {
|
|
217
|
+
return false; // Signal to remove this element
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Handle bq-show: set display:none if falsy
|
|
222
|
+
const showExpr = el.getAttribute(`${prefix}-show`);
|
|
223
|
+
if (showExpr !== null) {
|
|
224
|
+
const condition = evaluateSSR<boolean>(showExpr, context);
|
|
225
|
+
if (!condition) {
|
|
226
|
+
const htmlEl = el as unknown as { style?: { display?: string } };
|
|
227
|
+
if (htmlEl.style) {
|
|
228
|
+
htmlEl.style.display = 'none';
|
|
229
|
+
} else {
|
|
230
|
+
el.setAttribute('style', 'display: none;');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Handle bq-text: set text content
|
|
236
|
+
const textExpr = el.getAttribute(`${prefix}-text`);
|
|
237
|
+
if (textExpr !== null) {
|
|
238
|
+
const value = evaluateSSR(textExpr, context);
|
|
239
|
+
el.textContent = String(value ?? '');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Handle bq-html: sanitize to match client-side default behavior
|
|
243
|
+
const htmlExpr = el.getAttribute(`${prefix}-html`);
|
|
244
|
+
if (htmlExpr !== null) {
|
|
245
|
+
const value = evaluateSSR(htmlExpr, context);
|
|
246
|
+
el.innerHTML = String(sanitizeHtml(String(value ?? '')));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Handle bq-class: add classes
|
|
250
|
+
const classExpr = el.getAttribute(`${prefix}-class`);
|
|
251
|
+
if (classExpr !== null) {
|
|
252
|
+
const trimmedClass = classExpr.trim();
|
|
253
|
+
if (trimmedClass.startsWith('{')) {
|
|
254
|
+
// Object syntax: { active: isActive, disabled: !enabled }
|
|
255
|
+
const inner = trimmedClass.slice(1, -1).trim();
|
|
256
|
+
const pairs = inner.split(',');
|
|
257
|
+
for (const pair of pairs) {
|
|
258
|
+
const colonIdx = pair.indexOf(':');
|
|
259
|
+
if (colonIdx > -1) {
|
|
260
|
+
const className = pair
|
|
261
|
+
.slice(0, colonIdx)
|
|
262
|
+
.trim()
|
|
263
|
+
.replace(/^['"]|['"]$/g, '');
|
|
264
|
+
const condExpr = pair.slice(colonIdx + 1).trim();
|
|
265
|
+
const condition = evaluateSSR<boolean>(condExpr, context);
|
|
266
|
+
if (condition) {
|
|
267
|
+
el.classList.add(className);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
const result = evaluateSSR<string | string[]>(classExpr, context);
|
|
273
|
+
if (typeof result === 'string') {
|
|
274
|
+
result
|
|
275
|
+
.split(/\s+/)
|
|
276
|
+
.filter(Boolean)
|
|
277
|
+
.forEach((cls) => el.classList.add(cls));
|
|
278
|
+
} else if (Array.isArray(result)) {
|
|
279
|
+
result.filter(Boolean).forEach((cls) => el.classList.add(cls));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Handle bq-style: set inline styles
|
|
285
|
+
const styleExpr = el.getAttribute(`${prefix}-style`);
|
|
286
|
+
if (styleExpr !== null) {
|
|
287
|
+
const result = evaluateSSR<Record<string, string>>(styleExpr, context);
|
|
288
|
+
if (result && typeof result === 'object') {
|
|
289
|
+
const htmlEl = el as HTMLElement;
|
|
290
|
+
for (const [prop, val] of Object.entries(result)) {
|
|
291
|
+
// Convert camelCase to kebab-case
|
|
292
|
+
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
293
|
+
htmlEl.style.setProperty(cssProp, String(val));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Handle bq-bind:attr — set arbitrary attributes
|
|
299
|
+
const attrs = Array.from(el.attributes);
|
|
300
|
+
for (const attr of attrs) {
|
|
301
|
+
if (attr.name.startsWith(`${prefix}-bind:`)) {
|
|
302
|
+
const attrName = attr.name.slice(`${prefix}-bind:`.length);
|
|
303
|
+
const value = evaluateSSR(attr.value, context);
|
|
304
|
+
if (value === false || value === null || value === undefined) {
|
|
305
|
+
el.removeAttribute(attrName);
|
|
306
|
+
} else if (value === true) {
|
|
307
|
+
el.setAttribute(attrName, '');
|
|
308
|
+
} else {
|
|
309
|
+
el.setAttribute(attrName, String(value));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Handle bq-for: list rendering
|
|
315
|
+
const forExpr = el.getAttribute(`${prefix}-for`);
|
|
316
|
+
if (forExpr !== null) {
|
|
317
|
+
const parsed = parseForExpression(forExpr);
|
|
318
|
+
if (parsed) {
|
|
319
|
+
const list = evaluateSSR<unknown[]>(parsed.listExpr, context);
|
|
320
|
+
if (Array.isArray(list) && el.parentNode) {
|
|
321
|
+
const parent = el.parentNode;
|
|
322
|
+
for (let i = 0; i < list.length; i++) {
|
|
323
|
+
const item = list[i];
|
|
324
|
+
const clone = el.cloneNode(true) as Element;
|
|
325
|
+
|
|
326
|
+
// Remove the bq-for attribute from clones
|
|
327
|
+
clone.removeAttribute(`${prefix}-for`);
|
|
328
|
+
clone.removeAttribute(':key');
|
|
329
|
+
clone.removeAttribute(`${prefix}-key`);
|
|
330
|
+
|
|
331
|
+
// Create item context
|
|
332
|
+
const itemContext: BindingContext = {
|
|
333
|
+
...context,
|
|
334
|
+
[parsed.itemName]: item,
|
|
335
|
+
};
|
|
336
|
+
if (parsed.indexName) {
|
|
337
|
+
itemContext[parsed.indexName] = i;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Recursively process the clone
|
|
341
|
+
processSSRElement(clone, itemContext, prefix, doc);
|
|
342
|
+
processSSRChildren(clone, itemContext, prefix, doc);
|
|
343
|
+
|
|
344
|
+
parent.insertBefore(clone, el);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Remove the original template element
|
|
348
|
+
parent.removeChild(el);
|
|
349
|
+
return true; // Already handled children
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return true;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Recursively processes children of an element for SSR.
|
|
359
|
+
* @internal
|
|
360
|
+
*/
|
|
361
|
+
const processSSRChildren = (
|
|
362
|
+
parent: Element,
|
|
363
|
+
context: BindingContext,
|
|
364
|
+
prefix: string,
|
|
365
|
+
doc: Document
|
|
366
|
+
): void => {
|
|
367
|
+
// Process children in reverse to handle removals safely
|
|
368
|
+
const children = Array.from(parent.children);
|
|
369
|
+
for (const child of children) {
|
|
370
|
+
// Skip bq-for elements — they're handled by parent
|
|
371
|
+
if (child.hasAttribute(`${prefix}-for`)) {
|
|
372
|
+
// Process the for directive on this element
|
|
373
|
+
const keep = processSSRElement(child, context, prefix, doc);
|
|
374
|
+
if (!keep) {
|
|
375
|
+
child.remove();
|
|
376
|
+
}
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const keep = processSSRElement(child, context, prefix, doc);
|
|
381
|
+
if (!keep) {
|
|
382
|
+
child.remove();
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Recurse into children
|
|
387
|
+
processSSRChildren(child, context, prefix, doc);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Strips all directive attributes (bq-*) from an element and its descendants.
|
|
393
|
+
* @internal
|
|
394
|
+
*/
|
|
395
|
+
const stripDirectiveAttributes = (el: Element, prefix: string): void => {
|
|
396
|
+
// Remove directive attributes from this element
|
|
397
|
+
const attrs = Array.from(el.attributes);
|
|
398
|
+
for (const attr of attrs) {
|
|
399
|
+
if (attr.name.startsWith(`${prefix}-`) || attr.name.startsWith(':') || attr.name === ':key') {
|
|
400
|
+
el.removeAttribute(attr.name);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Recurse into children
|
|
405
|
+
for (const child of Array.from(el.children)) {
|
|
406
|
+
stripDirectiveAttributes(child, prefix);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Server-side renders a bQuery template to an HTML string.
|
|
412
|
+
*
|
|
413
|
+
* Takes an HTML template with bQuery directives (bq-text, bq-if, bq-for, etc.)
|
|
414
|
+
* and a data context, then evaluates the directives to produce a static HTML string.
|
|
415
|
+
* This HTML can be sent to the client and later hydrated with `mount()` using
|
|
416
|
+
* `{ hydrate: true }`.
|
|
417
|
+
*
|
|
418
|
+
* Supported directives:
|
|
419
|
+
* - `bq-text` — Sets text content
|
|
420
|
+
* - `bq-html` — Sets innerHTML
|
|
421
|
+
* - `bq-if` — Conditional rendering (removes element if falsy)
|
|
422
|
+
* - `bq-show` — Toggle visibility via `display: none`
|
|
423
|
+
* - `bq-class` — Dynamic class binding (object or expression syntax)
|
|
424
|
+
* - `bq-style` — Dynamic inline styles
|
|
425
|
+
* - `bq-for` — List rendering
|
|
426
|
+
* - `bq-bind:attr` — Dynamic attribute binding
|
|
427
|
+
*
|
|
428
|
+
* @param template - HTML template string with bq-* directives
|
|
429
|
+
* @param data - Plain data object (signals will be unwrapped automatically)
|
|
430
|
+
* @param options - Rendering options
|
|
431
|
+
* @returns SSR result with HTML string and optional store state
|
|
432
|
+
*
|
|
433
|
+
* @example
|
|
434
|
+
* ```ts
|
|
435
|
+
* import { renderToString } from '@bquery/bquery/ssr';
|
|
436
|
+
* import { signal } from '@bquery/bquery/reactive';
|
|
437
|
+
*
|
|
438
|
+
* const result = renderToString(
|
|
439
|
+
* '<div><h1 bq-text="title"></h1><p bq-if="showBody">Hello!</p></div>',
|
|
440
|
+
* { title: 'Welcome', showBody: true }
|
|
441
|
+
* );
|
|
442
|
+
*
|
|
443
|
+
* console.log(result.html);
|
|
444
|
+
* // '<div><h1>Welcome</h1><p>Hello!</p></div>'
|
|
445
|
+
* ```
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```ts
|
|
449
|
+
* // With bq-for list rendering
|
|
450
|
+
* const result = renderToString(
|
|
451
|
+
* '<ul><li bq-for="item in items" bq-text="item.name"></li></ul>',
|
|
452
|
+
* { items: [{ name: 'Alice' }, { name: 'Bob' }] }
|
|
453
|
+
* );
|
|
454
|
+
*
|
|
455
|
+
* console.log(result.html);
|
|
456
|
+
* // '<ul><li>Alice</li><li>Bob</li></ul>'
|
|
457
|
+
* ```
|
|
458
|
+
*/
|
|
459
|
+
export const renderToString = (
|
|
460
|
+
template: string,
|
|
461
|
+
data: BindingContext,
|
|
462
|
+
options: RenderOptions = {}
|
|
463
|
+
): SSRResult => {
|
|
464
|
+
const { prefix = 'bq', stripDirectives = false, includeStoreState = false } = options;
|
|
465
|
+
|
|
466
|
+
if (!template || typeof template !== 'string') {
|
|
467
|
+
throw new Error('bQuery SSR: template must be a non-empty string.');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (typeof DOMParser === 'undefined') {
|
|
471
|
+
throw new Error(
|
|
472
|
+
'bQuery SSR: DOMParser is not available in this environment. Provide a DOMParser-compatible implementation before calling renderToString().'
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Create a DOM document for processing
|
|
477
|
+
const parser = new DOMParser();
|
|
478
|
+
const doc = parser.parseFromString(template.trim(), 'text/html');
|
|
479
|
+
const body = doc.body || doc.documentElement;
|
|
480
|
+
|
|
481
|
+
if (!body) {
|
|
482
|
+
throw new Error('bQuery SSR: Failed to parse template.');
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Process all children of the body
|
|
486
|
+
processSSRChildren(body, data, prefix, doc);
|
|
487
|
+
|
|
488
|
+
// Strip directive attributes if requested
|
|
489
|
+
if (stripDirectives) {
|
|
490
|
+
for (const child of Array.from(body.children)) {
|
|
491
|
+
stripDirectiveAttributes(child, prefix);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let html = '';
|
|
496
|
+
for (const child of body.childNodes) {
|
|
497
|
+
html += serializeSSRNode(child);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Handle store state serialization
|
|
501
|
+
let storeState: string | undefined;
|
|
502
|
+
if (includeStoreState) {
|
|
503
|
+
const storeIds = Array.isArray(includeStoreState) ? includeStoreState : undefined;
|
|
504
|
+
storeState = serializeStoreState({ storeIds }).stateJson;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return { html, storeState };
|
|
508
|
+
};
|