@vmz/core 0.1.24 → 0.1.26

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,15 @@ 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;
164
+ /**
165
+ * Subscribe to a child component event (`@submit` → event name `submit`).
166
+ * Orthogonal to function props (`:on-submit` → prop `onSubmit`).
167
+ * @param {HTMLElement} hostEl
168
+ * @param {string} eventName
169
+ * @param {(ev?: any, ...rest: any[]) => any} handler
170
+ */
171
+ onComponentEvent(hostEl: any, eventName: any, handler: any): void;
142
172
  /**
143
173
  * Keep nested Direct child props live with parent field writes.
144
174
  * @param {object} hostInst
@@ -150,6 +180,7 @@ export declare const directApi: {
150
180
  bindComponentProp(hostInst: any, hostEl: any, propName: any, deps: any, get: any): void;
151
181
  /**
152
182
  * Project parent children into nested Direct component default `<slot>`.
183
+ * Uses {@link findOwnedDefaultSlot} so nested Button/Link slots are not stolen.
153
184
  * @param {HTMLElement} hostEl
154
185
  * @param {Node} node
155
186
  */
@@ -175,12 +206,20 @@ export declare const directApi: {
175
206
  eachBlock(inst: any, bindingId: any, deps: any, spec: any, regionId?: any): DocumentFragment;
176
207
  };
177
208
  /**
178
- * Vue-familiar `@click` / legacy `onClick` → canonical `onXxx` prop name.
179
- * Function props that are not events are treated as getters (`v.call(host)`).
209
+ * Function prop wire: only `onXxx` camelCase is a handler prop.
210
+ * Component `@event` must never normalize here — that channel is `onComponentEvent`.
180
211
  * @param {unknown} name
181
212
  * @returns {string | null}
182
213
  */
183
214
  export declare function eventPropHandlerName(name: any): string;
215
+ /**
216
+ * Dispatch a component event to parent subscribers registered via `onComponentEvent`.
217
+ * Installed on every Direct instance as `inst.emit`.
218
+ * @param {object} inst
219
+ * @param {string} eventName
220
+ * @param {...any} payload
221
+ */
222
+ export declare function emitComponentEvent(inst: any, eventName: any, ...payload: any[]): void;
184
223
  export declare function isEventPropName(name: any): boolean;
185
224
  /** HTML boolean attributes: presence means true; `false`/`null` must remove the attr. */
186
225
  export declare const BOOLEAN_HTML_ATTRS: Set<string>;
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,12 +492,21 @@ 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 || {})) {
508
+ // Function props that already look like `onXxx` stay as handlers.
509
+ // Component `@event` never arrives here (emit uses onComponentEvent).
435
510
  const onKey = typeof v === 'function' ? eventPropHandlerName(k) : null;
436
511
  if (onKey)
437
512
  resolved[onKey] = v;
@@ -471,9 +546,23 @@ export const directApi = {
471
546
  if (!(Ctor.__vmzDirect && typeof Ctor.__vmzCreate === 'function')) {
472
547
  throw new Error(`vmz:dom direct component <${name}> requires __vmzCreate (rebuild child with Direct)`);
473
548
  }
474
- const node = runDirectCreate(Ctor, child);
549
+ // Nested resume: keep parent `_resumeAdopt` so child reclaim parked SSR nodes.
550
+ // Enter the host's private child pool so nested create cannot see uncle siblings.
551
+ const adopt = directApi._resumeAdopt;
552
+ const entered = adopt && typeof adopt.enter === 'function' ? adopt.enter(host) : false;
553
+ let node;
554
+ try {
555
+ node = runDirectCreate(Ctor, child);
556
+ }
557
+ finally {
558
+ if (entered && adopt && typeof adopt.leave === 'function')
559
+ adopt.leave();
560
+ }
475
561
  if (node) {
476
- child.__vmzDomRoot = node;
562
+ // ifBlock/eachBlock roots are DocumentFragments: appendChild moves
563
+ // children into `host` and empties the fragment. Keep a live Element
564
+ // root so projectDefaultSlot can find `<slot>` (Button v-if/v-else).
565
+ child.__vmzDomRoot = node.nodeType === 11 ? host : node;
477
566
  host.appendChild(node);
478
567
  }
479
568
  host.__vmzInst = child;
@@ -487,6 +576,23 @@ export const directApi = {
487
576
  }
488
577
  return host;
489
578
  },
579
+ /**
580
+ * Subscribe to a child component event (`@submit` → event name `submit`).
581
+ * Orthogonal to function props (`:on-submit` → prop `onSubmit`).
582
+ * @param {HTMLElement} hostEl
583
+ * @param {string} eventName
584
+ * @param {(ev?: any, ...rest: any[]) => any} handler
585
+ */
586
+ onComponentEvent(hostEl, eventName, handler) {
587
+ const child = hostEl && hostEl.__vmzInst;
588
+ if (!child || typeof eventName !== 'string' || !eventName)
589
+ return;
590
+ if (typeof handler !== 'function')
591
+ return;
592
+ const bag = child.__vmzComponentListeners || (child.__vmzComponentListeners = Object.create(null));
593
+ const list = bag[eventName] || (bag[eventName] = []);
594
+ list.push(handler);
595
+ },
490
596
  /**
491
597
  * Keep nested Direct child props live with parent field writes.
492
598
  * @param {object} hostInst
@@ -524,6 +630,7 @@ export const directApi = {
524
630
  },
525
631
  /**
526
632
  * Project parent children into nested Direct component default `<slot>`.
633
+ * Uses {@link findOwnedDefaultSlot} so nested Button/Link slots are not stolen.
527
634
  * @param {HTMLElement} hostEl
528
635
  * @param {Node} node
529
636
  */
@@ -531,22 +638,20 @@ export const directApi = {
531
638
  if (!hostEl || node == null)
532
639
  return;
533
640
  const child = hostEl.__vmzInst;
534
- const root = (child && child.__vmzDomRoot) || hostEl;
641
+ let root = (child && child.__vmzDomRoot) || hostEl;
642
+ // Emptied DocumentFragment after append must not receive slot kids.
643
+ if (!root || root.nodeType !== 1)
644
+ root = hostEl;
535
645
  /** @type {Element | null} */
536
646
  let slot = null;
537
647
  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
- }
648
+ slot = findOwnedDefaultSlot(root);
544
649
  }
545
650
  if (slot && slot.parentNode) {
546
651
  slot.replaceWith(node);
547
652
  return;
548
653
  }
549
- if (root && typeof root.appendChild === 'function')
654
+ if (root && root.nodeType === 1 && typeof root.appendChild === 'function')
550
655
  root.appendChild(node);
551
656
  else
552
657
  hostEl.appendChild(node);
@@ -632,10 +737,14 @@ export const directApi = {
632
737
  directApi._branchBinds = binds;
633
738
  directApi._inst = inst;
634
739
  let created = null;
740
+ const adopt = directApi._resumeAdopt;
741
+ const endBranch = adopt && typeof adopt.beginBranchScope === 'function' ? adopt.beginBranchScope() : null;
635
742
  try {
636
743
  created = branches[next].create.call(inst, directApi);
637
744
  }
638
745
  finally {
746
+ if (typeof endBranch === 'function')
747
+ endBranch();
639
748
  directApi._branchBinds = prevSink;
640
749
  directApi._inst = prevInst;
641
750
  }
@@ -2646,8 +2755,8 @@ function wireDirectBind(inst, bindingId, deps, get, write, cf) {
2646
2755
  trackDirectBind(inst, liveDeps, apply, bindingId);
2647
2756
  }
2648
2757
  /**
2649
- * Vue-familiar `@click` / legacy `onClick` → canonical `onXxx` prop name.
2650
- * Function props that are not events are treated as getters (`v.call(host)`).
2758
+ * Function prop wire: only `onXxx` camelCase is a handler prop.
2759
+ * Component `@event` must never normalize here — that channel is `onComponentEvent`.
2651
2760
  * @param {unknown} name
2652
2761
  * @returns {string | null}
2653
2762
  */
@@ -2656,15 +2765,27 @@ export function eventPropHandlerName(name) {
2656
2765
  return null;
2657
2766
  if (/^on[A-Z]/.test(name))
2658
2767
  return name;
2659
- // `@input` → `onInput`; reject `@update:modelValue` until that surface exists.
2660
- if (name.charAt(0) === '@' && name.length > 1 && !name.includes(':')) {
2661
- const ev = name.slice(1);
2662
- if (!ev || !/^[A-Za-z]/.test(ev))
2663
- return null;
2664
- return `on${ev.charAt(0).toUpperCase()}${ev.slice(1)}`;
2665
- }
2666
2768
  return null;
2667
2769
  }
2770
+ /**
2771
+ * Dispatch a component event to parent subscribers registered via `onComponentEvent`.
2772
+ * Installed on every Direct instance as `inst.emit`.
2773
+ * @param {object} inst
2774
+ * @param {string} eventName
2775
+ * @param {...any} payload
2776
+ */
2777
+ export function emitComponentEvent(inst, eventName, ...payload) {
2778
+ if (!inst || typeof eventName !== 'string' || !eventName)
2779
+ return;
2780
+ const bag = inst.__vmzComponentListeners;
2781
+ const list = bag && bag[eventName];
2782
+ if (!Array.isArray(list) || list.length === 0)
2783
+ return;
2784
+ for (const fn of list) {
2785
+ if (typeof fn === 'function')
2786
+ fn(...payload);
2787
+ }
2788
+ }
2668
2789
  export function isEventPropName(name) {
2669
2790
  return eventPropHandlerName(name) != null;
2670
2791
  }
@@ -3047,6 +3168,11 @@ export function createInstance(Component, props = {}) {
3047
3168
  inst.__vmzBinders = Object.create(null);
3048
3169
  inst.__vmzBindings = Object.create(null);
3049
3170
  inst.__vmzDepToBindings = Object.create(null);
3171
+ inst.__vmzComponentListeners = Object.create(null);
3172
+ // Compiler intrinsic surface: `this.emit('submit', payload)` — not a string bus.
3173
+ inst.emit = function emit(eventName, ...payload) {
3174
+ return emitComponentEvent(inst, eventName, ...payload);
3175
+ };
3050
3176
  makeReactive(inst, Component.__vmzState || []);
3051
3177
  makeReactive(inst, Component.__vmzProps || []);
3052
3178
  // WriteBarrier: install Component helpers once (no import needed in emitted code).
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,15 @@ 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
+ },
581
+ onComponentEvent() {
582
+ /* component events attach only on the client */
583
+ },
595
584
  bindText(inst, bindingId, deps, get, textNode) {
596
585
  let raw = '';
597
586
  try {
@@ -620,13 +609,15 @@ const serializeApi = {
620
609
  return;
621
610
  // serializeApi.component returns a serialize el tree (or island shell).
622
611
  const root = hostEl.__kind === 'el' ? hostEl : null;
623
- const findSlot = (n) => {
612
+ const findOwnedSlot = (n) => {
624
613
  if (!n || n.__kind !== 'el')
625
614
  return null;
626
615
  if (n.tag === 'slot' && !(n.attrs && n.attrs.name))
627
616
  return n;
628
617
  for (const c of n.children || []) {
629
- const hit = findSlot(c);
618
+ if (c && c.__kind === 'el' && c.attrs && c.attrs['data-vmz'] != null)
619
+ continue;
620
+ const hit = findOwnedSlot(c);
630
621
  if (hit)
631
622
  return hit;
632
623
  }
@@ -636,12 +627,14 @@ const serializeApi = {
636
627
  let slot = null;
637
628
  if (root) {
638
629
  for (const c of root.children || []) {
639
- slot = findSlot(c);
630
+ if (c && c.__kind === 'el' && c.attrs && c.attrs['data-vmz'] != null)
631
+ continue;
632
+ slot = findOwnedSlot(c);
640
633
  if (slot)
641
634
  break;
642
635
  }
643
636
  if (!slot)
644
- slot = findSlot(root);
637
+ slot = findOwnedSlot(root);
645
638
  }
646
639
  if (slot) {
647
640
  slot.__rawHtml = null;
@@ -974,6 +967,167 @@ export function attachEventEntries(root = globalThis.document) {
974
967
  });
975
968
  }
976
969
  }
970
+ /**
971
+ * Adopt parked SSR/Island nodes while `__vmzCreate` rebuilds the live tree.
972
+ * Children of `rootEl` are moved into `pool` first so the create schedule never
973
+ * leaves orphan SSR siblings beside if/each markers (duplicate h2 / stale text).
974
+ *
975
+ * Scope rules (sibling-host invariant):
976
+ * - Candidates are **direct children** of the current scope pool only (no deep
977
+ * walk into other parked siblings).
978
+ * - Adopting a node parks its children into a **private** `__vmzResumePool`.
979
+ * - `enter(node)` / `leave()` push that private pool for depth-first child create.
980
+ * - `beginBranchScope()` moves one top-level SSR node into an isolated pool so
981
+ * ifBlock branch create cannot steal parent siblings (Drawer / Button hosts).
982
+ *
983
+ * @param {DocumentFragment} pool
984
+ * @param {Element} rootEl
985
+ */
986
+ function createResumeAdopt(pool, rootEl) {
987
+ const used = new WeakSet();
988
+ /** @type {DocumentFragment[]} */
989
+ const scopeStack = [pool];
990
+ /**
991
+ * @param {Node} root
992
+ * @param {(n: Text) => void} visit
993
+ */
994
+ const walkTexts = (root, visit) => {
995
+ if (!root)
996
+ return;
997
+ if (root.nodeType === 3) {
998
+ visit(/** @type {Text} */ (root));
999
+ return;
1000
+ }
1001
+ if (root.nodeType !== 1)
1002
+ return;
1003
+ for (let c = root.firstChild; c; c = c.nextSibling)
1004
+ walkTexts(c, visit);
1005
+ };
1006
+ const currentScope = () => scopeStack[scopeStack.length - 1] || pool;
1007
+ const markElement = (n) => {
1008
+ used.add(n);
1009
+ if (typeof n.getAttribute === 'function') {
1010
+ const k = n.getAttribute('data-vmz-key');
1011
+ if (k != null && n.__vmzKey == null)
1012
+ n.__vmzKey = k;
1013
+ }
1014
+ // Private child pool — never dump into the parent/global pool (that let
1015
+ // if branches reclaim Drawer/sibling hosts parked as uncles).
1016
+ const childPool = document.createDocumentFragment();
1017
+ while (n.firstChild)
1018
+ childPool.appendChild(n.firstChild);
1019
+ n.__vmzResumePool = childPool;
1020
+ return n;
1021
+ };
1022
+ /** @returns {Generator<Element>} */
1023
+ function* elementCandidates() {
1024
+ const scope = currentScope();
1025
+ if (scope === pool && rootEl && !used.has(rootEl))
1026
+ yield rootEl;
1027
+ // Snapshot — adopt may move nodes out of scope while iterating.
1028
+ const kids = [...scope.childNodes];
1029
+ for (const n of kids) {
1030
+ if (n.nodeType === 1 && !used.has(/** @type {Element} */ (n))) {
1031
+ yield /** @type {Element} */ (n);
1032
+ }
1033
+ }
1034
+ }
1035
+ /** @returns {Generator<Text>} */
1036
+ function* textCandidates() {
1037
+ const scope = currentScope();
1038
+ const kids = [...scope.childNodes];
1039
+ for (const n of kids) {
1040
+ /** @type {Text[]} */
1041
+ const found = [];
1042
+ walkTexts(n, (t) => {
1043
+ if (!used.has(t))
1044
+ found.push(t);
1045
+ });
1046
+ for (const t of found)
1047
+ yield t;
1048
+ }
1049
+ }
1050
+ const adoptElement = (tag) => {
1051
+ const want = String(tag || 'div').toLowerCase();
1052
+ for (const el of elementCandidates()) {
1053
+ if (String(el.tagName).toLowerCase() === want)
1054
+ return markElement(el);
1055
+ }
1056
+ noteDomCreate();
1057
+ return document.createElement(tag || 'div');
1058
+ };
1059
+ const adoptText = (value) => {
1060
+ for (const n of textCandidates()) {
1061
+ used.add(n);
1062
+ if (value != null && value !== '')
1063
+ n.textContent = String(value);
1064
+ return n;
1065
+ }
1066
+ noteDomCreate();
1067
+ return document.createTextNode(value == null ? '' : String(value));
1068
+ };
1069
+ /** Reclaim SSR `<div data-vmz="Name">` hosts for nested Direct components. */
1070
+ const componentHost = (name) => {
1071
+ const want = String(name || '');
1072
+ for (const el of elementCandidates()) {
1073
+ if (String(el.tagName).toLowerCase() !== 'div')
1074
+ continue;
1075
+ if (el.getAttribute('data-vmz') !== want)
1076
+ continue;
1077
+ return markElement(el);
1078
+ }
1079
+ return null;
1080
+ };
1081
+ const enter = (node) => {
1082
+ if (!node || !node.__vmzResumePool)
1083
+ return false;
1084
+ scopeStack.push(node.__vmzResumePool);
1085
+ return true;
1086
+ };
1087
+ const leave = () => {
1088
+ if (scopeStack.length <= 1)
1089
+ return;
1090
+ scopeStack.pop();
1091
+ };
1092
+ const scopeDepth = () => scopeStack.length;
1093
+ const rewindScope = (depth) => {
1094
+ const d = Math.max(1, Number(depth) || 1);
1095
+ while (scopeStack.length > d)
1096
+ scopeStack.pop();
1097
+ };
1098
+ /**
1099
+ * Isolate the next top-level SSR sibling as the only candidates for an
1100
+ * if/else branch create (commercial Card: Empty branch must not adopt Drawer).
1101
+ * @returns {() => void} end callback
1102
+ */
1103
+ const beginBranchScope = () => {
1104
+ const parent = currentScope();
1105
+ const branchPool = document.createDocumentFragment();
1106
+ for (const n of [...parent.childNodes]) {
1107
+ if (n.nodeType === 1 && !used.has(/** @type {Element} */ (n))) {
1108
+ branchPool.appendChild(n);
1109
+ break;
1110
+ }
1111
+ }
1112
+ scopeStack.push(branchPool);
1113
+ const depth = scopeStack.length;
1114
+ return () => {
1115
+ while (branchPool.firstChild)
1116
+ branchPool.removeChild(branchPool.firstChild);
1117
+ rewindScope(depth - 1);
1118
+ };
1119
+ };
1120
+ return {
1121
+ el: adoptElement,
1122
+ text: adoptText,
1123
+ componentHost,
1124
+ enter,
1125
+ leave,
1126
+ scopeDepth,
1127
+ rewindScope,
1128
+ beginBranchScope,
1129
+ };
1130
+ }
977
1131
  /**
978
1132
  * Adopt existing Island DOM while running the same `__vmzCreate` schedule (resume).
979
1133
  * @param {new (props?: object) => any} Component
@@ -985,50 +1139,32 @@ function runDirectResume(Component, inst, container) {
985
1139
  if (!rootEl || rootEl.nodeType !== 1) {
986
1140
  return runDirectCreate(Component, inst);
987
1141
  }
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);
1142
+ // Park SSR descendants so create can append if/each structure without orphan siblings.
1143
+ const pool = document.createDocumentFragment();
1144
+ while (rootEl.firstChild)
1145
+ pool.appendChild(rootEl.firstChild);
1146
+ const prevInst = directApi._inst;
1147
+ const prevBranch = directApi._branchBinds;
1148
+ const prevItems = directApi._itemPatches;
1149
+ const prevEach = directApi._eachCtx;
1150
+ const prevAdopt = directApi._resumeAdopt;
1151
+ directApi._inst = inst;
1152
+ directApi._branchBinds = null;
1153
+ directApi._itemPatches = null;
1154
+ directApi._eachCtx = null;
1155
+ directApi._resumeAdopt = createResumeAdopt(pool, rootEl);
1156
+ try {
1157
+ return Component.__vmzCreate.call(inst, directApi);
1158
+ }
1159
+ finally {
1160
+ directApi._resumeAdopt = prevAdopt;
1161
+ directApi._inst = prevInst;
1162
+ directApi._branchBinds = prevBranch;
1163
+ directApi._itemPatches = prevItems;
1164
+ directApi._eachCtx = prevEach;
1165
+ while (pool.firstChild)
1166
+ pool.removeChild(pool.firstChild);
1167
+ }
1032
1168
  }
1033
1169
  /**
1034
1170
  * @param {new (props?: object) => any} Component
@@ -1071,14 +1207,10 @@ export async function hydrate(Component, container, props = {}, opts = {}) {
1071
1207
  }
1072
1208
  }
1073
1209
  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) {
1210
+ // Deep adopt: park SSR children, rebuild schedule, reclaim nodes (no orphan siblings).
1211
+ const node = runDirectResume(Component, inst, container);
1212
+ if (node)
1079
1213
  inst.__vmzDomRoot = node;
1080
- container.appendChild(node);
1081
- }
1082
1214
  }
1083
1215
  await settlePendingChildMounts(inst);
1084
1216
  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.26",
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.26",
70
+ "@vmz/vmz-win32-arm64": "0.1.26",
71
+ "@vmz/vmz-darwin-x64": "0.1.26",
72
+ "@vmz/vmz-darwin-arm64": "0.1.26",
73
+ "@vmz/vmz-linux-x64": "0.1.26",
74
+ "@vmz/vmz-linux-arm64": "0.1.26"
75
75
  },
76
76
  "publishConfig": {
77
77
  "access": "public"