@bquery/bquery 1.10.0 → 1.11.0
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 +44 -19
- package/dist/{a11y-DG2i4iZN.js → a11y-DgUQ8-fI.js} +1 -1
- package/dist/{a11y-DG2i4iZN.js.map → a11y-DgUQ8-fI.js.map} +1 -1
- package/dist/a11y.es.mjs +1 -1
- package/dist/{component-DRotf1hl.js → component-D8ydhe58.js} +2 -2
- package/dist/{component-DRotf1hl.js.map → component-D8ydhe58.js.map} +1 -1
- package/dist/component.es.mjs +1 -1
- package/dist/concurrency-BU1wPEsZ.js.map +1 -1
- package/dist/{constraints-CqjhmpZC.js → constraints-Dlbx_m1b.js} +1 -1
- package/dist/{constraints-CqjhmpZC.js.map → constraints-Dlbx_m1b.js.map} +1 -1
- package/dist/{core-EMYSLzaT.js → core-tOP6QOrY.js} +2 -2
- package/dist/{core-EMYSLzaT.js.map → core-tOP6QOrY.js.map} +1 -1
- package/dist/core.es.mjs +1 -1
- package/dist/{custom-directives-BjFzFhuf.js → custom-directives-5DlKqvd2.js} +1 -1
- package/dist/{custom-directives-BjFzFhuf.js.map → custom-directives-5DlKqvd2.js.map} +1 -1
- package/dist/{devtools-C5FExMwv.js → devtools-QosAqo0T.js} +2 -2
- package/dist/{devtools-C5FExMwv.js.map → devtools-QosAqo0T.js.map} +1 -1
- package/dist/devtools.es.mjs +1 -1
- package/dist/{dnd-BAqzPlSo.js → dnd-d2OU4len.js} +1 -1
- package/dist/{dnd-BAqzPlSo.js.map → dnd-d2OU4len.js.map} +1 -1
- package/dist/dnd.es.mjs +1 -1
- package/dist/{forms-Dx1Scvh0.js → forms-BLx4ZzT7.js} +1 -1
- package/dist/{forms-Dx1Scvh0.js.map → forms-BLx4ZzT7.js.map} +1 -1
- package/dist/forms.es.mjs +1 -1
- package/dist/full.d.ts +4 -2
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +258 -219
- package/dist/full.iife.js +41 -37
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +41 -37
- package/dist/full.umd.js.map +1 -1
- package/dist/{i18n-Cazyk9RD.js → i18n--p7PM-9r.js} +1 -1
- package/dist/{i18n-Cazyk9RD.js.map → i18n--p7PM-9r.js.map} +1 -1
- package/dist/i18n.es.mjs +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.es.mjs +291 -252
- package/dist/match-CrZRVC4z.js +174 -0
- package/dist/match-CrZRVC4z.js.map +1 -0
- package/dist/{media-dAKIGPk3.js → media-gjbWNq50.js} +1 -1
- package/dist/{media-dAKIGPk3.js.map → media-gjbWNq50.js.map} +1 -1
- package/dist/media.es.mjs +1 -1
- package/dist/motion-BBMso9Ir.js.map +1 -1
- package/dist/{mount-C8O2vXkQ.js → mount-0A9qtcRJ.js} +3 -3
- package/dist/{mount-C8O2vXkQ.js.map → mount-0A9qtcRJ.js.map} +1 -1
- package/dist/platform-BPHIXbw8.js.map +1 -1
- package/dist/{plugin-DjTqWg-P.js → plugin-SZEirbwq.js} +2 -2
- package/dist/{plugin-DjTqWg-P.js.map → plugin-SZEirbwq.js.map} +1 -1
- package/dist/plugin.es.mjs +1 -1
- package/dist/reactive-BAd2hfl8.js.map +1 -1
- package/dist/{registry-Cr6VH8CR.js → registry-jpUQHf4E.js} +1 -1
- package/dist/{registry-Cr6VH8CR.js.map → registry-jpUQHf4E.js.map} +1 -1
- package/dist/router-C4weu0QL.js +333 -0
- package/dist/router-C4weu0QL.js.map +1 -0
- package/dist/router.es.mjs +1 -1
- package/dist/{sanitize-B1V4JswB.js → sanitize-DOMkRO9G.js} +12 -7
- package/dist/{sanitize-B1V4JswB.js.map → sanitize-DOMkRO9G.js.map} +1 -1
- package/dist/security.es.mjs +1 -1
- package/dist/server/create-server.d.ts +25 -0
- package/dist/server/create-server.d.ts.map +1 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/types.d.ts +396 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server-QdyKtCS1.js +349 -0
- package/dist/server-QdyKtCS1.js.map +1 -0
- package/dist/server.es.mjs +6 -0
- package/dist/ssr/adapters.d.ts +74 -0
- package/dist/ssr/adapters.d.ts.map +1 -0
- package/dist/ssr/async.d.ts +40 -0
- package/dist/ssr/async.d.ts.map +1 -0
- package/dist/ssr/config.d.ts +60 -0
- package/dist/ssr/config.d.ts.map +1 -0
- package/dist/ssr/context.d.ts +73 -0
- package/dist/ssr/context.d.ts.map +1 -0
- package/dist/ssr/defer-brand.d.ts +5 -0
- package/dist/ssr/defer-brand.d.ts.map +1 -0
- package/dist/ssr/escape.d.ts +17 -0
- package/dist/ssr/escape.d.ts.map +1 -0
- package/dist/ssr/expression.d.ts +44 -0
- package/dist/ssr/expression.d.ts.map +1 -0
- package/dist/ssr/hash.d.ts +39 -0
- package/dist/ssr/hash.d.ts.map +1 -0
- package/dist/ssr/head.d.ts +102 -0
- package/dist/ssr/head.d.ts.map +1 -0
- package/dist/ssr/html-parser.d.ts +58 -0
- package/dist/ssr/html-parser.d.ts.map +1 -0
- package/dist/ssr/index.d.ts +49 -43
- package/dist/ssr/index.d.ts.map +1 -1
- package/dist/ssr/mismatch.d.ts +60 -0
- package/dist/ssr/mismatch.d.ts.map +1 -0
- package/dist/ssr/render-async.d.ts +84 -0
- package/dist/ssr/render-async.d.ts.map +1 -0
- package/dist/ssr/render.d.ts.map +1 -1
- package/dist/ssr/renderer.d.ts +25 -0
- package/dist/ssr/renderer.d.ts.map +1 -0
- package/dist/ssr/resumability.d.ts +65 -0
- package/dist/ssr/resumability.d.ts.map +1 -0
- package/dist/ssr/router-bridge.d.ts +101 -0
- package/dist/ssr/router-bridge.d.ts.map +1 -0
- package/dist/ssr/runtime.d.ts +63 -0
- package/dist/ssr/runtime.d.ts.map +1 -0
- package/dist/ssr/serialize.d.ts.map +1 -1
- package/dist/ssr/store-snapshot.d.ts +87 -0
- package/dist/ssr/store-snapshot.d.ts.map +1 -0
- package/dist/ssr/strategies.d.ts +43 -0
- package/dist/ssr/strategies.d.ts.map +1 -0
- package/dist/ssr/suspense.d.ts +47 -0
- package/dist/ssr/suspense.d.ts.map +1 -0
- package/dist/ssr/types.d.ts +17 -0
- package/dist/ssr/types.d.ts.map +1 -1
- package/dist/ssr-Bt6BQA3J.js +2127 -0
- package/dist/ssr-Bt6BQA3J.js.map +1 -0
- package/dist/ssr.es.mjs +42 -7
- package/dist/{store-CjmEeX9-.js → store-DnXuu6Li.js} +2 -2
- package/dist/{store-CjmEeX9-.js.map → store-DnXuu6Li.js.map} +1 -1
- package/dist/store.es.mjs +2 -2
- package/dist/storybook.es.mjs +1 -1
- package/dist/{testing-TdfaL7VE.js → testing-CeMUwrRD.js} +2 -2
- package/dist/{testing-TdfaL7VE.js.map → testing-CeMUwrRD.js.map} +1 -1
- package/dist/testing.es.mjs +1 -1
- package/dist/view.es.mjs +1 -1
- package/package.json +17 -12
- package/src/full.ts +99 -0
- package/src/index.ts +3 -0
- package/src/server/create-server.ts +754 -0
- package/src/server/index.ts +33 -0
- package/src/server/types.ts +490 -0
- package/src/ssr/adapters.ts +330 -0
- package/src/ssr/async.ts +125 -0
- package/src/ssr/config.ts +86 -0
- package/src/ssr/context.ts +245 -0
- package/src/ssr/defer-brand.ts +3 -0
- package/src/ssr/escape.ts +25 -0
- package/src/ssr/expression.ts +669 -0
- package/src/ssr/hash.ts +71 -0
- package/src/ssr/head.ts +240 -0
- package/src/ssr/html-parser.ts +387 -0
- package/src/ssr/index.ts +136 -43
- package/src/ssr/mismatch.ts +110 -0
- package/src/ssr/render-async.ts +286 -0
- package/src/ssr/render.ts +130 -59
- package/src/ssr/renderer.ts +453 -0
- package/src/ssr/resumability.ts +142 -0
- package/src/ssr/router-bridge.ts +177 -0
- package/src/ssr/runtime.ts +131 -0
- package/src/ssr/serialize.ts +1 -27
- package/src/ssr/store-snapshot.ts +209 -0
- package/src/ssr/strategies.ts +245 -0
- package/src/ssr/suspense.ts +504 -0
- package/src/ssr/types.ts +18 -0
- package/dist/router-CCepRMpC.js +0 -493
- package/dist/router-CCepRMpC.js.map +0 -1
- package/dist/ssr-D-1IPcfw.js +0 -248
- package/dist/ssr-D-1IPcfw.js.map +0 -1
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM-free SSR renderer.
|
|
3
|
+
*
|
|
4
|
+
* Operates on the virtual node tree produced by `html-parser.ts` and
|
|
5
|
+
* evaluates `bq-*` directives without depending on any browser DOM API.
|
|
6
|
+
* Runs unmodified on Bun, Deno and Node and is the default backend used by
|
|
7
|
+
* `renderToString()` whenever the global `DOMParser` is not configured to
|
|
8
|
+
* take precedence.
|
|
9
|
+
*
|
|
10
|
+
* @module bquery/ssr
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
DANGEROUS_ATTR_PREFIXES,
|
|
16
|
+
DANGEROUS_PROTOCOLS,
|
|
17
|
+
DANGEROUS_TAGS,
|
|
18
|
+
DEFAULT_ALLOWED_ATTRIBUTES,
|
|
19
|
+
DEFAULT_ALLOWED_TAGS,
|
|
20
|
+
RESERVED_IDS,
|
|
21
|
+
} from '../security/constants';
|
|
22
|
+
import type { BindingContext } from '../view/types';
|
|
23
|
+
import { evaluateExpression } from './expression';
|
|
24
|
+
import { cheapHash, collectDirectiveSignatureFromAttrs, HYDRATION_HASH_ATTR } from './hash';
|
|
25
|
+
import {
|
|
26
|
+
cloneNode,
|
|
27
|
+
parseTemplate,
|
|
28
|
+
serializeTree,
|
|
29
|
+
type SSRElement,
|
|
30
|
+
type SSRNode,
|
|
31
|
+
} from './html-parser';
|
|
32
|
+
|
|
33
|
+
const isUnsafeUrlAttribute = (name: string): boolean => {
|
|
34
|
+
const n = name.toLowerCase();
|
|
35
|
+
return (
|
|
36
|
+
n === 'href' ||
|
|
37
|
+
n === 'src' ||
|
|
38
|
+
n === 'xlink:href' ||
|
|
39
|
+
n === 'formaction' ||
|
|
40
|
+
n === 'action' ||
|
|
41
|
+
n === 'poster' ||
|
|
42
|
+
n === 'background' ||
|
|
43
|
+
n === 'cite' ||
|
|
44
|
+
n === 'data'
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const sanitizeUrlForProtocolCheck = (value: string): string =>
|
|
49
|
+
value
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/[\u0000-\u001F\u007F]+/g, '')
|
|
52
|
+
.replace(/[\u200B-\u200D\uFEFF\u2028\u2029]+/g, '')
|
|
53
|
+
.replace(/\\u[\da-fA-F]{4}/g, '')
|
|
54
|
+
.replace(/\s+/g, '')
|
|
55
|
+
.toLowerCase();
|
|
56
|
+
|
|
57
|
+
const isUnsafeUrlValue = (value: string): boolean => {
|
|
58
|
+
const normalized = sanitizeUrlForProtocolCheck(value);
|
|
59
|
+
return DANGEROUS_PROTOCOLS.some((protocol) => normalized.startsWith(protocol));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const URL_PROTOCOL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
|
|
63
|
+
const REL_SPLIT_PATTERN = /\s+/;
|
|
64
|
+
|
|
65
|
+
const isAllowedHtmlAttribute = (name: string): boolean => {
|
|
66
|
+
const lowerName = name.toLowerCase();
|
|
67
|
+
|
|
68
|
+
for (const prefix of DANGEROUS_ATTR_PREFIXES) {
|
|
69
|
+
if (lowerName.startsWith(prefix)) return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (lowerName.startsWith('data-')) return true;
|
|
73
|
+
if (lowerName.startsWith('aria-')) return true;
|
|
74
|
+
|
|
75
|
+
return DEFAULT_ALLOWED_ATTRIBUTES.has(lowerName);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const isSafeHtmlIdOrName = (value: string): boolean => !RESERVED_IDS.has(value.toLowerCase().trim());
|
|
79
|
+
|
|
80
|
+
const isExternalHtmlUrl = (url: string): boolean => {
|
|
81
|
+
try {
|
|
82
|
+
const trimmedUrl = url.trim();
|
|
83
|
+
if (trimmedUrl.startsWith('//')) return true;
|
|
84
|
+
|
|
85
|
+
const lowerUrl = trimmedUrl.toLowerCase();
|
|
86
|
+
if (!lowerUrl.startsWith('http://') && !lowerUrl.startsWith('https://')) {
|
|
87
|
+
if (!URL_PROTOCOL_PATTERN.test(trimmedUrl)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof window === 'undefined' || !window.location) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const urlObj = new URL(trimmedUrl, window.location.href);
|
|
98
|
+
return urlObj.origin !== window.location.origin;
|
|
99
|
+
} catch {
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
interface RenderOpts {
|
|
105
|
+
prefix: string;
|
|
106
|
+
stripDirectives: boolean;
|
|
107
|
+
/** Whether to add `data-bq-h` mismatch hashes to elements with directives. */
|
|
108
|
+
annotateHydration: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `cheapHash` and `HYDRATION_HASH_ATTR` are imported from `./hash` so the
|
|
113
|
+
* server-side annotation and client-side verifier stay in lock-step.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
const setClass = (el: SSRElement, cls: string): void => {
|
|
117
|
+
if (!cls) return;
|
|
118
|
+
const existing = el.attributes['class'];
|
|
119
|
+
const merged = existing ? `${existing} ${cls}` : cls;
|
|
120
|
+
if (!('class' in el.attributes)) el.attributeOrder.push('class');
|
|
121
|
+
el.attributes['class'] = merged;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const setStyle = (el: SSRElement, declarations: Record<string, unknown>): void => {
|
|
125
|
+
let css = el.attributes['style'] ?? '';
|
|
126
|
+
for (const [prop, val] of Object.entries(declarations)) {
|
|
127
|
+
if (val === undefined || val === null || val === false) continue;
|
|
128
|
+
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
129
|
+
if (css && !css.endsWith(';')) css += '; ';
|
|
130
|
+
css += `${cssProp}: ${String(val)};`;
|
|
131
|
+
}
|
|
132
|
+
if (!('style' in el.attributes)) el.attributeOrder.push('style');
|
|
133
|
+
el.attributes['style'] = css;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const removeAttr = (el: SSRElement, name: string): void => {
|
|
137
|
+
if (name in el.attributes) {
|
|
138
|
+
delete el.attributes[name];
|
|
139
|
+
const idx = el.attributeOrder.indexOf(name);
|
|
140
|
+
if (idx !== -1) el.attributeOrder.splice(idx, 1);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const setAttr = (el: SSRElement, name: string, value: string): void => {
|
|
145
|
+
if (!(name in el.attributes)) el.attributeOrder.push(name);
|
|
146
|
+
el.attributes[name] = value;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const collectDirectiveSignature = (el: SSRElement, prefix: string): string =>
|
|
150
|
+
collectDirectiveSignatureFromAttrs(el.attributeOrder, el.attributes, prefix);
|
|
151
|
+
|
|
152
|
+
const parseForExpression = (
|
|
153
|
+
expression: string
|
|
154
|
+
): { itemName: string; indexName?: string; listExpr: string } | null => {
|
|
155
|
+
const match = expression.match(/^\(?(\w+)(?:\s*,\s*(\w+))?\)?\s+in\s+(\S.*)$/);
|
|
156
|
+
if (!match) return null;
|
|
157
|
+
return {
|
|
158
|
+
itemName: match[1],
|
|
159
|
+
indexName: match[2] || undefined,
|
|
160
|
+
listExpr: match[3].trim(),
|
|
161
|
+
};
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const stripDirectiveAttributes = (node: SSRNode, prefix: string): void => {
|
|
165
|
+
if (node.type !== 'element') {
|
|
166
|
+
if (node.type === 'fragment') {
|
|
167
|
+
for (const child of node.children) stripDirectiveAttributes(child, prefix);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
for (const name of [...node.attributeOrder]) {
|
|
172
|
+
if (name.startsWith(`${prefix}-`) || name.startsWith(':')) {
|
|
173
|
+
removeAttr(node, name);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const child of node.children) stripDirectiveAttributes(child, prefix);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const setText = (el: SSRElement, value: string): void => {
|
|
180
|
+
el.children = [{ type: 'text', value }];
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const setHtml = (el: SSRElement, raw: string): void => {
|
|
184
|
+
// Parse the sanitized HTML and replace children with the resulting tree.
|
|
185
|
+
const fragment = parseTemplate(raw);
|
|
186
|
+
el.children = fragment.children;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const sanitizeHtmlForSSR = (raw: string): string => {
|
|
190
|
+
const sanitizeNode = (node: SSRNode): SSRNode | null => {
|
|
191
|
+
if (node.type === 'fragment') {
|
|
192
|
+
node.children = node.children.flatMap((child) => {
|
|
193
|
+
const sanitized = sanitizeNode(child);
|
|
194
|
+
return sanitized ? [sanitized] : [];
|
|
195
|
+
});
|
|
196
|
+
return node;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (node.type !== 'element') {
|
|
200
|
+
return node;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (DANGEROUS_TAGS.has(node.tag) || !DEFAULT_ALLOWED_TAGS.has(node.tag)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const name of [...node.attributeOrder]) {
|
|
208
|
+
const value = node.attributes[name];
|
|
209
|
+
const attrName = name.toLowerCase();
|
|
210
|
+
|
|
211
|
+
if (!isAllowedHtmlAttribute(attrName)) {
|
|
212
|
+
removeAttr(node, name);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if ((attrName === 'id' || attrName === 'name') && !isSafeHtmlIdOrName(value)) {
|
|
217
|
+
removeAttr(node, name);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if ((attrName === 'href' || attrName === 'src') && isUnsafeUrlValue(value)) {
|
|
222
|
+
removeAttr(node, name);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (node.tag === 'a') {
|
|
228
|
+
const href = node.attributes.href;
|
|
229
|
+
const target = node.attributes.target;
|
|
230
|
+
const hasTargetBlank = target?.toLowerCase() === '_blank';
|
|
231
|
+
const isExternal = href ? isExternalHtmlUrl(href) : false;
|
|
232
|
+
|
|
233
|
+
if (hasTargetBlank || isExternal) {
|
|
234
|
+
const relValues = new Set((node.attributes.rel ?? '').trim().split(REL_SPLIT_PATTERN).filter(Boolean));
|
|
235
|
+
relValues.add('noopener');
|
|
236
|
+
relValues.add('noreferrer');
|
|
237
|
+
setAttr(node, 'rel', Array.from(relValues).join(' '));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
node.children = node.children.flatMap((child) => {
|
|
242
|
+
const sanitized = sanitizeNode(child);
|
|
243
|
+
return sanitized ? [sanitized] : [];
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return node;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return serializeTree(sanitizeNode(parseTemplate(raw)) ?? { type: 'fragment', children: [] });
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const evaluateChildren = (parent: SSRElement, context: BindingContext, opts: RenderOpts): void => {
|
|
253
|
+
const out: SSRNode[] = [];
|
|
254
|
+
for (const child of parent.children) {
|
|
255
|
+
if (child.type !== 'element') {
|
|
256
|
+
out.push(child);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const result = evaluateElement(child, context, opts);
|
|
260
|
+
if (result === null) continue;
|
|
261
|
+
if (Array.isArray(result)) {
|
|
262
|
+
for (const r of result) out.push(r);
|
|
263
|
+
} else {
|
|
264
|
+
out.push(result);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
parent.children = out;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Evaluates directives on a single element. Returns:
|
|
272
|
+
* - `null` to remove the element (e.g. `bq-if` falsy);
|
|
273
|
+
* - an array to replace the element with N siblings (e.g. `bq-for`);
|
|
274
|
+
* - the element itself (possibly mutated) otherwise.
|
|
275
|
+
*/
|
|
276
|
+
const evaluateElement = (
|
|
277
|
+
el: SSRElement,
|
|
278
|
+
context: BindingContext,
|
|
279
|
+
opts: RenderOpts
|
|
280
|
+
): SSRNode | SSRNode[] | null => {
|
|
281
|
+
const { prefix } = opts;
|
|
282
|
+
|
|
283
|
+
// bq-for: handled before bq-if/etc. so each clone is processed independently.
|
|
284
|
+
const forExpr = el.attributes[`${prefix}-for`];
|
|
285
|
+
if (forExpr !== undefined) {
|
|
286
|
+
const parsed = parseForExpression(forExpr);
|
|
287
|
+
if (!parsed) {
|
|
288
|
+
removeAttr(el, `${prefix}-for`);
|
|
289
|
+
}
|
|
290
|
+
if (parsed) {
|
|
291
|
+
const list = evaluateExpression<unknown>(parsed.listExpr, context);
|
|
292
|
+
if (!Array.isArray(list)) return null;
|
|
293
|
+
const out: SSRNode[] = [];
|
|
294
|
+
for (let i = 0; i < list.length; i++) {
|
|
295
|
+
const clone = cloneNode(el) as SSRElement;
|
|
296
|
+
removeAttr(clone, `${prefix}-for`);
|
|
297
|
+
removeAttr(clone, `${prefix}-key`);
|
|
298
|
+
removeAttr(clone, ':key');
|
|
299
|
+
const childCtx: BindingContext = {
|
|
300
|
+
...context,
|
|
301
|
+
[parsed.itemName]: list[i],
|
|
302
|
+
};
|
|
303
|
+
if (parsed.indexName) childCtx[parsed.indexName] = i;
|
|
304
|
+
const result = evaluateElement(clone, childCtx, opts);
|
|
305
|
+
if (result === null) continue;
|
|
306
|
+
if (Array.isArray(result)) out.push(...result);
|
|
307
|
+
else out.push(result);
|
|
308
|
+
}
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Capture directive signature for hydration mismatch detection (before stripping).
|
|
314
|
+
const signature = opts.annotateHydration ? collectDirectiveSignature(el, prefix) : '';
|
|
315
|
+
|
|
316
|
+
// bq-if
|
|
317
|
+
const ifExpr = el.attributes[`${prefix}-if`];
|
|
318
|
+
if (ifExpr !== undefined) {
|
|
319
|
+
const cond = evaluateExpression<unknown>(ifExpr, context);
|
|
320
|
+
if (!cond) return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// bq-show
|
|
324
|
+
const showExpr = el.attributes[`${prefix}-show`];
|
|
325
|
+
if (showExpr !== undefined) {
|
|
326
|
+
const cond = evaluateExpression<unknown>(showExpr, context);
|
|
327
|
+
if (!cond) {
|
|
328
|
+
setStyle(el, { display: 'none' });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// bq-text
|
|
333
|
+
const textExpr = el.attributes[`${prefix}-text`];
|
|
334
|
+
if (textExpr !== undefined) {
|
|
335
|
+
const value = evaluateExpression<unknown>(textExpr, context);
|
|
336
|
+
setText(el, String(value ?? ''));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// bq-html
|
|
340
|
+
const htmlExpr = el.attributes[`${prefix}-html`];
|
|
341
|
+
if (htmlExpr !== undefined) {
|
|
342
|
+
const value = evaluateExpression<unknown>(htmlExpr, context);
|
|
343
|
+
setHtml(el, sanitizeHtmlForSSR(String(value ?? '')));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// bq-class
|
|
347
|
+
const classExpr = el.attributes[`${prefix}-class`];
|
|
348
|
+
if (classExpr !== undefined) {
|
|
349
|
+
const trimmed = classExpr.trim();
|
|
350
|
+
if (trimmed.startsWith('{')) {
|
|
351
|
+
const inner = trimmed.slice(1, -1);
|
|
352
|
+
const pairs = inner.split(',');
|
|
353
|
+
for (const pair of pairs) {
|
|
354
|
+
const colon = pair.indexOf(':');
|
|
355
|
+
if (colon < 0) continue;
|
|
356
|
+
const name = pair
|
|
357
|
+
.slice(0, colon)
|
|
358
|
+
.trim()
|
|
359
|
+
.replace(/^['"]|['"]$/g, '');
|
|
360
|
+
const cond = evaluateExpression<unknown>(pair.slice(colon + 1), context);
|
|
361
|
+
if (cond) setClass(el, name);
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
const result = evaluateExpression<unknown>(classExpr, context);
|
|
365
|
+
if (typeof result === 'string') {
|
|
366
|
+
for (const cls of result.split(/\s+/).filter(Boolean)) setClass(el, cls);
|
|
367
|
+
} else if (Array.isArray(result)) {
|
|
368
|
+
for (const cls of result) {
|
|
369
|
+
if (typeof cls === 'string' && cls) setClass(el, cls);
|
|
370
|
+
}
|
|
371
|
+
} else if (result && typeof result === 'object') {
|
|
372
|
+
for (const [name, cond] of Object.entries(result as Record<string, unknown>)) {
|
|
373
|
+
if (cond) setClass(el, name);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// bq-style
|
|
380
|
+
const styleExpr = el.attributes[`${prefix}-style`];
|
|
381
|
+
if (styleExpr !== undefined) {
|
|
382
|
+
const result = evaluateExpression<unknown>(styleExpr, context);
|
|
383
|
+
if (result && typeof result === 'object') {
|
|
384
|
+
setStyle(el, result as Record<string, unknown>);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// bq-bind:*
|
|
389
|
+
for (const name of [...el.attributeOrder]) {
|
|
390
|
+
if (!name.startsWith(`${prefix}-bind:`)) continue;
|
|
391
|
+
const attrName = name.slice(`${prefix}-bind:`.length);
|
|
392
|
+
const value = evaluateExpression<unknown>(el.attributes[name], context);
|
|
393
|
+
if (value === false || value == null) {
|
|
394
|
+
removeAttr(el, attrName);
|
|
395
|
+
} else if (value === true) {
|
|
396
|
+
setAttr(el, attrName, '');
|
|
397
|
+
} else {
|
|
398
|
+
setAttr(el, attrName, String(value));
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Drop on*-attributes and unsafe URL attributes for security parity with the
|
|
403
|
+
// legacy serializer.
|
|
404
|
+
for (const name of [...el.attributeOrder]) {
|
|
405
|
+
const n = name.toLowerCase();
|
|
406
|
+
if (n.startsWith('on')) {
|
|
407
|
+
removeAttr(el, name);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
if (isUnsafeUrlAttribute(n) && isUnsafeUrlValue(el.attributes[name] ?? '')) {
|
|
411
|
+
removeAttr(el, name);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (el.tag === 'script') {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Recurse into children
|
|
420
|
+
evaluateChildren(el, context, opts);
|
|
421
|
+
|
|
422
|
+
if (signature) {
|
|
423
|
+
setAttr(el, HYDRATION_HASH_ATTR, cheapHash(signature));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return el;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Renders a template through the DOM-free pipeline.
|
|
431
|
+
*
|
|
432
|
+
* @internal
|
|
433
|
+
*/
|
|
434
|
+
export const renderTemplatePure = (
|
|
435
|
+
template: string,
|
|
436
|
+
data: BindingContext,
|
|
437
|
+
options: { prefix?: string; stripDirectives?: boolean; annotateHydration?: boolean } = {}
|
|
438
|
+
): string => {
|
|
439
|
+
const opts: RenderOpts = {
|
|
440
|
+
prefix: options.prefix ?? 'bq',
|
|
441
|
+
stripDirectives: options.stripDirectives ?? false,
|
|
442
|
+
annotateHydration: options.annotateHydration ?? false,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
const fragment = parseTemplate(template);
|
|
446
|
+
evaluateChildren(fragment as unknown as SSRElement, data, opts);
|
|
447
|
+
|
|
448
|
+
if (opts.stripDirectives) {
|
|
449
|
+
stripDirectiveAttributes(fragment, opts.prefix);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return serializeTree(fragment);
|
|
453
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resumability hooks.
|
|
3
|
+
*
|
|
4
|
+
* A *very* small primitive that lets the server publish JSON-serializable
|
|
5
|
+
* values which the client can read back without re-running the producer.
|
|
6
|
+
* Think of it as a typed key/value store that survives the serialization
|
|
7
|
+
* boundary.
|
|
8
|
+
*
|
|
9
|
+
* It is intentionally not a full Qwik-style resumability system — bQuery's
|
|
10
|
+
* reactive graph is rebuilt on the client. Instead, this primitive helps
|
|
11
|
+
* applications avoid double-fetching loader-style data by parking it on
|
|
12
|
+
* `window.__BQUERY_RESUME__` (or a custom global) inside a CSP-nonce-aware
|
|
13
|
+
* `<script>` tag.
|
|
14
|
+
*
|
|
15
|
+
* @module bquery/ssr
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { isPrototypePollutionKey } from '../core/utils/object';
|
|
19
|
+
import { escapeForHtmlAttribute, escapeForScript } from './escape';
|
|
20
|
+
|
|
21
|
+
/** Server-side resumable state collector. */
|
|
22
|
+
export interface ResumableState {
|
|
23
|
+
/** Stash a JSON-serializable value under `key`. */
|
|
24
|
+
set: (key: string, value: unknown) => void;
|
|
25
|
+
/** Read a value back (server-side, useful for tests). */
|
|
26
|
+
get: <T = unknown>(key: string) => T | undefined;
|
|
27
|
+
/** All collected entries. */
|
|
28
|
+
entries: () => Record<string, unknown>;
|
|
29
|
+
/** Build the `<script>` tag to embed in HTML. */
|
|
30
|
+
render: (options?: { nonce?: string; scriptId?: string; globalKey?: string }) => string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Options for `createResumableState()`. */
|
|
34
|
+
export interface CreateResumableStateOptions {
|
|
35
|
+
/** Initial entries. */
|
|
36
|
+
initial?: Record<string, unknown>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const cloneResumableEntries = (data: Record<string, unknown>): Record<string, unknown> => {
|
|
40
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
41
|
+
for (const [key, value] of Object.entries(data)) {
|
|
42
|
+
if (isPrototypePollutionKey(key)) continue;
|
|
43
|
+
out[key] = value;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Creates a server-side resumable state collector.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const resume = createResumableState();
|
|
54
|
+
* resume.set('user', { id: 1, name: 'Ada' });
|
|
55
|
+
*
|
|
56
|
+
* // Inject into HTML:
|
|
57
|
+
* const tag = resume.render({ nonce: ctx.nonce });
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export const createResumableState = (options: CreateResumableStateOptions = {}): ResumableState => {
|
|
61
|
+
const data: Record<string, unknown> = Object.create(null);
|
|
62
|
+
if (options.initial) {
|
|
63
|
+
for (const [k, v] of Object.entries(options.initial)) {
|
|
64
|
+
if (!isPrototypePollutionKey(k)) data[k] = v;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
set(key, value) {
|
|
69
|
+
if (isPrototypePollutionKey(key)) return;
|
|
70
|
+
data[key] = value;
|
|
71
|
+
},
|
|
72
|
+
get<T = unknown>(key: string): T | undefined {
|
|
73
|
+
return data[key] as T | undefined;
|
|
74
|
+
},
|
|
75
|
+
entries() {
|
|
76
|
+
return cloneResumableEntries(data);
|
|
77
|
+
},
|
|
78
|
+
render(opts = {}) {
|
|
79
|
+
const scriptId = opts.scriptId ?? '__BQUERY_RESUME__';
|
|
80
|
+
const globalKey = opts.globalKey ?? '__BQUERY_RESUME__';
|
|
81
|
+
if (isPrototypePollutionKey(scriptId) || isPrototypePollutionKey(globalKey)) {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
const json = JSON.stringify(data);
|
|
85
|
+
const escapedJson = escapeForScript(json);
|
|
86
|
+
const escapedKey = escapeForScript(JSON.stringify(globalKey));
|
|
87
|
+
const escapedId = escapeForHtmlAttribute(scriptId);
|
|
88
|
+
const nonceAttr = opts.nonce ? ` nonce="${escapeForHtmlAttribute(opts.nonce)}"` : '';
|
|
89
|
+
return `<script id="${escapedId}"${nonceAttr}>window[${escapedKey}]=${escapedJson}</script>`;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Reader returned by `resumeState()`. */
|
|
95
|
+
export interface ResumeReader {
|
|
96
|
+
/** Get a typed value from the resumable snapshot. */
|
|
97
|
+
get: <T = unknown>(key: string) => T | undefined;
|
|
98
|
+
/** Whether the snapshot was found. */
|
|
99
|
+
hasSnapshot: boolean;
|
|
100
|
+
/** All entries (read-only). */
|
|
101
|
+
entries: () => Record<string, unknown>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reads a previously-emitted resumable snapshot from `window` and cleans it
|
|
106
|
+
* up. Safe to call in any environment; returns an empty reader when no
|
|
107
|
+
* snapshot is present (server, tests, etc.).
|
|
108
|
+
*/
|
|
109
|
+
export const resumeState = (
|
|
110
|
+
globalKey = '__BQUERY_RESUME__',
|
|
111
|
+
scriptId = '__BQUERY_RESUME__'
|
|
112
|
+
): ResumeReader => {
|
|
113
|
+
const empty: ResumeReader = {
|
|
114
|
+
get: () => undefined,
|
|
115
|
+
hasSnapshot: false,
|
|
116
|
+
entries: () => ({}),
|
|
117
|
+
};
|
|
118
|
+
if (isPrototypePollutionKey(globalKey) || isPrototypePollutionKey(scriptId)) return empty;
|
|
119
|
+
if (typeof window === 'undefined') return empty;
|
|
120
|
+
const raw = (window as unknown as Record<string, unknown>)[globalKey];
|
|
121
|
+
try {
|
|
122
|
+
delete (window as unknown as Record<string, unknown>)[globalKey];
|
|
123
|
+
} catch {
|
|
124
|
+
(window as unknown as Record<string, unknown>)[globalKey] = undefined;
|
|
125
|
+
}
|
|
126
|
+
if (typeof document !== 'undefined' && typeof document.getElementById === 'function') {
|
|
127
|
+
const el = document.getElementById(scriptId);
|
|
128
|
+
if (el) el.remove();
|
|
129
|
+
}
|
|
130
|
+
if (!raw || typeof raw !== 'object') return empty;
|
|
131
|
+
const data = raw as Record<string, unknown>;
|
|
132
|
+
return {
|
|
133
|
+
hasSnapshot: true,
|
|
134
|
+
get<T = unknown>(key: string): T | undefined {
|
|
135
|
+
if (isPrototypePollutionKey(key)) return undefined;
|
|
136
|
+
return data[key] as T | undefined;
|
|
137
|
+
},
|
|
138
|
+
entries() {
|
|
139
|
+
return cloneResumableEntries(data);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
};
|