@zubra/server 5.4.0 → 5.5.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/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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
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,6 +40,8 @@ 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
46
  export function createZubra({ components = 'components', prefix = '/_zubra', cache = true, production = process.env.NODE_ENV === 'production', devtools = process.env.ZUBRA_DEVTOOLS === '1' || !production, imports = {} } = {}) {
47
47
  const root = resolve(components);
@@ -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
- const memo = new Map();
87
- const once = (key, make) => {
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,36 @@ 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
- const cacheControl = cache ? 'public, max-age=3600' : 'no-cache';
99
+ };
100
+
101
+ const core = createCore({
102
+ request: (body) => compiler.request(body),
103
+ files,
104
+ sourcesOf: (path) => compiler.sources(path),
105
+ cache,
106
+ devtools,
107
+ base,
108
+ imports,
109
+ syncImports: syncDir ? { '@zubra/sync': `${base}/sync/index.js`, '@zubra/sync/connect': `${base}/sync/connect.js` } : {},
110
+ needs,
111
+ where: root,
112
+ assets,
113
+ });
151
114
 
152
115
  /** Node's http (and Express, Koa, Fastify's raw request): true when it answered. */
153
116
  function handle(req, res) {
154
- const served = serve(new URL(req.url, 'http://x').pathname);
117
+ const served = core.serve(new URL(req.url, 'http://x').pathname);
155
118
  if (!served) return false;
156
- res.writeHead(served.status, { 'content-type': served.type, 'cache-control': cacheControl });
119
+ res.writeHead(served.status, { 'content-type': served.type, 'cache-control': core.cacheControl });
157
120
  res.end(served.body);
158
121
  return true;
159
122
  }
160
123
 
161
- /** Fetch-style servers: a Response for our paths, or null. */
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()`). */
124
+ /** One page, as in core.js, and `stream(html)`: the same chunks as a Node Readable. */
169
125
  function page() {
170
- const used = [];
171
- const use = (name) => {
172
- if (!used.includes(name)) used.push(name);
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);
126
+ const one = core.page();
127
+ one.stream = (html) => nodeStream.Readable.from(one.chunks(html));
128
+ return one;
318
129
  }
319
130
 
320
131
  /**
@@ -326,7 +137,7 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
326
137
  for (const name of files.keys()) {
327
138
  const file = join(out, 'components', `${name}.js`);
328
139
  mkdirSync(dirname(file), { recursive: true });
329
- writeFileSync(file, js(name));
140
+ writeFileSync(file, core.module(name));
330
141
  }
331
142
  cpSync(runtimeDir, join(out, 'runtime'), { recursive: true });
332
143
  if (syncDir) cpSync(syncDir, join(out, 'sync'), { recursive: true, filter: (from) => !from.endsWith('.d.ts') });
@@ -348,14 +159,14 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
348
159
  let cut = open > all.lastIndexOf('>') ? open : all.length;
349
160
  // A host's start tag near the end: what follows may be its own <template> (a React
350
161
  // wrapper renders it), so wait for more before deciding.
351
- const { pattern } = shadowHosts(options?.lightDom);
162
+ const { pattern } = core.shadowHosts(options?.lightDom);
352
163
  if (!flush && pattern) {
353
164
  let last = null;
354
165
  for (const match of all.slice(0, cut).matchAll(pattern)) last = match;
355
166
  if (last && cut - (last.index + last[0].length) < 64) cut = last.index;
356
167
  }
357
168
  carry = all.slice(cut);
358
- return injectShadowRoots(all.slice(0, cut), options);
169
+ return core.injectShadowRoots(all.slice(0, cut), options);
359
170
  };
360
171
  return new Transform({
361
172
  transform(chunk, _encoding, callback) {
@@ -370,263 +181,16 @@ export function createZubra({ components = 'components', prefix = '/_zubra', cac
370
181
  return {
371
182
  writeAssets,
372
183
  injectShadowRootsStream,
373
- module: (name) => js(nameOf(name) ?? name),
374
- names: [...files.keys()],
375
- describe: (name) => describe(nameOf(name) ?? name),
376
- render: (name, props, slots) => render(nameOf(name) ?? name, props, slots),
184
+ module: core.module,
185
+ names: core.names,
186
+ describe: core.describe,
187
+ render: core.render,
377
188
  page,
378
189
  handle,
379
- fetch: fetchHandler,
380
- importMap,
381
- head: (names = [...files.keys()]) => head(names),
382
- scripts: (names = [...files.keys()]) => scripts(names),
383
- injectShadowRoots,
190
+ fetch: core.fetch,
191
+ importMap: core.importMap,
192
+ head: core.head,
193
+ scripts: core.scripts,
194
+ injectShadowRoots: core.injectShadowRoots,
384
195
  };
385
196
  }
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
- }