@zubra/server 0.0.0-stage → 5.0.3
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/LICENSE +15 -0
- package/package.json +51 -4
- package/src/adapters.d.ts +3 -0
- package/src/compiler.js +29 -0
- package/src/express.js +20 -0
- package/src/fastify.js +23 -0
- package/src/hono.js +19 -0
- package/src/index.d.ts +70 -0
- package/src/index.js +598 -0
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Mikey Nichols
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zubra/server",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "5.0.3",
|
|
4
|
+
"description": "Zubra on the server: render components into any template engine's pages, serve their browser modules, and give frameworks' HTML its components' shadow roots.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./express": {
|
|
14
|
+
"types": "./src/adapters.d.ts",
|
|
15
|
+
"default": "./src/express.js"
|
|
16
|
+
},
|
|
17
|
+
"./fastify": {
|
|
18
|
+
"types": "./src/adapters.d.ts",
|
|
19
|
+
"default": "./src/fastify.js"
|
|
20
|
+
},
|
|
21
|
+
"./hono": {
|
|
22
|
+
"types": "./src/adapters.d.ts",
|
|
23
|
+
"default": "./src/hono.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test \"test/*.test.js\""
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@zubra/compiler": "^5.0.0",
|
|
31
|
+
"@zubra/runtime": "^5.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@zubra/compiler": "file:../compiler",
|
|
35
|
+
"@zubra/runtime": "file:../runtime",
|
|
36
|
+
"express": "5.3.0",
|
|
37
|
+
"fastify": "5.12.8",
|
|
38
|
+
"hono": "4.13.17",
|
|
39
|
+
"playwright": "1.63.0"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src"
|
|
43
|
+
],
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=20"
|
|
46
|
+
},
|
|
47
|
+
"license": "ISC",
|
|
48
|
+
"repository": {
|
|
49
|
+
"type": "git",
|
|
50
|
+
"url": "git+https://github.com/journey-to-code-org/zubra.git",
|
|
51
|
+
"directory": "server"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/compiler.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Which compiler @zubra/compiler uses. Installed from npm it carries its WebAssembly build. Inside
|
|
2
|
+
// the zubra repository that build may not exist (it needs Rust's wasm target), so the CLI that
|
|
3
|
+
// `cargo build` makes stands in, through ZUBRA_COMPILER.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const exe = process.platform === 'win32' ? '.exe' : '';
|
|
9
|
+
|
|
10
|
+
/** `{ kind, path }` of the compiler to use, or null; sets ZUBRA_COMPILER for a native CLI. */
|
|
11
|
+
export function findCompiler() {
|
|
12
|
+
const wasm = new URL('./zubra.wasm', import.meta.resolve('@zubra/compiler'));
|
|
13
|
+
if (existsSync(fileURLToPath(wasm))) return { kind: 'WebAssembly', path: fileURLToPath(wasm) };
|
|
14
|
+
if (process.env.ZUBRA_COMPILER && existsSync(process.env.ZUBRA_COMPILER)) return { kind: 'CLI', path: process.env.ZUBRA_COMPILER };
|
|
15
|
+
// A checkout of the repository: target/{debug,release}/zubra above here or above the app.
|
|
16
|
+
for (const start of [process.cwd(), dirname(fileURLToPath(import.meta.url))]) {
|
|
17
|
+
let dir = start;
|
|
18
|
+
for (let up = 0; up < 8; up++, dir = dirname(dir)) {
|
|
19
|
+
for (const profile of ['debug', 'release']) {
|
|
20
|
+
const cli = join(dir, 'target', profile, `zubra${exe}`);
|
|
21
|
+
if (existsSync(cli)) {
|
|
22
|
+
process.env.ZUBRA_COMPILER = cli;
|
|
23
|
+
return { kind: 'CLI', path: cli };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
package/src/express.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Express (and Connect) middleware: serves the components' modules and the runtime, and gives
|
|
2
|
+
// each request a page as `res.locals.zubra`, for any template engine:
|
|
3
|
+
//
|
|
4
|
+
// import { zubra } from '@zubra/server/express';
|
|
5
|
+
// app.use(zubra({ components: 'components' }));
|
|
6
|
+
// // in a view (EJS): <%- zubra.render('ticket-table', { tickets }) %>
|
|
7
|
+
// // then: res.send(res.locals.zubra.finish(html)), or <%- zubra.head() %> / <%- zubra.scripts() %>
|
|
8
|
+
import { createZubra } from './index.js';
|
|
9
|
+
|
|
10
|
+
export function zubra(options) {
|
|
11
|
+
const instance = createZubra(options);
|
|
12
|
+
const middleware = (req, res, next) => {
|
|
13
|
+
if (instance.handle(req, res)) return;
|
|
14
|
+
res.locals ??= {};
|
|
15
|
+
res.locals.zubra = instance.page();
|
|
16
|
+
next();
|
|
17
|
+
};
|
|
18
|
+
middleware.zubra = instance;
|
|
19
|
+
return middleware;
|
|
20
|
+
}
|
package/src/fastify.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// A Fastify plugin: serves the components' modules and the runtime, and decorates each request
|
|
2
|
+
// with a page (`request.zubra`).
|
|
3
|
+
//
|
|
4
|
+
// import { zubra } from '@zubra/server/fastify';
|
|
5
|
+
// await app.register(zubra, { components: 'components' });
|
|
6
|
+
// app.get('/', (request, reply) => reply.type('text/html').send(request.zubra.finish(page(request.zubra))));
|
|
7
|
+
import { createZubra } from './index.js';
|
|
8
|
+
|
|
9
|
+
export async function zubra(fastify, options) {
|
|
10
|
+
const instance = createZubra(options);
|
|
11
|
+
fastify.decorate('zubra', instance);
|
|
12
|
+
fastify.decorateRequest('zubra', null);
|
|
13
|
+
fastify.addHook('onRequest', (request, reply, done) => {
|
|
14
|
+
if (instance.handle(request.raw, reply.raw)) {
|
|
15
|
+
reply.hijack();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
request.zubra = instance.page();
|
|
19
|
+
done();
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
// (Registered without encapsulation, so the hook and decorations apply to the whole app.)
|
|
23
|
+
zubra[Symbol.for('skip-override')] = true;
|
package/src/hono.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Hono middleware (Node.js, Bun, Deno, workers with a filesystem): serves the components'
|
|
2
|
+
// modules and the runtime, and sets a page as `c.get('zubra')`.
|
|
3
|
+
//
|
|
4
|
+
// import { zubra } from '@zubra/server/hono';
|
|
5
|
+
// app.use(zubra({ components: 'components' }));
|
|
6
|
+
// app.get('/', (c) => c.html(c.get('zubra').finish(layout(c.get('zubra').render('price-tag', { cents })))));
|
|
7
|
+
import { createZubra } from './index.js';
|
|
8
|
+
|
|
9
|
+
export function zubra(options) {
|
|
10
|
+
const instance = createZubra(options);
|
|
11
|
+
const middleware = async (c, next) => {
|
|
12
|
+
const response = await instance.fetch(c.req.raw);
|
|
13
|
+
if (response) return response;
|
|
14
|
+
c.set('zubra', instance.page());
|
|
15
|
+
await next();
|
|
16
|
+
};
|
|
17
|
+
middleware.zubra = instance;
|
|
18
|
+
return middleware;
|
|
19
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface PropInfo { name: string; attribute: string; type: string; default: unknown; model: boolean; required: boolean }
|
|
2
|
+
export interface ComponentInfo { tag: string; shadow: boolean; form: boolean; props: PropInfo[]; events: string[] }
|
|
3
|
+
|
|
4
|
+
export interface Page {
|
|
5
|
+
/** The components this page rendered, in order. */
|
|
6
|
+
readonly used: string[];
|
|
7
|
+
/** A component's server-rendered HTML (by its file name, or its tag). */
|
|
8
|
+
render(name: string, props?: Record<string, unknown>, slots?: Record<string, string>): string;
|
|
9
|
+
/** The used components' CSS and the import map, for `<head>`. */
|
|
10
|
+
head(): string;
|
|
11
|
+
/** The module script that hydrates the used components, for the end of `<body>`. */
|
|
12
|
+
scripts(): string;
|
|
13
|
+
/** A whole page with `head()` put before `</head>` and `scripts()` before `</body>`. */
|
|
14
|
+
finish(html: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether a rendered component was given a promise as a prop. Such a prop renders as pending
|
|
17
|
+
* (its `zb-await` shows its pending content); `stream()` sends the resolved region with the
|
|
18
|
+
* value after the page.
|
|
19
|
+
*/
|
|
20
|
+
readonly pending: boolean;
|
|
21
|
+
/** The page as a Node stream: `finish(html)`, then each resolved region as its promise settles. */
|
|
22
|
+
stream(html: string): import('node:stream').Readable;
|
|
23
|
+
/** The same as a web `Response` (fetch-style servers). */
|
|
24
|
+
streamResponse(html: string, init?: ResponseInit): Response;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Zubra {
|
|
28
|
+
/** The components found, by file name (without the extension). */
|
|
29
|
+
readonly names: string[];
|
|
30
|
+
describe(name: string): ComponentInfo;
|
|
31
|
+
render(name: string, props?: Record<string, unknown>, slots?: Record<string, string>): string;
|
|
32
|
+
/** One page's renders, CSS and scripts. */
|
|
33
|
+
page(): Page;
|
|
34
|
+
/** Node's http: serves the browser modules and runtime under the prefix; true when it answered. */
|
|
35
|
+
handle(req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse): boolean;
|
|
36
|
+
/** Fetch-style servers: a Response for the prefix's paths, or null. */
|
|
37
|
+
fetch(request: Request): Promise<Response | null>;
|
|
38
|
+
importMap(): string;
|
|
39
|
+
head(names?: string[]): string;
|
|
40
|
+
scripts(names?: string[]): string;
|
|
41
|
+
/**
|
|
42
|
+
* A framework's HTML with each shadow-DOM component's server-rendered template as a declarative
|
|
43
|
+
* shadow root, and a hidden input for form-associated ones with a `name` (`forms: false` leaves
|
|
44
|
+
* those out).
|
|
45
|
+
*/
|
|
46
|
+
injectShadowRoots(html: string, options?: { forms?: boolean; lightDom?: boolean }): string;
|
|
47
|
+
/** `injectShadowRoots` as a Node Transform stream, for streamed pages. */
|
|
48
|
+
injectShadowRootsStream(options?: { forms?: boolean; lightDom?: boolean }): import('node:stream').Transform;
|
|
49
|
+
/** A component's browser module (JavaScript). */
|
|
50
|
+
module(name: string): string;
|
|
51
|
+
/** Static sites: write the browser modules and @zubra/runtime under `dir` (at `<dir>/_zubra/…`). */
|
|
52
|
+
writeAssets(dir: string): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ZubraOptions {
|
|
56
|
+
/** The folder of components (default `components`). */
|
|
57
|
+
components?: string;
|
|
58
|
+
/** Where the browser modules are served (default `/_zubra`). */
|
|
59
|
+
prefix?: string;
|
|
60
|
+
/** Keep compiled output (default true); false recompiles on every request, for development. */
|
|
61
|
+
cache?: boolean;
|
|
62
|
+
/** Serve the runtime's production build, without hot reloading (default: `NODE_ENV` is `production`). */
|
|
63
|
+
production?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export declare function createZubra(options?: ZubraOptions): Zubra;
|
|
67
|
+
export declare function parseAttributes(text: string): Record<string, string>;
|
|
68
|
+
export declare function propsFrom(attributeText: string, info: ComponentInfo): Record<string, unknown>;
|
|
69
|
+
/** The compiler @zubra/compiler will use (`{ kind: 'WebAssembly' | 'CLI', path }`), or null. */
|
|
70
|
+
export declare function findCompiler(): { kind: 'WebAssembly' | 'CLI'; path: string } | null;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// @zubra/server: zubra components in a server's pages, with any template engine and no bundler.
|
|
2
|
+
//
|
|
3
|
+
// import { createZubra } from '@zubra/server';
|
|
4
|
+
// const zubra = createZubra({ components: 'components' });
|
|
5
|
+
//
|
|
6
|
+
// const page = zubra.page(); // one per request
|
|
7
|
+
// const body = `<h1>Tickets</h1>${page.render('ticket-table', { tickets })}`;
|
|
8
|
+
// res.end(page.finish(layout(body))); // the components' CSS and modules go in
|
|
9
|
+
//
|
|
10
|
+
// zubra.handle(req, res) // Node: serves /_zubra/… (component modules, @zubra/runtime); false otherwise
|
|
11
|
+
// zubra.fetch(request) // the same for fetch-style servers (Hono, Bun, Deno): a Response or null
|
|
12
|
+
//
|
|
13
|
+
// And for frameworks that render the host tags themselves (React Router, Nuxt, SvelteKit,
|
|
14
|
+
// SolidStart, Next): `zubra.injectShadowRoots(html)` puts each shadow-DOM component's
|
|
15
|
+
// server-rendered template into the page as a declarative shadow root, and gives
|
|
16
|
+
// form-associated ones a hidden input, so the page is complete before JavaScript.
|
|
17
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, extname, join, relative, resolve, sep } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
21
|
+
import * as nodeStream from 'node:stream';
|
|
22
|
+
const { Transform } = nodeStream;
|
|
23
|
+
import { findCompiler } from './compiler.js';
|
|
24
|
+
|
|
25
|
+
export { findCompiler };
|
|
26
|
+
|
|
27
|
+
findCompiler(); // (points @zubra/compiler at a native CLI when its WebAssembly build is missing)
|
|
28
|
+
const compiler = await import('@zubra/compiler');
|
|
29
|
+
|
|
30
|
+
const SOURCE = /\.zb(?:\.[jt]sx)?$/;
|
|
31
|
+
const posix = (path) => path.split(sep).join('/');
|
|
32
|
+
const escapeAttribute = (text) => String(text).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
33
|
+
const TYPES = { '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json' };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `components`: the folder of `.zb` (and `.zb.jsx`/`.zb.tsx`) files. `prefix`: the URL path
|
|
37
|
+
* the browser modules are served under. `cache`: keep compiled output (turn it off in
|
|
38
|
+
* development to pick up edits). `production`: serve the runtime's production build, without hot
|
|
39
|
+
* reloading (by default when `NODE_ENV` is `production`).
|
|
40
|
+
*/
|
|
41
|
+
export function createZubra({ components = 'components', prefix = '/_zubra', cache = true, production = process.env.NODE_ENV === 'production' } = {}) {
|
|
42
|
+
const root = resolve(components);
|
|
43
|
+
if (!existsSync(root)) throw new Error(`@zubra/server: no components folder at ${root}`);
|
|
44
|
+
const sourceDir = dirname(fileURLToPath(import.meta.resolve('@zubra/runtime')));
|
|
45
|
+
const productionDir = join(sourceDir, '..', 'dist', 'production');
|
|
46
|
+
const runtimeDir = production && existsSync(productionDir) ? productionDir : sourceDir;
|
|
47
|
+
const base = prefix.replace(/\/$/, '');
|
|
48
|
+
|
|
49
|
+
// name (the path without its extension, "/"-separated) → file
|
|
50
|
+
const files = new Map();
|
|
51
|
+
(function walk(dir) {
|
|
52
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
53
|
+
const path = join(dir, entry);
|
|
54
|
+
if (statSync(path).isDirectory()) {
|
|
55
|
+
if (entry !== 'node_modules' && !entry.startsWith('.')) walk(path);
|
|
56
|
+
} else if (SOURCE.test(entry)) {
|
|
57
|
+
files.set(posix(relative(root, path)).replace(SOURCE, ''), path);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
})(root);
|
|
61
|
+
|
|
62
|
+
const memo = new Map();
|
|
63
|
+
const once = (key, make) => {
|
|
64
|
+
if (!cache || !memo.has(key)) memo.set(key, make());
|
|
65
|
+
return memo.get(key);
|
|
66
|
+
};
|
|
67
|
+
const need = () => compiler;
|
|
68
|
+
const pathOf = (name) => {
|
|
69
|
+
const path = files.get(name) ?? files.get(byTag().get(name));
|
|
70
|
+
if (!path) throw new Error(`@zubra/server: no component "${name}" in ${root} (have: ${[...files.keys()].join(', ')})`);
|
|
71
|
+
return path;
|
|
72
|
+
};
|
|
73
|
+
const sourcesOf = (path) => once(`sources:${path}`, () => need().sources(path));
|
|
74
|
+
const call = (path, body) => {
|
|
75
|
+
const response = need().request({ path, files: sourcesOf(path), ...body });
|
|
76
|
+
if (!response.ok) throw new Error(response.error);
|
|
77
|
+
return response.output;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Each component's interface: `{ tag, shadow, form, props: [{ name, attribute, type, default, model }] }`. */
|
|
81
|
+
const describe = (name) => once(`interface:${name}`, () => JSON.parse(call(pathOf(name), { emit: 'interface' })));
|
|
82
|
+
const byTag = () => once('tags', () => new Map([...files.keys()].map((name) => [describe(name).tag, name])));
|
|
83
|
+
const nameOf = (nameOrTag) => (files.has(nameOrTag) ? nameOrTag : byTag().get(nameOrTag));
|
|
84
|
+
|
|
85
|
+
const render = (name, props = {}, slots = {}, pending = []) =>
|
|
86
|
+
call(pathOf(name), { emit: 'html', props, slots, slotSource: 'html', partial: true, ...(pending.length ? { pending } : {}) });
|
|
87
|
+
const css = (name) => once(`css:${name}`, () => call(pathOf(name), { emit: 'css' }));
|
|
88
|
+
const js = (name) => once(`js:${name}`, () => {
|
|
89
|
+
const response = need().compileFile(pathOf(name), { emit: 'js', runtime: '@zubra/runtime' });
|
|
90
|
+
if (!response.ok) throw new Error(response.error);
|
|
91
|
+
return response.output;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
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` } })}</script>`;
|
|
95
|
+
const head = (names) => {
|
|
96
|
+
const styles = names.map(css).join('\n');
|
|
97
|
+
return `${styles ? `<style data-zb-components>${styles}</style>\n` : ''}${importMap()}`;
|
|
98
|
+
};
|
|
99
|
+
const scripts = (names, async = false) => (names.length ? `<script type="module"${async ? ' async' : ''}>${names.map((name) => `import '${base}/components/${name}.js';`).join(' ')}</script>` : '');
|
|
100
|
+
|
|
101
|
+
/** What a request for `pathname` gets: `{ status, type, body }`, or null if it isn't ours. */
|
|
102
|
+
function serve(pathname) {
|
|
103
|
+
if (!pathname.startsWith(`${base}/`)) return null;
|
|
104
|
+
const rest = decodeURIComponent(pathname.slice(base.length + 1));
|
|
105
|
+
if (rest.startsWith('components/') && rest.endsWith('.js')) {
|
|
106
|
+
const name = rest.slice('components/'.length, -3);
|
|
107
|
+
if (!files.has(name)) return { status: 404, type: 'text/plain', body: 'Not found' };
|
|
108
|
+
return { status: 200, type: TYPES['.js'], body: js(name) };
|
|
109
|
+
}
|
|
110
|
+
if (rest.startsWith('runtime/')) {
|
|
111
|
+
const file = resolve(runtimeDir, rest.slice('runtime/'.length));
|
|
112
|
+
if (!file.startsWith(runtimeDir + sep) || !existsSync(file) || statSync(file).isDirectory()) return { status: 404, type: 'text/plain', body: 'Not found' };
|
|
113
|
+
return { status: 200, type: TYPES[extname(file)] ?? 'application/octet-stream', body: readFileSync(file) };
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const cacheControl = cache ? 'public, max-age=3600' : 'no-cache';
|
|
118
|
+
|
|
119
|
+
/** Node's http (and Express, Koa, Fastify's raw request): true when it answered. */
|
|
120
|
+
function handle(req, res) {
|
|
121
|
+
const served = serve(new URL(req.url, 'http://x').pathname);
|
|
122
|
+
if (!served) return false;
|
|
123
|
+
res.writeHead(served.status, { 'content-type': served.type, 'cache-control': cacheControl });
|
|
124
|
+
res.end(served.body);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Fetch-style servers: a Response for our paths, or null. */
|
|
129
|
+
async function fetchHandler(request) {
|
|
130
|
+
const served = serve(new URL(request.url).pathname);
|
|
131
|
+
if (!served) return null;
|
|
132
|
+
return new Response(served.body, { status: served.status, headers: { 'content-type': served.type, 'cache-control': cacheControl } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One page: render components into it, then `finish()` it (or use `head()` and `scripts()`). */
|
|
136
|
+
function page() {
|
|
137
|
+
const used = [];
|
|
138
|
+
const use = (name) => {
|
|
139
|
+
if (!used.includes(name)) used.push(name);
|
|
140
|
+
};
|
|
141
|
+
// Streaming (3.3): a prop given as a promise renders as pending (its `zb-await` shows its
|
|
142
|
+
// pending content, in an empty region), and `stream()` sends the resolved region after the
|
|
143
|
+
// page, with the value, for the element to hydrate.
|
|
144
|
+
const pendingRenders = [];
|
|
145
|
+
let streamed = 0;
|
|
146
|
+
return {
|
|
147
|
+
used,
|
|
148
|
+
render(name, props = {}, slots) {
|
|
149
|
+
const key = nameOf(name) ?? name;
|
|
150
|
+
use(key);
|
|
151
|
+
const promises = Object.entries(props).filter(([, value]) => value && typeof value.then === 'function');
|
|
152
|
+
if (!promises.length) return render(key, props, slots);
|
|
153
|
+
const given = Object.fromEntries(Object.entries(props).filter(([, value]) => !(value && typeof value.then === 'function')));
|
|
154
|
+
const names = promises.map(([name]) => name);
|
|
155
|
+
const id = String(streamed++);
|
|
156
|
+
const shell = withStreamId(render(key, given, slots, names), id);
|
|
157
|
+
const entry = { key, id, given, slots, promises: new Map(promises), shell, count: 0 };
|
|
158
|
+
// Each pending region is a named range (`<?start name>`…`<?end>`) that a streamed
|
|
159
|
+
// `<template for>` replaces (out-of-order streaming).
|
|
160
|
+
entry.names = pendingRegions(shell).map(() => `zb-${id}-${entry.count++}`);
|
|
161
|
+
pendingRenders.push(entry);
|
|
162
|
+
// Until the values arrive, the element (should it start first) holds a promise for
|
|
163
|
+
// each, set before it upgrades, so its `zb-await` shows the pending content.
|
|
164
|
+
return `${named(shell, entry.names)}<script>(${holdInBrowser.toString()})(${JSON.stringify(id)},${JSON.stringify(names)})</script>`;
|
|
165
|
+
},
|
|
166
|
+
/** Whether a rendered component is waiting on a promise prop (then `stream()`, not `finish()`). */
|
|
167
|
+
get pending() {
|
|
168
|
+
return pendingRenders.length > 0;
|
|
169
|
+
},
|
|
170
|
+
/**
|
|
171
|
+
* The page as a stream: `finish(html)` first, then, as each promise prop settles, the
|
|
172
|
+
* resolved regions, each in a `<template for>` (which the browser puts in place itself
|
|
173
|
+
* where it streams out of order), and a short inline script that hands the element the
|
|
174
|
+
* value, and puts the regions in place where the browser didn't.
|
|
175
|
+
* `stream(html)` is a Node Readable; `streamResponse(html)` a web `Response` body.
|
|
176
|
+
*/
|
|
177
|
+
stream(html) {
|
|
178
|
+
const { Readable } = nodeStream;
|
|
179
|
+
return Readable.from(chunks(html));
|
|
180
|
+
},
|
|
181
|
+
streamResponse(html, init = {}) {
|
|
182
|
+
const { Readable } = nodeStream;
|
|
183
|
+
const body = Readable.toWeb(Readable.from(chunks(html)));
|
|
184
|
+
return new Response(body, { ...init, headers: { 'content-type': 'text/html; charset=utf-8', ...init.headers } });
|
|
185
|
+
},
|
|
186
|
+
/** The components' CSS and the import map, for the page's `<head>`. */
|
|
187
|
+
head: () => head(used),
|
|
188
|
+
/** The module script that makes the components interactive, for the end of `<body>`. */
|
|
189
|
+
scripts: () => scripts(used),
|
|
190
|
+
/** A whole page with the head and scripts put in (before `</head>` and `</body>`). */
|
|
191
|
+
finish(html) {
|
|
192
|
+
return insertBefore(insertBefore(html, '</head>', head(used)), '</body>', scripts(used));
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
async function* chunks(html) {
|
|
197
|
+
// The shell: as the ordinary page, but with the module script `async`, so the elements
|
|
198
|
+
// start as soon as it has loaded instead of when the stream ends (a module script is
|
|
199
|
+
// deferred until the document is complete). A region that arrives after an element has
|
|
200
|
+
// started reaches it as a property change.
|
|
201
|
+
yield insertBefore(insertBefore(html, '</head>', head(used)), '</body>', scripts(used, true));
|
|
202
|
+
const waiting = pendingRenders.splice(0).flatMap((entry) =>
|
|
203
|
+
[...entry.promises].map(([name, promise]) => promise.then(
|
|
204
|
+
(value) => ({ entry, name, value }),
|
|
205
|
+
(error) => ({ entry, name, error: error instanceof Error ? error.message : String(error) }),
|
|
206
|
+
)));
|
|
207
|
+
// In the order they settle.
|
|
208
|
+
const open = new Set(waiting.map((promise) => {
|
|
209
|
+
const tagged = promise.then((result) => ({ result, tagged }));
|
|
210
|
+
return tagged;
|
|
211
|
+
}));
|
|
212
|
+
while (open.size) {
|
|
213
|
+
const { result, tagged } = await Promise.race(open);
|
|
214
|
+
open.delete(tagged);
|
|
215
|
+
const { entry, name, value, error } = result;
|
|
216
|
+
if (error !== undefined) {
|
|
217
|
+
yield swapScript(entry.id, null, { [name]: { error } });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
entry.given[name] = value;
|
|
221
|
+
entry.promises.delete(name);
|
|
222
|
+
const resolved = withStreamId(render(entry.key, entry.given, entry.slots, [...entry.promises.keys()]), entry.id);
|
|
223
|
+
const swaps = new Map(regionSwaps(entry.shell, resolved).map((swap) => [swap.index, swap.html]));
|
|
224
|
+
entry.shell = resolved;
|
|
225
|
+
// The regions still pending keep their names; one that resolved is replaced, along with
|
|
226
|
+
// its name, by the pending regions inside it.
|
|
227
|
+
const regions = [];
|
|
228
|
+
entry.names = entry.names.flatMap((region, index) => {
|
|
229
|
+
if (!swaps.has(index)) return [region];
|
|
230
|
+
const html = swaps.get(index);
|
|
231
|
+
const inner = pendingRegions(html).map(() => `zb-${entry.id}-${entry.count++}`);
|
|
232
|
+
regions.push({ name: region, html: named(html, inner) });
|
|
233
|
+
return inner;
|
|
234
|
+
});
|
|
235
|
+
yield swapScript(entry.id, regions, { [name]: { value } });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Shadow roots for a framework's HTML: each shadow-DOM component's host gets its
|
|
242
|
+
* server-rendered template as a declarative shadow root (read from the host's `zb-props`
|
|
243
|
+
* JSON, or its attributes), so it shows before JavaScript; the browser attaches it while
|
|
244
|
+
* parsing, before any framework hydrates. Form-associated components with a `name` also get a
|
|
245
|
+
* hidden input with their value, so the form submits without JavaScript (the element removes
|
|
246
|
+
* it when it starts). Light-DOM components are left alone unless `lightDom: true` (3.3): then
|
|
247
|
+
* an empty light-DOM host gets its rendered children, for frameworks that leave nodes they
|
|
248
|
+
* didn't render alone while hydrating (Solid; React and Vue through their wrappers do this
|
|
249
|
+
* themselves). Not for Svelte, which claims every node.
|
|
250
|
+
*/
|
|
251
|
+
/** The components by tag (shadow-DOM ones, or all with `lightDom`), and a pattern for their hosts' start tags. */
|
|
252
|
+
function shadowHosts(lightDom = false) {
|
|
253
|
+
const shadowTags = new Map();
|
|
254
|
+
for (const name of files.keys()) {
|
|
255
|
+
const info = describe(name);
|
|
256
|
+
if (info.shadow || lightDom) shadowTags.set(info.tag, { name, info });
|
|
257
|
+
}
|
|
258
|
+
const pattern = shadowTags.size ? new RegExp(`<(${[...shadowTags.keys()].map((tag) => tag.replace(/[-]/g, '\\-')).join('|')})(\\s[^>]*)?>`, 'g') : null;
|
|
259
|
+
return { shadowTags, pattern };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function injectShadowRoots(html, { forms = true, lightDom = false } = {}) {
|
|
263
|
+
const { shadowTags, pattern } = shadowHosts(lightDom);
|
|
264
|
+
if (!pattern) return html;
|
|
265
|
+
let out = '';
|
|
266
|
+
let last = 0;
|
|
267
|
+
for (const match of html.matchAll(pattern)) {
|
|
268
|
+
const end = match.index + match[0].length;
|
|
269
|
+
const { name, info } = shadowTags.get(match[1]);
|
|
270
|
+
// Already has one (rendered by zubra, or a second pass); a light-DOM host must be empty.
|
|
271
|
+
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;
|
|
272
|
+
const props = propsFrom(match[2] ?? '', info);
|
|
273
|
+
let rendered;
|
|
274
|
+
try {
|
|
275
|
+
rendered = render(name, props);
|
|
276
|
+
} catch {
|
|
277
|
+
continue; // leave it to the browser rather than break the page
|
|
278
|
+
}
|
|
279
|
+
const template = info.shadow ? shadowTemplate(rendered) : lightContent(rendered, match[1]);
|
|
280
|
+
if (!template) continue;
|
|
281
|
+
out += html.slice(last, end) + template + (forms ? fallbackInput(match[2] ?? '', info, props) : '');
|
|
282
|
+
last = end;
|
|
283
|
+
}
|
|
284
|
+
return out + html.slice(last);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* For static sites: write every component's browser module and @zubra/runtime under `dir`, at
|
|
289
|
+
* the paths the pages import (`<dir>/_zubra/components/…`, `<dir>/_zubra/runtime/…`).
|
|
290
|
+
*/
|
|
291
|
+
function writeAssets(dir) {
|
|
292
|
+
const out = join(resolve(dir), ...base.split('/').filter(Boolean));
|
|
293
|
+
for (const name of files.keys()) {
|
|
294
|
+
const file = join(out, 'components', `${name}.js`);
|
|
295
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
296
|
+
writeFileSync(file, js(name));
|
|
297
|
+
}
|
|
298
|
+
cpSync(runtimeDir, join(out, 'runtime'), { recursive: true });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `injectShadowRoots` for a streamed page (React's renderToPipeableStream, any Node stream of
|
|
303
|
+
* HTML): a Transform that passes the page through as it comes, holding back only an unfinished
|
|
304
|
+
* tag at the end of a chunk until the next one completes it.
|
|
305
|
+
*/
|
|
306
|
+
function injectShadowRootsStream(options) {
|
|
307
|
+
const decoder = new StringDecoder('utf8');
|
|
308
|
+
let carry = '';
|
|
309
|
+
const pass = (text, flush) => {
|
|
310
|
+
const all = carry + text;
|
|
311
|
+
// Hold back from an unclosed `<` (a tag cut in two), and from an opened host tag whose
|
|
312
|
+
// next characters might start its <template> (to not inject twice): flushing at '>'.
|
|
313
|
+
const open = flush ? -1 : all.lastIndexOf('<');
|
|
314
|
+
let cut = open > all.lastIndexOf('>') ? open : all.length;
|
|
315
|
+
// A host's start tag near the end: what follows may be its own <template> (a React
|
|
316
|
+
// wrapper renders it), so wait for more before deciding.
|
|
317
|
+
const { pattern } = shadowHosts(options?.lightDom);
|
|
318
|
+
if (!flush && pattern) {
|
|
319
|
+
let last = null;
|
|
320
|
+
for (const match of all.slice(0, cut).matchAll(pattern)) last = match;
|
|
321
|
+
if (last && cut - (last.index + last[0].length) < 64) cut = last.index;
|
|
322
|
+
}
|
|
323
|
+
carry = all.slice(cut);
|
|
324
|
+
return injectShadowRoots(all.slice(0, cut), options);
|
|
325
|
+
};
|
|
326
|
+
return new Transform({
|
|
327
|
+
transform(chunk, _encoding, callback) {
|
|
328
|
+
callback(null, pass(decoder.write(chunk), false));
|
|
329
|
+
},
|
|
330
|
+
flush(callback) {
|
|
331
|
+
callback(null, pass(decoder.end(), true));
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
writeAssets,
|
|
338
|
+
injectShadowRootsStream,
|
|
339
|
+
module: (name) => js(nameOf(name) ?? name),
|
|
340
|
+
names: [...files.keys()],
|
|
341
|
+
describe: (name) => describe(nameOf(name) ?? name),
|
|
342
|
+
render: (name, props, slots) => render(nameOf(name) ?? name, props, slots),
|
|
343
|
+
page,
|
|
344
|
+
handle,
|
|
345
|
+
fetch: fetchHandler,
|
|
346
|
+
importMap,
|
|
347
|
+
head: (names = [...files.keys()]) => head(names),
|
|
348
|
+
scripts: (names = [...files.keys()]) => scripts(names),
|
|
349
|
+
injectShadowRoots,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function insertBefore(html, marker, text) {
|
|
354
|
+
if (!text) return html;
|
|
355
|
+
const at = html.lastIndexOf(marker);
|
|
356
|
+
return at < 0 ? html + text : html.slice(0, at) + text + html.slice(at);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- reading a host tag ---------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", '#34': '"' };
|
|
362
|
+
const decode = (text) => text.replace(/&(#\d+|#x[0-9a-f]+|[a-z]+);/gi, (whole, name) => {
|
|
363
|
+
if (ENTITIES[name.toLowerCase()]) return ENTITIES[name.toLowerCase()];
|
|
364
|
+
if (name[0] === '#') return String.fromCodePoint(name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : Number(name.slice(1)));
|
|
365
|
+
return whole;
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
/** An opening tag's attributes: `{ name: value }` (a bare attribute is `""`). */
|
|
369
|
+
export function parseAttributes(text) {
|
|
370
|
+
const attributes = {};
|
|
371
|
+
const pattern = /([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
372
|
+
for (const match of text.matchAll(pattern)) {
|
|
373
|
+
attributes[match[1].toLowerCase()] = decode(match[2] ?? match[3] ?? match[4] ?? '');
|
|
374
|
+
}
|
|
375
|
+
return attributes;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The props a host tag carries: `zb-props` JSON first, then each prop's attribute by its type. */
|
|
379
|
+
export function propsFrom(attributeText, info) {
|
|
380
|
+
const attributes = parseAttributes(attributeText);
|
|
381
|
+
let props = {};
|
|
382
|
+
if (attributes['zb-props']) {
|
|
383
|
+
try {
|
|
384
|
+
props = JSON.parse(attributes['zb-props']);
|
|
385
|
+
} catch {}
|
|
386
|
+
}
|
|
387
|
+
for (const prop of info.props) {
|
|
388
|
+
if (prop.name in props || !(prop.attribute in attributes)) continue;
|
|
389
|
+
const raw = attributes[prop.attribute];
|
|
390
|
+
const kind = Array.isArray(prop.default) || (prop.default && typeof prop.default === 'object') ? 'json' : prop.type;
|
|
391
|
+
if (kind === 'number') {
|
|
392
|
+
const number = Number(raw);
|
|
393
|
+
if (raw.trim() !== '' && Number.isFinite(number)) props[prop.name] = number;
|
|
394
|
+
} else if (kind === 'boolean') {
|
|
395
|
+
props[prop.name] = raw !== 'false';
|
|
396
|
+
} else if (kind === 'json' || /\[\]$|^\{/.test(prop.type)) {
|
|
397
|
+
if (raw === '[object Object]') continue; // Svelte's SSR: the real value comes later
|
|
398
|
+
try {
|
|
399
|
+
props[prop.name] = JSON.parse(raw);
|
|
400
|
+
} catch {}
|
|
401
|
+
} else {
|
|
402
|
+
props[prop.name] = raw;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return props;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** The `<template shadowrootmode>…</template>` at the start of a rendered host's content. */
|
|
409
|
+
function shadowTemplate(rendered) {
|
|
410
|
+
const start = rendered.search(/<template\s[^>]*shadowrootmode/i);
|
|
411
|
+
if (start < 0) return null;
|
|
412
|
+
const tags = /<template\b[^>]*>|<\/template>/gi;
|
|
413
|
+
tags.lastIndex = start;
|
|
414
|
+
let depth = 0;
|
|
415
|
+
for (const match of rendered.matchAll(tags)) {
|
|
416
|
+
if (match.index < start) continue;
|
|
417
|
+
depth += match[0][1] === '/' ? -1 : 1;
|
|
418
|
+
if (depth === 0) return rendered.slice(start, match.index + match[0].length);
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** The host's start tag with `data-zb-stream="<id>"`, so the stream's scripts can find it. */
|
|
424
|
+
function withStreamId(html, id) {
|
|
425
|
+
return html.replace(/^(\s*<[a-z][\w.-]*)/, `$1 data-zb-stream="${id}"`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The pending regions of `shell` that `resolved` has rendered: `[{ index, html }]`, where
|
|
430
|
+
* `index` counts the shell's top-level `<?zb [??>` regions in order and `html` is the resolved
|
|
431
|
+
* region (markers included). The two renders differ only inside those regions (and the host's
|
|
432
|
+
* start tag, which is compared past).
|
|
433
|
+
*/
|
|
434
|
+
function regionSwaps(shell, resolved) {
|
|
435
|
+
const skipTag = (html) => html.match(/^\s*<[a-z][\w.-]*(?:\s[^>]*)?>/)?.[0].length ?? 0;
|
|
436
|
+
let a = skipTag(shell);
|
|
437
|
+
let b = skipTag(resolved);
|
|
438
|
+
const swaps = [];
|
|
439
|
+
let index = 0;
|
|
440
|
+
const PENDING = '<?zb [??>';
|
|
441
|
+
const regionEnd = (html, start) => {
|
|
442
|
+
// From the region's opening marker to its matching `<?zb ]?>`.
|
|
443
|
+
let depth = 0;
|
|
444
|
+
for (const match of html.slice(start).matchAll(/<\?zb \[[?!]?\?>|<\?zb \]\?>/g)) {
|
|
445
|
+
depth += match[0] === '<?zb ]?>' ? -1 : 1;
|
|
446
|
+
if (depth === 0) return start + match.index + match[0].length;
|
|
447
|
+
}
|
|
448
|
+
return html.length;
|
|
449
|
+
};
|
|
450
|
+
while (a < shell.length && b < resolved.length) {
|
|
451
|
+
if (shell.startsWith(PENDING, a)) {
|
|
452
|
+
const endA = regionEnd(shell, a);
|
|
453
|
+
const endB = regionEnd(resolved, b);
|
|
454
|
+
const region = resolved.slice(b, endB);
|
|
455
|
+
if (!region.startsWith(PENDING)) swaps.push({ index, html: region });
|
|
456
|
+
index++;
|
|
457
|
+
a = endA;
|
|
458
|
+
b = endB;
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (shell[a] !== resolved[b]) break; // not a difference this function understands
|
|
462
|
+
a++;
|
|
463
|
+
b++;
|
|
464
|
+
}
|
|
465
|
+
return swaps;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The top-level pending regions of `html` (`<?zb [??>` to its `<?zb ]?>`, not inside another
|
|
470
|
+
* pending region), as `[start, end]` offsets: the ones `regionSwaps` counts.
|
|
471
|
+
*/
|
|
472
|
+
function pendingRegions(html) {
|
|
473
|
+
const found = [];
|
|
474
|
+
let depth = 0;
|
|
475
|
+
let start = -1;
|
|
476
|
+
let outer = 0;
|
|
477
|
+
for (const match of html.matchAll(/<\?zb (\[[?!]?|\])\?>/g)) {
|
|
478
|
+
if (match[1] === ']') {
|
|
479
|
+
depth--;
|
|
480
|
+
if (start >= 0 && depth === outer) {
|
|
481
|
+
found.push([start, match.index + match[0].length]);
|
|
482
|
+
start = -1;
|
|
483
|
+
}
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (match[1] === '[?' && start < 0) {
|
|
487
|
+
start = match.index;
|
|
488
|
+
outer = depth;
|
|
489
|
+
}
|
|
490
|
+
depth++;
|
|
491
|
+
}
|
|
492
|
+
return found;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** `html` with its top-level pending regions in named ranges, for `<template for>`. */
|
|
496
|
+
function named(html, names) {
|
|
497
|
+
let out = html;
|
|
498
|
+
pendingRegions(html).reverse().forEach(([start, end], i, all) => {
|
|
499
|
+
const name = names[all.length - 1 - i];
|
|
500
|
+
out = `${out.slice(0, start)}<?start name="${name}"?>${out.slice(start, end)}<?end?>${out.slice(end)}`;
|
|
501
|
+
});
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The chunk for one settled promise: each resolved region in a `<template for>`, and the script. */
|
|
506
|
+
function swapScript(id, regions, values) {
|
|
507
|
+
const templates = (regions ?? []).map((region) => `<template for="${region.name}">${region.html}</template>`).join('');
|
|
508
|
+
const script = `<script>(${swapInBrowser.toString()})(${JSON.stringify(id)},${JSON.stringify(values)})</script>`;
|
|
509
|
+
return `${templates}${script}\n`;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Runs in the browser, inlined, right after a streamed host: a promise per pending prop, as a
|
|
513
|
+
// property the element captures when it upgrades, resolved by the swap script later.
|
|
514
|
+
function holdInBrowser(id, names) {
|
|
515
|
+
var host = document.querySelector('[data-zb-stream="' + id + '"]');
|
|
516
|
+
var held = (window.__zubraStream = window.__zubraStream || {});
|
|
517
|
+
for (var i = 0; i < names.length; i++) {
|
|
518
|
+
(function (name) {
|
|
519
|
+
host[name] = new Promise(function (resolve, reject) { held[id + ':' + name] = { resolve: resolve, reject: reject }; });
|
|
520
|
+
})(names[i]);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Runs in the browser, inlined: hand the element the values (as properties, captured when it
|
|
525
|
+
// upgrades), and, before it starts, put the resolved regions in place where the browser didn't:
|
|
526
|
+
// browsers without out-of-order streaming leave each `<template for>` as an element, and those
|
|
527
|
+
// with it don't reach into a shadow root. Once the element has started it renders the regions
|
|
528
|
+
// itself (it removed their names, so the browser has nothing to replace).
|
|
529
|
+
function swapInBrowser(id, values) {
|
|
530
|
+
var host = document.querySelector('[data-zb-stream="' + id + '"]');
|
|
531
|
+
var script = document.currentScript;
|
|
532
|
+
var templates = [];
|
|
533
|
+
for (var node = script && script.previousElementSibling; node && node.tagName === 'TEMPLATE' && node.hasAttribute('for'); node = node.previousElementSibling) templates.push(node);
|
|
534
|
+
// A `<?start name>` or `<?end>`: a processing instruction, or the comment it parses as.
|
|
535
|
+
var kind = function (node) {
|
|
536
|
+
return node.nodeType === 7 ? node.target : node.nodeType === 8 ? (/^\?(start|end)\b/.exec(node.data) || [])[1] : null;
|
|
537
|
+
};
|
|
538
|
+
var started = host && host.matches(':state(hydrated), [data-hydrated]');
|
|
539
|
+
for (var i = 0; i < templates.length; i++) {
|
|
540
|
+
var template = templates[i];
|
|
541
|
+
template.remove();
|
|
542
|
+
if (!host || started) continue;
|
|
543
|
+
var name = 'name="' + template.getAttribute('for') + '"';
|
|
544
|
+
var start = null;
|
|
545
|
+
var roots = host.shadowRoot ? [host.shadowRoot, host] : [host];
|
|
546
|
+
for (var r = 0; r < roots.length && !start; r++) {
|
|
547
|
+
var walker = document.createTreeWalker(roots[r], NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_PROCESSING_INSTRUCTION);
|
|
548
|
+
for (var at = walker.nextNode(); at; at = walker.nextNode()) {
|
|
549
|
+
if (kind(at) === 'start' && at.data.replace(/^\?start |\?$/g, '') === name) { start = at; break; }
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
var end = start && start.nextSibling;
|
|
553
|
+
for (var depth = 0; end; end = end.nextSibling) {
|
|
554
|
+
if (kind(end) === 'start') depth++;
|
|
555
|
+
else if (kind(end) === 'end' && depth-- === 0) break;
|
|
556
|
+
}
|
|
557
|
+
if (!end) continue;
|
|
558
|
+
var range = document.createRange();
|
|
559
|
+
range.setStartBefore(start);
|
|
560
|
+
range.setEndAfter(end);
|
|
561
|
+
range.deleteContents();
|
|
562
|
+
range.insertNode(template.content);
|
|
563
|
+
}
|
|
564
|
+
var held = window.__zubraStream || {};
|
|
565
|
+
for (var name in values) {
|
|
566
|
+
var hold = held[id + ':' + name];
|
|
567
|
+
if (values[name].error !== undefined) {
|
|
568
|
+
var rejected = Promise.reject(new Error(values[name].error));
|
|
569
|
+
rejected.catch(function () {});
|
|
570
|
+
if (hold) hold.reject(new Error(values[name].error));
|
|
571
|
+
else host[name] = rejected;
|
|
572
|
+
} else {
|
|
573
|
+
if (hold) hold.resolve(values[name].value);
|
|
574
|
+
host[name] = values[name].value;
|
|
575
|
+
}
|
|
576
|
+
delete held[id + ':' + name];
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** A rendered light-DOM host's children: between its start tag and its end tag. */
|
|
581
|
+
function lightContent(rendered, tag) {
|
|
582
|
+
const open = rendered.match(/^\s*<[a-z][\w.-]*(?:\s[^>]*)?>/);
|
|
583
|
+
const close = rendered.lastIndexOf(`</${tag}>`);
|
|
584
|
+
if (!open || close < open[0].length) return null;
|
|
585
|
+
return rendered.slice(open[0].length, close);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** A form-associated component's value, as the form would submit it, in a hidden input. */
|
|
589
|
+
function fallbackInput(attributeText, info, props) {
|
|
590
|
+
if (!info.form) return '';
|
|
591
|
+
const name = parseAttributes(attributeText).name;
|
|
592
|
+
const models = info.props.filter((prop) => prop.model);
|
|
593
|
+
if (!name || models.length !== 1) return '';
|
|
594
|
+
const value = models[0].name in props ? props[models[0].name] : models[0].default;
|
|
595
|
+
if (value == null || value === false || value === '') return '';
|
|
596
|
+
const text = value === true ? 'on' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
597
|
+
return `<input type="hidden" name="${escapeAttribute(name)}" value="${escapeAttribute(text)}" data-zb-fallback>`;
|
|
598
|
+
}
|
package/README.md
DELETED