@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,71 @@
1
+ /**
2
+ * Shared hashing helpers used by the SSR renderer and the client-side
3
+ * hydration mismatch verifier.
4
+ *
5
+ * The hash is intentionally cheap (DJB2 → base36) — its goal is to spot
6
+ * Server↔Client divergence in dev, not to provide cryptographic guarantees.
7
+ *
8
+ * @module bquery/ssr
9
+ */
10
+
11
+ /**
12
+ * Computes a stable, very small hash for a string. Used to attach a hydration
13
+ * annotation that the client can compare against during dev-time hydration.
14
+ *
15
+ * Collisions are acceptable here: the goal is a mismatch *warning*, not
16
+ * security.
17
+ *
18
+ * @internal
19
+ */
20
+ export const cheapHash = (input: string): string => {
21
+ let h = 5381;
22
+ for (let i = 0; i < input.length; i++) {
23
+ h = ((h << 5) + h + input.charCodeAt(i)) | 0;
24
+ }
25
+ return (h >>> 0).toString(36);
26
+ };
27
+
28
+ /**
29
+ * The attribute that holds the directive-signature hash on a server-rendered
30
+ * element. Public name so userland can read/write it deterministically.
31
+ */
32
+ export const HYDRATION_HASH_ATTR = 'data-bq-h';
33
+
34
+ /**
35
+ * Collects the directive signature for a virtual element (used by the pure
36
+ * renderer).
37
+ *
38
+ * @internal
39
+ */
40
+ export const collectDirectiveSignatureFromAttrs = (
41
+ attributeOrder: readonly string[],
42
+ attributes: Readonly<Record<string, string | undefined>>,
43
+ prefix: string
44
+ ): string => {
45
+ const parts: string[] = [];
46
+ for (const name of attributeOrder) {
47
+ if (name === HYDRATION_HASH_ATTR) continue;
48
+ if (name.startsWith(`${prefix}-`) || name.startsWith(':')) {
49
+ parts.push(`${name}=${attributes[name] ?? ''}`);
50
+ }
51
+ }
52
+ return parts.join('|');
53
+ };
54
+
55
+ /**
56
+ * Collects the directive signature for a real DOM `Element`. Used by the
57
+ * client-side mismatch verifier and the DOM-backed renderer.
58
+ *
59
+ * @internal
60
+ */
61
+ export const collectDirectiveSignatureFromElement = (el: Element, prefix: string): string => {
62
+ const parts: string[] = [];
63
+ // Iterate in attribute insertion order to match the pure renderer.
64
+ for (const attr of Array.from(el.attributes)) {
65
+ if (attr.name === HYDRATION_HASH_ATTR) continue;
66
+ if (attr.name.startsWith(`${prefix}-`) || attr.name.startsWith(':')) {
67
+ parts.push(`${attr.name}=${attr.value}`);
68
+ }
69
+ }
70
+ return parts.join('|');
71
+ };
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Head manager for SSR.
3
+ *
4
+ * Collects `<title>`, `<meta>`, `<link>` and inline `<script>` directives that
5
+ * a render path wants to inject into the document head, then serialises them
6
+ * as a single HTML string. The same descriptor shape is reused by the
7
+ * server-side head manager methods across SSR entry points.
8
+ *
9
+ * @module bquery/ssr
10
+ */
11
+
12
+ import { isPrototypePollutionKey } from '../core/utils/object';
13
+
14
+ const escapeAttr = (value: string): string =>
15
+ value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
16
+
17
+ const escapeText = (value: string): string =>
18
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+
20
+ const escapeScriptBody = (value: string): string =>
21
+ value
22
+ .replace(/<\/(script)/gi, '<\\/$1')
23
+ .replace(/<!--/g, '<\\!--')
24
+ .replace(/\u2028/g, '\\u2028')
25
+ .replace(/\u2029/g, '\\u2029');
26
+
27
+ /** A `<meta>` tag descriptor. */
28
+ export interface SSRMeta {
29
+ name?: string;
30
+ property?: string;
31
+ httpEquiv?: string;
32
+ charset?: string;
33
+ content?: string;
34
+ }
35
+
36
+ /** A `<link>` tag descriptor. */
37
+ export interface SSRLink {
38
+ rel: string;
39
+ href: string;
40
+ as?: string;
41
+ type?: string;
42
+ crossorigin?: string;
43
+ media?: string;
44
+ integrity?: string;
45
+ nonce?: string;
46
+ }
47
+
48
+ /** An inline or external `<script>` tag descriptor. */
49
+ export interface SSRScript {
50
+ src?: string;
51
+ type?: string;
52
+ body?: string;
53
+ defer?: boolean;
54
+ async?: boolean;
55
+ nonce?: string;
56
+ crossorigin?: string;
57
+ integrity?: string;
58
+ module?: boolean;
59
+ }
60
+
61
+ /** Options accepted by `HeadManager.add()`. */
62
+ export interface UseHeadOptions {
63
+ title?: string;
64
+ titleTemplate?: string;
65
+ meta?: SSRMeta[];
66
+ link?: SSRLink[];
67
+ script?: SSRScript[];
68
+ }
69
+
70
+ /** Aggregated head state collected during a render. */
71
+ export interface SSRHeadState {
72
+ title: string | null;
73
+ titleTemplate: string | null;
74
+ meta: SSRMeta[];
75
+ link: SSRLink[];
76
+ script: SSRScript[];
77
+ }
78
+
79
+ /** Public head manager handle. */
80
+ export interface HeadManager {
81
+ /** Add or replace head entries. */
82
+ add(options: UseHeadOptions): void;
83
+ /** Returns the current state snapshot. */
84
+ state(): SSRHeadState;
85
+ /** Renders the collected head into HTML. */
86
+ render(options?: { nonce?: string }): string;
87
+ /** Resets the manager to an empty state. */
88
+ reset(): void;
89
+ }
90
+
91
+ /**
92
+ * Creates an isolated head manager. Each SSR context owns one instance.
93
+ */
94
+ export const createHeadManager = (): HeadManager => {
95
+ const state: SSRHeadState = {
96
+ title: null,
97
+ titleTemplate: null,
98
+ meta: [],
99
+ link: [],
100
+ script: [],
101
+ };
102
+
103
+ const add: HeadManager['add'] = (options) => {
104
+ if (typeof options.title === 'string') state.title = options.title;
105
+ if (typeof options.titleTemplate === 'string') state.titleTemplate = options.titleTemplate;
106
+ if (Array.isArray(options.meta)) state.meta.push(...options.meta);
107
+ if (Array.isArray(options.link)) state.link.push(...options.link);
108
+ if (Array.isArray(options.script)) state.script.push(...options.script);
109
+ };
110
+
111
+ const render: HeadManager['render'] = (renderOpts = {}) => {
112
+ let html = '';
113
+ if (state.title !== null) {
114
+ const formatted = state.titleTemplate
115
+ ? state.titleTemplate.replace(/%s/g, state.title)
116
+ : state.title;
117
+ html += `<title>${escapeText(formatted)}</title>`;
118
+ }
119
+ for (const m of state.meta) {
120
+ let attrs = '';
121
+ for (const [k, v] of Object.entries(m)) {
122
+ if (v === undefined || v === null) continue;
123
+ if (isPrototypePollutionKey(k)) continue;
124
+ const attrName = k === 'httpEquiv' ? 'http-equiv' : k;
125
+ attrs += ` ${attrName}="${escapeAttr(String(v))}"`;
126
+ }
127
+ html += `<meta${attrs}>`;
128
+ }
129
+ for (const l of state.link) {
130
+ let attrs = ` rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
131
+ if (l.as) attrs += ` as="${escapeAttr(l.as)}"`;
132
+ if (l.type) attrs += ` type="${escapeAttr(l.type)}"`;
133
+ if (l.crossorigin) attrs += ` crossorigin="${escapeAttr(l.crossorigin)}"`;
134
+ if (l.media) attrs += ` media="${escapeAttr(l.media)}"`;
135
+ if (l.integrity) attrs += ` integrity="${escapeAttr(l.integrity)}"`;
136
+ if (l.nonce ?? renderOpts.nonce) {
137
+ attrs += ` nonce="${escapeAttr(l.nonce ?? renderOpts.nonce!)}"`;
138
+ }
139
+ html += `<link${attrs}>`;
140
+ }
141
+ for (const sc of state.script) {
142
+ let attrs = '';
143
+ if (sc.src) attrs += ` src="${escapeAttr(sc.src)}"`;
144
+ if (sc.type) attrs += ` type="${escapeAttr(sc.type)}"`;
145
+ else if (sc.module) attrs += ' type="module"';
146
+ if (sc.defer) attrs += ' defer';
147
+ if (sc.async) attrs += ' async';
148
+ if (sc.crossorigin) attrs += ` crossorigin="${escapeAttr(sc.crossorigin)}"`;
149
+ if (sc.integrity) attrs += ` integrity="${escapeAttr(sc.integrity)}"`;
150
+ const nonce = sc.nonce ?? renderOpts.nonce;
151
+ if (nonce) attrs += ` nonce="${escapeAttr(nonce)}"`;
152
+ html += `<script${attrs}>${sc.body ? escapeScriptBody(sc.body) : ''}</script>`;
153
+ }
154
+ return html;
155
+ };
156
+
157
+ const reset: HeadManager['reset'] = () => {
158
+ state.title = null;
159
+ state.titleTemplate = null;
160
+ state.meta = [];
161
+ state.link = [];
162
+ state.script = [];
163
+ };
164
+
165
+ return {
166
+ add,
167
+ state: () =>
168
+ ({
169
+ ...state,
170
+ meta: [...state.meta],
171
+ link: [...state.link],
172
+ script: [...state.script],
173
+ }) as SSRHeadState,
174
+ render,
175
+ reset,
176
+ };
177
+ };
178
+
179
+ /* ---------------------------------------------------------------------------
180
+ * Asset manifest
181
+ * ------------------------------------------------------------------------- */
182
+
183
+ /** Asset preload entry. */
184
+ export interface SSRAsset {
185
+ href: string;
186
+ rel: 'preload' | 'modulepreload' | 'stylesheet';
187
+ as?: string;
188
+ type?: string;
189
+ crossorigin?: string;
190
+ integrity?: string;
191
+ }
192
+
193
+ /** Public asset manager handle. */
194
+ export interface AssetManager {
195
+ /** Add a generic preload (`<link rel="preload">`). */
196
+ preload(href: string, opts?: Omit<SSRAsset, 'href' | 'rel'>): void;
197
+ /** Add a JS module preload (`<link rel="modulepreload">`). */
198
+ module(href: string, opts?: Omit<SSRAsset, 'href' | 'rel' | 'as'>): void;
199
+ /** Add a stylesheet link (`<link rel="stylesheet">`). */
200
+ style(href: string, opts?: Omit<SSRAsset, 'href' | 'rel' | 'as'>): void;
201
+ /** Returns the current asset list snapshot. */
202
+ list(): SSRAsset[];
203
+ /** Renders all assets to a series of `<link>` tags. */
204
+ render(options?: { nonce?: string }): string;
205
+ /** Resets the manifest. */
206
+ reset(): void;
207
+ }
208
+
209
+ export const createAssetManager = (): AssetManager => {
210
+ const assets: SSRAsset[] = [];
211
+
212
+ return {
213
+ preload(href, opts = {}) {
214
+ assets.push({ href, rel: 'preload', ...opts });
215
+ },
216
+ module(href, opts = {}) {
217
+ assets.push({ href, rel: 'modulepreload', ...opts });
218
+ },
219
+ style(href, opts = {}) {
220
+ assets.push({ href, rel: 'stylesheet', ...opts });
221
+ },
222
+ list: () => [...assets],
223
+ render(renderOpts = {}) {
224
+ let html = '';
225
+ for (const a of assets) {
226
+ let attrs = ` rel="${escapeAttr(a.rel)}" href="${escapeAttr(a.href)}"`;
227
+ if (a.as) attrs += ` as="${escapeAttr(a.as)}"`;
228
+ if (a.type) attrs += ` type="${escapeAttr(a.type)}"`;
229
+ if (a.crossorigin) attrs += ` crossorigin="${escapeAttr(a.crossorigin)}"`;
230
+ if (a.integrity) attrs += ` integrity="${escapeAttr(a.integrity)}"`;
231
+ if (renderOpts.nonce) attrs += ` nonce="${escapeAttr(renderOpts.nonce)}"`;
232
+ html += `<link${attrs}>`;
233
+ }
234
+ return html;
235
+ },
236
+ reset() {
237
+ assets.length = 0;
238
+ },
239
+ };
240
+ };
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Minimal, runtime-agnostic HTML parser for the SSR renderer.
3
+ *
4
+ * This is intentionally a small, linear scanner rather than a full HTML5
5
+ * tokenizer. It is sufficient for templates authored against the `bQuery`
6
+ * directive vocabulary (HTML fragments, common void/raw elements, attributes)
7
+ * and lets the SSR pipeline run on Bun, Deno and Node without depending on a
8
+ * `DOMParser` polyfill.
9
+ *
10
+ * Output: a tiny virtual node tree (`SSRNode`) compatible with the pluggable
11
+ * DOM adapter API used by `renderer.ts`.
12
+ *
13
+ * @module bquery/ssr
14
+ * @internal
15
+ */
16
+
17
+ const VOID_ELEMENTS = new Set([
18
+ 'area',
19
+ 'base',
20
+ 'br',
21
+ 'col',
22
+ 'embed',
23
+ 'hr',
24
+ 'img',
25
+ 'input',
26
+ 'link',
27
+ 'meta',
28
+ 'param',
29
+ 'source',
30
+ 'track',
31
+ 'wbr',
32
+ ]);
33
+
34
+ const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title']);
35
+
36
+ /** A DOM-free node structure produced by `parseTemplate()`. */
37
+ export type SSRNode = SSRElement | SSRText | SSRComment | SSRDocumentFragment;
38
+
39
+ export interface SSRElement {
40
+ type: 'element';
41
+ tag: string;
42
+ attributes: Record<string, string>;
43
+ /** Order-preserving attribute list (so output is deterministic). */
44
+ attributeOrder: string[];
45
+ children: SSRNode[];
46
+ /** Whether this element should be serialised as a void element. */
47
+ void: boolean;
48
+ /** Whether the children are raw (e.g. `<script>` content). */
49
+ raw: boolean;
50
+ }
51
+
52
+ export interface SSRText {
53
+ type: 'text';
54
+ value: string;
55
+ }
56
+
57
+ export interface SSRComment {
58
+ type: 'comment';
59
+ value: string;
60
+ }
61
+
62
+ export interface SSRDocumentFragment {
63
+ type: 'fragment';
64
+ children: SSRNode[];
65
+ }
66
+
67
+ const HTML_ENTITIES: Record<string, string> = {
68
+ amp: '&',
69
+ lt: '<',
70
+ gt: '>',
71
+ quot: '"',
72
+ apos: "'",
73
+ nbsp: '\u00a0',
74
+ };
75
+
76
+ /**
77
+ * Decodes the named/numeric HTML entities the SSR parser actually needs.
78
+ * Anything else is preserved verbatim.
79
+ */
80
+ export const decodeEntities = (input: string): string => {
81
+ if (input.indexOf('&') === -1) return input;
82
+ return input.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, code: string) => {
83
+ if (code[0] === '#') {
84
+ const isHex = code[1] === 'x' || code[1] === 'X';
85
+ const num = parseInt(code.slice(isHex ? 2 : 1), isHex ? 16 : 10);
86
+ if (Number.isFinite(num)) {
87
+ try {
88
+ return String.fromCodePoint(num);
89
+ } catch {
90
+ return match;
91
+ }
92
+ }
93
+ return match;
94
+ }
95
+ const name = code.toLowerCase();
96
+ return HTML_ENTITIES[name] ?? match;
97
+ });
98
+ };
99
+
100
+ interface ParseState {
101
+ src: string;
102
+ pos: number;
103
+ }
104
+
105
+ const isWs = (ch: string): boolean =>
106
+ ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f';
107
+
108
+ const skipWs = (s: ParseState): void => {
109
+ while (s.pos < s.src.length && isWs(s.src[s.pos])) s.pos++;
110
+ };
111
+
112
+ const readUntil = (s: ParseState, stop: string): string => {
113
+ const start = s.pos;
114
+ const idx = s.src.indexOf(stop, start);
115
+ if (idx === -1) {
116
+ s.pos = s.src.length;
117
+ return s.src.slice(start);
118
+ }
119
+ s.pos = idx;
120
+ return s.src.slice(start, idx);
121
+ };
122
+
123
+ const readTagName = (s: ParseState): string => {
124
+ const start = s.pos;
125
+ while (s.pos < s.src.length) {
126
+ const ch = s.src[s.pos];
127
+ if (isWs(ch) || ch === '>' || ch === '/') break;
128
+ s.pos++;
129
+ }
130
+ return s.src.slice(start, s.pos).toLowerCase();
131
+ };
132
+
133
+ const readAttrName = (s: ParseState): string => {
134
+ const start = s.pos;
135
+ while (s.pos < s.src.length) {
136
+ const ch = s.src[s.pos];
137
+ if (isWs(ch) || ch === '=' || ch === '>' || ch === '/') break;
138
+ s.pos++;
139
+ }
140
+ return s.src.slice(start, s.pos);
141
+ };
142
+
143
+ const readAttrValue = (s: ParseState): string => {
144
+ const ch = s.src[s.pos];
145
+ if (ch === '"' || ch === "'") {
146
+ const quote = ch;
147
+ s.pos++;
148
+ const start = s.pos;
149
+ while (s.pos < s.src.length && s.src[s.pos] !== quote) s.pos++;
150
+ const value = s.src.slice(start, s.pos);
151
+ if (s.pos < s.src.length) s.pos++; // consume closing quote
152
+ return decodeEntities(value);
153
+ }
154
+ // Unquoted
155
+ const start = s.pos;
156
+ while (s.pos < s.src.length) {
157
+ const c = s.src[s.pos];
158
+ if (isWs(c) || c === '>' || (c === '/' && s.src[s.pos + 1] === '>')) break;
159
+ s.pos++;
160
+ }
161
+ return decodeEntities(s.src.slice(start, s.pos));
162
+ };
163
+
164
+ const parseAttributes = (
165
+ s: ParseState
166
+ ): { attributes: Record<string, string>; order: string[]; selfClose: boolean } => {
167
+ const attributes: Record<string, string> = Object.create(null) as Record<string, string>;
168
+ const order: string[] = [];
169
+ let selfClose = false;
170
+
171
+ while (s.pos < s.src.length) {
172
+ skipWs(s);
173
+ const ch = s.src[s.pos];
174
+ if (ch === '>') {
175
+ s.pos++;
176
+ break;
177
+ }
178
+ if (ch === '/') {
179
+ s.pos++;
180
+ skipWs(s);
181
+ if (s.src[s.pos] === '>') {
182
+ selfClose = true;
183
+ s.pos++;
184
+ break;
185
+ }
186
+ continue;
187
+ }
188
+ if (s.pos >= s.src.length) break;
189
+
190
+ const rawName = readAttrName(s);
191
+ if (!rawName) {
192
+ // Defensive: skip ahead to the next whitespace, '/' or '>' to avoid
193
+ // pathological 1-char-per-iteration advancement on malformed input.
194
+ while (s.pos < s.src.length) {
195
+ const c = s.src[s.pos];
196
+ if (isWs(c) || c === '>' || c === '/') break;
197
+ s.pos++;
198
+ }
199
+ continue;
200
+ }
201
+ const name = rawName.toLowerCase();
202
+ skipWs(s);
203
+ let value = '';
204
+ if (s.src[s.pos] === '=') {
205
+ s.pos++;
206
+ skipWs(s);
207
+ value = readAttrValue(s);
208
+ }
209
+ if (!(name in attributes)) {
210
+ order.push(name);
211
+ }
212
+ attributes[name] = value;
213
+ }
214
+
215
+ return { attributes, order, selfClose };
216
+ };
217
+
218
+ /**
219
+ * Parses an HTML template string into a virtual node tree without depending
220
+ * on a DOM implementation. The parser is intentionally permissive: it does
221
+ * not validate nesting, but it preserves attribute order and never throws on
222
+ * malformed input.
223
+ */
224
+ export const parseTemplate = (template: string): SSRDocumentFragment => {
225
+ const s: ParseState = { src: template, pos: 0 };
226
+ const root: SSRDocumentFragment = { type: 'fragment', children: [] };
227
+ // Open-element stack so children attach to the correct parent.
228
+ const stack: Array<SSRElement | SSRDocumentFragment> = [root];
229
+
230
+ const top = (): SSRElement | SSRDocumentFragment => stack[stack.length - 1];
231
+
232
+ while (s.pos < s.src.length) {
233
+ if (s.src[s.pos] === '<') {
234
+ // Comment
235
+ if (s.src.startsWith('<!--', s.pos)) {
236
+ s.pos += 4;
237
+ const end = s.src.indexOf('-->', s.pos);
238
+ const value = end === -1 ? s.src.slice(s.pos) : s.src.slice(s.pos, end);
239
+ s.pos = end === -1 ? s.src.length : end + 3;
240
+ top().children.push({ type: 'comment', value });
241
+ continue;
242
+ }
243
+ // Doctype/processing instruction — skip silently
244
+ if (s.src.startsWith('<!', s.pos) || s.src.startsWith('<?', s.pos)) {
245
+ const end = s.src.indexOf('>', s.pos);
246
+ s.pos = end === -1 ? s.src.length : end + 1;
247
+ continue;
248
+ }
249
+
250
+ // Closing tag
251
+ if (s.src[s.pos + 1] === '/') {
252
+ s.pos += 2;
253
+ const name = readTagName(s);
254
+ const end = s.src.indexOf('>', s.pos);
255
+ s.pos = end === -1 ? s.src.length : end + 1;
256
+ // Pop the matching element if found, otherwise ignore.
257
+ for (let i = stack.length - 1; i > 0; i--) {
258
+ const node = stack[i];
259
+ if (node.type === 'element' && node.tag === name) {
260
+ stack.length = i;
261
+ break;
262
+ }
263
+ }
264
+ continue;
265
+ }
266
+
267
+ // Opening tag
268
+ if (s.pos + 1 < s.src.length && /[a-zA-Z]/.test(s.src[s.pos + 1])) {
269
+ s.pos++;
270
+ const tag = readTagName(s);
271
+ const { attributes, order, selfClose } = parseAttributes(s);
272
+ const isVoid = VOID_ELEMENTS.has(tag);
273
+ const element: SSRElement = {
274
+ type: 'element',
275
+ tag,
276
+ attributes,
277
+ attributeOrder: order,
278
+ children: [],
279
+ void: isVoid,
280
+ raw: RAW_TEXT_ELEMENTS.has(tag),
281
+ };
282
+ top().children.push(element);
283
+
284
+ if (isVoid || selfClose) {
285
+ // Don't push onto stack.
286
+ continue;
287
+ }
288
+
289
+ if (element.raw) {
290
+ // Raw-text element: read until matching close tag.
291
+ const closeTag = `</${tag}`;
292
+ const start = s.pos;
293
+ const lower = s.src.toLowerCase();
294
+ const idx = lower.indexOf(closeTag, start);
295
+ const rawText = idx === -1 ? s.src.slice(start) : s.src.slice(start, idx);
296
+ if (rawText) {
297
+ element.children.push({ type: 'text', value: rawText });
298
+ }
299
+ if (idx === -1) {
300
+ s.pos = s.src.length;
301
+ } else {
302
+ s.pos = idx;
303
+ // Consume the </tag>
304
+ const close = s.src.indexOf('>', s.pos);
305
+ s.pos = close === -1 ? s.src.length : close + 1;
306
+ }
307
+ continue;
308
+ }
309
+
310
+ stack.push(element);
311
+ continue;
312
+ }
313
+
314
+ top().children.push({ type: 'text', value: '<' });
315
+ s.pos++;
316
+ continue;
317
+ }
318
+
319
+ // Plain text
320
+ const text = readUntil(s, '<');
321
+ if (text) {
322
+ top().children.push({ type: 'text', value: decodeEntities(text) });
323
+ }
324
+ }
325
+
326
+ return root;
327
+ };
328
+
329
+ const escapeText = (value: string): string =>
330
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
331
+
332
+ const escapeAttr = (value: string): string =>
333
+ value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
334
+
335
+ /** Serialises a virtual node tree to an HTML string. */
336
+ export const serializeTree = (node: SSRNode): string => {
337
+ if (node.type === 'text') return escapeText(node.value);
338
+ if (node.type === 'comment') return `<!--${node.value}-->`;
339
+ if (node.type === 'fragment') {
340
+ let out = '';
341
+ for (const child of node.children) out += serializeTree(child);
342
+ return out;
343
+ }
344
+
345
+ const el = node;
346
+ let attrs = '';
347
+ for (const name of el.attributeOrder) {
348
+ const value = el.attributes[name];
349
+ attrs += ` ${name}="${escapeAttr(value)}"`;
350
+ }
351
+
352
+ if (el.void) {
353
+ return `<${el.tag}${attrs}>`;
354
+ }
355
+
356
+ let inner = '';
357
+ if (el.raw) {
358
+ // Raw-text elements: don't escape children, they're already raw text.
359
+ for (const child of el.children) {
360
+ if (child.type === 'text') inner += child.value;
361
+ }
362
+ } else {
363
+ for (const child of el.children) inner += serializeTree(child);
364
+ }
365
+
366
+ return `<${el.tag}${attrs}>${inner}</${el.tag}>`;
367
+ };
368
+
369
+ /** Recursively clones a virtual node (used by `bq-for`). */
370
+ export const cloneNode = (node: SSRNode): SSRNode => {
371
+ if (node.type === 'element') {
372
+ return {
373
+ type: 'element',
374
+ tag: node.tag,
375
+ attributes: { ...node.attributes },
376
+ attributeOrder: [...node.attributeOrder],
377
+ children: node.children.map(cloneNode),
378
+ void: node.void,
379
+ raw: node.raw,
380
+ };
381
+ }
382
+ if (node.type === 'fragment') {
383
+ return { type: 'fragment', children: node.children.map(cloneNode) };
384
+ }
385
+ if (node.type === 'text') return { type: 'text', value: node.value };
386
+ return { type: 'comment', value: node.value };
387
+ };