@vmz/core 0.0.4 → 0.1.1

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,970 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * VMZ DOM SSR / hydrate / resume — precise patches, no VDOM diff.
4
+ * Imports client DOM primitives from ./dom-core.js for tree-shakeable browser entry.
5
+ */
6
+ import { applyDomAttr, applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, isEventPropName, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from './dom-core.js';
7
+ /**
8
+ * Node SSR has no browser `document`. rowKernel omits `createItem` and materializes
9
+ * rows via `html` + `hydrate`, which needs `document.createElement('template')`.
10
+ * Install a linkedom document once when missing (optional at runtime if linkedom is present).
11
+ *
12
+ * Must not statically import `node:module`: `dom.js` / `vmz-dom.js` re-export this file,
13
+ * and browser hosts load that barrel.
14
+ */
15
+ function ensureSsrDocument() {
16
+ if (typeof globalThis.document !== 'undefined' && typeof globalThis.document.createElement === 'function') {
17
+ return true;
18
+ }
19
+ const proc = globalThis.process;
20
+ if (!proc?.versions?.node)
21
+ return false;
22
+ try {
23
+ // Node 20.16+ / 22.3+: sync builtin load without a static `node:` import.
24
+ const mod = typeof proc.getBuiltinModule === 'function' ? proc.getBuiltinModule('module') : null;
25
+ if (!mod?.createRequire)
26
+ return false;
27
+ const { parseHTML } = mod.createRequire(import.meta.url)('linkedom');
28
+ const { window, document } = parseHTML('<!DOCTYPE html><html><body></body></html>');
29
+ globalThis.window = window;
30
+ globalThis.document = document;
31
+ return typeof document.createElement === 'function';
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /**
38
+ * @param {new (props?: object) => any} Component
39
+ * @param {object} [props]
40
+ */
41
+ export async function renderToString(Component, props = {}, opts = {}) {
42
+ ensureSsrDocument();
43
+ const signal = opts && opts.signal;
44
+ if (signal && signal.aborted)
45
+ return '';
46
+ const inst = createInstance(Component, props);
47
+ if (typeof inst.onMount === 'function') {
48
+ await inst.onMount();
49
+ }
50
+ if (signal && signal.aborted)
51
+ return '';
52
+ // production Direct emit: SSR only via Direct serialize schedule — never `render`.
53
+ if (!(Component && Component.__vmzDirect && typeof Component.__vmzCreate === 'function')) {
54
+ throw new Error(`vmz:dom renderToString() requires __vmzCreate (Direct); blueprint render() removed (production Direct emit)`);
55
+ }
56
+ const root = await runDirectSerializeTreeWithMounts(Component, inst);
57
+ if (opts && opts.slotHtml != null)
58
+ injectDefaultSlotHtml(root, opts.slotHtml);
59
+ return flattenSerializeNode(root);
60
+ }
61
+ /**
62
+ * Stream SSR via the same Direct serialize schedule as `renderToString`.
63
+ * Yields HTML chunks (open tag → children → close). Joining chunks equals `renderToString`.
64
+ * Supports AbortSignal for cancel; consumers should respect backpressure (await between chunks).
65
+ * @param {new (props?: object) => any} Component
66
+ * @param {object} [props]
67
+ * @param {{ signal?: AbortSignal, slotHtml?: string }} [opts]
68
+ * @returns {AsyncGenerator<string, void, void>}
69
+ */
70
+ export async function* renderToStream(Component, props = {}, opts = {}) {
71
+ ensureSsrDocument();
72
+ const signal = opts && opts.signal;
73
+ const aborted = () => Boolean(signal && signal.aborted);
74
+ if (aborted())
75
+ return;
76
+ const inst = createInstance(Component, props);
77
+ try {
78
+ if (typeof inst.onMount === 'function') {
79
+ await inst.onMount();
80
+ }
81
+ if (aborted())
82
+ return;
83
+ if (!(Component && Component.__vmzDirect && typeof Component.__vmzCreate === 'function')) {
84
+ throw new Error(`vmz:dom renderToStream() requires __vmzCreate (Direct); blueprint render() removed (production Direct emit)`);
85
+ }
86
+ const root = await runDirectSerializeTreeWithMounts(Component, inst);
87
+ if (opts && opts.slotHtml != null)
88
+ injectDefaultSlotHtml(root, opts.slotHtml);
89
+ if (aborted())
90
+ return;
91
+ for (const chunk of streamSerializeChunks(root)) {
92
+ if (aborted())
93
+ return;
94
+ yield chunk;
95
+ // Allow consumers / HTTP hosts to flush between chunks (backpressure point).
96
+ await Promise.resolve();
97
+ }
98
+ }
99
+ finally {
100
+ // Abort and normal completion both dispose the SSR instance (lifetime).
101
+ destroy(inst);
102
+ }
103
+ }
104
+ /**
105
+ * Fill the layout-owned default `<slot>` with pre-rendered HTML (layout SSR wrap).
106
+ * Skips nested component hosts (`data-vmz`) — their slots are for child projection
107
+ * (e.g. Button label), not the page outlet. Without this, DFS hits LocaleToggle→Button
108
+ * before Layout's `<main><slot>`, and the entire page HTML lands inside a button.
109
+ * @param {any} node
110
+ * @param {string} html
111
+ */
112
+ function injectDefaultSlotHtml(node, html) {
113
+ if (!node || typeof node !== 'object')
114
+ return false;
115
+ if (node.__kind === 'el' && node.tag === 'slot' && !(node.attrs && node.attrs.name)) {
116
+ node.__rawHtml = String(html ?? '');
117
+ node.children = [];
118
+ return true;
119
+ }
120
+ // Nested Direct component wrapper from serializeApi.component — do not search inside.
121
+ if (node.__kind === 'el' && node.attrs && node.attrs['data-vmz'] != null) {
122
+ return false;
123
+ }
124
+ const kids = node.children;
125
+ if (Array.isArray(kids)) {
126
+ for (const c of kids) {
127
+ if (injectDefaultSlotHtml(c, html))
128
+ return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+ /**
134
+ * Live-DOM counterpart: first default `<slot>` owned by this tree, not by a nested
135
+ * `[data-vmz]` component (Button/Link labels, etc.).
136
+ * @param {Element | null | undefined} root
137
+ * @returns {Element | null}
138
+ */
139
+ export function findOwnedDefaultSlot(root) {
140
+ if (!root || root.nodeType !== 1)
141
+ return null;
142
+ const tag = String(root.tagName || '').toLowerCase();
143
+ if (tag === 'slot' && !root.getAttribute('name'))
144
+ return root;
145
+ const kids = root.children;
146
+ if (!kids || !kids.length)
147
+ return null;
148
+ for (let i = 0; i < kids.length; i++) {
149
+ const c = kids[i];
150
+ if (c.nodeType !== 1)
151
+ continue;
152
+ // Nested component host — its slots are not the layout page outlet.
153
+ if (c.hasAttribute('data-vmz'))
154
+ continue;
155
+ const hit = findOwnedDefaultSlot(c);
156
+ if (hit)
157
+ return hit;
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * Hydrate/mount a file-route page inside an optional layout chain (outer → inner).
163
+ * Mirrors SSR `slotHtml` wrapping: each layout's owned default slot becomes the
164
+ * outlet for the next layout or the page. Retains layout instances on `container`
165
+ * so SPA transitions can dispose only the page host.
166
+ * @param {new (props?: object) => any} Page
167
+ * @param {Element} container
168
+ * @param {object} [props]
169
+ * @param {Array<new (props?: object) => any>} [layoutCtors] outer → inner
170
+ * @param {{ preserveState?: boolean | Record<string, unknown>, skipOnMount?: boolean }} [opts]
171
+ */
172
+ export async function hydrateRoute(Page, container, props = {}, layoutCtors = [], opts = {}) {
173
+ if (typeof document === 'undefined') {
174
+ throw new Error('vmz:dom hydrateRoute() requires a document (browser)');
175
+ }
176
+ if (container.__vmzInst) {
177
+ destroy(container.__vmzInst);
178
+ container.__vmzInst = null;
179
+ }
180
+ container.__vmzPageHost = null;
181
+ container.__vmzLayoutInsts = null;
182
+ /** @type {object[]} */
183
+ const layoutInsts = [];
184
+ let host = container;
185
+ const ctors = Array.isArray(layoutCtors) ? layoutCtors.filter(Boolean) : [];
186
+ for (const Layout of ctors) {
187
+ const inst = await mount(Layout, host, {});
188
+ layoutInsts.push(inst);
189
+ const slot = findOwnedDefaultSlot(inst.__vmzDomRoot);
190
+ const outlet = document.createElement('div');
191
+ outlet.setAttribute('data-vmz-outlet', '');
192
+ if (slot && slot.parentNode)
193
+ slot.replaceWith(outlet);
194
+ else if (inst.__vmzDomRoot && typeof inst.__vmzDomRoot.appendChild === 'function') {
195
+ inst.__vmzDomRoot.appendChild(outlet);
196
+ }
197
+ else {
198
+ host.appendChild(outlet);
199
+ }
200
+ host = outlet;
201
+ }
202
+ const pageInst = await hydrate(Page, host, props, opts);
203
+ container.__vmzPageHost = host;
204
+ container.__vmzLayoutInsts = layoutInsts;
205
+ // Outer layout (or page if no layouts) owns the #app instance for destroy().
206
+ container.__vmzInst = layoutInsts[0] || pageInst;
207
+ return pageInst;
208
+ }
209
+ /**
210
+ * SPA same-layout transition: dispose only the page host, keep layout instances.
211
+ * Callers must verify `data-vmz-layout` is unchanged before using this.
212
+ * @param {new (props?: object) => any} Page
213
+ * @param {Element} container `#app` that already has `__vmzPageHost` / `__vmzLayoutInsts`
214
+ * @param {object} [props]
215
+ * @param {{ preserveState?: boolean | Record<string, unknown>, skipOnMount?: boolean }} [opts]
216
+ */
217
+ export async function hydrateRoutePage(Page, container, props = {}, opts = {}) {
218
+ if (typeof document === 'undefined') {
219
+ throw new Error('vmz:dom hydrateRoutePage() requires a document (browser)');
220
+ }
221
+ const pageHost = container.__vmzPageHost || container;
222
+ if (pageHost.__vmzInst) {
223
+ destroy(pageHost.__vmzInst);
224
+ pageHost.__vmzInst = null;
225
+ }
226
+ const pageInst = await hydrate(Page, pageHost, props, opts);
227
+ container.__vmzPageHost = pageHost;
228
+ const layouts = container.__vmzLayoutInsts;
229
+ if (!Array.isArray(layouts) || layouts.length === 0) {
230
+ container.__vmzInst = pageInst;
231
+ }
232
+ return pageInst;
233
+ }
234
+ /**
235
+ * SSR: run the same __vmzCreate schedule against a serialize host (no render).
236
+ * @param {new (props?: object) => any} Component
237
+ * @param {object} inst
238
+ */
239
+ function runDirectSerializeTree(Component, inst) {
240
+ serializeApi._inst = inst;
241
+ try {
242
+ return Component.__vmzCreate.call(inst, serializeApi);
243
+ }
244
+ finally {
245
+ serializeApi._inst = null;
246
+ }
247
+ }
248
+ /**
249
+ * SSR child onMount: sync `__vmzCreate` cannot await nested mounts.
250
+ * Expand rounds — reuse prior child instances, await newly discovered onMounts, re-emit.
251
+ * @param {new (props?: object) => any} Component
252
+ * @param {object} inst
253
+ */
254
+ async function runDirectSerializeTreeWithMounts(Component, inst) {
255
+ /** @type {object[]} */
256
+ let preMounted = [];
257
+ /** @type {any} */
258
+ let tree = null;
259
+ for (let round = 0; round < 32; round++) {
260
+ serializeApi._ssrPreMounted = preMounted;
261
+ serializeApi._ssrPreIdx = 0;
262
+ serializeApi._ssrCollected = [];
263
+ tree = runDirectSerializeTree(Component, inst);
264
+ if (serializeApi._ssrPreIdx !== preMounted.length) {
265
+ throw new Error(`vmz:dom SSR child mount queue desync (used ${serializeApi._ssrPreIdx}, had ${preMounted.length})`);
266
+ }
267
+ const collected = serializeApi._ssrCollected;
268
+ serializeApi._ssrPreMounted = null;
269
+ serializeApi._ssrCollected = null;
270
+ if (!collected.length)
271
+ return tree;
272
+ for (const child of collected) {
273
+ if (typeof child.onMount === 'function') {
274
+ await child.onMount();
275
+ }
276
+ }
277
+ preMounted = preMounted.concat(collected);
278
+ }
279
+ throw new Error('vmz:dom SSR child onMount expansion exceeded 32 rounds');
280
+ }
281
+ function serializeOpenTag(node) {
282
+ const tag = node.tag || 'div';
283
+ let attrs = '';
284
+ for (const [k, v] of Object.entries(node.attrs || {})) {
285
+ if (v == null || v === false)
286
+ continue;
287
+ if (k === 'className')
288
+ attrs += ` class="${escapeHtml(v)}"`;
289
+ else
290
+ attrs += ` ${k}="${escapeHtml(v)}"`;
291
+ }
292
+ return { tag, open: `<${tag}${attrs}>` };
293
+ }
294
+ function flattenSerializeNode(node) {
295
+ if (node == null || node === false)
296
+ return '';
297
+ if (typeof node === 'string' || typeof node === 'number')
298
+ return escapeHtml(node);
299
+ if (node.__kind === 'text')
300
+ return escapeHtml(node.value);
301
+ if (node.__kind === 'frag') {
302
+ if (node.__rawHtml != null)
303
+ return String(node.__rawHtml);
304
+ return (node.children || []).map(flattenSerializeNode).join('');
305
+ }
306
+ if (node.__kind === 'el') {
307
+ const tag = node.tag || 'div';
308
+ if (tag === 'slot') {
309
+ if (node.__rawHtml != null)
310
+ return String(node.__rawHtml);
311
+ return (node.children || []).map(flattenSerializeNode).join('');
312
+ }
313
+ // rowKernel SSR: full outerHTML payload (do not re-wrap).
314
+ if (node.__rawOuter && node.__rawHtml != null)
315
+ return String(node.__rawHtml);
316
+ const { open } = serializeOpenTag(node);
317
+ if (node.__rawHtml != null) {
318
+ return `${open}${String(node.__rawHtml)}</${tag}>`;
319
+ }
320
+ const inner = (node.children || []).map(flattenSerializeNode).join('');
321
+ return `${open}${inner}</${tag}>`;
322
+ }
323
+ return '';
324
+ }
325
+ /**
326
+ * Progressive HTML chunks from a serialize tree (same nodes as flattenSerializeNode).
327
+ * @param {any} node
328
+ * @returns {Generator<string, void, void>}
329
+ */
330
+ function* streamSerializeChunks(node) {
331
+ if (node == null || node === false)
332
+ return;
333
+ if (typeof node === 'string' || typeof node === 'number') {
334
+ yield escapeHtml(node);
335
+ return;
336
+ }
337
+ if (node.__kind === 'text') {
338
+ yield escapeHtml(node.value);
339
+ return;
340
+ }
341
+ if (node.__kind === 'frag') {
342
+ if (node.__rawHtml != null) {
343
+ yield String(node.__rawHtml);
344
+ return;
345
+ }
346
+ for (const c of node.children || [])
347
+ yield* streamSerializeChunks(c);
348
+ return;
349
+ }
350
+ if (node.__kind === 'el') {
351
+ const tag = node.tag || 'div';
352
+ if (tag === 'slot') {
353
+ if (node.__rawHtml != null) {
354
+ yield String(node.__rawHtml);
355
+ return;
356
+ }
357
+ for (const c of node.children || [])
358
+ yield* streamSerializeChunks(c);
359
+ return;
360
+ }
361
+ if (node.__rawOuter && node.__rawHtml != null) {
362
+ yield String(node.__rawHtml);
363
+ return;
364
+ }
365
+ const { open } = serializeOpenTag(node);
366
+ yield open;
367
+ if (node.__rawHtml != null) {
368
+ yield String(node.__rawHtml);
369
+ }
370
+ else {
371
+ for (const c of node.children || [])
372
+ yield* streamSerializeChunks(c);
373
+ }
374
+ yield `</${tag}>`;
375
+ }
376
+ }
377
+ /**
378
+ * SSR row when `createItem` was omitted (rowKernel client emit).
379
+ * Hydrate a detached DOM node from `rowKernel.html`, then ship outerHTML.
380
+ * @param {object} inst
381
+ * @param {{ html: string, hydrate?: Function }} rk
382
+ * @param {{ item: any, index: number }} box
383
+ * @param {any} key
384
+ */
385
+ function serializeRowFromKernel(inst, rk, box, key) {
386
+ if (!ensureSsrDocument()) {
387
+ throw new Error('vmz:dom SSR rowKernel requires a document (createItem omitted)');
388
+ }
389
+ const tpl = document.createElement('template');
390
+ tpl.innerHTML = rk.html;
391
+ const root = tpl.content.firstElementChild;
392
+ if (!root || root.nodeType !== 1) {
393
+ throw new Error('vmz:dom SSR rowKernel html produced no element');
394
+ }
395
+ if (typeof rk.hydrate === 'function') {
396
+ rk.hydrate.call(inst, root, box.item);
397
+ }
398
+ if (key != null)
399
+ root.setAttribute('data-vmz-key', String(key));
400
+ return {
401
+ __kind: 'el',
402
+ tag: root.tagName.toLowerCase(),
403
+ attrs: Object.create(null),
404
+ children: [],
405
+ __rawOuter: true,
406
+ __rawHtml: root.outerHTML,
407
+ appendChild() { },
408
+ };
409
+ }
410
+ /** Serialize host mirroring directApi — returns virtual nodes, not DOM. */
411
+ const serializeApi = {
412
+ /** @type {object | null} */
413
+ _inst: null,
414
+ /** @type {null} */
415
+ _branchBinds: null,
416
+ /** @type {null} */
417
+ _itemPatches: null,
418
+ /** @type {object[] | null} reused child instances from prior SSR mount rounds */
419
+ _ssrPreMounted: null,
420
+ /** @type {number} */
421
+ _ssrPreIdx: 0,
422
+ /** @type {object[] | null} newly created child instances this round */
423
+ _ssrCollected: null,
424
+ /**
425
+ * @param {new (props?: object) => any} Ctor
426
+ * @param {object} resolved
427
+ */
428
+ _ssrChildInstance(Ctor, resolved) {
429
+ const pre = serializeApi._ssrPreMounted;
430
+ if (pre && serializeApi._ssrPreIdx < pre.length) {
431
+ return pre[serializeApi._ssrPreIdx++];
432
+ }
433
+ const child = createInstance(Ctor, resolved);
434
+ if (serializeApi._ssrCollected)
435
+ serializeApi._ssrCollected.push(child);
436
+ return child;
437
+ },
438
+ el(tag) {
439
+ return {
440
+ __kind: 'el',
441
+ tag: tag || 'div',
442
+ attrs: {},
443
+ children: [],
444
+ appendChild(c) {
445
+ if (c != null)
446
+ this.children.push(c);
447
+ },
448
+ };
449
+ },
450
+ text(value) {
451
+ return { __kind: 'text', value: value == null ? '' : String(value) };
452
+ },
453
+ frag() {
454
+ return {
455
+ __kind: 'frag',
456
+ children: [],
457
+ appendChild(c) {
458
+ if (c != null)
459
+ this.children.push(c);
460
+ },
461
+ };
462
+ },
463
+ attr(el, name, value) {
464
+ if (!el || el.__kind !== 'el')
465
+ return;
466
+ applySerializeAttr(el, name, value);
467
+ },
468
+ on() {
469
+ /* events are no-ops during SSR */
470
+ },
471
+ onMethod() {
472
+ /* named method events are also attached only during client resume */
473
+ },
474
+ bindText(inst, bindingId, deps, get, textNode) {
475
+ let raw = '';
476
+ try {
477
+ raw = get.call(inst);
478
+ }
479
+ catch {
480
+ raw = '';
481
+ }
482
+ textNode.value = String(raw ?? '');
483
+ },
484
+ bindAttr(inst, bindingId, deps, get, el, name) {
485
+ let raw;
486
+ try {
487
+ raw = get.call(inst);
488
+ }
489
+ catch {
490
+ raw = null;
491
+ }
492
+ applySerializeAttr(el, name, raw);
493
+ },
494
+ bindComponentProp() {
495
+ /* SSR: props already resolved into the child instance at create */
496
+ },
497
+ projectDefaultSlot(hostEl, node) {
498
+ if (!hostEl || node == null)
499
+ return;
500
+ // serializeApi.component returns a serialize el tree (or island shell).
501
+ const root = hostEl.__kind === 'el' ? hostEl : null;
502
+ const findSlot = (n) => {
503
+ if (!n || n.__kind !== 'el')
504
+ return null;
505
+ if (n.tag === 'slot' && !(n.attrs && n.attrs.name))
506
+ return n;
507
+ for (const c of n.children || []) {
508
+ const hit = findSlot(c);
509
+ if (hit)
510
+ return hit;
511
+ }
512
+ return null;
513
+ };
514
+ // Prefer searching the component body (first child of host wrapper).
515
+ let slot = null;
516
+ if (root) {
517
+ for (const c of root.children || []) {
518
+ slot = findSlot(c);
519
+ if (slot)
520
+ break;
521
+ }
522
+ if (!slot)
523
+ slot = findSlot(root);
524
+ }
525
+ if (slot) {
526
+ slot.__rawHtml = null;
527
+ if (!Array.isArray(slot.children))
528
+ slot.children = [];
529
+ // Append — multiple projectDefaultSlot calls must accumulate (SSR).
530
+ // Client path replaces the live <slot> then appends siblings; serialize must push.
531
+ slot.children.push(node);
532
+ return;
533
+ }
534
+ if (root)
535
+ root.appendChild(node);
536
+ },
537
+ setHtml(el, value) {
538
+ if (!el || el.__kind !== 'el')
539
+ return;
540
+ el.__rawHtml = value == null ? '' : String(value);
541
+ el.children = [];
542
+ },
543
+ bindHtml(inst, bindingId, deps, get, el) {
544
+ let raw = '';
545
+ try {
546
+ raw = get.call(inst);
547
+ }
548
+ catch {
549
+ raw = '';
550
+ }
551
+ el.__rawHtml = raw == null ? '' : String(raw);
552
+ el.children = [];
553
+ },
554
+ ifBlock(inst, bindingId, deps, branches) {
555
+ const host = {
556
+ __kind: 'el',
557
+ tag: 'span',
558
+ attrs: { 'data-vmz-if': '' },
559
+ children: [],
560
+ appendChild(c) {
561
+ if (c != null)
562
+ this.children.push(c);
563
+ },
564
+ };
565
+ let idx = -1;
566
+ for (let i = 0; i < branches.length; i++) {
567
+ const b = branches[i];
568
+ if (!b.cond) {
569
+ idx = i;
570
+ break;
571
+ }
572
+ try {
573
+ if (b.cond.call(inst)) {
574
+ idx = i;
575
+ break;
576
+ }
577
+ }
578
+ catch {
579
+ /* continue */
580
+ }
581
+ }
582
+ if (idx >= 0 && branches[idx].create) {
583
+ const created = branches[idx].create.call(inst, serializeApi);
584
+ if (created)
585
+ host.children.push(created);
586
+ }
587
+ return host;
588
+ },
589
+ eachBlock(inst, bindingId, deps, spec) {
590
+ const frag = serializeApi.frag();
591
+ let list = [];
592
+ try {
593
+ list = spec.list.call(inst) || [];
594
+ }
595
+ catch {
596
+ list = [];
597
+ }
598
+ if (!Array.isArray(list))
599
+ list = [...list];
600
+ for (let i = 0; i < list.length; i++) {
601
+ const box = { item: list[i], index: i };
602
+ let k = i;
603
+ if (typeof spec.key === 'function') {
604
+ try {
605
+ k = spec.key.call(inst, box);
606
+ }
607
+ catch {
608
+ k = i;
609
+ }
610
+ }
611
+ let dom = null;
612
+ if (typeof spec.createItem === 'function') {
613
+ dom = spec.createItem.call(inst, serializeApi, box);
614
+ }
615
+ else if (spec.rowKernel && typeof spec.rowKernel.html === 'string') {
616
+ dom = serializeRowFromKernel(inst, spec.rowKernel, box, k);
617
+ }
618
+ if (dom) {
619
+ // SSR only: serialize key into HTML for hydrate/debug. Direct client does not write this attr.
620
+ if (dom.__kind === 'el' && !dom.__rawOuter)
621
+ serializeApi.attr(dom, 'data-vmz-key', String(k));
622
+ frag.appendChild(dom);
623
+ }
624
+ }
625
+ return frag;
626
+ },
627
+ component(hostInst, name, props, client) {
628
+ const Ctor = getRegisteredComponent(name);
629
+ if (!Ctor)
630
+ throw new Error(`vmz:dom unknown component <${name} />`);
631
+ /** @type {Record<string, any>} */
632
+ const resolved = {};
633
+ for (const [k, v] of Object.entries(props || {})) {
634
+ if (typeof v === 'function' && isEventPropName(k))
635
+ continue;
636
+ else if (typeof v === 'function')
637
+ resolved[k] = v.call(hostInst);
638
+ else
639
+ resolved[k] = v;
640
+ }
641
+ if (client) {
642
+ // resume: Island SSR includes body + ResumeEntry slice (same Direct schedule).
643
+ const child = serializeApi._ssrChildInstance(Ctor, resolved);
644
+ let body = null;
645
+ if (Ctor.__vmzDirect && typeof Ctor.__vmzCreate === 'function') {
646
+ const prev = serializeApi._inst;
647
+ serializeApi._inst = child;
648
+ try {
649
+ body = Ctor.__vmzCreate.call(child, serializeApi);
650
+ }
651
+ finally {
652
+ serializeApi._inst = prev;
653
+ }
654
+ }
655
+ const state = snapshotInstanceState(child) || {};
656
+ const plan = Ctor.__vmzPlan || null;
657
+ const resume = {
658
+ schema: 'vmz.resume.v0',
659
+ component: name,
660
+ strategy: String(client),
661
+ props: stripFns(resolved),
662
+ state,
663
+ planSchema: plan?.schema || null,
664
+ planRootIds: plan?.root_ids || [],
665
+ };
666
+ /** @type {Record<string, string>} */
667
+ const attrs = {
668
+ 'data-vmz': name,
669
+ 'data-vmz-island': name,
670
+ 'data-vmz-client': String(client),
671
+ 'data-vmz-props': JSON.stringify(stripFns(resolved)),
672
+ 'data-vmz-resume': JSON.stringify(resume),
673
+ };
674
+ if (isEventEntryStrategy(String(client))) {
675
+ attrs['data-vmz-entry'] = 'event';
676
+ }
677
+ return {
678
+ __kind: 'el',
679
+ tag: 'div',
680
+ attrs,
681
+ children: body ? [body] : [],
682
+ appendChild(c) {
683
+ if (c != null)
684
+ this.children.push(c);
685
+ },
686
+ };
687
+ }
688
+ const child = serializeApi._ssrChildInstance(Ctor, resolved);
689
+ if (Ctor.__vmzDirect && typeof Ctor.__vmzCreate === 'function') {
690
+ const prev = serializeApi._inst;
691
+ serializeApi._inst = child;
692
+ try {
693
+ const node = Ctor.__vmzCreate.call(child, serializeApi);
694
+ return {
695
+ __kind: 'el',
696
+ tag: 'div',
697
+ attrs: { 'data-vmz': name },
698
+ children: node ? [node] : [],
699
+ appendChild(c) {
700
+ if (c != null)
701
+ this.children.push(c);
702
+ },
703
+ };
704
+ }
705
+ finally {
706
+ serializeApi._inst = prev;
707
+ }
708
+ }
709
+ throw new Error(`vmz:dom serialize component <${name}> requires __vmzCreate (rebuild child with Direct)`);
710
+ },
711
+ };
712
+ /**
713
+ * Serialize-tree attr write (SSR).
714
+ * @param {any} el
715
+ * @param {string} name
716
+ * @param {any} value
717
+ */
718
+ function applySerializeAttr(el, name, value) {
719
+ if (!el || el.__kind !== 'el')
720
+ return;
721
+ const key = name === 'className' ? 'class' : name;
722
+ if (BOOLEAN_HTML_ATTRS.has(String(key).toLowerCase())) {
723
+ if (value === false || value == null || value === '')
724
+ delete el.attrs[key];
725
+ else
726
+ el.attrs[key] = value === true ? '' : String(value);
727
+ return;
728
+ }
729
+ if (value == null || value === false)
730
+ delete el.attrs[key];
731
+ else
732
+ el.attrs[key] = value === true ? '' : String(value);
733
+ }
734
+ /**
735
+ * resume: attach to existing Island DOM without re-running construct structure or onMount.
736
+ * Consumes ResumeEntry product (`data-vmz-resume`) derived from the same Execution Plan.
737
+ * @param {new (props?: object) => any} Component
738
+ * @param {HTMLElement} container
739
+ * @param {{ props?: object, state?: Record<string, unknown>, strategy?: string } | null} [slice]
740
+ */
741
+ export async function resume(Component, container, slice = null) {
742
+ if (typeof document === 'undefined') {
743
+ throw new Error('vmz:dom resume() requires a document (browser)');
744
+ }
745
+ let parsed = slice;
746
+ if (!parsed) {
747
+ const raw = container.getAttribute('data-vmz-resume');
748
+ if (raw) {
749
+ try {
750
+ parsed = JSON.parse(raw);
751
+ }
752
+ catch {
753
+ parsed = null;
754
+ }
755
+ }
756
+ }
757
+ if (!parsed) {
758
+ let props = {};
759
+ try {
760
+ props = JSON.parse(container.getAttribute('data-vmz-props') || '{}');
761
+ }
762
+ catch {
763
+ props = {};
764
+ }
765
+ parsed = { props, state: {} };
766
+ }
767
+ if (container.__vmzInst) {
768
+ destroy(container.__vmzInst);
769
+ container.__vmzInst = null;
770
+ }
771
+ const props = parsed.props || {};
772
+ const inst = createInstance(Component, props);
773
+ if (parsed.state)
774
+ applyPreservedState(inst, parsed.state);
775
+ // Intentionally never call onMount — SSR already completed that work.
776
+ if (Component.__vmzDirect && typeof Component.__vmzCreate === 'function') {
777
+ if (!hasMeaningfulChild(container)) {
778
+ const node = runDirectCreate(Component, inst);
779
+ if (node) {
780
+ inst.__vmzDomRoot = node;
781
+ container.appendChild(node);
782
+ }
783
+ }
784
+ else {
785
+ // Island leaf adopt: preserve Element identity (resume nodeIdentity).
786
+ const node = runDirectResume(Component, inst, container);
787
+ if (node)
788
+ inst.__vmzDomRoot = node;
789
+ }
790
+ }
791
+ else {
792
+ throw new Error(`vmz:dom resume() requires __vmzCreate (Direct); blueprint render() removed (production Direct emit)`);
793
+ }
794
+ container.__vmzInst = inst;
795
+ container.__vmzResumed = true;
796
+ return inst;
797
+ }
798
+ /**
799
+ * Resume all `[data-vmz-island]` hosts (prefer ResumeEntry / EventEntry over mount).
800
+ * Event strategy islands wait for the DOM event before attach (lazy EventEntry).
801
+ * @param {ParentNode} [root]
802
+ */
803
+ export function resumeIslands(root = globalThis.document) {
804
+ if (!root || typeof root.querySelectorAll !== 'function') {
805
+ throw new Error('vmz:dom resumeIslands() requires a DOM root');
806
+ }
807
+ const nodes = [...root.querySelectorAll('[data-vmz-island]')];
808
+ for (const el of nodes) {
809
+ const name = el.getAttribute('data-vmz-island');
810
+ const strategy = el.getAttribute('data-vmz-client') || 'load';
811
+ scheduleClientOn(el, strategy, async () => {
812
+ const Ctor = await resolveComponent(name);
813
+ if (!Ctor) {
814
+ console.error(`vmz:dom resume: unknown component ${name}`);
815
+ return;
816
+ }
817
+ await resume(Ctor, el);
818
+ });
819
+ }
820
+ }
821
+ /**
822
+ * EventEntry attach: only wire `client:event` / `client:event:*` islands.
823
+ * Idle/load/visible ResumeEntries are left alone (static shell can defer framework work).
824
+ * @param {ParentNode} [root]
825
+ */
826
+ export function attachEventEntries(root = globalThis.document) {
827
+ if (!root || typeof root.querySelectorAll !== 'function') {
828
+ throw new Error('vmz:dom attachEventEntries() requires a DOM root');
829
+ }
830
+ const nodes = [...root.querySelectorAll('[data-vmz-island]')];
831
+ for (const el of nodes) {
832
+ const strategy = el.getAttribute('data-vmz-client') || '';
833
+ if (!isEventEntryStrategy(strategy))
834
+ continue;
835
+ const name = el.getAttribute('data-vmz-island');
836
+ el.setAttribute('data-vmz-entry', 'event');
837
+ scheduleClientOn(el, strategy, async () => {
838
+ const Ctor = await resolveComponent(name);
839
+ if (!Ctor) {
840
+ console.error(`vmz:dom EventEntry: unknown component ${name}`);
841
+ return;
842
+ }
843
+ await resume(Ctor, el);
844
+ });
845
+ }
846
+ }
847
+ /**
848
+ * Adopt existing Island DOM while running the same `__vmzCreate` schedule (resume).
849
+ * @param {new (props?: object) => any} Component
850
+ * @param {object} inst
851
+ * @param {Element} container
852
+ */
853
+ function runDirectResume(Component, inst, container) {
854
+ const rootEl = [...container.childNodes].find((n) => n.nodeType === 1 || (n.nodeType === 3 && String(n.textContent).trim() !== ''));
855
+ if (!rootEl || rootEl.nodeType !== 1) {
856
+ return runDirectCreate(Component, inst);
857
+ }
858
+ let textI = 0;
859
+ const api = {
860
+ _inst: inst,
861
+ _branchBinds: null,
862
+ _itemPatches: null,
863
+ el(tag) {
864
+ if (String(rootEl.tagName).toLowerCase() !== String(tag).toLowerCase()) {
865
+ noteDomCreate();
866
+ return document.createElement(tag);
867
+ }
868
+ return rootEl;
869
+ },
870
+ frag() {
871
+ return document.createDocumentFragment();
872
+ },
873
+ text(s) {
874
+ while (textI < rootEl.childNodes.length) {
875
+ const n = rootEl.childNodes[textI++];
876
+ if (n.nodeType === 3) {
877
+ if (s != null && s !== '')
878
+ n.textContent = String(s);
879
+ return n;
880
+ }
881
+ }
882
+ noteDomCreate();
883
+ return document.createTextNode(String(s ?? ''));
884
+ },
885
+ attr(el, name, value) {
886
+ applyDomAttr(el, name, value);
887
+ },
888
+ on(el, type, handler) {
889
+ el.addEventListener(type, handler);
890
+ },
891
+ bindText: directApi.bindText,
892
+ bindAttr: directApi.bindAttr,
893
+ bindComponentProp: directApi.bindComponentProp,
894
+ projectDefaultSlot: directApi.projectDefaultSlot,
895
+ setHtml: directApi.setHtml,
896
+ bindHtml: directApi.bindHtml,
897
+ ifBlock: directApi.ifBlock,
898
+ eachBlock: directApi.eachBlock,
899
+ component: directApi.component,
900
+ };
901
+ return Component.__vmzCreate.call(inst, api);
902
+ }
903
+ /**
904
+ * @param {new (props?: object) => any} Component
905
+ * @param {HTMLElement} container
906
+ * @param {object} [props]
907
+ * @param {{ preserveState?: boolean | Record<string, unknown>, skipOnMount?: boolean }} [opts]
908
+ */
909
+ export async function hydrate(Component, container, props = {}, opts = {}) {
910
+ if (typeof document === 'undefined') {
911
+ throw new Error('vmz:dom hydrate() requires a document (browser)');
912
+ }
913
+ /** @type {Record<string, unknown> | null} */
914
+ let preserved = null;
915
+ if (opts.preserveState && typeof opts.preserveState === 'object') {
916
+ preserved = opts.preserveState;
917
+ }
918
+ else if (opts.preserveState === true && container.__vmzInst) {
919
+ preserved = snapshotInstanceState(container.__vmzInst);
920
+ }
921
+ if (container.__vmzInst) {
922
+ destroy(container.__vmzInst);
923
+ container.__vmzInst = null;
924
+ }
925
+ const inst = createInstance(Component, props);
926
+ if (preserved) {
927
+ applyPreservedState(inst, preserved);
928
+ }
929
+ // production Direct emit: hydrate uses the same Direct schedule as resume (no render).
930
+ if (!(Component && Component.__vmzDirect && typeof Component.__vmzCreate === 'function')) {
931
+ throw new Error(`vmz:dom hydrate() requires __vmzCreate (Direct); blueprint render() removed (production Direct emit)`);
932
+ }
933
+ // Wire DOM + events BEFORE awaiting onMount. SSR shell is already visible; if we
934
+ // wait on RPC/bootstrap first, buttons look real but have no listeners (dead UI).
935
+ // onMount may still patch state / redirect afterwards (same end state as SSR order).
936
+ if (!hasMeaningfulChild(container)) {
937
+ const node = runDirectCreate(Component, inst);
938
+ if (node) {
939
+ inst.__vmzDomRoot = node;
940
+ container.appendChild(node);
941
+ }
942
+ }
943
+ else {
944
+ // Interim: shallow resume leaves if/each binders unbound. Recreate against live
945
+ // DOM so patches attach. Leaf nodeIdentity (same Element) remains TODO for deep adopt.
946
+ container.replaceChildren();
947
+ const node = runDirectCreate(Component, inst);
948
+ if (node) {
949
+ inst.__vmzDomRoot = node;
950
+ container.appendChild(node);
951
+ }
952
+ }
953
+ await settlePendingChildMounts(inst);
954
+ container.__vmzInst = inst;
955
+ const runMount = opts.skipOnMount !== true && !preserved && typeof inst.onMount === 'function';
956
+ if (runMount) {
957
+ await inst.onMount();
958
+ }
959
+ return inst;
960
+ }
961
+ /**
962
+ * @param {ParentNode} [root]
963
+ */
964
+ export function hydrateIslands(root = globalThis.document) {
965
+ // resume: hydrateIslands is an alias for resumeIslands (same Plan attach).
966
+ return resumeIslands(root);
967
+ }
968
+ function escapeHtml(s) {
969
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
970
+ }