@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.
Files changed (155) hide show
  1. package/README.md +44 -19
  2. package/dist/{a11y-DG2i4iZN.js → a11y-DgUQ8-fI.js} +1 -1
  3. package/dist/{a11y-DG2i4iZN.js.map → a11y-DgUQ8-fI.js.map} +1 -1
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/{component-DRotf1hl.js → component-D8ydhe58.js} +2 -2
  6. package/dist/{component-DRotf1hl.js.map → component-D8ydhe58.js.map} +1 -1
  7. package/dist/component.es.mjs +1 -1
  8. package/dist/concurrency-BU1wPEsZ.js.map +1 -1
  9. package/dist/{constraints-CqjhmpZC.js → constraints-Dlbx_m1b.js} +1 -1
  10. package/dist/{constraints-CqjhmpZC.js.map → constraints-Dlbx_m1b.js.map} +1 -1
  11. package/dist/{core-EMYSLzaT.js → core-tOP6QOrY.js} +2 -2
  12. package/dist/{core-EMYSLzaT.js.map → core-tOP6QOrY.js.map} +1 -1
  13. package/dist/core.es.mjs +1 -1
  14. package/dist/{custom-directives-BjFzFhuf.js → custom-directives-5DlKqvd2.js} +1 -1
  15. package/dist/{custom-directives-BjFzFhuf.js.map → custom-directives-5DlKqvd2.js.map} +1 -1
  16. package/dist/{devtools-C5FExMwv.js → devtools-QosAqo0T.js} +2 -2
  17. package/dist/{devtools-C5FExMwv.js.map → devtools-QosAqo0T.js.map} +1 -1
  18. package/dist/devtools.es.mjs +1 -1
  19. package/dist/{dnd-BAqzPlSo.js → dnd-d2OU4len.js} +1 -1
  20. package/dist/{dnd-BAqzPlSo.js.map → dnd-d2OU4len.js.map} +1 -1
  21. package/dist/dnd.es.mjs +1 -1
  22. package/dist/{forms-Dx1Scvh0.js → forms-BLx4ZzT7.js} +1 -1
  23. package/dist/{forms-Dx1Scvh0.js.map → forms-BLx4ZzT7.js.map} +1 -1
  24. package/dist/forms.es.mjs +1 -1
  25. package/dist/full.d.ts +4 -2
  26. package/dist/full.d.ts.map +1 -1
  27. package/dist/full.es.mjs +258 -219
  28. package/dist/full.iife.js +41 -37
  29. package/dist/full.iife.js.map +1 -1
  30. package/dist/full.umd.js +41 -37
  31. package/dist/full.umd.js.map +1 -1
  32. package/dist/{i18n-Cazyk9RD.js → i18n--p7PM-9r.js} +1 -1
  33. package/dist/{i18n-Cazyk9RD.js.map → i18n--p7PM-9r.js.map} +1 -1
  34. package/dist/i18n.es.mjs +1 -1
  35. package/dist/index.d.ts +1 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.es.mjs +291 -252
  38. package/dist/match-CrZRVC4z.js +174 -0
  39. package/dist/match-CrZRVC4z.js.map +1 -0
  40. package/dist/{media-dAKIGPk3.js → media-gjbWNq50.js} +1 -1
  41. package/dist/{media-dAKIGPk3.js.map → media-gjbWNq50.js.map} +1 -1
  42. package/dist/media.es.mjs +1 -1
  43. package/dist/motion-BBMso9Ir.js.map +1 -1
  44. package/dist/{mount-C8O2vXkQ.js → mount-0A9qtcRJ.js} +3 -3
  45. package/dist/{mount-C8O2vXkQ.js.map → mount-0A9qtcRJ.js.map} +1 -1
  46. package/dist/platform-BPHIXbw8.js.map +1 -1
  47. package/dist/{plugin-DjTqWg-P.js → plugin-SZEirbwq.js} +2 -2
  48. package/dist/{plugin-DjTqWg-P.js.map → plugin-SZEirbwq.js.map} +1 -1
  49. package/dist/plugin.es.mjs +1 -1
  50. package/dist/reactive-BAd2hfl8.js.map +1 -1
  51. package/dist/{registry-Cr6VH8CR.js → registry-jpUQHf4E.js} +1 -1
  52. package/dist/{registry-Cr6VH8CR.js.map → registry-jpUQHf4E.js.map} +1 -1
  53. package/dist/router-C4weu0QL.js +333 -0
  54. package/dist/router-C4weu0QL.js.map +1 -0
  55. package/dist/router.es.mjs +1 -1
  56. package/dist/{sanitize-B1V4JswB.js → sanitize-DOMkRO9G.js} +12 -7
  57. package/dist/{sanitize-B1V4JswB.js.map → sanitize-DOMkRO9G.js.map} +1 -1
  58. package/dist/security.es.mjs +1 -1
  59. package/dist/server/create-server.d.ts +25 -0
  60. package/dist/server/create-server.d.ts.map +1 -0
  61. package/dist/server/index.d.ts +11 -0
  62. package/dist/server/index.d.ts.map +1 -0
  63. package/dist/server/types.d.ts +396 -0
  64. package/dist/server/types.d.ts.map +1 -0
  65. package/dist/server-QdyKtCS1.js +349 -0
  66. package/dist/server-QdyKtCS1.js.map +1 -0
  67. package/dist/server.es.mjs +6 -0
  68. package/dist/ssr/adapters.d.ts +74 -0
  69. package/dist/ssr/adapters.d.ts.map +1 -0
  70. package/dist/ssr/async.d.ts +40 -0
  71. package/dist/ssr/async.d.ts.map +1 -0
  72. package/dist/ssr/config.d.ts +60 -0
  73. package/dist/ssr/config.d.ts.map +1 -0
  74. package/dist/ssr/context.d.ts +73 -0
  75. package/dist/ssr/context.d.ts.map +1 -0
  76. package/dist/ssr/defer-brand.d.ts +5 -0
  77. package/dist/ssr/defer-brand.d.ts.map +1 -0
  78. package/dist/ssr/escape.d.ts +17 -0
  79. package/dist/ssr/escape.d.ts.map +1 -0
  80. package/dist/ssr/expression.d.ts +44 -0
  81. package/dist/ssr/expression.d.ts.map +1 -0
  82. package/dist/ssr/hash.d.ts +39 -0
  83. package/dist/ssr/hash.d.ts.map +1 -0
  84. package/dist/ssr/head.d.ts +102 -0
  85. package/dist/ssr/head.d.ts.map +1 -0
  86. package/dist/ssr/html-parser.d.ts +58 -0
  87. package/dist/ssr/html-parser.d.ts.map +1 -0
  88. package/dist/ssr/index.d.ts +49 -43
  89. package/dist/ssr/index.d.ts.map +1 -1
  90. package/dist/ssr/mismatch.d.ts +60 -0
  91. package/dist/ssr/mismatch.d.ts.map +1 -0
  92. package/dist/ssr/render-async.d.ts +84 -0
  93. package/dist/ssr/render-async.d.ts.map +1 -0
  94. package/dist/ssr/render.d.ts.map +1 -1
  95. package/dist/ssr/renderer.d.ts +25 -0
  96. package/dist/ssr/renderer.d.ts.map +1 -0
  97. package/dist/ssr/resumability.d.ts +65 -0
  98. package/dist/ssr/resumability.d.ts.map +1 -0
  99. package/dist/ssr/router-bridge.d.ts +101 -0
  100. package/dist/ssr/router-bridge.d.ts.map +1 -0
  101. package/dist/ssr/runtime.d.ts +63 -0
  102. package/dist/ssr/runtime.d.ts.map +1 -0
  103. package/dist/ssr/serialize.d.ts.map +1 -1
  104. package/dist/ssr/store-snapshot.d.ts +87 -0
  105. package/dist/ssr/store-snapshot.d.ts.map +1 -0
  106. package/dist/ssr/strategies.d.ts +43 -0
  107. package/dist/ssr/strategies.d.ts.map +1 -0
  108. package/dist/ssr/suspense.d.ts +47 -0
  109. package/dist/ssr/suspense.d.ts.map +1 -0
  110. package/dist/ssr/types.d.ts +17 -0
  111. package/dist/ssr/types.d.ts.map +1 -1
  112. package/dist/ssr-Bt6BQA3J.js +2127 -0
  113. package/dist/ssr-Bt6BQA3J.js.map +1 -0
  114. package/dist/ssr.es.mjs +42 -7
  115. package/dist/{store-CjmEeX9-.js → store-DnXuu6Li.js} +2 -2
  116. package/dist/{store-CjmEeX9-.js.map → store-DnXuu6Li.js.map} +1 -1
  117. package/dist/store.es.mjs +2 -2
  118. package/dist/storybook.es.mjs +1 -1
  119. package/dist/{testing-TdfaL7VE.js → testing-CeMUwrRD.js} +2 -2
  120. package/dist/{testing-TdfaL7VE.js.map → testing-CeMUwrRD.js.map} +1 -1
  121. package/dist/testing.es.mjs +1 -1
  122. package/dist/view.es.mjs +1 -1
  123. package/package.json +17 -12
  124. package/src/full.ts +99 -0
  125. package/src/index.ts +3 -0
  126. package/src/server/create-server.ts +754 -0
  127. package/src/server/index.ts +33 -0
  128. package/src/server/types.ts +490 -0
  129. package/src/ssr/adapters.ts +330 -0
  130. package/src/ssr/async.ts +125 -0
  131. package/src/ssr/config.ts +86 -0
  132. package/src/ssr/context.ts +245 -0
  133. package/src/ssr/defer-brand.ts +3 -0
  134. package/src/ssr/escape.ts +25 -0
  135. package/src/ssr/expression.ts +669 -0
  136. package/src/ssr/hash.ts +71 -0
  137. package/src/ssr/head.ts +240 -0
  138. package/src/ssr/html-parser.ts +387 -0
  139. package/src/ssr/index.ts +136 -43
  140. package/src/ssr/mismatch.ts +110 -0
  141. package/src/ssr/render-async.ts +286 -0
  142. package/src/ssr/render.ts +130 -59
  143. package/src/ssr/renderer.ts +453 -0
  144. package/src/ssr/resumability.ts +142 -0
  145. package/src/ssr/router-bridge.ts +177 -0
  146. package/src/ssr/runtime.ts +131 -0
  147. package/src/ssr/serialize.ts +1 -27
  148. package/src/ssr/store-snapshot.ts +209 -0
  149. package/src/ssr/strategies.ts +245 -0
  150. package/src/ssr/suspense.ts +504 -0
  151. package/src/ssr/types.ts +18 -0
  152. package/dist/router-CCepRMpC.js +0 -493
  153. package/dist/router-CCepRMpC.js.map +0 -1
  154. package/dist/ssr-D-1IPcfw.js +0 -248
  155. 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
+ };