@vmz/core 0.3.14 → 0.3.15

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.
@@ -127,6 +127,10 @@ export type DirectApi = {
127
127
  el: (tag: string) => Element;
128
128
  text: (value?: unknown) => Text;
129
129
  frag: () => DocumentFragment;
130
+ /** Multi-root `<template v-for>` row fragment (scoped on resume by key). */
131
+ fragRow: (key?: unknown) => DocumentFragment;
132
+ /** Closes the row scope opened by `fragRow`. */
133
+ rowEnd: () => void;
130
134
  comment: (value?: string) => Comment;
131
135
  attr: (el: Element, name: string, value: unknown) => void;
132
136
  mergeClass: (...parts: unknown[]) => string;
@@ -140,6 +144,7 @@ export type DirectApi = {
140
144
  onMethod: (el: Element, type: string, method: string) => void;
141
145
  onComponentEvent: (el: Element, type: string, method: string) => void;
142
146
  adoptEnter: (node: Element) => boolean;
147
+ adoptEnterSlot: (hostEl: Element, name: string) => boolean;
143
148
  adoptLeave: () => void;
144
149
  projectDefaultSlot: (hostEl: HTMLElement, node: Node) => void;
145
150
  projectNamedSlot: (hostEl: HTMLElement, name: string, node: Node) => void;
@@ -9,3 +9,14 @@ export declare function resolveDirectHostBox(name: string, Ctor: ComponentCtor |
9
9
  export declare function applyDirectHostBox(host: HTMLElement, name: string, Ctor: ComponentCtor | null | undefined): void;
10
10
  /** SSR attrs fragment for host box (only when contents). */
11
11
  export declare function directHostBoxStyleAttr(name: string, Ctor: ComponentCtor | null | undefined): string | null;
12
+ /**
13
+ * Host element tag (`div` vs `span`).
14
+ *
15
+ * `display: contents` chips are phrasing content, so a `<div>` host is invalid
16
+ * HTML inside `<p>`. The browser parser then closes the `<p>` early and moves
17
+ * the host (and its trailing text) out of it, which desynchronizes the SSR tree
18
+ * from the client tree and makes resume rebuild those nodes. Emitting a `<span>`
19
+ * host keeps the SSR markup parse-stable: the parsed tree matches the tree the
20
+ * client `__vmzCreate` builds.
21
+ */
22
+ export declare function resolveDirectHostTag(name: string, Ctor: ComponentCtor | null | undefined): 'div' | 'span';
@@ -23,3 +23,16 @@ export function applyDirectHostBox(host, name, Ctor) {
23
23
  export function directHostBoxStyleAttr(name, Ctor) {
24
24
  return resolveDirectHostBox(name, Ctor) === 'contents' ? 'display:contents' : null;
25
25
  }
26
+ /**
27
+ * Host element tag (`div` vs `span`).
28
+ *
29
+ * `display: contents` chips are phrasing content, so a `<div>` host is invalid
30
+ * HTML inside `<p>`. The browser parser then closes the `<p>` early and moves
31
+ * the host (and its trailing text) out of it, which desynchronizes the SSR tree
32
+ * from the client tree and makes resume rebuild those nodes. Emitting a `<span>`
33
+ * host keeps the SSR markup parse-stable: the parsed tree matches the tree the
34
+ * client `__vmzCreate` builds.
35
+ */
36
+ export function resolveDirectHostTag(name, Ctor) {
37
+ return resolveDirectHostBox(name, Ctor) === 'contents' ? 'span' : 'div';
38
+ }
@@ -25,8 +25,16 @@ export declare const directApi: {
25
25
  nsScopeDepth(): number;
26
26
  rewindNsScope(depth: any): void;
27
27
  adoptEnter(node: any): boolean;
28
+ adoptEnterSlot(hostEl: any, name: any): boolean;
28
29
  adoptLeave(): void;
29
30
  frag(): DocumentFragment;
31
+ /**
32
+ * Multi-root `<template v-for>` row. Resume narrows adoption to the SSR
33
+ * marker pair for this key; a key with no pair gets an empty scope so a new
34
+ * row can never steal nodes that belong to another key.
35
+ */
36
+ fragRow(key?: any): DocumentFragment;
37
+ rowEnd(): void;
30
38
  comment(value: any): Comment;
31
39
  insertBefore(parent: any, node: any, ref: any): void;
32
40
  removeNode(node: any): void;
@@ -1,6 +1,6 @@
1
1
  import { mergeClassParts, mergeStyleParts } from '../../shared/dom-attr-normalize.js';
2
2
  import { HTML_HASH_ATTR, htmlDigest } from '../../shared/html-digest.js';
3
- import { applyDirectHostBox } from '../direct-host-box.js';
3
+ import { applyDirectHostBox, resolveDirectHostTag } from '../direct-host-box.js';
4
4
  import { createUnknownComponentElement } from '../unknown-component.js';
5
5
  import { applyDomAttr, eventPropHandlerName } from './attributes.js';
6
6
  import { createInstance, destroy, disposeDomTree, findOwnedDefaultSlotTarget, findOwnedNamedSlot, insertProjectedSlotNode, parkSlotNode, runDirectCreate, slotAnchorFor, stripFns, } from './component.js';
@@ -192,15 +192,41 @@ export const directApi = {
192
192
  * new payload and refreshes the marker for later identical runs.
193
193
  */
194
194
  html(el, value) {
195
- const next = value == null ? '' : String(value);
195
+ let next;
196
+ if (value == null) {
197
+ // `null` is "this create has no payload yet", not "the payload is
198
+ // empty": a resumed host may still own the SSR payload, so hand the
199
+ // parked subtree back instead of clearing the server's markup. After
200
+ // the resume pass ends `_resumeAdopt` is gone and a null patch
201
+ // clears the host like Vue's `v-html` does.
202
+ const adopt = directApi._resumeAdopt;
203
+ if (adopt && typeof adopt.keepHtml === 'function' && adopt.keepHtml(el))
204
+ return;
205
+ // Already holding the SSR payload from an earlier null create: the
206
+ // create and its tracked patch both pass null, and only a real payload
207
+ // (or a host without one) may release the server's markup.
208
+ if (el.__vmzHtmlKeep)
209
+ return;
210
+ next = '';
211
+ }
212
+ else {
213
+ el.__vmzHtmlKeep = false;
214
+ next = String(value);
215
+ }
196
216
  const digest = htmlDigest(next);
197
217
  const adopt = directApi._resumeAdopt;
198
218
  if (adopt && typeof adopt.reclaimHtml === 'function' && adopt.reclaimHtml(el, digest))
199
219
  return;
200
- const marker = typeof el.getAttribute === 'function' ? el.getAttribute(HTML_HASH_ATTR) : null;
220
+ // The browser serializer expands self-closing tags (`<path/>` becomes
221
+ // `<path></path>`), so `innerHTML` never equals the generated string for
222
+ // SVG payloads. Compare the payload digest instead: an equal stamp means
223
+ // this host already holds exactly this payload, so a repeated patch must
224
+ // keep the live nodes (identity, focus, and `ResizeObserver` targets).
225
+ if (typeof el.getAttribute === 'function' && el.getAttribute(HTML_HASH_ATTR) === digest)
226
+ return;
201
227
  if (el.innerHTML !== next)
202
228
  el.innerHTML = next;
203
- if (marker != null)
229
+ if (typeof el.setAttribute === 'function')
204
230
  el.setAttribute(HTML_HASH_ATTR, digest);
205
231
  },
206
232
  nsScopeDepth() {
@@ -222,6 +248,17 @@ export const directApi = {
222
248
  }
223
249
  return false;
224
250
  },
251
+ adoptEnterSlot(hostEl, name) {
252
+ nsStack.push(isSvgNode(hostEl));
253
+ const adopt = directApi._resumeAdopt;
254
+ if (adopt && typeof adopt.enterSlot === 'function') {
255
+ const ok = adopt.enterSlot(hostEl, name);
256
+ if (ok)
257
+ adopt._enterBalance = (adopt._enterBalance || 0) + 1;
258
+ return ok;
259
+ }
260
+ return false;
261
+ },
225
262
  adoptLeave() {
226
263
  nsStack.pop();
227
264
  const adopt = directApi._resumeAdopt;
@@ -239,6 +276,22 @@ export const directApi = {
239
276
  noteDomCreate();
240
277
  return document.createDocumentFragment();
241
278
  },
279
+ /**
280
+ * Multi-root `<template v-for>` row. Resume narrows adoption to the SSR
281
+ * marker pair for this key; a key with no pair gets an empty scope so a new
282
+ * row can never steal nodes that belong to another key.
283
+ */
284
+ fragRow(key) {
285
+ const adopt = directApi._resumeAdopt;
286
+ if (adopt && typeof adopt.rowStart === 'function')
287
+ adopt.rowStart(key);
288
+ return directApi.frag();
289
+ },
290
+ rowEnd() {
291
+ const adopt = directApi._resumeAdopt;
292
+ if (adopt && typeof adopt.rowEnd === 'function')
293
+ adopt.rowEnd();
294
+ },
242
295
  comment(value) {
243
296
  noteDomCreate();
244
297
  return document.createComment(value == null ? '' : String(value));
@@ -307,6 +360,15 @@ export const directApi = {
307
360
  disposeDomTree(root);
308
361
  },
309
362
  attr(el, name, value) {
363
+ // `v-pre`: the authored pair goes out untouched — no `className` → `class`
364
+ // rewrite, no class/style merge, no property special-casing.
365
+ if (el.__vmzVerbatim) {
366
+ if (value == null || value === false)
367
+ el.removeAttribute(name);
368
+ else
369
+ el.setAttribute(name, value === true ? '' : String(value));
370
+ return;
371
+ }
310
372
  applyDomAttr(el, name, value);
311
373
  },
312
374
  mergeClass(...parts) {
@@ -481,9 +543,11 @@ export const directApi = {
481
543
  raw = get.call(inst);
482
544
  }
483
545
  catch {
484
- raw = '';
546
+ raw = null;
485
547
  }
486
- el.innerHTML = raw == null ? '' : String(raw);
548
+ // Same payload contract as the emitted `v-html` call: digest
549
+ // matching and resume adoption must not be bypassed here.
550
+ directApi.html(el, raw);
487
551
  }, bindingId);
488
552
  },
489
553
  /**
@@ -513,7 +577,7 @@ export const directApi = {
513
577
  }
514
578
  if (!host) {
515
579
  noteDomCreate();
516
- host = document.createElement('div');
580
+ host = document.createElement(resolveDirectHostTag(name, nameOrCtor));
517
581
  host.setAttribute('data-vmz', name);
518
582
  }
519
583
  if (key != null)
@@ -543,7 +607,7 @@ export const directApi = {
543
607
  }
544
608
  if (!host) {
545
609
  noteDomCreate();
546
- host = document.createElement('div');
610
+ host = document.createElement(resolveDirectHostTag(name, initial));
547
611
  host.setAttribute('data-vmz', name);
548
612
  }
549
613
  if (key != null)
@@ -7,6 +7,8 @@ export type ComponentCtor = ComponentCtorLike & {
7
7
  [key: string]: unknown;
8
8
  };
9
9
  export type VmzDomElement = Element & {
10
+ /** `v-pre` subtree: attributes are written exactly as authored. */
11
+ __vmzVerbatim?: boolean;
10
12
  __vmzEvt?: Record<string, EventListener>;
11
13
  __vmzObjEvt?: Record<string, {
12
14
  types: Record<string, {
@@ -80,12 +82,19 @@ export type ResumeAdoptCtx = {
80
82
  text: (value?: unknown) => Text;
81
83
  componentHost?: (name: string, key?: unknown) => HTMLElement | null;
82
84
  enter?: (node: Element) => boolean;
85
+ /** Open a named `<slot name="…">` projection scope inside a component host. */
86
+ enterSlot?: (hostEl: Element, name: string) => boolean;
83
87
  leave?: () => void;
84
88
  scopeDepth?: () => number;
85
89
  rewindScope?: (depth: number) => void;
86
90
  beginBranchScope?: () => () => void;
91
+ /** Open a multi-root `<template v-for>` row scope (claim the SSR marker pair). */
92
+ rowStart?: (key: unknown) => boolean;
93
+ rowEnd?: () => void;
87
94
  /** Keep the parked SSR subtree when its SSR payload digest equals `digest`. */
88
95
  reclaimHtml?: (el: Element, digest: string) => boolean;
96
+ /** Keep the parked SSR subtree when this create has no payload yet. */
97
+ keepHtml?: (el: Element) => boolean;
89
98
  _enterBalance?: number;
90
99
  inst?: DirectInstance;
91
100
  };
@@ -1,5 +1,6 @@
1
- import { directApi, findOwnedDefaultSlot, noteDomCreate, runDirectCreate, } from '../dom-core.js';
1
+ import { directApi, findOwnedDefaultSlot, findOwnedNamedSlot, noteDomCreate, runDirectCreate, } from '../dom-core.js';
2
2
  import { HTML_HASH_ATTR } from '../../shared/html-digest.js';
3
+ import { ROW_END_MARKER_PREFIX, ROW_MARKER_PREFIX, rowEndMarker, rowMarker } from '../../shared/row-key.js';
3
4
  const SVG_NS = 'http://www.w3.org/2000/svg';
4
5
  function createResumeAdopt(pool, rootEl) {
5
6
  const used = new WeakSet();
@@ -33,6 +34,13 @@ function createResumeAdopt(pool, rootEl) {
33
34
  n.__vmzResumePool = childPool;
34
35
  return n;
35
36
  };
37
+ /**
38
+ * Component hosts are claimed only by `componentHost`, never by plain element
39
+ * adoption. Hosts may be `<span>` (phrasing inline chips) or `<div>` (block
40
+ * surfaces), so tag matching alone would let a page `<span>` steal a chip host
41
+ * and force a rebuild.
42
+ */
43
+ const isComponentHost = (el) => typeof el.getAttribute === 'function' && el.getAttribute('data-vmz') != null;
36
44
  function* elementCandidates() {
37
45
  const scope = currentScope();
38
46
  if (scope === pool && rootEl && !used.has(rootEl))
@@ -80,7 +88,7 @@ function createResumeAdopt(pool, rootEl) {
80
88
  return fresh;
81
89
  }
82
90
  for (const el of elementCandidates()) {
83
- if (String(el.tagName).toLowerCase() === want)
91
+ if (String(el.tagName).toLowerCase() === want && !isComponentHost(el))
84
92
  return markElement(el);
85
93
  }
86
94
  noteDomCreate();
@@ -153,8 +161,6 @@ function createResumeAdopt(pool, rootEl) {
153
161
  const want = String(name || '');
154
162
  const wanted = key == null ? null : String(key);
155
163
  for (const el of elementCandidates()) {
156
- if (String(el.tagName).toLowerCase() !== 'div')
157
- continue;
158
164
  if (el.getAttribute('data-vmz') !== want)
159
165
  continue;
160
166
  if (wanted != null && el.getAttribute('data-vmz-key') !== wanted)
@@ -187,9 +193,42 @@ function createResumeAdopt(pool, rootEl) {
187
193
  return true;
188
194
  }
189
195
  }
190
- if (!node.__vmzResumePool)
196
+ // Emitted `adoptEnter` is always paired with `adoptLeave`, but a node that
197
+ // has no SSR counterpart (fresh element or fresh component host) has no
198
+ // parked pool. Returning without pushing made that paired leave pop the
199
+ // caller's scope instead, so each rebuilt node drifted the stack one level
200
+ // up and later projection siblings (slot roots 2..n) lost their pool.
201
+ // Push an isolated empty scope: the rebuilt subtree has no SSR counterpart
202
+ // to reclaim, and its children must not steal the enclosing siblings.
203
+ scopeStack.push(node.__vmzResumePool || document.createDocumentFragment());
204
+ return true;
205
+ };
206
+ /**
207
+ * Push the parked pool of an owned named `<slot name="…">` outlet.
208
+ *
209
+ * SSR inlines named projections exactly like the default outlet, so the nodes
210
+ * a page projects into `#header` sit under the named outlet and stay
211
+ * unreachable from the default pool that `enter(host)` pushes. Without this
212
+ * scope the named projection is rebuilt on every load while the default
213
+ * projection is reclaimed. A host without that outlet (fresh component or a
214
+ * name the component never declares) gets an isolated empty scope so the
215
+ * emitted enter/leave pair always stays balanced.
216
+ */
217
+ const enterSlot = (hostEl, name) => {
218
+ if (!hostEl)
191
219
  return false;
192
- scopeStack.push(node.__vmzResumePool);
220
+ const wanted = name == null ? '' : String(name);
221
+ if (!wanted || wanted === 'default')
222
+ return enter(hostEl);
223
+ const owner = hostEl.__vmzInst;
224
+ const root = (owner && owner.__vmzDomRoot) || hostEl;
225
+ const slot = root && root.nodeType === 1 ? findOwnedNamedSlot(root, wanted) : null;
226
+ const slotPool = slot && slot.__vmzResumePool;
227
+ if (slotPool && hasUnusedNode(slotPool)) {
228
+ scopeStack.push(slotPool);
229
+ return true;
230
+ }
231
+ scopeStack.push(document.createDocumentFragment());
193
232
  return true;
194
233
  };
195
234
  const leave = () => {
@@ -251,18 +290,117 @@ function createResumeAdopt(pool, rootEl) {
251
290
  el.appendChild(childPool.firstChild);
252
291
  return true;
253
292
  };
293
+ /**
294
+ * `v-html` create with no payload yet (e.g. an async highlighter that has
295
+ * not produced one). The host may still own the SSR payload: hand the parked
296
+ * subtree back and leave the SSR stamp untouched so a later authoritative
297
+ * patch can still match it. The host keeps that payload until a real payload
298
+ * lands; once one does, a null patch clears it like `v-html` always has.
299
+ */
300
+ const keepHtml = (el) => {
301
+ if (!used.has(el))
302
+ return false;
303
+ // The emitted `v-html` create and its tracked patch both run with a null
304
+ // payload (the patch applies once right after create), so the hold must
305
+ // outlive the resume pass: one restore is enough until a payload lands.
306
+ if (el.__vmzHtmlKeep)
307
+ return true;
308
+ const childPool = el.__vmzResumePool;
309
+ if (!childPool || !childPool.firstChild)
310
+ return false;
311
+ while (childPool.firstChild)
312
+ el.appendChild(childPool.firstChild);
313
+ el.__vmzResumePool = null;
314
+ el.__vmzHtmlKeep = true;
315
+ return true;
316
+ };
317
+ /**
318
+ * Scope adoption to one multi-root `<template v-for>` row.
319
+ *
320
+ * The row has no element root to carry `data-vmz-key`, so SSR wrote a
321
+ * `<!--vmz-row:KEY-->…<!--vmz-row-end:KEY-->` pair around its nodes. Claim
322
+ * exactly that pair, move its inner nodes into a private scope, and drop the
323
+ * markers so the resumed DOM matches a client-created row. A missing or
324
+ * unbalanced pair means "this row is not in SSR": push an empty scope so the
325
+ * row is built fresh instead of stealing a sibling row's nodes.
326
+ */
327
+ const rowStart = (key) => {
328
+ const scope = currentScope();
329
+ const rowPool = document.createDocumentFragment();
330
+ let claimed = false;
331
+ if (key != null) {
332
+ const wanted = rowMarker(key);
333
+ const wantedEnd = rowEndMarker(key);
334
+ const nodes = [...scope.childNodes];
335
+ let start = -1;
336
+ for (let i = 0; i < nodes.length; i++) {
337
+ const n = nodes[i];
338
+ if (n.nodeType !== 8)
339
+ continue;
340
+ if (String(n.nodeValue || '') === wanted) {
341
+ start = i;
342
+ break;
343
+ }
344
+ }
345
+ let end = -1;
346
+ if (start >= 0) {
347
+ // Nested rows directly inside this row contribute their own pair at
348
+ // the same sibling level, so pairing is depth-counted.
349
+ let depth = 0;
350
+ for (let i = start + 1; i < nodes.length; i++) {
351
+ const n = nodes[i];
352
+ if (n.nodeType !== 8)
353
+ continue;
354
+ const data = String(n.nodeValue || '');
355
+ if (data.startsWith(ROW_MARKER_PREFIX)) {
356
+ depth += 1;
357
+ }
358
+ else if (data.startsWith(ROW_END_MARKER_PREFIX)) {
359
+ if (depth === 0) {
360
+ if (data === wantedEnd)
361
+ end = i;
362
+ break;
363
+ }
364
+ depth -= 1;
365
+ }
366
+ }
367
+ }
368
+ if (end > start) {
369
+ const startMarker = nodes[start];
370
+ const endMarker = nodes[end];
371
+ for (let i = start + 1; i < end; i++)
372
+ rowPool.appendChild(nodes[i]);
373
+ if (startMarker.parentNode === scope)
374
+ scope.removeChild(startMarker);
375
+ if (endMarker.parentNode === scope)
376
+ scope.removeChild(endMarker);
377
+ claimed = true;
378
+ }
379
+ }
380
+ scopeStack.push(rowPool);
381
+ return claimed;
382
+ };
383
+ const rowEnd = () => {
384
+ if (scopeStack.length <= 1)
385
+ return;
386
+ scopeStack.pop();
387
+ };
254
388
  return {
255
389
  el: adoptElement,
256
390
  elNS: adoptElementNS,
257
391
  claimKeyed,
392
+ rowStart,
393
+ rowEnd,
258
394
  text: adoptText,
259
395
  componentHost,
260
396
  enter,
397
+ enterSlot,
261
398
  leave,
262
399
  scopeDepth,
263
400
  rewindScope,
264
401
  beginBranchScope,
265
402
  reclaimHtml,
403
+ keepHtml,
266
404
  };
267
405
  }
268
406
  export function runDirectResume(Component, inst, container) {
@@ -0,0 +1,13 @@
1
+ /** SSR row identity for multi-root `<template v-for>` rows. */
2
+ export declare const ROW_MARKER_PREFIX = "vmz-row:";
3
+ export declare const ROW_END_MARKER_PREFIX = "vmz-row-end:";
4
+ /**
5
+ * Comment-safe row key token.
6
+ *
7
+ * Row identity travels inside HTML comments, where `--` is illegal. Encode the
8
+ * key once (both SSR writer and resume reader use this same function) and strip
9
+ * the remaining hyphens so no key can ever close the comment early.
10
+ */
11
+ export declare function rowKeyToken(key: unknown): string;
12
+ export declare function rowMarker(key: unknown): string;
13
+ export declare function rowEndMarker(key: unknown): string;
@@ -0,0 +1,19 @@
1
+ /** SSR row identity for multi-root `<template v-for>` rows. */
2
+ export const ROW_MARKER_PREFIX = 'vmz-row:';
3
+ export const ROW_END_MARKER_PREFIX = 'vmz-row-end:';
4
+ /**
5
+ * Comment-safe row key token.
6
+ *
7
+ * Row identity travels inside HTML comments, where `--` is illegal. Encode the
8
+ * key once (both SSR writer and resume reader use this same function) and strip
9
+ * the remaining hyphens so no key can ever close the comment early.
10
+ */
11
+ export function rowKeyToken(key) {
12
+ return encodeURIComponent(String(key)).replace(/-/g, '%2D');
13
+ }
14
+ export function rowMarker(key) {
15
+ return `${ROW_MARKER_PREFIX}${rowKeyToken(key)}`;
16
+ }
17
+ export function rowEndMarker(key) {
18
+ return `${ROW_END_MARKER_PREFIX}${rowKeyToken(key)}`;
19
+ }
@@ -1,8 +1,9 @@
1
- import { directHostBoxStyleAttr } from '../browser/direct-host-box.js';
1
+ import { directHostBoxStyleAttr, resolveDirectHostTag } from '../browser/direct-host-box.js';
2
2
  import { BOOLEAN_HTML_ATTRS, createInstance, eventPropHandlerName, getRegisteredComponent, isEventEntryStrategy, mergeClassParts, mergeStyleParts, snapshotInstanceState, stripFns, } from '../browser/dom-core.js';
3
3
  import { serializeUnknownComponentNode } from '../browser/unknown-component.js';
4
4
  import { splitStyleDeclarations } from '../shared/dom-attr-normalize.js';
5
5
  import { HTML_HASH_ATTR, htmlDigest } from '../shared/html-digest.js';
6
+ import { rowEndMarker, rowMarker } from '../shared/row-key.js';
6
7
  import { _ssrDocumentLastError, ensureSsrDocument } from './document.js';
7
8
  import { awaitSsrOnMount, ssrComponentName, ssrOnMountContext } from './onmount.js';
8
9
  function runDirectSerializeTree(Component, inst) {
@@ -141,26 +142,33 @@ function serializeStyleAttr(node) {
141
142
  function serializeOpenTag(node) {
142
143
  const tag = node.tag || 'div';
143
144
  const lowerTag = String(tag).toLowerCase();
145
+ // `v-pre`: the authored attribute names are the output. Skipping the four
146
+ // special branches here is what keeps `className` / `style` / `value` /
147
+ // `data-vmz-html-hash` literal inside a verbatim subtree.
148
+ const verbatim = node.__vmzVerbatim === true;
144
149
  let attrs = '';
145
150
  for (const [k, v] of Object.entries(node.attrs || {})) {
146
151
  if (v == null || v === false)
147
152
  continue;
148
- // `textarea` value renders as text content and `select` value as a
149
- // selected option, so the raw attribute must not be emitted.
150
- if (k === 'value' && (lowerTag === 'textarea' || lowerTag === 'select'))
151
- continue;
152
- if (k === 'style')
153
- continue;
154
- // Always replaced by the digest of the payload written below, so an
155
- // author-supplied marker can never mask a different payload.
156
- if (k === HTML_HASH_ATTR)
157
- continue;
158
- if (k === 'className')
159
- attrs += ` class="${escapeHtml(v)}"`;
160
- else
161
- attrs += ` ${k}="${escapeHtml(v)}"`;
153
+ if (!verbatim) {
154
+ // `textarea` value renders as text content and `select` value as a
155
+ // selected option, so the raw attribute must not be emitted.
156
+ if (k === 'value' && (lowerTag === 'textarea' || lowerTag === 'select'))
157
+ continue;
158
+ if (k === 'style')
159
+ continue;
160
+ // Always replaced by the digest of the payload written below, so an
161
+ // author-supplied marker can never mask a different payload.
162
+ if (k === HTML_HASH_ATTR)
163
+ continue;
164
+ if (k === 'className') {
165
+ attrs += ` class="${escapeHtml(v)}"`;
166
+ continue;
167
+ }
168
+ }
169
+ attrs += ` ${k}="${escapeHtml(v)}"`;
162
170
  }
163
- const style = serializeStyleAttr(node);
171
+ const style = verbatim ? '' : serializeStyleAttr(node);
164
172
  if (style)
165
173
  attrs += ` style="${escapeHtml(style)}"`;
166
174
  // `v-html` hosts carry the digest of the exact string SSR wrote, so the
@@ -182,7 +190,14 @@ export function flattenSerializeNode(node) {
182
190
  if (node.__kind === 'frag') {
183
191
  if (node.__rawHtml != null)
184
192
  return String(node.__rawHtml);
185
- return (node.children || []).map(flattenSerializeNode).join('');
193
+ const inner = (node.children || []).map(flattenSerializeNode).join('');
194
+ // Multi-root `<template v-for>` rows have no element root to carry
195
+ // `data-vmz-key`, so their identity travels as a marker pair around the
196
+ // row's nodes. Resume reads the same pair and scopes adoption to it.
197
+ if (node.__vmzRowKey != null) {
198
+ return `<!--${rowMarker(node.__vmzRowKey)}-->${inner}<!--${rowEndMarker(node.__vmzRowKey)}-->`;
199
+ }
200
+ return inner;
186
201
  }
187
202
  if (node.__kind === 'el') {
188
203
  const tag = node.tag || 'div';
@@ -232,6 +247,13 @@ export function* streamSerializeChunks(node) {
232
247
  yield String(node.__rawHtml);
233
248
  return;
234
249
  }
250
+ if (node.__vmzRowKey != null) {
251
+ yield `<!--${rowMarker(node.__vmzRowKey)}-->`;
252
+ for (const c of node.children || [])
253
+ yield* streamSerializeChunks(c);
254
+ yield `<!--${rowEndMarker(node.__vmzRowKey)}-->`;
255
+ return;
256
+ }
235
257
  for (const c of node.children || [])
236
258
  yield* streamSerializeChunks(c);
237
259
  return;
@@ -578,6 +600,16 @@ const serializeApi = {
578
600
  },
579
601
  };
580
602
  },
603
+ /** Multi-root `<template v-for>` row fragment; serializes its marker pair. */
604
+ fragRow(key) {
605
+ const frag = serializeApi.frag();
606
+ if (key != null)
607
+ frag.__vmzRowKey = String(key);
608
+ return frag;
609
+ },
610
+ rowEnd() {
611
+ /* SSR rows are one-shot: the marker pair closes at serialization. */
612
+ },
581
613
  insertBefore(parent, node, ref) {
582
614
  virtualInsertBefore(parent, node, ref);
583
615
  },
@@ -627,6 +659,15 @@ const serializeApi = {
627
659
  attr(el, name, value) {
628
660
  if (!el || el.__kind !== 'el')
629
661
  return;
662
+ // `v-pre`: keep the authored name and value — no `className` → `class`
663
+ // rewrite, no class/style merge, no boolean-attribute normalization.
664
+ if (el.__vmzVerbatim) {
665
+ if (value == null || value === false)
666
+ delete el.attrs[name];
667
+ else
668
+ el.attrs[name] = value === true ? '' : String(value);
669
+ return;
670
+ }
630
671
  applySerializeAttr(el, name, value);
631
672
  },
632
673
  mergeClass(...parts) {
@@ -644,6 +685,9 @@ const serializeApi = {
644
685
  adoptEnter() {
645
686
  return false;
646
687
  },
688
+ adoptEnterSlot() {
689
+ return false;
690
+ },
647
691
  adoptLeave() {
648
692
  /* resume scope only */
649
693
  },
@@ -893,7 +937,7 @@ const serializeApi = {
893
937
  }
894
938
  return {
895
939
  __kind: 'el',
896
- tag: 'div',
940
+ tag: resolveDirectHostTag(name, Ctor),
897
941
  attrs,
898
942
  children: body ? [body] : [],
899
943
  appendChild(c) {
@@ -923,7 +967,7 @@ const serializeApi = {
923
967
  attrs['data-vmz-key'] = String(key);
924
968
  return {
925
969
  __kind: 'el',
926
- tag: 'div',
970
+ tag: resolveDirectHostTag(name, Ctor),
927
971
  attrs,
928
972
  children: node ? [node] : [],
929
973
  appendChild(c) {
package/package.json CHANGED
@@ -68,12 +68,12 @@
68
68
  "main": "./dist/faces/server.js",
69
69
  "name": "@vmz/core",
70
70
  "optionalDependencies": {
71
- "@vmz/vmz-darwin-arm64": "0.3.14",
72
- "@vmz/vmz-darwin-x64": "0.3.14",
73
- "@vmz/vmz-linux-arm64": "0.3.14",
74
- "@vmz/vmz-linux-x64": "0.3.14",
75
- "@vmz/vmz-win32-arm64": "0.3.14",
76
- "@vmz/vmz-win32-x64": "0.3.14"
71
+ "@vmz/vmz-darwin-arm64": "0.3.15",
72
+ "@vmz/vmz-darwin-x64": "0.3.15",
73
+ "@vmz/vmz-linux-arm64": "0.3.15",
74
+ "@vmz/vmz-linux-x64": "0.3.15",
75
+ "@vmz/vmz-win32-arm64": "0.3.15",
76
+ "@vmz/vmz-win32-x64": "0.3.15"
77
77
  },
78
78
  "publishConfig": {
79
79
  "access": "public"
@@ -88,5 +88,5 @@
88
88
  },
89
89
  "type": "module",
90
90
  "types": "./dist/faces/index.d.ts",
91
- "version": "0.3.14"
91
+ "version": "0.3.15"
92
92
  }