@bquery/bquery 1.7.0 → 1.8.2

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 (262) hide show
  1. package/README.md +760 -716
  2. package/dist/{a11y-C5QOVvRn.js → a11y-DVBCy09c.js} +3 -3
  3. package/dist/a11y-DVBCy09c.js.map +1 -0
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/component/library.d.ts.map +1 -1
  6. package/dist/{component-CuuTijA6.js → component-L3-JfOFz.js} +5 -5
  7. package/dist/component-L3-JfOFz.js.map +1 -0
  8. package/dist/component.es.mjs +1 -1
  9. package/dist/{config-BW35FKuA.js → config-DhT9auRm.js} +1 -1
  10. package/dist/{config-BW35FKuA.js.map → config-DhT9auRm.js.map} +1 -1
  11. package/dist/{constraints-3lV9yyBw.js → constraints-D5RHQLmP.js} +1 -1
  12. package/dist/constraints-D5RHQLmP.js.map +1 -0
  13. package/dist/core/collection.d.ts +86 -0
  14. package/dist/core/collection.d.ts.map +1 -1
  15. package/dist/core/element.d.ts +28 -0
  16. package/dist/core/element.d.ts.map +1 -1
  17. package/dist/core/shared.d.ts +6 -0
  18. package/dist/core/shared.d.ts.map +1 -1
  19. package/dist/core-DdtZHzsS.js +168 -0
  20. package/dist/core-DdtZHzsS.js.map +1 -0
  21. package/dist/{core-Cjl7GUu8.js → core-EMYSLzaT.js} +289 -259
  22. package/dist/core-EMYSLzaT.js.map +1 -0
  23. package/dist/core.es.mjs +48 -47
  24. package/dist/{custom-directives-7wAShnnd.js → custom-directives-Dr4C5lVV.js} +1 -1
  25. package/dist/custom-directives-Dr4C5lVV.js.map +1 -0
  26. package/dist/{devtools-D2fQLhDN.js → devtools-BhB2iDPT.js} +2 -2
  27. package/dist/devtools-BhB2iDPT.js.map +1 -0
  28. package/dist/devtools.es.mjs +1 -1
  29. package/dist/{dnd-B8EgyzaI.js → dnd-NwZBYh4l.js} +1 -1
  30. package/dist/dnd-NwZBYh4l.js.map +1 -0
  31. package/dist/dnd.es.mjs +1 -1
  32. package/dist/{env-NeVmr4Gf.js → env-CTdvLaH2.js} +1 -1
  33. package/dist/env-CTdvLaH2.js.map +1 -0
  34. package/dist/forms/create-form.d.ts.map +1 -1
  35. package/dist/forms/index.d.ts +3 -2
  36. package/dist/forms/index.d.ts.map +1 -1
  37. package/dist/forms/types.d.ts +46 -0
  38. package/dist/forms/types.d.ts.map +1 -1
  39. package/dist/forms/use-field.d.ts +34 -0
  40. package/dist/forms/use-field.d.ts.map +1 -0
  41. package/dist/forms/validators.d.ts +25 -0
  42. package/dist/forms/validators.d.ts.map +1 -1
  43. package/dist/forms-UcRHsYxC.js +227 -0
  44. package/dist/forms-UcRHsYxC.js.map +1 -0
  45. package/dist/forms.es.mjs +14 -12
  46. package/dist/full.d.ts +17 -26
  47. package/dist/full.d.ts.map +1 -1
  48. package/dist/full.es.mjs +206 -181
  49. package/dist/full.iife.js +33 -33
  50. package/dist/full.iife.js.map +1 -1
  51. package/dist/full.umd.js +33 -33
  52. package/dist/full.umd.js.map +1 -1
  53. package/dist/function-Cybd57JV.js +33 -0
  54. package/dist/function-Cybd57JV.js.map +1 -0
  55. package/dist/{i18n-BnnhTFOS.js → i18n-kuF6Ekj6.js} +3 -3
  56. package/dist/i18n-kuF6Ekj6.js.map +1 -0
  57. package/dist/i18n.es.mjs +1 -1
  58. package/dist/index.es.mjs +251 -228
  59. package/dist/media/breakpoints.d.ts.map +1 -1
  60. package/dist/media/types.d.ts +2 -2
  61. package/dist/media/types.d.ts.map +1 -1
  62. package/dist/{media-Di2Ta22s.js → media-i-fB5WxI.js} +3 -3
  63. package/dist/media-i-fB5WxI.js.map +1 -0
  64. package/dist/media.es.mjs +1 -1
  65. package/dist/{motion-qPj_TYGv.js → motion-BJsAuULb.js} +2 -2
  66. package/dist/motion-BJsAuULb.js.map +1 -0
  67. package/dist/motion.es.mjs +1 -1
  68. package/dist/{mount-SM07RUa6.js → mount-B4Y8bk8Z.js} +5 -5
  69. package/dist/mount-B4Y8bk8Z.js.map +1 -0
  70. package/dist/{platform-CPbCprb6.js → platform-Dw2gE3zI.js} +3 -3
  71. package/dist/{platform-CPbCprb6.js.map → platform-Dw2gE3zI.js.map} +1 -1
  72. package/dist/platform.es.mjs +2 -2
  73. package/dist/plugin/registry.d.ts.map +1 -1
  74. package/dist/{plugin-cPoOHFLY.js → plugin-C2WuC8SF.js} +20 -18
  75. package/dist/plugin-C2WuC8SF.js.map +1 -0
  76. package/dist/plugin.es.mjs +1 -1
  77. package/dist/reactive/async-data.d.ts +28 -3
  78. package/dist/reactive/async-data.d.ts.map +1 -1
  79. package/dist/reactive/computed.d.ts +3 -0
  80. package/dist/reactive/computed.d.ts.map +1 -1
  81. package/dist/reactive/effect.d.ts +3 -0
  82. package/dist/reactive/effect.d.ts.map +1 -1
  83. package/dist/reactive/http.d.ts +194 -0
  84. package/dist/reactive/http.d.ts.map +1 -0
  85. package/dist/reactive/index.d.ts +2 -2
  86. package/dist/reactive/index.d.ts.map +1 -1
  87. package/dist/reactive/pagination.d.ts +126 -0
  88. package/dist/reactive/pagination.d.ts.map +1 -0
  89. package/dist/reactive/polling.d.ts +55 -0
  90. package/dist/reactive/polling.d.ts.map +1 -0
  91. package/dist/reactive/readonly.d.ts +20 -1
  92. package/dist/reactive/readonly.d.ts.map +1 -1
  93. package/dist/reactive/rest.d.ts +293 -0
  94. package/dist/reactive/rest.d.ts.map +1 -0
  95. package/dist/reactive/scope.d.ts +140 -0
  96. package/dist/reactive/scope.d.ts.map +1 -0
  97. package/dist/reactive/signal.d.ts +16 -2
  98. package/dist/reactive/signal.d.ts.map +1 -1
  99. package/dist/reactive/to-value.d.ts +57 -0
  100. package/dist/reactive/to-value.d.ts.map +1 -0
  101. package/dist/reactive/websocket.d.ts +285 -0
  102. package/dist/reactive/websocket.d.ts.map +1 -0
  103. package/dist/reactive-DwkhUJfP.js +1148 -0
  104. package/dist/reactive-DwkhUJfP.js.map +1 -0
  105. package/dist/reactive.es.mjs +38 -19
  106. package/dist/{registry-CWf368tT.js → registry-B08iilIh.js} +1 -1
  107. package/dist/{registry-CWf368tT.js.map → registry-B08iilIh.js.map} +1 -1
  108. package/dist/router/constraints.d.ts.map +1 -1
  109. package/dist/router/index.d.ts +1 -1
  110. package/dist/router/index.d.ts.map +1 -1
  111. package/dist/router/router.d.ts.map +1 -1
  112. package/dist/router/state.d.ts +25 -2
  113. package/dist/router/state.d.ts.map +1 -1
  114. package/dist/router-CQikC9Ed.js +492 -0
  115. package/dist/router-CQikC9Ed.js.map +1 -0
  116. package/dist/router.es.mjs +9 -8
  117. package/dist/ssr/hydrate.d.ts.map +1 -1
  118. package/dist/{ssr-B2qd_WBB.js → ssr-_dAcGdzu.js} +4 -4
  119. package/dist/ssr-_dAcGdzu.js.map +1 -0
  120. package/dist/ssr.es.mjs +1 -1
  121. package/dist/store/persisted.d.ts.map +1 -1
  122. package/dist/{store-DWpyH6p5.js → store-Cb3gPRve.js} +7 -7
  123. package/dist/store-Cb3gPRve.js.map +1 -0
  124. package/dist/store.es.mjs +2 -2
  125. package/dist/storybook.es.mjs.map +1 -1
  126. package/dist/{testing-CsqjNUyy.js → testing-C5Sjfsna.js} +8 -8
  127. package/dist/testing-C5Sjfsna.js.map +1 -0
  128. package/dist/testing.es.mjs +1 -1
  129. package/dist/{type-guards-Do9DWgNp.js → type-guards-BMX2c0LP.js} +1 -1
  130. package/dist/{type-guards-Do9DWgNp.js.map → type-guards-BMX2c0LP.js.map} +1 -1
  131. package/dist/untrack-D0fnO5k2.js +36 -0
  132. package/dist/untrack-D0fnO5k2.js.map +1 -0
  133. package/dist/view/custom-directives.d.ts.map +1 -1
  134. package/dist/view.es.mjs +4 -4
  135. package/package.json +178 -177
  136. package/src/a11y/announce.ts +131 -131
  137. package/src/a11y/audit.ts +314 -314
  138. package/src/a11y/index.ts +68 -68
  139. package/src/a11y/media-preferences.ts +255 -255
  140. package/src/a11y/roving-tab-index.ts +164 -164
  141. package/src/a11y/skip-link.ts +255 -255
  142. package/src/a11y/trap-focus.ts +184 -184
  143. package/src/a11y/types.ts +183 -183
  144. package/src/component/component.ts +599 -599
  145. package/src/component/html.ts +153 -153
  146. package/src/component/index.ts +52 -52
  147. package/src/component/library.ts +540 -542
  148. package/src/component/scope.ts +212 -212
  149. package/src/component/types.ts +310 -310
  150. package/src/core/collection.ts +876 -707
  151. package/src/core/element.ts +1015 -981
  152. package/src/core/env.ts +60 -60
  153. package/src/core/index.ts +49 -49
  154. package/src/core/shared.ts +77 -62
  155. package/src/core/utils/index.ts +148 -148
  156. package/src/devtools/devtools.ts +410 -410
  157. package/src/devtools/index.ts +48 -48
  158. package/src/devtools/types.ts +104 -104
  159. package/src/dnd/draggable.ts +296 -296
  160. package/src/dnd/droppable.ts +228 -228
  161. package/src/dnd/index.ts +62 -62
  162. package/src/dnd/sortable.ts +307 -307
  163. package/src/dnd/types.ts +293 -293
  164. package/src/forms/create-form.ts +320 -278
  165. package/src/forms/index.ts +70 -65
  166. package/src/forms/types.ts +203 -154
  167. package/src/forms/use-field.ts +231 -0
  168. package/src/forms/validators.ts +294 -265
  169. package/src/full.ts +554 -480
  170. package/src/i18n/formatting.ts +67 -67
  171. package/src/i18n/i18n.ts +200 -200
  172. package/src/i18n/index.ts +67 -67
  173. package/src/i18n/translate.ts +182 -182
  174. package/src/i18n/types.ts +171 -171
  175. package/src/index.ts +108 -108
  176. package/src/media/battery.ts +116 -116
  177. package/src/media/breakpoints.ts +129 -131
  178. package/src/media/clipboard.ts +80 -80
  179. package/src/media/device-sensors.ts +158 -158
  180. package/src/media/geolocation.ts +119 -119
  181. package/src/media/index.ts +76 -76
  182. package/src/media/media-query.ts +92 -92
  183. package/src/media/network.ts +115 -115
  184. package/src/media/types.ts +177 -177
  185. package/src/media/viewport.ts +84 -84
  186. package/src/motion/index.ts +57 -57
  187. package/src/motion/morph.ts +151 -151
  188. package/src/motion/parallax.ts +120 -120
  189. package/src/motion/reduced-motion.ts +66 -66
  190. package/src/motion/types.ts +271 -271
  191. package/src/motion/typewriter.ts +164 -164
  192. package/src/plugin/index.ts +37 -37
  193. package/src/plugin/registry.ts +284 -269
  194. package/src/plugin/types.ts +137 -137
  195. package/src/reactive/async-data.ts +250 -29
  196. package/src/reactive/computed.ts +144 -130
  197. package/src/reactive/effect.ts +29 -6
  198. package/src/reactive/http.ts +790 -0
  199. package/src/reactive/index.ts +60 -0
  200. package/src/reactive/pagination.ts +317 -0
  201. package/src/reactive/polling.ts +179 -0
  202. package/src/reactive/readonly.ts +52 -8
  203. package/src/reactive/rest.ts +859 -0
  204. package/src/reactive/scope.ts +276 -0
  205. package/src/reactive/signal.ts +61 -1
  206. package/src/reactive/to-value.ts +71 -0
  207. package/src/reactive/websocket.ts +849 -0
  208. package/src/router/bq-link.ts +279 -279
  209. package/src/router/constraints.ts +204 -201
  210. package/src/router/index.ts +49 -49
  211. package/src/router/match.ts +312 -312
  212. package/src/router/path-pattern.ts +52 -52
  213. package/src/router/query.ts +38 -38
  214. package/src/router/router.ts +421 -402
  215. package/src/router/state.ts +51 -3
  216. package/src/router/types.ts +139 -139
  217. package/src/router/use-route.ts +68 -68
  218. package/src/router/utils.ts +157 -157
  219. package/src/security/index.ts +12 -12
  220. package/src/ssr/hydrate.ts +84 -82
  221. package/src/ssr/index.ts +70 -70
  222. package/src/ssr/render.ts +508 -508
  223. package/src/ssr/serialize.ts +296 -296
  224. package/src/ssr/types.ts +81 -81
  225. package/src/store/create-store.ts +467 -467
  226. package/src/store/index.ts +27 -27
  227. package/src/store/persisted.ts +245 -249
  228. package/src/store/types.ts +247 -247
  229. package/src/store/utils.ts +135 -135
  230. package/src/storybook/index.ts +480 -480
  231. package/src/testing/index.ts +42 -42
  232. package/src/testing/testing.ts +593 -593
  233. package/src/testing/types.ts +170 -170
  234. package/src/view/custom-directives.ts +28 -30
  235. package/src/view/evaluate.ts +292 -292
  236. package/src/view/process.ts +108 -108
  237. package/dist/a11y-C5QOVvRn.js.map +0 -1
  238. package/dist/component-CuuTijA6.js.map +0 -1
  239. package/dist/constraints-3lV9yyBw.js.map +0 -1
  240. package/dist/core-Cjl7GUu8.js.map +0 -1
  241. package/dist/core-DnlyjbF2.js +0 -112
  242. package/dist/core-DnlyjbF2.js.map +0 -1
  243. package/dist/custom-directives-7wAShnnd.js.map +0 -1
  244. package/dist/devtools-D2fQLhDN.js.map +0 -1
  245. package/dist/dnd-B8EgyzaI.js.map +0 -1
  246. package/dist/env-NeVmr4Gf.js.map +0 -1
  247. package/dist/forms-C3yovgH9.js +0 -141
  248. package/dist/forms-C3yovgH9.js.map +0 -1
  249. package/dist/i18n-BnnhTFOS.js.map +0 -1
  250. package/dist/media-Di2Ta22s.js.map +0 -1
  251. package/dist/motion-qPj_TYGv.js.map +0 -1
  252. package/dist/mount-SM07RUa6.js.map +0 -1
  253. package/dist/plugin-cPoOHFLY.js.map +0 -1
  254. package/dist/reactive-Cfv0RK6x.js +0 -233
  255. package/dist/reactive-Cfv0RK6x.js.map +0 -1
  256. package/dist/router-BrthaP_z.js +0 -473
  257. package/dist/router-BrthaP_z.js.map +0 -1
  258. package/dist/ssr-B2qd_WBB.js.map +0 -1
  259. package/dist/store-DWpyH6p5.js.map +0 -1
  260. package/dist/testing-CsqjNUyy.js.map +0 -1
  261. package/dist/untrack-DJVQQ2WM.js +0 -33
  262. 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
37
-
38
- const escapeHtmlAttribute = (value: string): string =>
39
- escapeHtmlText(value).replace(/"/g, '&quot;');
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
37
+
38
+ const escapeHtmlAttribute = (value: string): string =>
39
+ escapeHtmlText(value).replace(/"/g, '&quot;');
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
+ };