@vmz/core 0.1.24 → 0.1.25

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.
@@ -71,6 +71,14 @@ export declare function settlePendingChildMounts(inst: any): Promise<void>;
71
71
  */
72
72
  export declare function runDirectCreate(Component: any, inst: any): any;
73
73
  /** Host API for compiler-emitted `__vmzCreate` (direct path, Program IR B). */
74
+ /**
75
+ * First default `<slot>` owned by this component tree — skip nested `[data-vmz]`
76
+ * hosts (Button/Link/Empty label slots). `querySelector('slot')` steals those and
77
+ * projects parent siblings into the wrong host (commercial Drawer into Create btn).
78
+ * @param {Element | null | undefined} root
79
+ * @returns {Element | null}
80
+ */
81
+ export declare function findOwnedDefaultSlot(root: any): any;
74
82
  export declare const directApi: {
75
83
  /** @type {object | null} */
76
84
  _inst: any;
@@ -86,8 +94,22 @@ export declare const directApi: {
86
94
  * }}
87
95
  */
88
96
  _eachCtx: any;
97
+ /** @type {null | {
98
+ * el: (tag: string) => Element,
99
+ * text: (value?: any) => Text,
100
+ * componentHost?: (name: string) => HTMLElement | null,
101
+ * enter?: (node: Element) => boolean,
102
+ * leave?: () => void,
103
+ * scopeDepth?: () => number,
104
+ * rewindScope?: (depth: number) => void,
105
+ * beginBranchScope?: () => () => void,
106
+ * }} */
107
+ _resumeAdopt: any;
89
108
  el(tag: any): any;
90
- text(value: any): Text;
109
+ text(value: any): any;
110
+ /** Push adopted node's private resume pool for depth-first child create. */
111
+ adoptEnter(node: any): any;
112
+ adoptLeave(): void;
91
113
  frag(): DocumentFragment;
92
114
  attr(el: any, name: any, value: any): void;
93
115
  on(el: any, type: any, handler: any): void;
@@ -138,7 +160,7 @@ export declare const directApi: {
138
160
  * @param {Record<string, any>} props
139
161
  * @param {string | null} client
140
162
  */
141
- component(hostInst: any, name: any, props: any, client: any): HTMLDivElement;
163
+ component(hostInst: any, name: any, props: any, client: any): any;
142
164
  /**
143
165
  * Keep nested Direct child props live with parent field writes.
144
166
  * @param {object} hostInst
@@ -150,6 +172,7 @@ export declare const directApi: {
150
172
  bindComponentProp(hostInst: any, hostEl: any, propName: any, deps: any, get: any): void;
151
173
  /**
152
174
  * Project parent children into nested Direct component default `<slot>`.
175
+ * Uses {@link findOwnedDefaultSlot} so nested Button/Link slots are not stolen.
153
176
  * @param {HTMLElement} hostEl
154
177
  * @param {Node} node
155
178
  */
package/dist/dom-core.js CHANGED
@@ -262,6 +262,34 @@ export function runDirectCreate(Component, inst) {
262
262
  }
263
263
  }
264
264
  /** Host API for compiler-emitted `__vmzCreate` (direct path, Program IR B). */
265
+ /**
266
+ * First default `<slot>` owned by this component tree — skip nested `[data-vmz]`
267
+ * hosts (Button/Link/Empty label slots). `querySelector('slot')` steals those and
268
+ * projects parent siblings into the wrong host (commercial Drawer into Create btn).
269
+ * @param {Element | null | undefined} root
270
+ * @returns {Element | null}
271
+ */
272
+ export function findOwnedDefaultSlot(root) {
273
+ if (!root || root.nodeType !== 1)
274
+ return null;
275
+ const tag = String(root.tagName || '').toLowerCase();
276
+ if (tag === 'slot' && !root.getAttribute('name'))
277
+ return root;
278
+ const kids = root.children;
279
+ if (!kids || !kids.length)
280
+ return null;
281
+ for (let i = 0; i < kids.length; i++) {
282
+ const c = kids[i];
283
+ if (c.nodeType !== 1)
284
+ continue;
285
+ if (c.hasAttribute('data-vmz'))
286
+ continue;
287
+ const hit = findOwnedDefaultSlot(c);
288
+ if (hit)
289
+ return hit;
290
+ }
291
+ return null;
292
+ }
265
293
  export const directApi = {
266
294
  /** @type {object | null} */
267
295
  _inst: null,
@@ -277,14 +305,52 @@ export const directApi = {
277
305
  * }}
278
306
  */
279
307
  _eachCtx: null,
308
+ /** @type {null | {
309
+ * el: (tag: string) => Element,
310
+ * text: (value?: any) => Text,
311
+ * componentHost?: (name: string) => HTMLElement | null,
312
+ * enter?: (node: Element) => boolean,
313
+ * leave?: () => void,
314
+ * scopeDepth?: () => number,
315
+ * rewindScope?: (depth: number) => void,
316
+ * beginBranchScope?: () => () => void,
317
+ * }} */
318
+ _resumeAdopt: null,
280
319
  el(tag) {
320
+ if (directApi._resumeAdopt)
321
+ return directApi._resumeAdopt.el(tag);
281
322
  noteDomCreate();
282
323
  return document.createElement(tag || 'div');
283
324
  },
284
325
  text(value) {
326
+ if (directApi._resumeAdopt)
327
+ return directApi._resumeAdopt.text(value);
285
328
  noteDomCreate();
286
329
  return document.createTextNode(value == null ? '' : String(value));
287
330
  },
331
+ /** Push adopted node's private resume pool for depth-first child create. */
332
+ adoptEnter(node) {
333
+ const adopt = directApi._resumeAdopt;
334
+ if (adopt && typeof adopt.enter === 'function') {
335
+ const ok = adopt.enter(node);
336
+ if (ok)
337
+ adopt._enterBalance = (adopt._enterBalance || 0) + 1;
338
+ return ok;
339
+ }
340
+ return false;
341
+ },
342
+ adoptLeave() {
343
+ const adopt = directApi._resumeAdopt;
344
+ // Emit always pairs leave after enter; if enter failed (fresh node, no
345
+ // pool), do not pop — unbalanced leave previously stole parent scopes
346
+ // and projected siblings into nested Button slots.
347
+ if (!adopt || typeof adopt.leave !== 'function')
348
+ return;
349
+ if ((adopt._enterBalance || 0) <= 0)
350
+ return;
351
+ adopt._enterBalance -= 1;
352
+ adopt.leave();
353
+ },
288
354
  frag() {
289
355
  noteDomCreate();
290
356
  return document.createDocumentFragment();
@@ -426,9 +492,16 @@ export const directApi = {
426
492
  * @param {string | null} client
427
493
  */
428
494
  component(hostInst, name, props, client) {
429
- noteDomCreate();
430
- const host = document.createElement('div');
431
- host.setAttribute('data-vmz', name);
495
+ /** @type {HTMLElement | null} */
496
+ let host = null;
497
+ if (directApi._resumeAdopt && typeof directApi._resumeAdopt.componentHost === 'function') {
498
+ host = directApi._resumeAdopt.componentHost(name);
499
+ }
500
+ if (!host) {
501
+ noteDomCreate();
502
+ host = document.createElement('div');
503
+ host.setAttribute('data-vmz', name);
504
+ }
432
505
  /** @type {Record<string, any>} */
433
506
  const resolved = {};
434
507
  for (const [k, v] of Object.entries(props || {})) {
@@ -471,9 +544,23 @@ export const directApi = {
471
544
  if (!(Ctor.__vmzDirect && typeof Ctor.__vmzCreate === 'function')) {
472
545
  throw new Error(`vmz:dom direct component <${name}> requires __vmzCreate (rebuild child with Direct)`);
473
546
  }
474
- const node = runDirectCreate(Ctor, child);
547
+ // Nested resume: keep parent `_resumeAdopt` so child reclaim parked SSR nodes.
548
+ // Enter the host's private child pool so nested create cannot see uncle siblings.
549
+ const adopt = directApi._resumeAdopt;
550
+ const entered = adopt && typeof adopt.enter === 'function' ? adopt.enter(host) : false;
551
+ let node;
552
+ try {
553
+ node = runDirectCreate(Ctor, child);
554
+ }
555
+ finally {
556
+ if (entered && adopt && typeof adopt.leave === 'function')
557
+ adopt.leave();
558
+ }
475
559
  if (node) {
476
- child.__vmzDomRoot = node;
560
+ // ifBlock/eachBlock roots are DocumentFragments: appendChild moves
561
+ // children into `host` and empties the fragment. Keep a live Element
562
+ // root so projectDefaultSlot can find `<slot>` (Button v-if/v-else).
563
+ child.__vmzDomRoot = node.nodeType === 11 ? host : node;
477
564
  host.appendChild(node);
478
565
  }
479
566
  host.__vmzInst = child;
@@ -524,6 +611,7 @@ export const directApi = {
524
611
  },
525
612
  /**
526
613
  * Project parent children into nested Direct component default `<slot>`.
614
+ * Uses {@link findOwnedDefaultSlot} so nested Button/Link slots are not stolen.
527
615
  * @param {HTMLElement} hostEl
528
616
  * @param {Node} node
529
617
  */
@@ -531,22 +619,20 @@ export const directApi = {
531
619
  if (!hostEl || node == null)
532
620
  return;
533
621
  const child = hostEl.__vmzInst;
534
- const root = (child && child.__vmzDomRoot) || hostEl;
622
+ let root = (child && child.__vmzDomRoot) || hostEl;
623
+ // Emptied DocumentFragment after append must not receive slot kids.
624
+ if (!root || root.nodeType !== 1)
625
+ root = hostEl;
535
626
  /** @type {Element | null} */
536
627
  let slot = null;
537
628
  if (root && root.nodeType === 1) {
538
- if (String(root.tagName || '').toLowerCase() === 'slot' && !root.getAttribute('name')) {
539
- slot = root;
540
- }
541
- else if (typeof root.querySelector === 'function') {
542
- slot = root.querySelector('slot:not([name])');
543
- }
629
+ slot = findOwnedDefaultSlot(root);
544
630
  }
545
631
  if (slot && slot.parentNode) {
546
632
  slot.replaceWith(node);
547
633
  return;
548
634
  }
549
- if (root && typeof root.appendChild === 'function')
635
+ if (root && root.nodeType === 1 && typeof root.appendChild === 'function')
550
636
  root.appendChild(node);
551
637
  else
552
638
  hostEl.appendChild(node);
@@ -632,10 +718,14 @@ export const directApi = {
632
718
  directApi._branchBinds = binds;
633
719
  directApi._inst = inst;
634
720
  let created = null;
721
+ const adopt = directApi._resumeAdopt;
722
+ const endBranch = adopt && typeof adopt.beginBranchScope === 'function' ? adopt.beginBranchScope() : null;
635
723
  try {
636
724
  created = branches[next].create.call(inst, directApi);
637
725
  }
638
726
  finally {
727
+ if (typeof endBranch === 'function')
728
+ endBranch();
639
729
  directApi._branchBinds = prevSink;
640
730
  directApi._inst = prevInst;
641
731
  }
package/dist/dom-ssr.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * VMZ DOM SSR / hydrate / resume — precise patches, no VDOM diff.
3
3
  * Imports client DOM primitives from ./dom-core.js for tree-shakeable browser entry.
4
4
  */
5
+ import { findOwnedDefaultSlot } from './dom-core.js';
5
6
  export declare function renderToString(Component: any, props?: {}, opts?: {}): Promise<any>;
6
7
  /**
7
8
  * Stream SSR via the same Direct serialize schedule as `renderToString`.
@@ -16,10 +17,11 @@ export declare function renderToStream(Component: any, props?: {}, opts?: {}): A
16
17
  /**
17
18
  * Live-DOM counterpart: first default `<slot>` owned by this tree, not by a nested
18
19
  * `[data-vmz]` component (Button/Link labels, etc.).
20
+ * Re-exported from dom-core (single implementation).
19
21
  * @param {Element | null | undefined} root
20
22
  * @returns {Element | null}
21
23
  */
22
- export declare function findOwnedDefaultSlot(root: any): any;
24
+ export { findOwnedDefaultSlot };
23
25
  /**
24
26
  * Hydrate/mount a file-route page inside an optional layout chain (outer → inner).
25
27
  * Mirrors SSR `slotHtml` wrapping: each layout's owned default slot becomes the
package/dist/dom-ssr.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Imports client DOM primitives from ./dom-core.js for tree-shakeable browser entry.
5
5
  */
6
6
  import { applyDirectHostBox, directHostBoxStyleAttr } from './direct-host-box.js';
7
- import { applyDomAttr, applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, eventPropHandlerName, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from './dom-core.js';
7
+ import { applyPreservedState, BOOLEAN_HTML_ATTRS, createInstance, destroy, directApi, eventPropHandlerName, findOwnedDefaultSlot, getRegisteredComponent, hasMeaningfulChild, isEventEntryStrategy, mount, noteDomCreate, resolveComponent, runDirectCreate, scheduleClientOn, settlePendingChildMounts, snapshotInstanceState, stripFns, } from './dom-core.js';
8
8
  import { markUnknownComponentHost, serializeUnknownComponentNode } from './unknown-component.js';
9
9
  /** @type {Error | null} last linkedom resolve failure (for clear SSR errors) */
10
10
  let _ssrDocumentLastError = null;
@@ -185,31 +185,11 @@ function injectDefaultSlotHtml(node, html) {
185
185
  /**
186
186
  * Live-DOM counterpart: first default `<slot>` owned by this tree, not by a nested
187
187
  * `[data-vmz]` component (Button/Link labels, etc.).
188
+ * Re-exported from dom-core (single implementation).
188
189
  * @param {Element | null | undefined} root
189
190
  * @returns {Element | null}
190
191
  */
191
- export function findOwnedDefaultSlot(root) {
192
- if (!root || root.nodeType !== 1)
193
- return null;
194
- const tag = String(root.tagName || '').toLowerCase();
195
- if (tag === 'slot' && !root.getAttribute('name'))
196
- return root;
197
- const kids = root.children;
198
- if (!kids || !kids.length)
199
- return null;
200
- for (let i = 0; i < kids.length; i++) {
201
- const c = kids[i];
202
- if (c.nodeType !== 1)
203
- continue;
204
- // Nested component host — its slots are not the layout page outlet.
205
- if (c.hasAttribute('data-vmz'))
206
- continue;
207
- const hit = findOwnedDefaultSlot(c);
208
- if (hit)
209
- return hit;
210
- }
211
- return null;
212
- }
192
+ export { findOwnedDefaultSlot };
213
193
  /**
214
194
  * Hydrate/mount a file-route page inside an optional layout chain (outer → inner).
215
195
  * Mirrors SSR `slotHtml` wrapping: each layout's owned default slot becomes the
@@ -592,6 +572,12 @@ const serializeApi = {
592
572
  onMethod() {
593
573
  /* named method events are also attached only during client resume */
594
574
  },
575
+ adoptEnter() {
576
+ return false;
577
+ },
578
+ adoptLeave() {
579
+ /* resume scope only */
580
+ },
595
581
  bindText(inst, bindingId, deps, get, textNode) {
596
582
  let raw = '';
597
583
  try {
@@ -620,13 +606,15 @@ const serializeApi = {
620
606
  return;
621
607
  // serializeApi.component returns a serialize el tree (or island shell).
622
608
  const root = hostEl.__kind === 'el' ? hostEl : null;
623
- const findSlot = (n) => {
609
+ const findOwnedSlot = (n) => {
624
610
  if (!n || n.__kind !== 'el')
625
611
  return null;
626
612
  if (n.tag === 'slot' && !(n.attrs && n.attrs.name))
627
613
  return n;
628
614
  for (const c of n.children || []) {
629
- const hit = findSlot(c);
615
+ if (c && c.__kind === 'el' && c.attrs && c.attrs['data-vmz'] != null)
616
+ continue;
617
+ const hit = findOwnedSlot(c);
630
618
  if (hit)
631
619
  return hit;
632
620
  }
@@ -636,12 +624,14 @@ const serializeApi = {
636
624
  let slot = null;
637
625
  if (root) {
638
626
  for (const c of root.children || []) {
639
- slot = findSlot(c);
627
+ if (c && c.__kind === 'el' && c.attrs && c.attrs['data-vmz'] != null)
628
+ continue;
629
+ slot = findOwnedSlot(c);
640
630
  if (slot)
641
631
  break;
642
632
  }
643
633
  if (!slot)
644
- slot = findSlot(root);
634
+ slot = findOwnedSlot(root);
645
635
  }
646
636
  if (slot) {
647
637
  slot.__rawHtml = null;
@@ -974,6 +964,167 @@ export function attachEventEntries(root = globalThis.document) {
974
964
  });
975
965
  }
976
966
  }
967
+ /**
968
+ * Adopt parked SSR/Island nodes while `__vmzCreate` rebuilds the live tree.
969
+ * Children of `rootEl` are moved into `pool` first so the create schedule never
970
+ * leaves orphan SSR siblings beside if/each markers (duplicate h2 / stale text).
971
+ *
972
+ * Scope rules (sibling-host invariant):
973
+ * - Candidates are **direct children** of the current scope pool only (no deep
974
+ * walk into other parked siblings).
975
+ * - Adopting a node parks its children into a **private** `__vmzResumePool`.
976
+ * - `enter(node)` / `leave()` push that private pool for depth-first child create.
977
+ * - `beginBranchScope()` moves one top-level SSR node into an isolated pool so
978
+ * ifBlock branch create cannot steal parent siblings (Drawer / Button hosts).
979
+ *
980
+ * @param {DocumentFragment} pool
981
+ * @param {Element} rootEl
982
+ */
983
+ function createResumeAdopt(pool, rootEl) {
984
+ const used = new WeakSet();
985
+ /** @type {DocumentFragment[]} */
986
+ const scopeStack = [pool];
987
+ /**
988
+ * @param {Node} root
989
+ * @param {(n: Text) => void} visit
990
+ */
991
+ const walkTexts = (root, visit) => {
992
+ if (!root)
993
+ return;
994
+ if (root.nodeType === 3) {
995
+ visit(/** @type {Text} */ (root));
996
+ return;
997
+ }
998
+ if (root.nodeType !== 1)
999
+ return;
1000
+ for (let c = root.firstChild; c; c = c.nextSibling)
1001
+ walkTexts(c, visit);
1002
+ };
1003
+ const currentScope = () => scopeStack[scopeStack.length - 1] || pool;
1004
+ const markElement = (n) => {
1005
+ used.add(n);
1006
+ if (typeof n.getAttribute === 'function') {
1007
+ const k = n.getAttribute('data-vmz-key');
1008
+ if (k != null && n.__vmzKey == null)
1009
+ n.__vmzKey = k;
1010
+ }
1011
+ // Private child pool — never dump into the parent/global pool (that let
1012
+ // if branches reclaim Drawer/sibling hosts parked as uncles).
1013
+ const childPool = document.createDocumentFragment();
1014
+ while (n.firstChild)
1015
+ childPool.appendChild(n.firstChild);
1016
+ n.__vmzResumePool = childPool;
1017
+ return n;
1018
+ };
1019
+ /** @returns {Generator<Element>} */
1020
+ function* elementCandidates() {
1021
+ const scope = currentScope();
1022
+ if (scope === pool && rootEl && !used.has(rootEl))
1023
+ yield rootEl;
1024
+ // Snapshot — adopt may move nodes out of scope while iterating.
1025
+ const kids = [...scope.childNodes];
1026
+ for (const n of kids) {
1027
+ if (n.nodeType === 1 && !used.has(/** @type {Element} */ (n))) {
1028
+ yield /** @type {Element} */ (n);
1029
+ }
1030
+ }
1031
+ }
1032
+ /** @returns {Generator<Text>} */
1033
+ function* textCandidates() {
1034
+ const scope = currentScope();
1035
+ const kids = [...scope.childNodes];
1036
+ for (const n of kids) {
1037
+ /** @type {Text[]} */
1038
+ const found = [];
1039
+ walkTexts(n, (t) => {
1040
+ if (!used.has(t))
1041
+ found.push(t);
1042
+ });
1043
+ for (const t of found)
1044
+ yield t;
1045
+ }
1046
+ }
1047
+ const adoptElement = (tag) => {
1048
+ const want = String(tag || 'div').toLowerCase();
1049
+ for (const el of elementCandidates()) {
1050
+ if (String(el.tagName).toLowerCase() === want)
1051
+ return markElement(el);
1052
+ }
1053
+ noteDomCreate();
1054
+ return document.createElement(tag || 'div');
1055
+ };
1056
+ const adoptText = (value) => {
1057
+ for (const n of textCandidates()) {
1058
+ used.add(n);
1059
+ if (value != null && value !== '')
1060
+ n.textContent = String(value);
1061
+ return n;
1062
+ }
1063
+ noteDomCreate();
1064
+ return document.createTextNode(value == null ? '' : String(value));
1065
+ };
1066
+ /** Reclaim SSR `<div data-vmz="Name">` hosts for nested Direct components. */
1067
+ const componentHost = (name) => {
1068
+ const want = String(name || '');
1069
+ for (const el of elementCandidates()) {
1070
+ if (String(el.tagName).toLowerCase() !== 'div')
1071
+ continue;
1072
+ if (el.getAttribute('data-vmz') !== want)
1073
+ continue;
1074
+ return markElement(el);
1075
+ }
1076
+ return null;
1077
+ };
1078
+ const enter = (node) => {
1079
+ if (!node || !node.__vmzResumePool)
1080
+ return false;
1081
+ scopeStack.push(node.__vmzResumePool);
1082
+ return true;
1083
+ };
1084
+ const leave = () => {
1085
+ if (scopeStack.length <= 1)
1086
+ return;
1087
+ scopeStack.pop();
1088
+ };
1089
+ const scopeDepth = () => scopeStack.length;
1090
+ const rewindScope = (depth) => {
1091
+ const d = Math.max(1, Number(depth) || 1);
1092
+ while (scopeStack.length > d)
1093
+ scopeStack.pop();
1094
+ };
1095
+ /**
1096
+ * Isolate the next top-level SSR sibling as the only candidates for an
1097
+ * if/else branch create (commercial Card: Empty branch must not adopt Drawer).
1098
+ * @returns {() => void} end callback
1099
+ */
1100
+ const beginBranchScope = () => {
1101
+ const parent = currentScope();
1102
+ const branchPool = document.createDocumentFragment();
1103
+ for (const n of [...parent.childNodes]) {
1104
+ if (n.nodeType === 1 && !used.has(/** @type {Element} */ (n))) {
1105
+ branchPool.appendChild(n);
1106
+ break;
1107
+ }
1108
+ }
1109
+ scopeStack.push(branchPool);
1110
+ const depth = scopeStack.length;
1111
+ return () => {
1112
+ while (branchPool.firstChild)
1113
+ branchPool.removeChild(branchPool.firstChild);
1114
+ rewindScope(depth - 1);
1115
+ };
1116
+ };
1117
+ return {
1118
+ el: adoptElement,
1119
+ text: adoptText,
1120
+ componentHost,
1121
+ enter,
1122
+ leave,
1123
+ scopeDepth,
1124
+ rewindScope,
1125
+ beginBranchScope,
1126
+ };
1127
+ }
977
1128
  /**
978
1129
  * Adopt existing Island DOM while running the same `__vmzCreate` schedule (resume).
979
1130
  * @param {new (props?: object) => any} Component
@@ -985,50 +1136,32 @@ function runDirectResume(Component, inst, container) {
985
1136
  if (!rootEl || rootEl.nodeType !== 1) {
986
1137
  return runDirectCreate(Component, inst);
987
1138
  }
988
- let textI = 0;
989
- const api = {
990
- _inst: inst,
991
- _branchBinds: null,
992
- _itemPatches: null,
993
- el(tag) {
994
- if (String(rootEl.tagName).toLowerCase() !== String(tag).toLowerCase()) {
995
- noteDomCreate();
996
- return document.createElement(tag);
997
- }
998
- return rootEl;
999
- },
1000
- frag() {
1001
- return document.createDocumentFragment();
1002
- },
1003
- text(s) {
1004
- while (textI < rootEl.childNodes.length) {
1005
- const n = rootEl.childNodes[textI++];
1006
- if (n.nodeType === 3) {
1007
- if (s != null && s !== '')
1008
- n.textContent = String(s);
1009
- return n;
1010
- }
1011
- }
1012
- noteDomCreate();
1013
- return document.createTextNode(String(s ?? ''));
1014
- },
1015
- attr(el, name, value) {
1016
- applyDomAttr(el, name, value);
1017
- },
1018
- on(el, type, handler) {
1019
- el.addEventListener(type, handler);
1020
- },
1021
- bindText: directApi.bindText,
1022
- bindAttr: directApi.bindAttr,
1023
- bindComponentProp: directApi.bindComponentProp,
1024
- projectDefaultSlot: directApi.projectDefaultSlot,
1025
- setHtml: directApi.setHtml,
1026
- bindHtml: directApi.bindHtml,
1027
- ifBlock: directApi.ifBlock,
1028
- eachBlock: directApi.eachBlock,
1029
- component: directApi.component,
1030
- };
1031
- return Component.__vmzCreate.call(inst, api);
1139
+ // Park SSR descendants so create can append if/each structure without orphan siblings.
1140
+ const pool = document.createDocumentFragment();
1141
+ while (rootEl.firstChild)
1142
+ pool.appendChild(rootEl.firstChild);
1143
+ const prevInst = directApi._inst;
1144
+ const prevBranch = directApi._branchBinds;
1145
+ const prevItems = directApi._itemPatches;
1146
+ const prevEach = directApi._eachCtx;
1147
+ const prevAdopt = directApi._resumeAdopt;
1148
+ directApi._inst = inst;
1149
+ directApi._branchBinds = null;
1150
+ directApi._itemPatches = null;
1151
+ directApi._eachCtx = null;
1152
+ directApi._resumeAdopt = createResumeAdopt(pool, rootEl);
1153
+ try {
1154
+ return Component.__vmzCreate.call(inst, directApi);
1155
+ }
1156
+ finally {
1157
+ directApi._resumeAdopt = prevAdopt;
1158
+ directApi._inst = prevInst;
1159
+ directApi._branchBinds = prevBranch;
1160
+ directApi._itemPatches = prevItems;
1161
+ directApi._eachCtx = prevEach;
1162
+ while (pool.firstChild)
1163
+ pool.removeChild(pool.firstChild);
1164
+ }
1032
1165
  }
1033
1166
  /**
1034
1167
  * @param {new (props?: object) => any} Component
@@ -1071,14 +1204,10 @@ export async function hydrate(Component, container, props = {}, opts = {}) {
1071
1204
  }
1072
1205
  }
1073
1206
  else {
1074
- // Interim: shallow resume leaves if/each binders unbound. Recreate against live
1075
- // DOM so patches attach. Leaf nodeIdentity (same Element) remains TODO for deep adopt.
1076
- container.replaceChildren();
1077
- const node = runDirectCreate(Component, inst);
1078
- if (node) {
1207
+ // Deep adopt: park SSR children, rebuild schedule, reclaim nodes (no orphan siblings).
1208
+ const node = runDirectResume(Component, inst, container);
1209
+ if (node)
1079
1210
  inst.__vmzDomRoot = node;
1080
- container.appendChild(node);
1081
- }
1082
1211
  }
1083
1212
  await settlePendingChildMounts(inst);
1084
1213
  container.__vmzInst = inst;
@@ -1,7 +1,8 @@
1
1
  /**
2
- * Single HTML `<a data-vmz-route>` locale rewrite for serve-host and static-emit.
3
- * Plan-only hosts must not keep a second copy of this semantic (0.1.22).
2
+ * Plan-native locale link rewrite for serve-host and static-emit.
3
+ * Href authority is RouteId × LocaleId realization rows — not path parsing on existing href.
4
4
  */
5
+ export declare const LOCALE_LINK_PLAN_SCHEMA = "vmz.static.locale_link_plan.v0";
5
6
  export type LocaleHrefArtifact = {
6
7
  locales?: Array<{
7
8
  id: string;
@@ -12,8 +13,26 @@ export type LocaleHrefArtifact = {
12
13
  defaultPrefix?: string;
13
14
  defaultLocale?: string;
14
15
  };
16
+ realizations?: Array<{
17
+ routeId: string;
18
+ localeId: string;
19
+ path: string;
20
+ }>;
15
21
  };
16
- /** Rewrite a same-app href to the given LocaleId via route realization. */
17
- export declare function localizeSameAppHref(href: string, localeId: string, artifact: LocaleHrefArtifact): string;
18
- /** Rewrite `<a data-vmz-route href>` in HTML body to retain `localeId`. */
22
+ export type LocaleLinkPlanRow = {
23
+ routeId: string;
24
+ localeId: string;
25
+ href: string;
26
+ };
27
+ export type LocaleLinkPlan = {
28
+ schema: typeof LOCALE_LINK_PLAN_SCHEMA;
29
+ rows: LocaleLinkPlanRow[];
30
+ };
31
+ /** Build RouteId × LocaleId → href rows from locale route realization artifact. */
32
+ export declare function buildLocaleLinkPlan(artifact: LocaleHrefArtifact | null | undefined): LocaleLinkPlan;
33
+ /** Apply plan rows to `<a data-vmz-route="RouteId">` markers for one LocaleId. */
34
+ export declare function applyLocaleLinkPlan(html: string, localeId: string, plan: LocaleLinkPlan, escapeAttr?: (s: string) => string): string;
35
+ /** Rewrite `<a data-vmz-route href>` using locale realization plan (Plan-native). */
19
36
  export declare function localizeBodyLinks(html: string, localeId: string, artifact: LocaleHrefArtifact, escapeAttr?: (s: string) => string): string;
37
+ /** Legacy same-app href helper for callers without `data-vmz-route` markers. */
38
+ export declare function localizeSameAppHref(href: string, localeId: string, artifact: LocaleHrefArtifact): string;
@@ -1,7 +1,50 @@
1
1
  /**
2
- * Single HTML `<a data-vmz-route>` locale rewrite for serve-host and static-emit.
3
- * Plan-only hosts must not keep a second copy of this semantic (0.1.22).
2
+ * Plan-native locale link rewrite for serve-host and static-emit.
3
+ * Href authority is RouteId × LocaleId realization rows — not path parsing on existing href.
4
4
  */
5
+ export const LOCALE_LINK_PLAN_SCHEMA = 'vmz.static.locale_link_plan.v0';
6
+ /** Build RouteId × LocaleId → href rows from locale route realization artifact. */
7
+ export function buildLocaleLinkPlan(artifact) {
8
+ const rows = [];
9
+ for (const r of artifact?.realizations || []) {
10
+ if (!r?.routeId || !r?.localeId || !r?.path)
11
+ continue;
12
+ rows.push({ routeId: String(r.routeId), localeId: String(r.localeId), href: String(r.path) });
13
+ }
14
+ rows.sort((a, b) => (a.routeId === b.routeId ? a.localeId.localeCompare(b.localeId) : a.routeId.localeCompare(b.routeId)));
15
+ return { schema: LOCALE_LINK_PLAN_SCHEMA, rows };
16
+ }
17
+ /** Apply plan rows to `<a data-vmz-route="RouteId">` markers for one LocaleId. */
18
+ export function applyLocaleLinkPlan(html, localeId, plan, escapeAttr = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;')) {
19
+ if (!html || !localeId || !plan?.rows?.length)
20
+ return html;
21
+ const byRoute = new Map();
22
+ for (const row of plan.rows) {
23
+ if (row.localeId === localeId)
24
+ byRoute.set(row.routeId, row.href);
25
+ }
26
+ if (!byRoute.size)
27
+ return html;
28
+ return String(html).replace(/<a\b([^>]*)>/gi, (full, attrs) => {
29
+ const rm = attrs.match(/\bdata-vmz-route\s*=\s*"([^"]*)"/i);
30
+ if (!rm)
31
+ return full;
32
+ const href = byRoute.get(rm[1]);
33
+ if (!href)
34
+ return full;
35
+ const hm = attrs.match(/\bhref\s*=\s*"([^"]*)"/i);
36
+ if (hm && hm[1] === href)
37
+ return full;
38
+ const newAttrs = hm ? attrs.replace(/\bhref\s*=\s*"[^"]*"/i, `href="${escapeAttr(href)}"`) : ` href="${escapeAttr(href)}"${attrs}`;
39
+ return `<a${newAttrs}>`;
40
+ });
41
+ }
42
+ /** Rewrite `<a data-vmz-route href>` using locale realization plan (Plan-native). */
43
+ export function localizeBodyLinks(html, localeId, artifact, escapeAttr) {
44
+ if (!html || !localeId || !artifact)
45
+ return html;
46
+ return applyLocaleLinkPlan(html, localeId, buildLocaleLinkPlan(artifact), escapeAttr);
47
+ }
5
48
  function normalizePath(pathname) {
6
49
  let p = String(pathname || '/');
7
50
  if (!p.startsWith('/'))
@@ -10,32 +53,7 @@ function normalizePath(pathname) {
10
53
  p = p.slice(0, -1);
11
54
  return p || '/';
12
55
  }
13
- function parseLocaleFromPath(pathname, supportedLocales) {
14
- const parts = normalizePath(pathname).split('/').filter(Boolean);
15
- if (!parts.length)
16
- return { localeId: null, restPath: '/' };
17
- if (supportedLocales.includes(parts[0])) {
18
- const rest = parts.slice(1);
19
- return { localeId: parts[0], restPath: rest.length ? `/${rest.join('/')}` : '/' };
20
- }
21
- return { localeId: null, restPath: normalizePath(pathname) };
22
- }
23
- function realizeRoutePath(localeId, pathPattern, routing) {
24
- const strategy = routing.strategy || 'prefix';
25
- const defaultPrefix = routing.defaultPrefix || 'include';
26
- const defaultLocale = routing.defaultLocale;
27
- const base = normalizePath(pathPattern);
28
- if (strategy === 'none' || strategy === 'domain') {
29
- return { path: base };
30
- }
31
- const omitDefault = defaultPrefix === 'omit' && localeId === defaultLocale;
32
- if (omitDefault) {
33
- return { path: base };
34
- }
35
- const pathOut = base === '/' ? `/${localeId}` : `/${localeId}${base}`;
36
- return { path: pathOut };
37
- }
38
- /** Rewrite a same-app href to the given LocaleId via route realization. */
56
+ /** Legacy same-app href helper for callers without `data-vmz-route` markers. */
39
57
  export function localizeSameAppHref(href, localeId, artifact) {
40
58
  if (!href || !localeId || !artifact)
41
59
  return href;
@@ -43,6 +61,7 @@ export function localizeSameAppHref(href, localeId, artifact) {
43
61
  return href;
44
62
  if (/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('/'))
45
63
  return href;
64
+ const plan = buildLocaleLinkPlan(artifact);
46
65
  let pathname = String(href);
47
66
  let search = '';
48
67
  let hash = '';
@@ -56,34 +75,12 @@ export function localizeSameAppHref(href, localeId, artifact) {
56
75
  search = pathname.slice(qIdx);
57
76
  pathname = pathname.slice(0, qIdx);
58
77
  }
59
- if (!pathname)
60
- pathname = '/';
61
- const supported = (artifact.locales || []).map((l) => (typeof l === 'string' ? l : l.id)).filter(Boolean);
62
- const defaultLocale = artifact.defaultLocale || artifact.routing?.defaultLocale;
63
- const routing = {
64
- strategy: artifact.routing?.strategy || 'prefix',
65
- defaultPrefix: artifact.routing?.defaultPrefix || 'include',
66
- defaultLocale,
67
- };
68
- const parsed = parseLocaleFromPath(pathname, supported);
69
- const rest = parsed.restPath || '/';
70
- const realized = realizeRoutePath(localeId, rest, routing);
71
- return `${realized.path}${search}${hash}`;
72
- }
73
- /** Rewrite `<a data-vmz-route href>` in HTML body to retain `localeId`. */
74
- export function localizeBodyLinks(html, localeId, artifact, escapeAttr = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;')) {
75
- if (!html || !localeId || !artifact)
76
- return html;
77
- return String(html).replace(/<a\b([^>]*)>/gi, (full, attrs) => {
78
- if (!/\bdata-vmz-route\s*=/.test(attrs))
79
- return full;
80
- const hm = attrs.match(/\bhref\s*=\s*"([^"]*)"/i);
81
- if (!hm)
82
- return full;
83
- const next = localizeSameAppHref(hm[1], localeId, artifact);
84
- if (next === hm[1])
85
- return full;
86
- const newAttrs = attrs.replace(/\bhref\s*=\s*"[^"]*"/i, `href="${escapeAttr(next)}"`);
87
- return `<a${newAttrs}>`;
88
- });
78
+ pathname = normalizePath(pathname || '/');
79
+ for (const row of plan.rows) {
80
+ if (row.localeId !== localeId)
81
+ continue;
82
+ if (normalizePath(row.href) === pathname)
83
+ return `${row.href}${search}${hash}`;
84
+ }
85
+ return href;
89
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "type": "module",
5
5
  "description": "VMZ production runtime core — DOM / SSR / HTTP / WriteBarrier (no compiler)",
6
6
  "exports": {
@@ -66,12 +66,12 @@
66
66
  "linkedom": "^0.18.13"
67
67
  },
68
68
  "optionalDependencies": {
69
- "@vmz/vmz-win32-x64": "0.1.24",
70
- "@vmz/vmz-win32-arm64": "0.1.24",
71
- "@vmz/vmz-darwin-x64": "0.1.24",
72
- "@vmz/vmz-darwin-arm64": "0.1.24",
73
- "@vmz/vmz-linux-x64": "0.1.24",
74
- "@vmz/vmz-linux-arm64": "0.1.24"
69
+ "@vmz/vmz-win32-x64": "0.1.25",
70
+ "@vmz/vmz-win32-arm64": "0.1.25",
71
+ "@vmz/vmz-darwin-x64": "0.1.25",
72
+ "@vmz/vmz-darwin-arm64": "0.1.25",
73
+ "@vmz/vmz-linux-x64": "0.1.25",
74
+ "@vmz/vmz-linux-arm64": "0.1.25"
75
75
  },
76
76
  "publishConfig": {
77
77
  "access": "public"