@zubra/server 5.4.1 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +12 -3
- package/src/core.js +527 -0
- package/src/edge.d.ts +46 -0
- package/src/edge.js +86 -0
- package/src/hono-edge.d.ts +3 -0
- package/src/hono-edge.js +12 -0
- package/src/hono-middleware.js +13 -0
- package/src/hono.js +4 -10
- package/src/index.d.ts +6 -1
- package/src/index.js +42 -477
package/src/index.js
CHANGED
|
@@ -22,16 +22,14 @@ import { StringDecoder } from 'node:string_decoder';
|
|
|
22
22
|
import * as nodeStream from 'node:stream';
|
|
23
23
|
const { Transform } = nodeStream;
|
|
24
24
|
import { findCompiler } from './compiler.js';
|
|
25
|
+
import { createCore, parseAttributes, propsFrom, SOURCE_EXTENSION as SOURCE, TYPES } from './core.js';
|
|
25
26
|
|
|
26
|
-
export { findCompiler };
|
|
27
|
+
export { findCompiler, parseAttributes, propsFrom };
|
|
27
28
|
|
|
28
29
|
findCompiler(); // (points @zubra/compiler at a native CLI when its WebAssembly build is missing)
|
|
29
30
|
const compiler = await import('@zubra/compiler');
|
|
30
31
|
|
|
31
|
-
const SOURCE = /\.zb(?:\.[jt]sx)?$/;
|
|
32
32
|
const posix = (path) => path.split(sep).join('/');
|
|
33
|
-
const escapeAttribute = (text) => String(text).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
34
|
-
const TYPES = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json' };
|
|
35
33
|
|
|
36
34
|
/**
|
|
37
35
|
* `components`: the folder of `.zb` (and `.zb.jsx`/`.zb.tsx`) files. `prefix`: the URL path
|
|
@@ -42,8 +40,10 @@ const TYPES = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; char
|
|
|
42
40
|
* `ZUBRA_DEVTOOLS=1` turns it on there too, for a demo or a staging site). `imports`: more import map entries,
|
|
43
41
|
* for packages the components import beyond `@zubra/runtime` and `@zubra/sync` (both of which
|
|
44
42
|
* are served and mapped by themselves).
|
|
43
|
+
*
|
|
44
|
+
* Everything that doesn't need a disk is in `core.js`, shared with `edge.js` (Workers).
|
|
45
45
|
*/
|
|
46
|
-
export function createZubra({ components = 'components', prefix = '/_zubra', cache = true, production = process.env.NODE_ENV === 'production', devtools = process.env.ZUBRA_DEVTOOLS === '1' || !production, imports = {} } = {}) {
|
|
46
|
+
export function createZubra({ components = 'components', prefix = '/_zubra', cache = true, production = process.env.NODE_ENV === 'production', devtools = process.env.ZUBRA_DEVTOOLS === '1' || !production, imports = {}, warn } = {}) {
|
|
47
47
|
const root = resolve(components);
|
|
48
48
|
if (!existsSync(root)) throw new Error(`@zubra/server: no components folder at ${root}`);
|
|
49
49
|
const sourceDir = dirname(fileURLToPath(import.meta.resolve('@zubra/runtime')));
|
|
@@ -65,7 +65,7 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
|
|
|
65
65
|
})();
|
|
66
66
|
const needs = (name, code) => {
|
|
67
67
|
if (!syncDir && code.includes('from "@zubra/sync"')) {
|
|
68
|
-
throw new Error(`@zubra/server: ${relative(process.cwd(), pathOf(name))} starts with 'use collection', which needs @zubra/sync. Install it: npm install @zubra/sync`);
|
|
68
|
+
throw new Error(`@zubra/server: ${relative(process.cwd(), core.pathOf(name))} starts with 'use collection', which needs @zubra/sync. Install it: npm install @zubra/sync`);
|
|
69
69
|
}
|
|
70
70
|
return code;
|
|
71
71
|
};
|
|
@@ -83,58 +83,8 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
|
|
|
83
83
|
}
|
|
84
84
|
})(root);
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
if (!cache || !memo.has(key)) memo.set(key, make());
|
|
89
|
-
return memo.get(key);
|
|
90
|
-
};
|
|
91
|
-
const need = () => compiler;
|
|
92
|
-
const pathOf = (name) => {
|
|
93
|
-
const path = files.get(name) ?? files.get(byTag().get(name));
|
|
94
|
-
if (!path) throw new Error(`@zubra/server: no component "${name}" in ${root} (have: ${[...files.keys()].join(', ')})`);
|
|
95
|
-
return path;
|
|
96
|
-
};
|
|
97
|
-
const sourcesOf = (path) => once(`sources:${path}`, () => need().sources(path));
|
|
98
|
-
const call = (path, body) => {
|
|
99
|
-
const response = need().request({ path, files: sourcesOf(path), ...body });
|
|
100
|
-
if (!response.ok) throw new Error(response.error);
|
|
101
|
-
return response.output;
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
/** Each component's interface: `{ tag, shadow, form, props: [{ name, attribute, type, default, model }] }`. */
|
|
105
|
-
const describe = (name) => once(`interface:${name}`, () => JSON.parse(call(pathOf(name), { emit: 'interface' })));
|
|
106
|
-
const byTag = () => once('tags', () => new Map([...files.keys()].map((name) => [describe(name).tag, name])));
|
|
107
|
-
const nameOf = (nameOrTag) => (files.has(nameOrTag) ? nameOrTag : byTag().get(nameOrTag));
|
|
108
|
-
|
|
109
|
-
const render = (name, props = {}, slots = {}, pending = []) =>
|
|
110
|
-
call(pathOf(name), { emit: 'html', props, slots, slotSource: 'html', partial: true, ...(pending.length ? { pending } : {}) });
|
|
111
|
-
const css = (name) => once(`css:${name}`, () => call(pathOf(name), { emit: 'css' }));
|
|
112
|
-
const js = (name) => once(`js:${name}`, () => {
|
|
113
|
-
const response = need().compileFile(pathOf(name), { emit: 'js', runtime: '@zubra/runtime', devtools });
|
|
114
|
-
if (!response.ok) throw new Error(response.error);
|
|
115
|
-
return needs(name, response.output);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
const importMap = () => `<script type="importmap">${JSON.stringify({ imports: { '@zubra/runtime': `${base}/runtime/index.js`, '@zubra/runtime/spread': `${base}/runtime/spread.js`, '@zubra/runtime/primitives': `${base}/runtime/primitives.js`, ...syncImports, ...imports } })}</script>`;
|
|
119
|
-
const syncImports = syncDir ? { '@zubra/sync': `${base}/sync/index.js`, '@zubra/sync/connect': `${base}/sync/connect.js` } : {};
|
|
120
|
-
const head = (names) => {
|
|
121
|
-
// A component that needs a package that isn't installed fails here, while the page is built,
|
|
122
|
-
// with the reason, rather than later in the browser as an unresolved import.
|
|
123
|
-
for (const name of names) js(name);
|
|
124
|
-
const styles = names.map(css).join('\n');
|
|
125
|
-
return `${styles ? `<style data-zb-components>${styles}</style>\n` : ''}${importMap()}`;
|
|
126
|
-
};
|
|
127
|
-
const scripts = (names, async = false) => (names.length ? `<script type="module"${async ? ' async' : ''}>${names.map((name) => `import '${base}/components/${name}.js';`).join(' ')}</script>` : '');
|
|
128
|
-
|
|
129
|
-
/** What a request for `pathname` gets: `{ status, type, body }`, or null if it isn't ours. */
|
|
130
|
-
function serve(pathname) {
|
|
131
|
-
if (!pathname.startsWith(`${base}/`)) return null;
|
|
132
|
-
const rest = decodeURIComponent(pathname.slice(base.length + 1));
|
|
133
|
-
if (rest.startsWith('components/') && rest.endsWith('.js')) {
|
|
134
|
-
const name = rest.slice('components/'.length, -3);
|
|
135
|
-
if (!files.has(name)) return { status: 404, type: 'text/plain', body: 'Not found' };
|
|
136
|
-
return { status: 200, type: TYPES['.js'], body: js(name) };
|
|
137
|
-
}
|
|
86
|
+
// What <prefix>/ serves besides the components' modules: @zubra/sync and the runtime, from disk.
|
|
87
|
+
const assets = (rest) => {
|
|
138
88
|
if (rest.startsWith('sync/') && syncDir) {
|
|
139
89
|
const file = resolve(syncDir, rest.slice('sync/'.length));
|
|
140
90
|
if (!file.startsWith(syncDir + sep) || extname(file) !== '.js' || !existsSync(file)) return { status: 404, type: 'text/plain', body: 'Not found' };
|
|
@@ -146,175 +96,37 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
|
|
|
146
96
|
return { status: 200, type: TYPES[extname(file)] ?? 'application/octet-stream', body: readFileSync(file) };
|
|
147
97
|
}
|
|
148
98
|
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const core = createCore({
|
|
102
|
+
warn,
|
|
103
|
+
request: (body) => compiler.request(body),
|
|
104
|
+
files,
|
|
105
|
+
sourcesOf: (path) => compiler.sources(path),
|
|
106
|
+
cache,
|
|
107
|
+
devtools,
|
|
108
|
+
base,
|
|
109
|
+
imports,
|
|
110
|
+
syncImports: syncDir ? { '@zubra/sync': `${base}/sync/index.js`, '@zubra/sync/connect': `${base}/sync/connect.js` } : {},
|
|
111
|
+
needs,
|
|
112
|
+
where: root,
|
|
113
|
+
assets,
|
|
114
|
+
});
|
|
151
115
|
|
|
152
116
|
/** Node's http (and Express, Koa, Fastify's raw request): true when it answered. */
|
|
153
117
|
function handle(req, res) {
|
|
154
|
-
const served = serve(new URL(req.url, 'http://x').pathname);
|
|
118
|
+
const served = core.serve(new URL(req.url, 'http://x').pathname);
|
|
155
119
|
if (!served) return false;
|
|
156
|
-
res.writeHead(served.status, { 'content-type': served.type, 'cache-control': cacheControl });
|
|
120
|
+
res.writeHead(served.status, { 'content-type': served.type, 'cache-control': core.cacheControl });
|
|
157
121
|
res.end(served.body);
|
|
158
122
|
return true;
|
|
159
123
|
}
|
|
160
124
|
|
|
161
|
-
/**
|
|
162
|
-
async function fetchHandler(request) {
|
|
163
|
-
const served = serve(new URL(request.url).pathname);
|
|
164
|
-
if (!served) return null;
|
|
165
|
-
return new Response(served.body, { status: served.status, headers: { 'content-type': served.type, 'cache-control': cacheControl } });
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** One page: render components into it, then `finish()` it (or use `head()` and `scripts()`). */
|
|
125
|
+
/** One page, as in core.js, and `stream(html)`: the same chunks as a Node Readable. */
|
|
169
126
|
function page() {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
};
|
|
174
|
-
// Streaming (3.3): a prop given as a promise renders as pending (its `zb-await` shows its
|
|
175
|
-
// pending content, in an empty region), and `stream()` sends the resolved region after the
|
|
176
|
-
// page, with the value, for the element to hydrate.
|
|
177
|
-
const pendingRenders = [];
|
|
178
|
-
let streamed = 0;
|
|
179
|
-
return {
|
|
180
|
-
used,
|
|
181
|
-
render(name, props = {}, slots) {
|
|
182
|
-
const key = nameOf(name) ?? name;
|
|
183
|
-
use(key);
|
|
184
|
-
const promises = Object.entries(props).filter(([, value]) => value && typeof value.then === 'function');
|
|
185
|
-
if (!promises.length) return render(key, props, slots);
|
|
186
|
-
const given = Object.fromEntries(Object.entries(props).filter(([, value]) => !(value && typeof value.then === 'function')));
|
|
187
|
-
const names = promises.map(([name]) => name);
|
|
188
|
-
const id = String(streamed++);
|
|
189
|
-
const shell = withStreamId(render(key, given, slots, names), id);
|
|
190
|
-
const entry = { key, id, given, slots, promises: new Map(promises), shell, count: 0 };
|
|
191
|
-
// Each pending region is a named range (`<?start name>`…`<?end>`) that a streamed
|
|
192
|
-
// `<template for>` replaces (out-of-order streaming).
|
|
193
|
-
entry.names = pendingRegions(shell).map(() => `zb-${id}-${entry.count++}`);
|
|
194
|
-
pendingRenders.push(entry);
|
|
195
|
-
// Until the values arrive, the element (should it start first) holds a promise for
|
|
196
|
-
// each, set before it upgrades, so its `zb-await` shows the pending content.
|
|
197
|
-
return `${named(shell, entry.names)}<script>(${holdInBrowser.toString()})(${JSON.stringify(id)},${JSON.stringify(names)})</script>`;
|
|
198
|
-
},
|
|
199
|
-
/** Whether a rendered component is waiting on a promise prop (then `stream()`, not `finish()`). */
|
|
200
|
-
get pending() {
|
|
201
|
-
return pendingRenders.length > 0;
|
|
202
|
-
},
|
|
203
|
-
/**
|
|
204
|
-
* The page as a stream: `finish(html)` first, then, as each promise prop settles, the
|
|
205
|
-
* resolved regions, each in a `<template for>` (which the browser puts in place itself
|
|
206
|
-
* where it streams out of order), and a short inline script that hands the element the
|
|
207
|
-
* value, and puts the regions in place where the browser didn't.
|
|
208
|
-
* `stream(html)` is a Node Readable; `streamResponse(html)` a web `Response` body.
|
|
209
|
-
*/
|
|
210
|
-
stream(html) {
|
|
211
|
-
const { Readable } = nodeStream;
|
|
212
|
-
return Readable.from(chunks(html));
|
|
213
|
-
},
|
|
214
|
-
streamResponse(html, init = {}) {
|
|
215
|
-
const { Readable } = nodeStream;
|
|
216
|
-
const body = Readable.toWeb(Readable.from(chunks(html)));
|
|
217
|
-
return new Response(body, { ...init, headers: { 'content-type': 'text/html; charset=utf-8', ...init.headers } });
|
|
218
|
-
},
|
|
219
|
-
/** The components' CSS and the import map, for the page's `<head>`. */
|
|
220
|
-
head: () => head(used),
|
|
221
|
-
/** The module script that makes the components interactive, for the end of `<body>`. */
|
|
222
|
-
scripts: () => scripts(used),
|
|
223
|
-
/** A whole page with the head and scripts put in (before `</head>` and `</body>`). */
|
|
224
|
-
finish(html) {
|
|
225
|
-
return insertBefore(insertBefore(html, '</head>', head(used)), '</body>', scripts(used));
|
|
226
|
-
},
|
|
227
|
-
};
|
|
228
|
-
|
|
229
|
-
async function* chunks(html) {
|
|
230
|
-
// The shell: as the ordinary page, but with the module script `async`, so the elements
|
|
231
|
-
// start as soon as it has loaded instead of when the stream ends (a module script is
|
|
232
|
-
// deferred until the document is complete). A region that arrives after an element has
|
|
233
|
-
// started reaches it as a property change.
|
|
234
|
-
yield insertBefore(insertBefore(html, '</head>', head(used)), '</body>', scripts(used, true));
|
|
235
|
-
const waiting = pendingRenders.splice(0).flatMap((entry) =>
|
|
236
|
-
[...entry.promises].map(([name, promise]) => promise.then(
|
|
237
|
-
(value) => ({ entry, name, value }),
|
|
238
|
-
(error) => ({ entry, name, error: error instanceof Error ? error.message : String(error) }),
|
|
239
|
-
)));
|
|
240
|
-
// In the order they settle.
|
|
241
|
-
const open = new Set(waiting.map((promise) => {
|
|
242
|
-
const tagged = promise.then((result) => ({ result, tagged }));
|
|
243
|
-
return tagged;
|
|
244
|
-
}));
|
|
245
|
-
while (open.size) {
|
|
246
|
-
const { result, tagged } = await Promise.race(open);
|
|
247
|
-
open.delete(tagged);
|
|
248
|
-
const { entry, name, value, error } = result;
|
|
249
|
-
if (error !== undefined) {
|
|
250
|
-
yield swapScript(entry.id, null, { [name]: { error } });
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
entry.given[name] = value;
|
|
254
|
-
entry.promises.delete(name);
|
|
255
|
-
const resolved = withStreamId(render(entry.key, entry.given, entry.slots, [...entry.promises.keys()]), entry.id);
|
|
256
|
-
const swaps = new Map(regionSwaps(entry.shell, resolved).map((swap) => [swap.index, swap.html]));
|
|
257
|
-
entry.shell = resolved;
|
|
258
|
-
// The regions still pending keep their names; one that resolved is replaced, along with
|
|
259
|
-
// its name, by the pending regions inside it.
|
|
260
|
-
const regions = [];
|
|
261
|
-
entry.names = entry.names.flatMap((region, index) => {
|
|
262
|
-
if (!swaps.has(index)) return [region];
|
|
263
|
-
const html = swaps.get(index);
|
|
264
|
-
const inner = pendingRegions(html).map(() => `zb-${entry.id}-${entry.count++}`);
|
|
265
|
-
regions.push({ name: region, html: named(html, inner) });
|
|
266
|
-
return inner;
|
|
267
|
-
});
|
|
268
|
-
yield swapScript(entry.id, regions, { [name]: { value } });
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Shadow roots for a framework's HTML: each shadow-DOM component's host gets its
|
|
275
|
-
* server-rendered template as a declarative shadow root (read from the host's `zb-props`
|
|
276
|
-
* JSON, or its attributes), so it shows before JavaScript; the browser attaches it while
|
|
277
|
-
* parsing, before any framework hydrates. Form-associated components with a `name` also get a
|
|
278
|
-
* hidden input with their value, so the form submits without JavaScript (the element removes
|
|
279
|
-
* it when it starts). Light-DOM components are left alone unless `lightDom: true` (3.3): then
|
|
280
|
-
* an empty light-DOM host gets its rendered children, for frameworks that leave nodes they
|
|
281
|
-
* didn't render alone while hydrating (Solid; React and Vue through their wrappers do this
|
|
282
|
-
* themselves). Not for Svelte, which claims every node.
|
|
283
|
-
*/
|
|
284
|
-
/** The components by tag (shadow-DOM ones, or all with `lightDom`), and a pattern for their hosts' start tags. */
|
|
285
|
-
function shadowHosts(lightDom = false) {
|
|
286
|
-
const shadowTags = new Map();
|
|
287
|
-
for (const name of files.keys()) {
|
|
288
|
-
const info = describe(name);
|
|
289
|
-
if (info.shadow || lightDom) shadowTags.set(info.tag, { name, info });
|
|
290
|
-
}
|
|
291
|
-
const pattern = shadowTags.size ? new RegExp(`<(${[...shadowTags.keys()].map((tag) => tag.replace(/[-]/g, '\\-')).join('|')})(\\s[^>]*)?>`, 'g') : null;
|
|
292
|
-
return { shadowTags, pattern };
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function injectShadowRoots(html, { forms = true, lightDom = false } = {}) {
|
|
296
|
-
const { shadowTags, pattern } = shadowHosts(lightDom);
|
|
297
|
-
if (!pattern) return html;
|
|
298
|
-
let out = '';
|
|
299
|
-
let last = 0;
|
|
300
|
-
for (const match of html.matchAll(pattern)) {
|
|
301
|
-
const end = match.index + match[0].length;
|
|
302
|
-
const { name, info } = shadowTags.get(match[1]);
|
|
303
|
-
// Already has one (rendered by zubra, or a second pass); a light-DOM host must be empty.
|
|
304
|
-
if (info.shadow ? /^\s*<template\s[^>]*shadowroot/i.test(html.slice(end, end + 200)) : !new RegExp(`^\\s*</${match[1]}>`).test(html.slice(end, end + 200))) continue;
|
|
305
|
-
const props = propsFrom(match[2] ?? '', info);
|
|
306
|
-
let rendered;
|
|
307
|
-
try {
|
|
308
|
-
rendered = render(name, props);
|
|
309
|
-
} catch {
|
|
310
|
-
continue; // leave it to the browser rather than break the page
|
|
311
|
-
}
|
|
312
|
-
const template = info.shadow ? shadowTemplate(rendered) : lightContent(rendered, match[1]);
|
|
313
|
-
if (!template) continue;
|
|
314
|
-
out += html.slice(last, end) + template + (forms ? fallbackInput(match[2] ?? '', info, props) : '');
|
|
315
|
-
last = end;
|
|
316
|
-
}
|
|
317
|
-
return out + html.slice(last);
|
|
127
|
+
const one = core.page();
|
|
128
|
+
one.stream = (html) => nodeStream.Readable.from(one.chunks(html));
|
|
129
|
+
return one;
|
|
318
130
|
}
|
|
319
131
|
|
|
320
132
|
/**
|
|
@@ -326,7 +138,7 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
|
|
|
326
138
|
for (const name of files.keys()) {
|
|
327
139
|
const file = join(out, 'components', `${name}.js`);
|
|
328
140
|
mkdirSync(dirname(file), { recursive: true });
|
|
329
|
-
writeFileSync(file,
|
|
141
|
+
writeFileSync(file, core.module(name));
|
|
330
142
|
}
|
|
331
143
|
cpSync(runtimeDir, join(out, 'runtime'), { recursive: true });
|
|
332
144
|
if (syncDir) cpSync(syncDir, join(out, 'sync'), { recursive: true, filter: (from) => !from.endsWith('.d.ts') });
|
|
@@ -348,14 +160,14 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
|
|
|
348
160
|
let cut = open > all.lastIndexOf('>') ? open : all.length;
|
|
349
161
|
// A host's start tag near the end: what follows may be its own <template> (a React
|
|
350
162
|
// wrapper renders it), so wait for more before deciding.
|
|
351
|
-
const { pattern } = shadowHosts(options?.lightDom);
|
|
163
|
+
const { pattern } = core.shadowHosts(options?.lightDom);
|
|
352
164
|
if (!flush && pattern) {
|
|
353
165
|
let last = null;
|
|
354
166
|
for (const match of all.slice(0, cut).matchAll(pattern)) last = match;
|
|
355
167
|
if (last && cut - (last.index + last[0].length) < 64) cut = last.index;
|
|
356
168
|
}
|
|
357
169
|
carry = all.slice(cut);
|
|
358
|
-
return injectShadowRoots(all.slice(0, cut), options);
|
|
170
|
+
return core.injectShadowRoots(all.slice(0, cut), options);
|
|
359
171
|
};
|
|
360
172
|
return new Transform({
|
|
361
173
|
transform(chunk, _encoding, callback) {
|
|
@@ -370,263 +182,16 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
|
|
|
370
182
|
return {
|
|
371
183
|
writeAssets,
|
|
372
184
|
injectShadowRootsStream,
|
|
373
|
-
module:
|
|
374
|
-
names:
|
|
375
|
-
describe:
|
|
376
|
-
render:
|
|
185
|
+
module: core.module,
|
|
186
|
+
names: core.names,
|
|
187
|
+
describe: core.describe,
|
|
188
|
+
render: core.render,
|
|
377
189
|
page,
|
|
378
190
|
handle,
|
|
379
|
-
fetch:
|
|
380
|
-
importMap,
|
|
381
|
-
head:
|
|
382
|
-
scripts:
|
|
383
|
-
injectShadowRoots,
|
|
191
|
+
fetch: core.fetch,
|
|
192
|
+
importMap: core.importMap,
|
|
193
|
+
head: core.head,
|
|
194
|
+
scripts: core.scripts,
|
|
195
|
+
injectShadowRoots: core.injectShadowRoots,
|
|
384
196
|
};
|
|
385
197
|
}
|
|
386
|
-
|
|
387
|
-
function insertBefore(html, marker, text) {
|
|
388
|
-
if (!text) return html;
|
|
389
|
-
const at = html.lastIndexOf(marker);
|
|
390
|
-
return at < 0 ? html + text : html.slice(0, at) + text + html.slice(at);
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// ---- reading a host tag ---------------------------------------------------------------------
|
|
394
|
-
|
|
395
|
-
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#34': '"' };
|
|
396
|
-
const decode = (text) => text.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
|
|
397
|
-
if (ENTITIES[name.toLowerCase()]) return ENTITIES[name.toLowerCase()];
|
|
398
|
-
if (name[0] === '#') return String.fromCodePoint(name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
|
|
399
|
-
return whole;
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
/** An opening tag's attributes: `{ name: value }` (a bare attribute is `""`). */
|
|
403
|
-
export function parseAttributes(text) {
|
|
404
|
-
const attributes = {};
|
|
405
|
-
const pattern = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
406
|
-
for (const match of text.matchAll(pattern)) {
|
|
407
|
-
attributes[match[1].toLowerCase()] = decode(match[2] ?? match[3] ?? match[4] ?? '');
|
|
408
|
-
}
|
|
409
|
-
return attributes;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/** The props a host tag carries: `zb-props` JSON first, then each prop's attribute by its type. */
|
|
413
|
-
export function propsFrom(attributeText, info) {
|
|
414
|
-
const attributes = parseAttributes(attributeText);
|
|
415
|
-
let props = {};
|
|
416
|
-
if (attributes['zb-props']) {
|
|
417
|
-
try {
|
|
418
|
-
props = JSON.parse(attributes['zb-props']);
|
|
419
|
-
} catch {}
|
|
420
|
-
}
|
|
421
|
-
for (const prop of info.props) {
|
|
422
|
-
if (prop.name in props || !(prop.attribute in attributes)) continue;
|
|
423
|
-
const raw = attributes[prop.attribute];
|
|
424
|
-
const kind = Array.isArray(prop.default) || (prop.default && typeof prop.default === 'object') ? 'json' : prop.type;
|
|
425
|
-
if (kind === 'number') {
|
|
426
|
-
const number = Number(raw);
|
|
427
|
-
if (raw.trim() !== '' && Number.isFinite(number)) props[prop.name] = number;
|
|
428
|
-
} else if (kind === 'boolean') {
|
|
429
|
-
props[prop.name] = raw !== 'false';
|
|
430
|
-
} else if (kind === 'json' || /\[\]$|^\{/.test(prop.type)) {
|
|
431
|
-
if (raw === '[object Object]') continue; // Svelte's SSR: the real value comes later
|
|
432
|
-
try {
|
|
433
|
-
props[prop.name] = JSON.parse(raw);
|
|
434
|
-
} catch {}
|
|
435
|
-
} else {
|
|
436
|
-
props[prop.name] = raw;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
return props;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** The `<template shadowrootmode>…</template>` at the start of a rendered host's content. */
|
|
443
|
-
function shadowTemplate(rendered) {
|
|
444
|
-
const start = rendered.search(/<template\s[^>]*shadowrootmode/i);
|
|
445
|
-
if (start < 0) return null;
|
|
446
|
-
const tags = /<template\b[^>]*>|<\/template>/gi;
|
|
447
|
-
tags.lastIndex = start;
|
|
448
|
-
let depth = 0;
|
|
449
|
-
for (const match of rendered.matchAll(tags)) {
|
|
450
|
-
if (match.index < start) continue;
|
|
451
|
-
depth += match[0][1] === '/' ? -1 : 1;
|
|
452
|
-
if (depth === 0) return rendered.slice(start, match.index + match[0].length);
|
|
453
|
-
}
|
|
454
|
-
return null;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/** The host's start tag with `data-zb-stream="<id>"`, so the stream's scripts can find it. */
|
|
458
|
-
function withStreamId(html, id) {
|
|
459
|
-
return html.replace(/^(\s*<[a-z][\w.-]*)/, `$1 data-zb-stream="${id}"`);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* The pending regions of `shell` that `resolved` has rendered: `[{ index, html }]`, where
|
|
464
|
-
* `index` counts the shell's top-level `<?zb [??>` regions in order and `html` is the resolved
|
|
465
|
-
* region (markers included). The two renders differ only inside those regions (and the host's
|
|
466
|
-
* start tag, which is compared past).
|
|
467
|
-
*/
|
|
468
|
-
function regionSwaps(shell, resolved) {
|
|
469
|
-
const skipTag = (html) => html.match(/^\s*<[a-z][\w.-]*(?:\s[^>]*)?>/)?.[0].length ?? 0;
|
|
470
|
-
let a = skipTag(shell);
|
|
471
|
-
let b = skipTag(resolved);
|
|
472
|
-
const swaps = [];
|
|
473
|
-
let index = 0;
|
|
474
|
-
const PENDING = '<?zb [??>';
|
|
475
|
-
const regionEnd = (html, start) => {
|
|
476
|
-
// From the region's opening marker to its matching `<?zb ]?>`.
|
|
477
|
-
let depth = 0;
|
|
478
|
-
for (const match of html.slice(start).matchAll(/<\?zb \[[?!]?\?>|<\?zb \]\?>/g)) {
|
|
479
|
-
depth += match[0] === '<?zb ]?>' ? -1 : 1;
|
|
480
|
-
if (depth === 0) return start + match.index + match[0].length;
|
|
481
|
-
}
|
|
482
|
-
return html.length;
|
|
483
|
-
};
|
|
484
|
-
while (a < shell.length && b < resolved.length) {
|
|
485
|
-
if (shell.startsWith(PENDING, a)) {
|
|
486
|
-
const endA = regionEnd(shell, a);
|
|
487
|
-
const endB = regionEnd(resolved, b);
|
|
488
|
-
const region = resolved.slice(b, endB);
|
|
489
|
-
if (!region.startsWith(PENDING)) swaps.push({ index, html: region });
|
|
490
|
-
index++;
|
|
491
|
-
a = endA;
|
|
492
|
-
b = endB;
|
|
493
|
-
continue;
|
|
494
|
-
}
|
|
495
|
-
if (shell[a] !== resolved[b]) break; // not a difference this function understands
|
|
496
|
-
a++;
|
|
497
|
-
b++;
|
|
498
|
-
}
|
|
499
|
-
return swaps;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* The top-level pending regions of `html` (`<?zb [??>` to its `<?zb ]?>`, not inside another
|
|
504
|
-
* pending region), as `[start, end]` offsets: the ones `regionSwaps` counts.
|
|
505
|
-
*/
|
|
506
|
-
function pendingRegions(html) {
|
|
507
|
-
const found = [];
|
|
508
|
-
let depth = 0;
|
|
509
|
-
let start = -1;
|
|
510
|
-
let outer = 0;
|
|
511
|
-
for (const match of html.matchAll(/<\?zb (\[[?!]?|\])\?>/g)) {
|
|
512
|
-
if (match[1] === ']') {
|
|
513
|
-
depth--;
|
|
514
|
-
if (start >= 0 && depth === outer) {
|
|
515
|
-
found.push([start, match.index + match[0].length]);
|
|
516
|
-
start = -1;
|
|
517
|
-
}
|
|
518
|
-
continue;
|
|
519
|
-
}
|
|
520
|
-
if (match[1] === '[?' && start < 0) {
|
|
521
|
-
start = match.index;
|
|
522
|
-
outer = depth;
|
|
523
|
-
}
|
|
524
|
-
depth++;
|
|
525
|
-
}
|
|
526
|
-
return found;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/** `html` with its top-level pending regions in named ranges, for `<template for>`. */
|
|
530
|
-
function named(html, names) {
|
|
531
|
-
let out = html;
|
|
532
|
-
pendingRegions(html).reverse().forEach(([start, end], i, all) => {
|
|
533
|
-
const name = names[all.length - 1 - i];
|
|
534
|
-
out = `${out.slice(0, start)}<?start name="${name}"?>${out.slice(start, end)}<?end?>${out.slice(end)}`;
|
|
535
|
-
});
|
|
536
|
-
return out;
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
/** The chunk for one settled promise: each resolved region in a `<template for>`, and the script. */
|
|
540
|
-
function swapScript(id, regions, values) {
|
|
541
|
-
const templates = (regions ?? []).map((region) => `<template for="${region.name}">${region.html}</template>`).join('');
|
|
542
|
-
const script = `<script>(${swapInBrowser.toString()})(${JSON.stringify(id)},${JSON.stringify(values)})</script>`;
|
|
543
|
-
return `${templates}${script}\n`;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// Runs in the browser, inlined, right after a streamed host: a promise per pending prop, as a
|
|
547
|
-
// property the element captures when it upgrades, resolved by the swap script later.
|
|
548
|
-
function holdInBrowser(id, names) {
|
|
549
|
-
var host = document.querySelector('[data-zb-stream="' + id + '"]');
|
|
550
|
-
var held = (window.__zubraStream = window.__zubraStream || {});
|
|
551
|
-
for (var i = 0; i < names.length; i++) {
|
|
552
|
-
(function (name) {
|
|
553
|
-
host[name] = new Promise(function (resolve, reject) { held[id + ':' + name] = { resolve: resolve, reject: reject }; });
|
|
554
|
-
})(names[i]);
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
// Runs in the browser, inlined: hand the element the values (as properties, captured when it
|
|
559
|
-
// upgrades), and, before it starts, put the resolved regions in place where the browser didn't:
|
|
560
|
-
// browsers without out-of-order streaming leave each `<template for>` as an element, and those
|
|
561
|
-
// with it don't reach into a shadow root. Once the element has started it renders the regions
|
|
562
|
-
// itself (it removed their names, so the browser has nothing to replace).
|
|
563
|
-
function swapInBrowser(id, values) {
|
|
564
|
-
var host = document.querySelector('[data-zb-stream="' + id + '"]');
|
|
565
|
-
var script = document.currentScript;
|
|
566
|
-
var templates = [];
|
|
567
|
-
for (var node = script && script.previousElementSibling; node && node.tagName === 'TEMPLATE' && node.hasAttribute('for'); node = node.previousElementSibling) templates.push(node);
|
|
568
|
-
// A `<?start name>` or `<?end>`: a processing instruction, or the comment it parses as.
|
|
569
|
-
var kind = function (node) {
|
|
570
|
-
return node.nodeType === 7 ? node.target : node.nodeType === 8 ? (/^\?(start|end)\b/.exec(node.data) || [])[1] : null;
|
|
571
|
-
};
|
|
572
|
-
var started = host && host.matches(':state(hydrated), [data-hydrated]');
|
|
573
|
-
for (var i = 0; i < templates.length; i++) {
|
|
574
|
-
var template = templates[i];
|
|
575
|
-
template.remove();
|
|
576
|
-
if (!host || started) continue;
|
|
577
|
-
var name = 'name="' + template.getAttribute('for') + '"';
|
|
578
|
-
var start = null;
|
|
579
|
-
var roots = host.shadowRoot ? [host.shadowRoot, host] : [host];
|
|
580
|
-
for (var r = 0; r < roots.length && !start; r++) {
|
|
581
|
-
var walker = document.createTreeWalker(roots[r], NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_PROCESSING_INSTRUCTION);
|
|
582
|
-
for (var at = walker.nextNode(); at; at = walker.nextNode()) {
|
|
583
|
-
if (kind(at) === 'start' && at.data.replace(/^\?start |\?$/g, '') === name) { start = at; break; }
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
var end = start && start.nextSibling;
|
|
587
|
-
for (var depth = 0; end; end = end.nextSibling) {
|
|
588
|
-
if (kind(end) === 'start') depth++;
|
|
589
|
-
else if (kind(end) === 'end' && depth-- === 0) break;
|
|
590
|
-
}
|
|
591
|
-
if (!end) continue;
|
|
592
|
-
var range = document.createRange();
|
|
593
|
-
range.setStartBefore(start);
|
|
594
|
-
range.setEndAfter(end);
|
|
595
|
-
range.deleteContents();
|
|
596
|
-
range.insertNode(template.content);
|
|
597
|
-
}
|
|
598
|
-
var held = window.__zubraStream || {};
|
|
599
|
-
for (var name in values) {
|
|
600
|
-
var hold = held[id + ':' + name];
|
|
601
|
-
if (values[name].error !== undefined) {
|
|
602
|
-
var rejected = Promise.reject(new Error(values[name].error));
|
|
603
|
-
rejected.catch(function () {});
|
|
604
|
-
if (hold) hold.reject(new Error(values[name].error));
|
|
605
|
-
else host[name] = rejected;
|
|
606
|
-
} else {
|
|
607
|
-
if (hold) hold.resolve(values[name].value);
|
|
608
|
-
host[name] = values[name].value;
|
|
609
|
-
}
|
|
610
|
-
delete held[id + ':' + name];
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
/** A rendered light-DOM host's children: between its start tag and its end tag. */
|
|
615
|
-
function lightContent(rendered, tag) {
|
|
616
|
-
const open = rendered.match(/^\s*<[a-z][\w.-]*(?:\s[^>]*)?>/);
|
|
617
|
-
const close = rendered.lastIndexOf(`</${tag}>`);
|
|
618
|
-
if (!open || close < open[0].length) return null;
|
|
619
|
-
return rendered.slice(open[0].length, close);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
/** A form-associated component's value, as the form would submit it, in a hidden input. */
|
|
623
|
-
function fallbackInput(attributeText, info, props) {
|
|
624
|
-
if (!info.form) return '';
|
|
625
|
-
const name = parseAttributes(attributeText).name;
|
|
626
|
-
const models = info.props.filter((prop) => prop.model);
|
|
627
|
-
if (!name || models.length !== 1) return '';
|
|
628
|
-
const value = models[0].name in props ? props[models[0].name] : models[0].default;
|
|
629
|
-
if (value == null || value === false || value === '') return '';
|
|
630
|
-
const text = value === true ? 'on' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
631
|
-
return `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(text)}" data-zb-fallback>`;
|
|
632
|
-
}
|