@zubra/runtime 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.
@@ -0,0 +1,12 @@
1
+ // Generated by scripts/build-production.js from src/index.js: the runtime without hot reloading. Do not edit.
2
+ // @zubra/runtime — everything compiled components and their scripts import.
3
+ export { Signal, isNative } from './signal.js';
4
+ export { effect, scope, flush, tick } from './effect.js';
5
+ export { resource } from './resource.js';
6
+ export {
7
+ define, $define, ABI, state, computed, watch, onMount, onUnmount, id, props, host, emit, adoptStyles,
8
+ createContext, provide, inject, ContextRequestEvent, ContextProviderEvent,
9
+ formValue, validity, onFormReset, onFormRestore, onFormDisabled,
10
+ prevent, stop, selfOnly, submit, keys, model, hasSlot, hydrated, signal, from, raw,
11
+ isSafeUrl, $r, $t,
12
+ } from './element.js';
@@ -0,0 +1,108 @@
1
+ // Generated by scripts/build-production.js from src/primitives.js: the runtime without hot reloading. Do not edit.
2
+ // @zubra/runtime/primitives: the two keyboard behaviours the platform doesn't have.
3
+ //
4
+ // The platform now does most of what headless libraries used to: <dialog> traps focus and closes
5
+ // on Escape, `popover` gives a top layer and light dismissal, anchor positioning places it, and
6
+ // <details name> makes an exclusive group. What's left for menus, tab lists, toolbars and listboxes
7
+ // is moving focus among their items from the keyboard. Both functions here work on any element,
8
+ // in any framework, and return a cleanup:
9
+ //
10
+ // onMount(() => rovingFocus(menu, { selector: '[role=menuitem]', orientation: 'vertical' }))
11
+ // onMount(() => typeahead(menu, { selector: '[role=menuitem]' }))
12
+
13
+ const usable = (element) => !element.matches(':disabled, [aria-disabled=true], [hidden]') && element.getClientRects().length > 0;
14
+
15
+ /**
16
+ * One tab stop for a group of items, with arrow keys (and Home and End) moving focus among them:
17
+ * the WAI-ARIA "roving tabindex". The current item has tabindex 0 and the rest -1; focusing an
18
+ * item (by a click, say) makes it current. Items are found again on each key, so a list that
19
+ * changes needs nothing.
20
+ *
21
+ * - `selector`: the items, inside `container`;
22
+ * - `orientation`: `vertical` (Up and Down), `horizontal` (Left and Right, flipped in RTL), or
23
+ * `both`;
24
+ * - `loop`: from the last item, the next is the first (and back);
25
+ * - `onMove(item)`: called after focus moves, for widgets whose selection follows focus (tabs).
26
+ */
27
+ export function rovingFocus(container, { selector, orientation = 'both', loop = true, onMove } = {}) {
28
+ if (!selector) throw new TypeError('rovingFocus: a `selector` for the items is needed');
29
+ const items = () => [...container.querySelectorAll(selector)].filter(usable);
30
+ const current = () => {
31
+ const all = items();
32
+ return all.find((item) => item.tabIndex === 0) ?? all.find((item) => item.matches('[aria-selected=true], [aria-checked=true], [aria-current]:not([aria-current=false])')) ?? all[0];
33
+ };
34
+ const make = (target) => {
35
+ for (const item of container.querySelectorAll(selector)) item.tabIndex = item === target ? 0 : -1;
36
+ };
37
+ make(current());
38
+ const keys = (event) => {
39
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
40
+ const all = items();
41
+ const at = all.indexOf(event.target.closest?.(selector));
42
+ if (at < 0) return;
43
+ const rtl = getComputedStyle(container).direction === 'rtl';
44
+ const next = { vertical: 'ArrowDown', horizontal: rtl ? 'ArrowLeft' : 'ArrowRight' };
45
+ const previous = { vertical: 'ArrowUp', horizontal: rtl ? 'ArrowRight' : 'ArrowLeft' };
46
+ const forward = orientation === 'both' ? [next.vertical, next.horizontal] : [next[orientation]];
47
+ const back = orientation === 'both' ? [previous.vertical, previous.horizontal] : [previous[orientation]];
48
+ let to;
49
+ if (forward.includes(event.key)) to = at + 1 < all.length ? at + 1 : loop ? 0 : at;
50
+ else if (back.includes(event.key)) to = at > 0 ? at - 1 : loop ? all.length - 1 : at;
51
+ else if (event.key === 'Home') to = 0;
52
+ else if (event.key === 'End') to = all.length - 1;
53
+ else return;
54
+ event.preventDefault();
55
+ make(all[to]);
56
+ all[to].focus();
57
+ onMove?.(all[to]);
58
+ };
59
+ const focused = (event) => {
60
+ const item = event.target.closest?.(selector);
61
+ if (item && container.contains(item) && item.tabIndex !== 0) make(item);
62
+ };
63
+ container.addEventListener('keydown', keys);
64
+ container.addEventListener('focusin', focused);
65
+ return () => {
66
+ container.removeEventListener('keydown', keys);
67
+ container.removeEventListener('focusin', focused);
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Type to jump: printable keys typed in quick succession are a prefix, and focus moves to the
73
+ * next item (after the focused one, wrapping) whose text starts with it, ignoring case. Repeating
74
+ * one letter cycles through the items that start with it.
75
+ *
76
+ * - `selector`: the items, inside `container`;
77
+ * - `text(item)`: an item's text (its `textContent`, trimmed, by default);
78
+ * - `timeout`: how long a pause ends the prefix, in milliseconds (500);
79
+ * - `onMatch(item)`: called with the item instead of focusing it (for `aria-activedescendant`).
80
+ */
81
+ export function typeahead(container, { selector, text = (item) => item.textContent.trim(), timeout = 500, onMatch } = {}) {
82
+ if (!selector) throw new TypeError('typeahead: a `selector` for the items is needed');
83
+ let typed = '';
84
+ let timer = null;
85
+ const keys = (event) => {
86
+ if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.key.length !== 1) return;
87
+ if (event.key === ' ' && !typed) return; // Space activates, unless it's part of a prefix
88
+ const all = [...container.querySelectorAll(selector)].filter(usable);
89
+ if (!all.length) return;
90
+ clearTimeout(timer);
91
+ timer = setTimeout(() => (typed = ''), timeout);
92
+ typed += event.key.toLowerCase();
93
+ // "bbb" is "b" again, cycling.
94
+ const prefix = typed.split('').every((letter) => letter === typed[0]) ? typed[0] : typed;
95
+ const at = all.indexOf(document.activeElement?.closest?.(selector));
96
+ const ordered = [...all.slice(at + 1), ...all.slice(0, at + 1)];
97
+ const match = ordered.find((item) => text(item).toLowerCase().startsWith(prefix));
98
+ if (!match) return;
99
+ event.preventDefault();
100
+ if (onMatch) onMatch(match);
101
+ else match.focus();
102
+ };
103
+ container.addEventListener('keydown', keys);
104
+ return () => {
105
+ clearTimeout(timer);
106
+ container.removeEventListener('keydown', keys);
107
+ };
108
+ }
@@ -0,0 +1,125 @@
1
+ // Generated by scripts/build-production.js from src/react.js: the runtime without hot reloading. Do not edit.
2
+ // React adapter: a zubra element as a React component, for React 18 and 19.
3
+ //
4
+ // import Rating from './rating.zb' // the build plugin wraps it for React files
5
+ // <Rating value={stars} onValueChange={(event) => setStars(event.detail)} />
6
+ //
7
+ // - Props are set as properties (objects and arrays arrive as values, not JSON), already when
8
+ // React 19 creates the element, so its script sees them. A prop that's left out, or was given
9
+ // and then removed, is the component's default.
10
+ // - `on<Event>` subscribes to the component's event of that name, in any casing: `valuechange`
11
+ // is `onValueChange`, `onValuechange` or `onvaluechange`. The handler gets the DOM event (the
12
+ // value is in `event.detail`) and can change between renders without re-subscribing.
13
+ // - Everything else (className, style, id, aria-*, native event handlers like onClick, ref) is
14
+ // passed to the element itself, and children go into its slots (`slot="name"` for named ones).
15
+ // - `value` + `onValueChange` is React's controlled pattern; any `<prop>` + `<prop>change` pair
16
+ // works the same way.
17
+ import * as React from 'react';
18
+ import { serverContent } from './server.js';
19
+
20
+ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? React.useEffect : React.useLayoutEffect;
21
+
22
+ const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
23
+
24
+ /** `zb-star-rating` → `ZbStarRating`. */
25
+ const pascal = (tag) => tag.split(/[-.]/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('');
26
+
27
+ // React 18 and older set custom elements' props as attributes under their own names, so
28
+ // `className` would become a `classname` attribute; React 19 maps it.
29
+ const legacy = Number.parseInt(React.version, 10) < 19;
30
+ const attributes = (rest) => {
31
+ if (!legacy) return rest;
32
+ const { className, htmlFor, ...others } = rest;
33
+ if (className !== undefined) others.class = className;
34
+ if (htmlFor !== undefined) others.for = htmlFor;
35
+ return others;
36
+ };
37
+
38
+ /** The given props as JSON for the host's `zb-props`, or null (none, or not serializable). */
39
+ export function propsJson(values) {
40
+ const given = Object.entries(values).filter(([, value]) => value !== undefined && typeof value !== 'function');
41
+ if (!given.length) return null;
42
+ try {
43
+ return JSON.stringify(Object.fromEntries(given));
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ export function wrap(Element) {
50
+ const meta = Element?.zubra;
51
+ if (!meta) throw new TypeError('zubra: wrap() needs a compiled zubra element (the default export of a .zb module)');
52
+ const { tag, props, events } = meta;
53
+
54
+ const Component = React.forwardRef(function ZubraComponent(all, forwarded) {
55
+ const element = React.useRef(null);
56
+ const values = {};
57
+ const listeners = {};
58
+ const rest = {};
59
+ for (const key of Object.keys(all)) {
60
+ if (key === 'children') continue;
61
+ const event = key.length > 2 && key.startsWith('on') ? key.slice(2).toLowerCase() : null;
62
+ if (Object.hasOwn(props, key)) values[key] = all[key];
63
+ else if (event && events.includes(event)) listeners[event] = all[key];
64
+ else rest[key] = all[key];
65
+ }
66
+
67
+ // Subscribe once per event; always call the latest handler.
68
+ const handlers = React.useRef(listeners);
69
+ handlers.current = listeners;
70
+ useIsomorphicLayoutEffect(() => {
71
+ const node = element.current;
72
+ const subscriptions = events.map((name) => {
73
+ const listener = (event) => handlers.current[name]?.(event);
74
+ node.addEventListener(name, listener);
75
+ return () => node.removeEventListener(name, listener);
76
+ });
77
+ return () => subscriptions.forEach((unsubscribe) => unsubscribe());
78
+ }, []);
79
+
80
+ // Props as properties, after every render; removed props go back to their defaults.
81
+ const given = React.useRef(new Set());
82
+ useIsomorphicLayoutEffect(() => {
83
+ const node = element.current;
84
+ for (const name of given.current) {
85
+ if (!Object.hasOwn(values, name)) node[name] = clone(props[name].default);
86
+ }
87
+ for (const [name, value] of Object.entries(values)) {
88
+ if (value === undefined) node[name] = clone(props[name].default);
89
+ else if (node[name] !== value) node[name] = value;
90
+ }
91
+ given.current = new Set(Object.keys(values));
92
+ });
93
+
94
+ const ref = React.useCallback((node) => {
95
+ element.current = node;
96
+ if (typeof forwarded === 'function') forwarded(node);
97
+ else if (forwarded) forwarded.current = node;
98
+ }, [forwarded]);
99
+
100
+ // React 19 also sets the props itself when it creates the element, before inserting it, so
101
+ // the component's script sees them when it runs (`let draft = card`). (React 18 would write
102
+ // them as attributes; there, and for later changes and removals, the effect above sets them.)
103
+ const early = legacy || typeof window === 'undefined' ? {} : values;
104
+ // The props as JSON, on the server and in the browser alike (so hydration matches): the
105
+ // element starts with them before React sets them, and @zubra/server's injectShadowRoots
106
+ // renders the element's shadow root from them.
107
+ const json = propsJson(values);
108
+ const host = { ...early, ...attributes(rest), ...(json ? { 'zb-props': json } : {}), ref };
109
+ // An element with no React children: on the server, its content (a shadow-DOM element's
110
+ // declarative shadow root, a light-DOM element's rendered children, and a form control's
111
+ // fallback input) as the host's inner HTML, so it shows before JavaScript. React doesn't
112
+ // hydrate inner HTML, so nothing disagrees, and the element hydrates its markup in place (a
113
+ // light-DOM element from 3.3; shadow since 2.11). In the browser it's an empty string, which
114
+ // React leaves alone while hydrating and never changes after. (Next.js has no HTML hook for
115
+ // @zubra/server's injectShadowRoots; this works without one.)
116
+ if (React.Children.count(all.children) === 0) {
117
+ const html = typeof window === 'undefined' ? serverContent(Element, { props: json ? JSON.parse(json) : {}, name: rest.name }) : '';
118
+ return React.createElement(tag, { ...host, dangerouslySetInnerHTML: { __html: html }, suppressHydrationWarning: true });
119
+ }
120
+ return React.createElement(tag, host, all.children);
121
+ });
122
+ Component.displayName = pascal(tag);
123
+ Component.element = Element;
124
+ return Component;
125
+ }
@@ -0,0 +1,173 @@
1
+ // Generated by scripts/build-production.js from src/remark.js: the runtime without hot reloading. Do not edit.
2
+ // Server markup whose markers were stripped: the element's runtime loads this module only when it
3
+ // meets such markup.
4
+ //
5
+ // A sanitizer or a fragment parser can remove the server markup's markers: the region markers
6
+ // (<?zb [?>, <?zb ]?>) and text anchors (<?zb ?>), or the comments a 4.x server wrote, which an HTML
7
+ // minifier removes too. Rather than render such an element afresh, `remark` walks the template
8
+ // against the markup by structure and puts the markers back (as comments), where that's unambiguous:
9
+ // - a text anchor's text runs up to the next element, so its place is always known;
10
+ // - an zb-if region holds the branch the current values choose, and an zb-each region one row
11
+ // per item, each with the shape of its block (checked element by element);
12
+ // - a light-DOM slot region is the rest of its range, when nothing follows it there; its
13
+ // content is the fallback if it has the fallback's shape, and supplied content otherwise.
14
+ // Anything else (an zb-await, content projected into a child component, a region the server
15
+ // left to the browser, other than one left empty) can't be placed for sure, and the element
16
+ // renders afresh as before.
17
+
18
+ const TEXT = 3;
19
+ const COMMENT = 8;
20
+
21
+ /** Put back the comments of server markup under `root` (see above). Whether it could. */
22
+ export function remark(root, template, parts, { blueprint, isOpen, isClose, read: $r, untrack, State }) {
23
+ const edits = [];
24
+ const fail = { failed: true };
25
+ const mapOf = (list) => {
26
+ const map = new Map();
27
+ for (const part of list) {
28
+ if (part.hydrate) throw fail; // content projected into a child component
29
+ map.set(part.path.join(','), part);
30
+ }
31
+ return map;
32
+ };
33
+ const insert = (parent, before, data) => edits.push({ parent, before, data });
34
+ const skipText = (node) => {
35
+ while (node && node.nodeType === TEXT) node = node.nextSibling;
36
+ return node;
37
+ };
38
+ const isBlank = (node) => node.nodeType === TEXT && node.data.trim() === '';
39
+ // Whether markup `node` (or the end of the range, for null) is what comes after the template's
40
+ // region opened at `open`: its next element, or the end.
41
+ const followsRegion = (open, node) => {
42
+ let depth = 0;
43
+ let next = open.nextSibling;
44
+ for (; next; next = next.nextSibling) {
45
+ if (isOpen(next)) depth++;
46
+ else if (isClose(next) && depth-- === 0) break;
47
+ }
48
+ next = next?.nextSibling;
49
+ while (next && next.nodeType === TEXT) next = next.nextSibling;
50
+ if (!next) return !node;
51
+ return next.nodeType === 1 && node?.nodeType === 1 && node.localName === next.localName;
52
+ };
53
+ // Walk the template nodes from `t` against the markup from `cursor` (children of `parent`);
54
+ // returns the markup node after what they matched.
55
+ function walkRange(t, parent, cursor, prefix, map) {
56
+ let index = 0;
57
+ for (; t; t = t.nextSibling) {
58
+ if (t.nodeType === TEXT) {
59
+ if (cursor?.nodeType === TEXT) cursor = cursor.nextSibling; // static text (if kept)
60
+ continue;
61
+ }
62
+ const path = [...prefix, index];
63
+ if (t.nodeType === 1) {
64
+ const node = skipText(cursor);
65
+ if (!node || node.nodeType !== 1 || node.localName !== t.localName) throw fail;
66
+ if (t.localName.includes('-')) {
67
+ // A child component renders (and restores) its own content.
68
+ const key = path.join(',');
69
+ for (const other of map.keys()) if (other.startsWith(`${key},`)) throw fail;
70
+ } else {
71
+ const after = walkRange(t.firstChild, node, node.firstChild, path, map);
72
+ // Anything left must be text (an element's text part renders into it).
73
+ for (let rest = after; rest; rest = rest.nextSibling) if (rest.nodeType !== TEXT) throw fail;
74
+ }
75
+ cursor = node.nextSibling;
76
+ index++;
77
+ continue;
78
+ }
79
+ if (t.nodeType !== COMMENT) continue;
80
+ if (t.data === '') {
81
+ // A text anchor: its text is the run that starts here.
82
+ insert(parent, cursor, '');
83
+ if (cursor?.nodeType === TEXT) cursor = cursor.nextSibling;
84
+ index++;
85
+ continue;
86
+ }
87
+ if (t.data !== '[') throw fail;
88
+ const part = map.get(path.join(','));
89
+ if (!part) throw fail;
90
+ const block = (chosen, args) => {
91
+ const content = blueprint(chosen, parent);
92
+ return walkRange(content.firstChild, parent, cursor, [], mapOf(chosen.parts(...args)));
93
+ };
94
+ if (part.when || part.each) {
95
+ const saved = edits.length;
96
+ const start = cursor;
97
+ try {
98
+ insert(parent, cursor, '[');
99
+ if (part.when) {
100
+ const chosen = part.blocks[untrack(() => $r(part.when())) ? 0 : 1];
101
+ if (chosen) cursor = block(chosen, []);
102
+ } else {
103
+ const list = untrack(() => $r(part.each()));
104
+ if (!Array.isArray(list)) throw fail;
105
+ list.forEach((item, position) => {
106
+ insert(parent, cursor, '[');
107
+ cursor = block(part.block, [new State(item), new State(position)]);
108
+ insert(parent, cursor, ']');
109
+ });
110
+ }
111
+ insert(parent, cursor, ']');
112
+ } catch (error) {
113
+ if (error !== fail) throw error;
114
+ // Nothing here where the region's content would be: the server left it to the
115
+ // browser (`[?`), when what follows in the markup is what follows in the template.
116
+ edits.length = saved;
117
+ cursor = start;
118
+ if (!followsRegion(t, skipText(cursor))) throw fail;
119
+ insert(parent, cursor, '[?');
120
+ insert(parent, cursor, ']');
121
+ }
122
+ } else if (part.slot) {
123
+ // Only as the last thing in its range: then its content is everything that's left.
124
+ let next = t.nextSibling;
125
+ while (next && next.nodeType === COMMENT && next.data === ']') next = next.nextSibling;
126
+ for (let rest = next; rest; rest = rest.nextSibling) if (!(rest.nodeType === TEXT)) throw fail;
127
+ let fallback = !!part.fallback;
128
+ if (fallback) {
129
+ // The fallback's shape exactly (its elements, in order): it's the fallback.
130
+ const saved = edits.length;
131
+ try {
132
+ const probe = [];
133
+ const end = block(part.fallback, []);
134
+ for (let rest = end; rest; rest = rest.nextSibling) if (!isBlank(rest)) throw fail;
135
+ probe.push(...edits.splice(saved));
136
+ insert(parent, cursor, '[');
137
+ edits.push(...probe);
138
+ insert(parent, null, ']');
139
+ } catch (error) {
140
+ if (error !== fail) throw error;
141
+ edits.length = saved;
142
+ fallback = false;
143
+ }
144
+ }
145
+ if (!fallback) {
146
+ insert(parent, cursor, '[!');
147
+ insert(parent, null, ']');
148
+ }
149
+ cursor = null;
150
+ } else {
151
+ throw fail; // zb-await
152
+ }
153
+ // Past the template's matching close.
154
+ let depth = 0;
155
+ for (t = t.nextSibling; t; t = t.nextSibling) {
156
+ if (isOpen(t)) depth++;
157
+ else if (isClose(t) && depth-- === 0) break;
158
+ }
159
+ if (!t) throw fail;
160
+ index++;
161
+ }
162
+ return cursor;
163
+ }
164
+ try {
165
+ const after = walkRange(template.firstChild, root, root.firstChild, [], mapOf(parts));
166
+ for (let rest = after; rest; rest = rest.nextSibling) if (!isBlank(rest)) throw fail;
167
+ } catch (error) {
168
+ if (error === fail) return false;
169
+ throw error;
170
+ }
171
+ for (const { parent, before, data } of edits) parent.insertBefore(document.createComment(data), before);
172
+ return true;
173
+ }
@@ -0,0 +1,76 @@
1
+ // Generated by scripts/build-production.js from src/resource.js: the runtime without hot reloading. Do not edit.
2
+ // Async data as signals: `resource(fetcher)`.
3
+ //
4
+ // const user = resource(({ signal }) => fetch(`/api/users/${p.id}`, { signal }).then((r) => r.json()))
5
+ // <zb-if test={user.loading}><p>Loading…</p><zb-else><p>{user.value?.name}</p></zb-else></zb-if>
6
+ //
7
+ // The fetcher runs at once and again whenever a signal it read before its first `await`
8
+ // changes (a prop, a state), like an effect. A new run aborts the previous one through the
9
+ // `AbortSignal` it was given, and so does the element's removal; a result that arrives after a
10
+ // newer run started is dropped, so the value is always the latest request's. While a new run is
11
+ // pending the previous value stays (`loading` is true), so the UI doesn't flash empty.
12
+ import { Signal } from './signal.js';
13
+ import { effect } from './effect.js';
14
+
15
+ const { untrack } = Signal.subtle;
16
+
17
+ /** Marks a resource, so `<zb-await>` follows its state instead of awaiting it once. */
18
+ export const RESOURCE = Symbol.for('zubra.resource');
19
+
20
+ /**
21
+ * A signal-backed view of async data. `value`, `error`, `loading` and `status` (`"pending"`,
22
+ * `"ready"`, `"error"`) are reactive reads; `refresh()` runs the fetcher again; `then()` makes
23
+ * it awaitable (resolving to the current run's value).
24
+ */
25
+ export function resource(fetcher, { initial } = {}) {
26
+ if (typeof fetcher !== 'function') throw new TypeError('zubra: resource() takes a function that returns a value or a promise');
27
+ const value = new Signal.State(initial);
28
+ const error = new Signal.State(undefined);
29
+ const status = new Signal.State('pending');
30
+ const version = new Signal.State(0);
31
+ let current = null; // the latest run's promise
32
+
33
+ effect(() => {
34
+ version.get();
35
+ const controller = new AbortController();
36
+ let result;
37
+ try {
38
+ result = fetcher({ signal: controller.signal });
39
+ } catch (thrown) {
40
+ result = Promise.reject(thrown);
41
+ }
42
+ untrack(() => {
43
+ if (status.get() !== 'pending') status.set('pending');
44
+ });
45
+ const run = Promise.resolve(result).then(
46
+ (resolved) => {
47
+ if (run !== current) return resolved;
48
+ value.set(resolved);
49
+ error.set(undefined);
50
+ status.set('ready');
51
+ return resolved;
52
+ },
53
+ (thrown) => {
54
+ if (run !== current) throw thrown;
55
+ // An aborted run was replaced or the element left: not an error to show.
56
+ if (controller.signal.aborted) return untrack(() => value.get());
57
+ error.set(thrown);
58
+ status.set('error');
59
+ throw thrown;
60
+ },
61
+ );
62
+ run.catch(() => {}); // reported through `error`
63
+ current = run;
64
+ return () => controller.abort();
65
+ });
66
+
67
+ return {
68
+ [RESOURCE]: true,
69
+ get value() { return value.get(); },
70
+ get error() { return error.get(); },
71
+ get status() { return status.get(); },
72
+ get loading() { return status.get() === 'pending'; },
73
+ refresh() { version.set(untrack(() => version.get()) + 1); },
74
+ then(resolve, reject) { return current.then(resolve, reject); },
75
+ };
76
+ }
@@ -0,0 +1,98 @@
1
+ // Generated by scripts/build-production.js from src/server.js: the runtime without hot reloading. Do not edit.
2
+ // Server rendering of zubra elements, for framework integrations (the Astro renderer, …).
3
+ //
4
+ // A `.zb` module loads on a server: `define()` returns a stand-in that carries the element's
5
+ // `zubra` description and can't be constructed. The build plugin's server build of each component
6
+ // registers how to render it (through @zubra/compiler, with the component's sources embedded), and
7
+ // `renderToString(Element, …)` uses that. Without the plugin, render a file with
8
+ // `@zubra/compiler`'s `renderToString(path, …)` instead.
9
+
10
+ // One registry per process, even with several copies of the runtime.
11
+ const renderers = (globalThis[Symbol.for('zubra.renderers')] ??= new Map());
12
+
13
+ /** Register how to server-render `tag`: `(props, slots) => html`. Called by the plugin's output. */
14
+ export function registerRenderer(tag, render) {
15
+ renderers.set(tag, render);
16
+ }
17
+
18
+ /** Whether `Element` is a zubra element that can be rendered here. */
19
+ export function canRender(Element) {
20
+ return typeof Element?.zubra?.tag === 'string' && renderers.has(Element.zubra.tag);
21
+ }
22
+
23
+ /**
24
+ * The element's server-rendered HTML, which it hydrates in the browser. `props` are its prop
25
+ * values (JSON), `attributes` other attributes for the host (`class`, `id`, …), and `slots` the
26
+ * supplied content by slot name (`default` for unnamed content), as HTML.
27
+ */
28
+ export function renderToString(Element, { props = {}, attributes = {}, slots = {} } = {}) {
29
+ const meta = Element?.zubra;
30
+ if (!meta) throw new TypeError('zubra: renderToString() needs a compiled zubra element (the default export of a .zb module)');
31
+ const render = renderers.get(meta.tag);
32
+ if (!render) {
33
+ throw new Error(`zubra: no server renderer for <${meta.tag}>; build it with the zubra plugin (in its server build), or use @zubra/compiler's renderToString(path)`);
34
+ }
35
+ for (const name of Object.keys(slots)) {
36
+ if (name !== 'default' && !meta.slots.includes(name)) throw new Error(`zubra: <${meta.tag}> has no slot \`${name}\``);
37
+ }
38
+ const html = render(props, slots);
39
+ const extra = Object.entries(attributes)
40
+ .filter(([, value]) => value != null && value !== false)
41
+ .map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(value))}"`))
42
+ .join('');
43
+ // Onto the host's start tag (the markup starts with it).
44
+ return extra ? html.replace(/^(<[a-z][\w.-]*)/, `$1${extra}`) : html;
45
+ }
46
+
47
+ /**
48
+ * An element's server-rendered content, for a host a framework renders bare: for a shadow-DOM
49
+ * element its declarative shadow root (`<template shadowrootmode>…</template>`), for a light-DOM
50
+ * element its rendered children (with their hydration markers); plus, for a form-associated
51
+ * element with a `name`, a hidden input with the value it would submit (the element removes it
52
+ * when it starts). The framework must leave the host's content alone while hydrating (React's
53
+ * `dangerouslySetInnerHTML`, Vue's `innerHTML`). Empty when it can't be rendered here.
54
+ */
55
+ export function serverContent(Element, { props = {}, name } = {}) {
56
+ const meta = Element?.zubra;
57
+ if (!meta || !canRender(Element)) return '';
58
+ let html;
59
+ try {
60
+ html = renderers.get(meta.tag)(props, {});
61
+ } catch {
62
+ return ''; // left to the browser rather than break the page
63
+ }
64
+ let content = '';
65
+ if (meta.shadow) {
66
+ const start = html.search(/<template\s[^>]*shadowrootmode/i);
67
+ if (start < 0) return '';
68
+ let depth = 0;
69
+ for (const match of html.slice(start).matchAll(/<template\b[^>]*>|<\/template>/gi)) {
70
+ depth += match[0][1] === '/' ? -1 : 1;
71
+ if (depth === 0) {
72
+ content = html.slice(start, start + match.index + match[0].length);
73
+ break;
74
+ }
75
+ }
76
+ } else {
77
+ // The host's children: from the end of its start tag to its end tag.
78
+ const open = html.match(/^<[a-z][\w.-]*(?:\s[^>]*)?>/);
79
+ const close = html.lastIndexOf(`</${meta.tag}>`);
80
+ if (!open || close < open[0].length) return '';
81
+ content = html.slice(open[0].length, close);
82
+ }
83
+ if (!content) return '';
84
+ const models = Object.entries(meta.props).filter(([, spec]) => spec.model);
85
+ if (!meta.form || !name || models.length !== 1) return content;
86
+ const [[model, spec]] = models;
87
+ const value = model in props ? props[model] : spec.default;
88
+ if (value == null || value === false || value === '') return content;
89
+ const text = value === true ? 'on' : typeof value === 'object' ? JSON.stringify(value) : String(value);
90
+ return `${content}<input type="hidden" name="${escapeAttribute(String(name))}" value="${escapeAttribute(text)}" data-zb-fallback>`;
91
+ }
92
+
93
+ /** `serverContent` for a shadow-DOM element (the 2.11 name); empty for a light-DOM one. */
94
+ export function shadowContent(Element, options) {
95
+ return Element?.zubra?.shadow ? serverContent(Element, options) : '';
96
+ }
97
+
98
+ const escapeAttribute = (value) => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');