@bquery/bquery 1.10.0 → 1.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -65
- package/dist/{a11y-DG2i4iZN.js → a11y-DgUQ8-fI.js} +1 -1
- package/dist/{a11y-DG2i4iZN.js.map → a11y-DgUQ8-fI.js.map} +1 -1
- package/dist/a11y.es.mjs +1 -1
- package/dist/{component-DRotf1hl.js → component-D8ydhe58.js} +2 -2
- package/dist/{component-DRotf1hl.js.map → component-D8ydhe58.js.map} +1 -1
- package/dist/component.es.mjs +1 -1
- package/dist/concurrency-BU1wPEsZ.js.map +1 -1
- package/dist/{constraints-CqjhmpZC.js → constraints-Dlbx_m1b.js} +1 -1
- package/dist/{constraints-CqjhmpZC.js.map → constraints-Dlbx_m1b.js.map} +1 -1
- package/dist/{core-EMYSLzaT.js → core-tOP6QOrY.js} +2 -2
- package/dist/{core-EMYSLzaT.js.map → core-tOP6QOrY.js.map} +1 -1
- package/dist/core.es.mjs +1 -1
- package/dist/{custom-directives-BjFzFhuf.js → custom-directives-5DlKqvd2.js} +1 -1
- package/dist/{custom-directives-BjFzFhuf.js.map → custom-directives-5DlKqvd2.js.map} +1 -1
- package/dist/{devtools-C5FExMwv.js → devtools-QosAqo0T.js} +2 -2
- package/dist/{devtools-C5FExMwv.js.map → devtools-QosAqo0T.js.map} +1 -1
- package/dist/devtools.es.mjs +1 -1
- package/dist/{dnd-BAqzPlSo.js → dnd-d2OU4len.js} +1 -1
- package/dist/{dnd-BAqzPlSo.js.map → dnd-d2OU4len.js.map} +1 -1
- package/dist/dnd.es.mjs +1 -1
- package/dist/{forms-Dx1Scvh0.js → forms-BLx4ZzT7.js} +1 -1
- package/dist/{forms-Dx1Scvh0.js.map → forms-BLx4ZzT7.js.map} +1 -1
- package/dist/forms.es.mjs +1 -1
- package/dist/full.d.ts +4 -2
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +258 -219
- package/dist/full.iife.js +41 -37
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +41 -37
- package/dist/full.umd.js.map +1 -1
- package/dist/{i18n-Cazyk9RD.js → i18n--p7PM-9r.js} +1 -1
- package/dist/{i18n-Cazyk9RD.js.map → i18n--p7PM-9r.js.map} +1 -1
- package/dist/i18n.es.mjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.es.mjs +291 -252
- package/dist/match-CrZRVC4z.js +174 -0
- package/dist/match-CrZRVC4z.js.map +1 -0
- package/dist/{media-dAKIGPk3.js → media-gjbWNq50.js} +1 -1
- package/dist/{media-dAKIGPk3.js.map → media-gjbWNq50.js.map} +1 -1
- package/dist/media.es.mjs +1 -1
- package/dist/motion-BBMso9Ir.js.map +1 -1
- package/dist/{mount-C8O2vXkQ.js → mount-0A9qtcRJ.js} +3 -3
- package/dist/{mount-C8O2vXkQ.js.map → mount-0A9qtcRJ.js.map} +1 -1
- package/dist/platform-BPHIXbw8.js.map +1 -1
- package/dist/{plugin-DjTqWg-P.js → plugin-SZEirbwq.js} +2 -2
- package/dist/{plugin-DjTqWg-P.js.map → plugin-SZEirbwq.js.map} +1 -1
- package/dist/plugin.es.mjs +1 -1
- package/dist/reactive-BAd2hfl8.js.map +1 -1
- package/dist/{registry-Cr6VH8CR.js → registry-jpUQHf4E.js} +1 -1
- package/dist/{registry-Cr6VH8CR.js.map → registry-jpUQHf4E.js.map} +1 -1
- package/dist/router-C4weu0QL.js +333 -0
- package/dist/router-C4weu0QL.js.map +1 -0
- package/dist/router.es.mjs +1 -1
- package/dist/{sanitize-B1V4JswB.js → sanitize-DOMkRO9G.js} +12 -7
- package/dist/{sanitize-B1V4JswB.js.map → sanitize-DOMkRO9G.js.map} +1 -1
- package/dist/security.es.mjs +1 -1
- package/dist/server/create-server.d.ts +25 -0
- package/dist/server/create-server.d.ts.map +1 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/types.d.ts +396 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server-QdyKtCS1.js +349 -0
- package/dist/server-QdyKtCS1.js.map +1 -0
- package/dist/server.es.mjs +6 -0
- package/dist/ssr/adapters.d.ts +74 -0
- package/dist/ssr/adapters.d.ts.map +1 -0
- package/dist/ssr/async.d.ts +40 -0
- package/dist/ssr/async.d.ts.map +1 -0
- package/dist/ssr/config.d.ts +60 -0
- package/dist/ssr/config.d.ts.map +1 -0
- package/dist/ssr/context.d.ts +73 -0
- package/dist/ssr/context.d.ts.map +1 -0
- package/dist/ssr/defer-brand.d.ts +5 -0
- package/dist/ssr/defer-brand.d.ts.map +1 -0
- package/dist/ssr/escape.d.ts +17 -0
- package/dist/ssr/escape.d.ts.map +1 -0
- package/dist/ssr/expression.d.ts +44 -0
- package/dist/ssr/expression.d.ts.map +1 -0
- package/dist/ssr/hash.d.ts +39 -0
- package/dist/ssr/hash.d.ts.map +1 -0
- package/dist/ssr/head.d.ts +102 -0
- package/dist/ssr/head.d.ts.map +1 -0
- package/dist/ssr/html-parser.d.ts +58 -0
- package/dist/ssr/html-parser.d.ts.map +1 -0
- package/dist/ssr/index.d.ts +49 -43
- package/dist/ssr/index.d.ts.map +1 -1
- package/dist/ssr/mismatch.d.ts +60 -0
- package/dist/ssr/mismatch.d.ts.map +1 -0
- package/dist/ssr/render-async.d.ts +84 -0
- package/dist/ssr/render-async.d.ts.map +1 -0
- package/dist/ssr/render.d.ts.map +1 -1
- package/dist/ssr/renderer.d.ts +25 -0
- package/dist/ssr/renderer.d.ts.map +1 -0
- package/dist/ssr/resumability.d.ts +65 -0
- package/dist/ssr/resumability.d.ts.map +1 -0
- package/dist/ssr/router-bridge.d.ts +101 -0
- package/dist/ssr/router-bridge.d.ts.map +1 -0
- package/dist/ssr/runtime.d.ts +63 -0
- package/dist/ssr/runtime.d.ts.map +1 -0
- package/dist/ssr/serialize.d.ts.map +1 -1
- package/dist/ssr/store-snapshot.d.ts +87 -0
- package/dist/ssr/store-snapshot.d.ts.map +1 -0
- package/dist/ssr/strategies.d.ts +43 -0
- package/dist/ssr/strategies.d.ts.map +1 -0
- package/dist/ssr/suspense.d.ts +47 -0
- package/dist/ssr/suspense.d.ts.map +1 -0
- package/dist/ssr/types.d.ts +17 -0
- package/dist/ssr/types.d.ts.map +1 -1
- package/dist/ssr-Bt6BQA3J.js +2127 -0
- package/dist/ssr-Bt6BQA3J.js.map +1 -0
- package/dist/ssr.es.mjs +42 -7
- package/dist/{store-CjmEeX9-.js → store-DnXuu6Li.js} +2 -2
- package/dist/{store-CjmEeX9-.js.map → store-DnXuu6Li.js.map} +1 -1
- package/dist/store.es.mjs +2 -2
- package/dist/storybook.es.mjs +1 -1
- package/dist/{testing-TdfaL7VE.js → testing-CeMUwrRD.js} +2 -2
- package/dist/{testing-TdfaL7VE.js.map → testing-CeMUwrRD.js.map} +1 -1
- package/dist/testing.es.mjs +1 -1
- package/dist/view.es.mjs +1 -1
- package/package.json +19 -14
- package/src/full.ts +99 -0
- package/src/index.ts +5 -2
- package/src/server/create-server.ts +754 -0
- package/src/server/index.ts +33 -0
- package/src/server/types.ts +490 -0
- package/src/ssr/adapters.ts +330 -0
- package/src/ssr/async.ts +125 -0
- package/src/ssr/config.ts +86 -0
- package/src/ssr/context.ts +245 -0
- package/src/ssr/defer-brand.ts +3 -0
- package/src/ssr/escape.ts +25 -0
- package/src/ssr/expression.ts +669 -0
- package/src/ssr/hash.ts +71 -0
- package/src/ssr/head.ts +240 -0
- package/src/ssr/html-parser.ts +387 -0
- package/src/ssr/index.ts +136 -43
- package/src/ssr/mismatch.ts +110 -0
- package/src/ssr/render-async.ts +286 -0
- package/src/ssr/render.ts +130 -59
- package/src/ssr/renderer.ts +453 -0
- package/src/ssr/resumability.ts +142 -0
- package/src/ssr/router-bridge.ts +177 -0
- package/src/ssr/runtime.ts +131 -0
- package/src/ssr/serialize.ts +1 -27
- package/src/ssr/store-snapshot.ts +209 -0
- package/src/ssr/strategies.ts +245 -0
- package/src/ssr/suspense.ts +504 -0
- package/src/ssr/types.ts +18 -0
- package/dist/router-CCepRMpC.js +0 -493
- package/dist/router-CCepRMpC.js.map +0 -1
- package/dist/ssr-D-1IPcfw.js +0 -248
- package/dist/ssr-D-1IPcfw.js.map +0 -1
package/src/ssr/hash.ts
ADDED
|
@@ -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
|
+
};
|
package/src/ssr/head.ts
ADDED
|
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
16
|
+
|
|
17
|
+
const escapeText = (value: string): string =>
|
|
18
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
331
|
+
|
|
332
|
+
const escapeAttr = (value: string): string =>
|
|
333
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
};
|