@zubra/server 0.0.0-stage → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "5.0.2",
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
+ }
@@ -0,0 +1,3 @@
1
+ import type { Zubra, ZubraOptions } from './index.js';
2
+ /** Express/Connect middleware, Fastify plugin or Hono middleware (by entry point). */
3
+ export declare function zubra(options?: ZubraOptions): any & { zubra: Zubra };
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.