@zubra/runtime 0.0.0-stage → 5.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ // An implementation of the TC39 Signals proposal API (https://github.com/tc39/proposal-signals).
2
+ //
3
+ // This is zubra's polyfill boundary for reactivity: compiled components talk only to this API,
4
+ // and runtime/src/signal.js uses the native `globalThis.Signal` instead whenever one exists.
5
+ //
6
+ // Model: push-pull with version numbers.
7
+ // - Every signal has a `version` that increments when its value actually changes (per
8
+ // `equals`), and every State write bumps a global `epoch`.
9
+ // - A Computed records, per dependency, the version it last read. On `get()` it re-validates
10
+ // by bringing each dependency up to date and comparing versions, and re-runs only if one
11
+ // really changed ("equality cut-off" — what makes reads glitch-free).
12
+ // - Signals reachable from a Watcher are "live": they keep back-links to their consumers, so
13
+ // a write can *push* a dirty mark down to watchers. Signals nobody watches keep no
14
+ // back-links, so an unwatched graph is garbage-collectable and costs nothing on write.
15
+ //
16
+ // Dependency bookkeeping is positional, with back-indices for O(1) unlinking, and follows the
17
+ // proposal's reference polyfill exactly: the differential tests in runtime/test compare this
18
+ // implementation with `signal-polyfill` on hand-written scenarios and hundreds of random graphs,
19
+ // including the order in which watchers are notified and watched/unwatched hooks fire.
20
+ // Deliberate differences (all stricter, none observable in correct programs) are listed in
21
+ // runtime/README.md.
22
+
23
+ const UNSET = Symbol('unset');
24
+ const COMPUTING = Symbol('computing');
25
+ const ERRORED = Symbol('errored');
26
+
27
+ const watched = Symbol('watched');
28
+ const unwatched = Symbol('unwatched');
29
+
30
+ let activeConsumer = null; // the node whose reads are currently being recorded
31
+ let frozen = false; // true while watchers are notified or hooks run
32
+ let epoch = 0;
33
+
34
+ const frozenError = () =>
35
+ new Error('Signals cannot be read or written during a Watcher notification or a watched/unwatched callback');
36
+
37
+ // ---- graph nodes ---------------------------------------------------------------------------
38
+ //
39
+ // Consumer side (Computed, Watcher):
40
+ // sources[i] the i-th dependency read (duplicates allowed, as in the reference)
41
+ // sourceVersions[i] that dependency's version when it was read
42
+ // sourceSlots[i] where this consumer sits in sources[i].sinks (only while live)
43
+ // nextSource read cursor during a computation / append cursor for a Watcher
44
+ // Producer side (State, Computed):
45
+ // sinks[j] live consumers
46
+ // sinkSlots[j] where this producer sits in sinks[j].sources
47
+
48
+ class Node {
49
+ constructor(wrapper) {
50
+ this.wrapper = wrapper;
51
+ this.version = 0;
52
+ this.dirty = false;
53
+ this.sinks = [];
54
+ this.sinkSlots = [];
55
+ this.sources = [];
56
+ this.sourceVersions = [];
57
+ this.sourceSlots = [];
58
+ this.nextSource = 0;
59
+ }
60
+ get live() { return this.sinks.length > 0; }
61
+ }
62
+
63
+ class StateNode extends Node {
64
+ constructor(wrapper, value, options) {
65
+ super(wrapper);
66
+ this.value = value;
67
+ this.equals = options?.equals ?? Object.is;
68
+ this.onWatched = options?.[watched];
69
+ this.onUnwatched = options?.[unwatched];
70
+ }
71
+ }
72
+
73
+ class ComputedNode extends Node {
74
+ constructor(wrapper, computation, options) {
75
+ super(wrapper);
76
+ this.computation = computation;
77
+ this.value = UNSET;
78
+ this.error = undefined;
79
+ this.dirty = true;
80
+ this.lastCleanEpoch = -1;
81
+ this.equals = options?.equals ?? Object.is;
82
+ this.onWatched = options?.[watched];
83
+ this.onUnwatched = options?.[unwatched];
84
+ }
85
+ }
86
+
87
+ class WatcherNode extends Node {
88
+ constructor(wrapper, notify) {
89
+ super(wrapper);
90
+ this.notify = notify;
91
+ }
92
+ get live() { return true; }
93
+ }
94
+
95
+ // ---- liveness: linking and unlinking back-references ---------------------------------------
96
+
97
+ function runFrozen(hook, receiver) {
98
+ if (typeof hook !== 'function') return;
99
+ const previous = frozen;
100
+ frozen = true;
101
+ try { hook.call(receiver); } finally { frozen = previous; }
102
+ }
103
+
104
+ /** Add `consumer` as a live sink of `producer`; returns the slot it occupies. */
105
+ function link(producer, consumer, slotInConsumer) {
106
+ if (producer.sinks.length === 0) {
107
+ runFrozen(producer.onWatched, producer.wrapper);
108
+ for (let i = 0; i < producer.sources.length; i++) {
109
+ producer.sourceSlots[i] = link(producer.sources[i], producer, i);
110
+ }
111
+ }
112
+ producer.sinkSlots.push(slotInConsumer);
113
+ return producer.sinks.push(consumer) - 1;
114
+ }
115
+
116
+ /** Remove the live sink at `slot` of `producer` (swap-remove, fixing the moved back-index). */
117
+ function unlink(producer, slot) {
118
+ if (producer.sinks.length === 1) {
119
+ runFrozen(producer.onUnwatched, producer.wrapper);
120
+ for (let i = 0; i < producer.sources.length; i++) unlink(producer.sources[i], producer.sourceSlots[i]);
121
+ }
122
+ const last = producer.sinks.length - 1;
123
+ producer.sinks[slot] = producer.sinks[last];
124
+ producer.sinkSlots[slot] = producer.sinkSlots[last];
125
+ producer.sinks.length = last;
126
+ producer.sinkSlots.length = last;
127
+ if (slot < last) producer.sinks[slot].sourceSlots[producer.sinkSlots[slot]] = slot;
128
+ }
129
+
130
+ // ---- tracking reads ------------------------------------------------------------------------
131
+
132
+ function track(producer) {
133
+ if (frozen) throw frozenError();
134
+ const consumer = activeConsumer;
135
+ if (!consumer) return;
136
+ const i = consumer.nextSource++;
137
+ const previous = consumer.sources[i];
138
+ if (previous !== producer) {
139
+ if (previous !== undefined && consumer.live) unlink(previous, consumer.sourceSlots[i]);
140
+ consumer.sources[i] = producer;
141
+ consumer.sourceSlots[i] = consumer.live ? link(producer, consumer, i) : 0;
142
+ }
143
+ consumer.sourceVersions[i] = producer.version;
144
+ }
145
+
146
+ /** Drop dependencies from the last run that this run didn't read. */
147
+ function trimSources(consumer) {
148
+ const count = consumer.nextSource;
149
+ if (consumer.live) {
150
+ for (let i = count; i < consumer.sources.length; i++) unlink(consumer.sources[i], consumer.sourceSlots[i]);
151
+ }
152
+ consumer.sources.length = count;
153
+ consumer.sourceVersions.length = count;
154
+ consumer.sourceSlots.length = count;
155
+ }
156
+
157
+ // ---- push: a write marks live consumers dirty and notifies watchers ------------------------
158
+
159
+ function notifySinks(producer) {
160
+ const previous = frozen;
161
+ frozen = true;
162
+ try {
163
+ for (const consumer of producer.sinks) if (!consumer.dirty) markDirty(consumer);
164
+ } finally {
165
+ frozen = previous;
166
+ }
167
+ }
168
+
169
+ function markDirty(consumer) {
170
+ consumer.dirty = true;
171
+ notifySinks(consumer);
172
+ if (consumer instanceof WatcherNode && typeof consumer.notify === 'function') {
173
+ consumer.notify.call(consumer.wrapper);
174
+ }
175
+ }
176
+
177
+ // ---- pull: bringing a computed up to date --------------------------------------------------
178
+
179
+ function refresh(node) {
180
+ if (!(node instanceof ComputedNode)) return;
181
+ // Validated during this epoch and not marked dirty since: nothing can have changed.
182
+ // (No shortcut for live nodes: a node that became live while stale was never marked dirty,
183
+ // and marking it would stop later writes from reaching its watchers.)
184
+ if (!node.dirty && node.lastCleanEpoch === epoch) return;
185
+ if (node.value === UNSET || node.value === COMPUTING || sourcesChanged(node)) recompute(node);
186
+ node.dirty = false;
187
+ node.lastCleanEpoch = epoch;
188
+ }
189
+
190
+ function sourcesChanged(node) {
191
+ for (let i = 0; i < node.sources.length; i++) {
192
+ const source = node.sources[i];
193
+ const seen = node.sourceVersions[i];
194
+ if (source.version !== seen) return true;
195
+ refresh(source);
196
+ if (source.version !== seen) return true;
197
+ }
198
+ return false;
199
+ }
200
+
201
+ function recompute(node) {
202
+ if (node.value === COMPUTING) throw new Error('Detected cycle in computations.');
203
+ const previousValue = node.value;
204
+ node.value = COMPUTING;
205
+ node.nextSource = 0;
206
+ const previousConsumer = activeConsumer;
207
+ activeConsumer = node;
208
+ let value;
209
+ let equal = false;
210
+ try {
211
+ value = node.computation.call(node.wrapper);
212
+ equal = previousValue !== UNSET && previousValue !== ERRORED
213
+ && node.equals.call(node.wrapper, previousValue, value);
214
+ } catch (error) {
215
+ value = ERRORED;
216
+ node.error = error;
217
+ } finally {
218
+ activeConsumer = previousConsumer;
219
+ trimSources(node);
220
+ }
221
+ if (equal) {
222
+ node.value = previousValue;
223
+ return;
224
+ }
225
+ node.value = value;
226
+ node.version++;
227
+ }
228
+
229
+ // ---- public API ----------------------------------------------------------------------------
230
+
231
+ const nodes = new WeakMap();
232
+ const nodeFor = (receiver, Kind, method) => {
233
+ const node = nodes.get(receiver);
234
+ if (!(node instanceof Kind)) throw new TypeError(`Wrong receiver type for ${method}`);
235
+ return node;
236
+ };
237
+ const isSignal = (value) => {
238
+ const node = nodes.get(value);
239
+ return node instanceof StateNode || node instanceof ComputedNode;
240
+ };
241
+ const isSink = (value) => {
242
+ const node = nodes.get(value);
243
+ return node instanceof ComputedNode || node instanceof WatcherNode;
244
+ };
245
+
246
+ class State {
247
+ constructor(initialValue, options) {
248
+ nodes.set(this, new StateNode(this, initialValue, options));
249
+ }
250
+
251
+ get() {
252
+ const node = nodeFor(this, StateNode, 'Signal.State.prototype.get');
253
+ track(node);
254
+ return node.value;
255
+ }
256
+
257
+ set(value) {
258
+ const node = nodeFor(this, StateNode, 'Signal.State.prototype.set');
259
+ if (frozen) throw new Error('Writes to signals not permitted during Watcher callback');
260
+ if (node.equals.call(this, node.value, value)) return;
261
+ node.value = value;
262
+ node.version++;
263
+ epoch++;
264
+ notifySinks(node);
265
+ }
266
+ }
267
+
268
+ class Computed {
269
+ constructor(computation, options) {
270
+ if (typeof computation !== 'function') throw new TypeError('Signal.Computed requires a function');
271
+ nodes.set(this, new ComputedNode(this, computation, options));
272
+ }
273
+
274
+ get() {
275
+ const node = nodeFor(this, ComputedNode, 'Signal.Computed.prototype.get');
276
+ // Checked before refreshing, so a forbidden read can't poison the cached value.
277
+ if (frozen) throw frozenError();
278
+ refresh(node);
279
+ track(node);
280
+ if (node.value === ERRORED) throw node.error;
281
+ return node.value;
282
+ }
283
+ }
284
+
285
+ class Watcher {
286
+ constructor(notify) {
287
+ nodes.set(this, new WatcherNode(this, notify));
288
+ }
289
+
290
+ /** Add signals to the watched set and re-arm notification. */
291
+ watch(...signals) {
292
+ const node = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.watch');
293
+ if (!signals.every(isSignal)) throw new TypeError('Called watch/unwatch without a Computed or State argument');
294
+ node.dirty = false;
295
+ const previousConsumer = activeConsumer;
296
+ activeConsumer = node;
297
+ try {
298
+ for (const signal of signals) track(nodes.get(signal));
299
+ } finally {
300
+ activeConsumer = previousConsumer;
301
+ }
302
+ }
303
+
304
+ /** Remove every occurrence of the given signals from the watched set. */
305
+ unwatch(...signals) {
306
+ const node = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.unwatch');
307
+ if (!signals.every(isSignal)) throw new TypeError('Called watch/unwatch without a Computed or State argument');
308
+ const removing = new Set(signals.map((signal) => nodes.get(signal)));
309
+ for (let i = node.sources.length - 1; i >= 0; i--) {
310
+ if (!removing.has(node.sources[i])) continue;
311
+ unlink(node.sources[i], node.sourceSlots[i]);
312
+ const last = node.sources.length - 1;
313
+ node.sources[i] = node.sources[last];
314
+ node.sourceSlots[i] = node.sourceSlots[last];
315
+ node.sourceVersions[i] = node.sourceVersions[last];
316
+ node.sources.length = last;
317
+ node.sourceSlots.length = last;
318
+ node.sourceVersions.length = last;
319
+ node.nextSource--;
320
+ if (i < last) node.sources[i].sinkSlots[node.sourceSlots[i]] = i;
321
+ }
322
+ }
323
+
324
+ /** The watched computeds that have been marked dirty and not yet re-read. */
325
+ getPending() {
326
+ const node = nodeFor(this, WatcherNode, 'Signal.subtle.Watcher.prototype.getPending');
327
+ return node.sources.filter((source) => source.dirty).map((source) => source.wrapper);
328
+ }
329
+ }
330
+
331
+ function untrack(callback) {
332
+ const previous = activeConsumer;
333
+ activeConsumer = null;
334
+ try { return callback(); } finally { activeConsumer = previous; }
335
+ }
336
+
337
+ function currentComputed() {
338
+ return activeConsumer instanceof ComputedNode ? activeConsumer.wrapper : undefined;
339
+ }
340
+
341
+ function introspectSources(sink) {
342
+ if (!isSink(sink)) throw new TypeError('Called introspectSources without a Computed or Watcher argument');
343
+ return nodes.get(sink).sources.map((source) => source.wrapper);
344
+ }
345
+
346
+ function introspectSinks(signal) {
347
+ if (!isSignal(signal)) throw new TypeError('Called introspectSinks without a Signal argument');
348
+ return nodes.get(signal).sinks.map((consumer) => consumer.wrapper);
349
+ }
350
+
351
+ function hasSinks(signal) {
352
+ if (!isSignal(signal)) throw new TypeError('Called hasSinks without a Signal argument');
353
+ return nodes.get(signal).sinks.length > 0;
354
+ }
355
+
356
+ function hasSources(sink) {
357
+ if (!isSink(sink)) throw new TypeError('Called hasSources without a Computed or Watcher argument');
358
+ return nodes.get(sink).sources.length > 0;
359
+ }
360
+
361
+ export const Signal = Object.freeze({
362
+ State,
363
+ Computed,
364
+ subtle: Object.freeze({
365
+ Watcher,
366
+ untrack,
367
+ currentComputed,
368
+ introspectSources,
369
+ introspectSinks,
370
+ hasSinks,
371
+ hasSources,
372
+ watched,
373
+ unwatched,
374
+ }),
375
+ });
package/src/signal.js ADDED
@@ -0,0 +1,9 @@
1
+ // The one place compiled zubra code gets reactivity from.
2
+ //
3
+ // If the platform provides the TC39 Signals API natively, use it. Otherwise fall back to our
4
+ // implementation of the same API. Nothing else in the runtime may depend on which one it got,
5
+ // so the day browsers ship `Signal`, this file is the only thing that changes behavior.
6
+ import { Signal as Implementation } from './signal-impl.js';
7
+
8
+ export const Signal = globalThis.Signal ?? Implementation;
9
+ export const isNative = Signal !== Implementation;
package/src/spread.js ADDED
@@ -0,0 +1,154 @@
1
+ // {...spread} in compiled components: an object's entries as an element's attributes,
2
+ // properties and listeners. Its own entry (`@zubra/runtime/spread`), which a compiled module imports
3
+ // only when its template spreads, so components that don't, don't load it.
4
+ import { $r, isSafeUrl } from './element.js';
5
+ import { effect } from './effect.js';
6
+
7
+ const see = (section) => ` (see https://github.com/journey-to-code-org/zubra#${section})`;
8
+
9
+ // ---- {...spread}: an object's entries as attributes, properties and listeners --------------
10
+
11
+ // The same lists the compiler checks attributes against (src/validate.rs).
12
+ const SPREAD_URLS = new Set(['action', 'background', 'cite', 'codebase', 'data', 'formaction', 'href', 'longdesc', 'manifest', 'poster', 'profile', 'src', 'xlink:href', 'usemap']);
13
+ // Keys that would write markup or script: never from a spread.
14
+ const SPREAD_REFUSED = new Set(['innerhtml', 'outerhtml', 'innertext', 'textcontent', 'srcdoc', 'dangerouslysetinnerhtml', 'children']);
15
+ const SPREAD_PROPERTIES = new Set(['value', 'checked', 'selected', 'indeterminate']);
16
+ const SPREAD_NAMES = { classname: 'class', htmlfor: 'for' };
17
+ const kebab = (key) => (key.startsWith('--') ? key : key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`));
18
+
19
+ /**
20
+ * `{...object}` on an element: each entry an attribute (`true` a bare one, `false`, `null` and
21
+ * `undefined` none; `aria-*` booleans as "true"/"false", as Zag and React write them), `on<Name>`
22
+ * functions listeners, `value`/`checked`/`selected`/`indeterminate` properties, `class` merged
23
+ * token by token, `style` an object set through the CSSOM. Keys the element binds itself
24
+ * (`part.own`) are left to it. Entries that disappear are removed. Nothing a spread does could
25
+ * not be done by an attribute: string handlers, style strings, unsafe URLs and markup are refused.
26
+ */
27
+ export function spread(part, node) {
28
+ const own = new Set(part.own ?? []);
29
+ const listeners = new Map(); // event → { latest, listener }
30
+ let previous = new Map(); // normalized name → value
31
+ let classes = [];
32
+ let styles = new Set();
33
+ effect(() => {
34
+ let given = $r(part.spread());
35
+ if (given == null) given = {};
36
+ if (typeof given !== 'object' || Array.isArray(given)) {
37
+ console.error('zubra: {...} spreads an object of attributes; got', given);
38
+ given = {};
39
+ }
40
+ const next = new Map();
41
+ const events = new Set();
42
+ let nextClasses = [];
43
+ let nextStyles = new Set();
44
+ for (const [key, value] of Object.entries(given)) {
45
+ if (value === undefined) continue;
46
+ const lower = key.toLowerCase();
47
+ if (lower.length > 2 && lower.startsWith('on') && (typeof value === 'function' || typeof value === 'string')) {
48
+ if (typeof value === 'string') {
49
+ console.error(`zubra: a spread can't add a handler as text ("${key}"); pass a function${see('safety')}`);
50
+ continue;
51
+ }
52
+ const event = lower.slice(2);
53
+ if (own.has(`on${event}`)) continue;
54
+ events.add(event);
55
+ let entry = listeners.get(event);
56
+ if (!entry) {
57
+ entry = { latest: value, listener: (domEvent) => entry.latest.call(node, domEvent) };
58
+ listeners.set(event, entry);
59
+ node.addEventListener(event, entry.listener);
60
+ }
61
+ entry.latest = value;
62
+ continue;
63
+ }
64
+ const name = SPREAD_NAMES[lower] ?? (lower.startsWith('aria-') || lower.startsWith('data-') || node.namespaceURI !== 'http://www.w3.org/1999/xhtml' ? key : lower);
65
+ if (SPREAD_REFUSED.has(lower)) {
66
+ console.error(`zubra: a spread can't set "${key}"${see('safety')}`);
67
+ continue;
68
+ }
69
+ if (name === 'class') {
70
+ nextClasses = String(value ?? '').split(/\s+/).filter(Boolean);
71
+ continue;
72
+ }
73
+ if (name === 'style') {
74
+ if (value === null || value === false) continue;
75
+ if (typeof value !== 'object') {
76
+ console.error(`zubra: a spread's style must be an object of properties (set through the CSSOM), not text${see('safety')}:`, value);
77
+ continue;
78
+ }
79
+ for (const [property, propertyValue] of Object.entries(value)) {
80
+ const css = kebab(property);
81
+ if (own.has(`style:${css}`)) continue;
82
+ if (propertyValue == null || propertyValue === false) continue;
83
+ nextStyles.add(css);
84
+ if (node.style.getPropertyValue(css) !== String(propertyValue)) node.style.setProperty(css, String(propertyValue));
85
+ }
86
+ continue;
87
+ }
88
+ if (own.has(name.toLowerCase())) continue;
89
+ next.set(name, value);
90
+ }
91
+ // Listeners whose keys went away.
92
+ for (const [event, entry] of listeners) {
93
+ if (!events.has(event)) {
94
+ node.removeEventListener(event, entry.listener);
95
+ listeners.delete(event);
96
+ }
97
+ }
98
+ // Classes, token by token (the element's own class and class: toggles stay).
99
+ for (const token of classes) if (!nextClasses.includes(token)) node.classList.remove(token);
100
+ for (const token of nextClasses) node.classList.add(token);
101
+ classes = nextClasses;
102
+ for (const css of styles) if (!nextStyles.has(css)) node.style.removeProperty(css);
103
+ if (styles.size && !nextStyles.size && node.getAttribute('style') === '') node.removeAttribute('style');
104
+ styles = nextStyles;
105
+ // Attributes and properties.
106
+ for (const [name, value] of previous) {
107
+ if (next.has(name)) continue;
108
+ if (SPREAD_PROPERTIES.has(name)) node[name] = name === 'value' ? '' : false;
109
+ else node.removeAttribute(name);
110
+ }
111
+ for (const [name, value] of next) {
112
+ if (SPREAD_PROPERTIES.has(name) && name in node) {
113
+ const property = name === 'value' ? (value == null ? '' : String(value)) : !!value;
114
+ if (node[name] !== property) node[name] = property;
115
+ continue;
116
+ }
117
+ if (value === null || value === false && !name.startsWith('aria-')) {
118
+ node.removeAttribute(name);
119
+ continue;
120
+ }
121
+ if (typeof value === 'boolean') {
122
+ if (name.startsWith('aria-')) node.setAttribute(name, String(value));
123
+ else node.toggleAttribute(name, value);
124
+ continue;
125
+ }
126
+ if (typeof value === 'object' || typeof value === 'function') {
127
+ console.error(`zubra: a spread can't write ${typeof value === 'function' ? 'a function' : 'an object'} to the "${name}" attribute`, value);
128
+ node.removeAttribute(name);
129
+ continue;
130
+ }
131
+ const text = String(value);
132
+ if (SPREAD_URLS.has(name.toLowerCase())) {
133
+ if (text.trim() === '') {
134
+ node.removeAttribute(name);
135
+ continue;
136
+ }
137
+ if (!isSafeUrl(text)) {
138
+ console.error(`zubra: blocked an unsafe URL in "${name}"${see('safety')}:`, text);
139
+ node.removeAttribute(name);
140
+ continue;
141
+ }
142
+ }
143
+ if (node.getAttribute(name) !== text) node.setAttribute(name, text);
144
+ }
145
+ previous = next;
146
+ return undefined;
147
+ });
148
+ // The listeners go with the element's scope.
149
+ effect(() => () => {
150
+ for (const [event, entry] of listeners) node.removeEventListener(event, entry.listener);
151
+ listeners.clear();
152
+ });
153
+ }
154
+
package/src/vue.js ADDED
@@ -0,0 +1,125 @@
1
+ // Vue 3 adapter: a zubra element as a Vue component.
2
+ //
3
+ // <script setup>
4
+ // import Rating from './rating.zb' // the build plugin wraps it for .vue files
5
+ // </script>
6
+ // <Rating v-model="stars" @change="saved" />
7
+ //
8
+ // - Props are set as DOM properties when the element is created, before it starts, so it never
9
+ // renders with defaults first. A prop that's left out is the component's default.
10
+ // - The component's events are Vue events (`@valuechange`); the handler gets the DOM event.
11
+ // - Two-way binding comes from naming: a `<prop>` + `<prop>change` pair supports
12
+ // `v-model:<prop>`, and `v-model` binds the `value` pair (or the only pair, if there's one).
13
+ // - Other attributes and listeners (class, style, @click, …) go to the element; slots become its
14
+ // children, named slots with `slot="name"`.
15
+ import { defineComponent, h, withDirectives, Text, Comment, Fragment } from 'vue';
16
+ import { serverContent } from './server.js';
17
+
18
+ const clone = (value) => (value && typeof value === 'object' ? structuredClone(value) : value);
19
+ const pascal = (tag) => tag.split(/[-.]/).filter(Boolean).map((part) => part[0].toUpperCase() + part.slice(1)).join('');
20
+
21
+ /** `<prop>` + `<prop>change` pairs: the component's two-way bindings. */
22
+ export function models({ props, events }) {
23
+ return Object.keys(props)
24
+ .filter((name) => events.includes(`${name.toLowerCase()}change`))
25
+ .map((name) => ({ prop: name, event: `${name.toLowerCase()}change` }));
26
+ }
27
+
28
+ /** Give each node of a named slot its `slot` attribute (text gets a <span> to carry it). */
29
+ function named(name, nodes) {
30
+ return nodes.flatMap((node) => {
31
+ if (node.type === Fragment) return named(name, node.children ?? []);
32
+ if (node.type === Comment) return [];
33
+ if (node.type === Text || typeof node !== 'object') return [h('span', { slot: name }, node.children ?? String(node))];
34
+ return [h(node, { slot: name })];
35
+ });
36
+ }
37
+
38
+ const same = (a, b) => Object.is(a, b);
39
+
40
+ /** The given props as JSON for the host's `zb-props`, or null. */
41
+ function propsJson(values) {
42
+ const given = Object.entries(values).filter(([, value]) => value !== undefined && typeof value !== 'function');
43
+ if (!given.length) return null;
44
+ try {
45
+ return JSON.stringify(Object.fromEntries(given));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ // Props are set by a directive, after Vue creates the element and before it's inserted (so before
52
+ // the element starts) and before each update. Not as `.name` DOM props: Vue turns `null` into
53
+ // `0`, `''` or `false` there, by the property's current type, and a prop's null is meaningful.
54
+ const setProps = {
55
+ beforeMount(element, { value }) {
56
+ for (const name of Object.keys(value)) element[name] = value[name];
57
+ },
58
+ beforeUpdate(element, { value }) {
59
+ for (const name of Object.keys(value)) if (!same(element[name], value[name])) element[name] = value[name];
60
+ },
61
+ };
62
+
63
+ export function wrap(Element) {
64
+ const meta = Element?.zubra;
65
+ if (!meta) throw new TypeError('zubra: wrap() needs a compiled zubra element (the default export of a .zb module)');
66
+ const { tag, props, events } = meta;
67
+ const pairs = models(meta);
68
+ const primary = pairs.find((pair) => pair.prop === 'value') ?? (pairs.length === 1 ? pairs[0] : null);
69
+
70
+ return defineComponent({
71
+ name: pascal(tag),
72
+ inheritAttrs: false,
73
+ props: {
74
+ ...Object.fromEntries(Object.keys(props).map((name) => [name, { default: undefined }])),
75
+ ...(primary ? { modelValue: { default: undefined } } : {}),
76
+ },
77
+ emits: [
78
+ ...events,
79
+ ...pairs.map((pair) => `update:${pair.prop}`),
80
+ ...(primary ? ['update:modelValue'] : []),
81
+ ],
82
+ setup(given, { attrs, slots, emit, expose }) {
83
+ const listeners = Object.fromEntries(events.map((name) => {
84
+ // `on` + the name as given is how Vue's patchProp attaches a listener for exactly it.
85
+ const key = `on${name[0].toUpperCase()}${name.slice(1)}`;
86
+ return [key, (event) => {
87
+ // Only the element's own event (one from inside with the same name was stopped there).
88
+ emit(name, event);
89
+ for (const pair of pairs) {
90
+ if (pair.event !== name) continue;
91
+ emit(`update:${pair.prop}`, event.detail);
92
+ if (pair === primary) emit('update:modelValue', event.detail);
93
+ }
94
+ }];
95
+ }));
96
+ let element = null;
97
+ const defaults = {}; // one copy per instance, so re-rendering doesn't set a new one
98
+ expose({ get element() { return element; } });
99
+ return () => {
100
+ const values = {};
101
+ for (const name of Object.keys(props)) {
102
+ let value = given[name];
103
+ if (primary && name === primary.prop && given.modelValue !== undefined) value = given.modelValue;
104
+ values[name] = value === undefined ? (defaults[name] ??= clone(props[name].default)) : value;
105
+ }
106
+ const children = [];
107
+ for (const [name, slot] of Object.entries(slots)) {
108
+ const nodes = slot() ?? [];
109
+ children.push(...(name === 'default' ? nodes : named(name, nodes)));
110
+ }
111
+ // The given props as JSON, on the server and in the browser alike (see the React adapter).
112
+ const json = propsJson(Object.fromEntries(Object.keys(props).map((name) => [name, primary && name === primary.prop && given.modelValue !== undefined ? given.modelValue : given[name]])));
113
+ // An element with no children: on the server, its content (a shadow-DOM element's
114
+ // declarative shadow root, a light-DOM element's rendered children) as the host's inner
115
+ // HTML, so it shows before JavaScript. In the browser the vnode has no children at all
116
+ // (`null`, not `[]`): Vue then never looks inside the host while hydrating, so the
117
+ // element's markup stays for the element to hydrate in place (3.3). (A page can also go
118
+ // through @zubra/server's injectShadowRoots, which is the same for shadow DOM.)
119
+ const bare = children.length === 0;
120
+ const inner = bare && typeof window === 'undefined' ? { innerHTML: serverContent(Element, { props: json ? JSON.parse(json) : {}, name: attrs.name }) } : {};
121
+ return withDirectives(h(tag, { ...attrs, ...listeners, ...(json ? { 'zb-props': json } : {}), ...inner, ref: (node) => { element = node; } }, bare ? null : children), [[setProps, values]]);
122
+ };
123
+ },
124
+ });
125
+ }