@vmz/core 0.1.28 → 0.1.30

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.
@@ -370,21 +370,40 @@ export function installClientNavigation(opts = {}) {
370
370
  if (a.hasAttribute('download') || a.getAttribute('target') === '_blank')
371
371
  return;
372
372
  ev.preventDefault();
373
- // Retain current LocaleId on same-app Link (realization) — never trust a stale unprefixed href.
374
- const realized = localizeClickHref(u.pathname + u.search + u.hash);
373
+ // Prefer frozen RouteId×LocaleId href table (0.1.30); fall back only when table missing.
374
+ const realized = localizeClickHref(u.pathname + u.search + u.hash, a.getAttribute('data-vmz-route'));
375
375
  void transitionTo(realized, {
376
376
  replace: a.getAttribute('data-vmz-replace') === 'true',
377
377
  });
378
378
  }
379
379
  /**
380
380
  * @param {string} href
381
+ * @param {string | null} [routeId]
381
382
  */
382
- function localizeClickHref(href) {
383
+ function localizeClickHref(href, routeId) {
383
384
  if (!doc?.documentElement)
384
385
  return href;
385
386
  const locale = doc.documentElement.getAttribute('data-locale');
387
+ if (!locale)
388
+ return href;
389
+ const frozen = lookupFrozenLocaleHref(routeId, locale);
390
+ if (frozen) {
391
+ let search = '';
392
+ let hash = '';
393
+ const hashIdx = href.indexOf('#');
394
+ let pathPart = href;
395
+ if (hashIdx >= 0) {
396
+ hash = pathPart.slice(hashIdx);
397
+ pathPart = pathPart.slice(0, hashIdx);
398
+ }
399
+ const qIdx = pathPart.indexOf('?');
400
+ if (qIdx >= 0) {
401
+ search = pathPart.slice(qIdx);
402
+ }
403
+ return `${frozen}${search}${hash}`;
404
+ }
386
405
  const raw = doc.documentElement.getAttribute('data-vmz-locale-routing');
387
- if (!locale || !raw)
406
+ if (!raw)
388
407
  return href;
389
408
  let routing;
390
409
  try {
@@ -617,7 +636,7 @@ export function installClientNavigation(opts = {}) {
617
636
  }
618
637
  }
619
638
  /**
620
- * Re-realize current URL under target LocaleId (stable path stays; LocaleId is projection).
639
+ * Re-realize current URL under target LocaleId via frozen href table when present.
621
640
  * @param {string} href
622
641
  * @param {string} localeId
623
642
  * @param {{ strategy?: string, defaultPrefix?: string, defaultLocale?: string, locales?: string[] }} routing
@@ -638,6 +657,13 @@ export function installClientNavigation(opts = {}) {
638
657
  }
639
658
  if (!pathname)
640
659
  pathname = '/';
660
+ const fromLocale = doc.documentElement?.getAttribute('data-locale') || null;
661
+ const routeId = doc.documentElement?.getAttribute('data-vmz-route') ||
662
+ doc.querySelector?.('[data-vmz-app][data-vmz-route]')?.getAttribute?.('data-vmz-route') ||
663
+ resolveRouteIdFromHrefTable(pathname, fromLocale);
664
+ const frozen = lookupFrozenLocaleHref(routeId, localeId);
665
+ if (frozen)
666
+ return `${frozen}${search}${hash}`;
641
667
  const supported = Array.isArray(routing.locales) ? routing.locales : [];
642
668
  const parts = pathname.split('/').filter(Boolean);
643
669
  let rest = pathname;
@@ -659,6 +685,52 @@ export function installClientNavigation(opts = {}) {
659
685
  const pathOut = rest === '/' ? `/${localeId}` : `/${localeId}${rest}`;
660
686
  return `${pathOut}${search}${hash}`;
661
687
  }
688
+ /**
689
+ * @returns {Record<string, Record<string, string>> | null}
690
+ */
691
+ function readLocaleHrefTable() {
692
+ const raw = doc.documentElement?.getAttribute('data-vmz-locale-hrefs');
693
+ if (!raw)
694
+ return null;
695
+ try {
696
+ const table = JSON.parse(raw);
697
+ return table && typeof table === 'object' ? table : null;
698
+ }
699
+ catch {
700
+ return null;
701
+ }
702
+ }
703
+ /**
704
+ * @param {string | null | undefined} routeId
705
+ * @param {string} localeId
706
+ */
707
+ function lookupFrozenLocaleHref(routeId, localeId) {
708
+ if (!routeId || !localeId)
709
+ return null;
710
+ const table = readLocaleHrefTable();
711
+ const href = table?.[routeId]?.[localeId];
712
+ return typeof href === 'string' && href && !/\[[^\]]+\]/.test(href) && !/\/:[^/]+/.test(href) ? href : null;
713
+ }
714
+ /**
715
+ * Reverse-lookup RouteId from frozen table when html lacks data-vmz-route.
716
+ * @param {string} pathname
717
+ * @param {string | null} localeId
718
+ */
719
+ function resolveRouteIdFromHrefTable(pathname, localeId) {
720
+ const table = readLocaleHrefTable();
721
+ if (!table || !localeId)
722
+ return null;
723
+ const norm = pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname || '/';
724
+ for (const [routeId, byLocale] of Object.entries(table)) {
725
+ const href = byLocale?.[localeId];
726
+ if (typeof href !== 'string')
727
+ continue;
728
+ const h = href.length > 1 && href.endsWith('/') ? href.slice(0, -1) : href;
729
+ if (h === norm)
730
+ return routeId;
731
+ }
732
+ return null;
733
+ }
662
734
  if (win) {
663
735
  win.__vmzTransitionLocale = transitionLocale;
664
736
  win.__vmzClientNavSetFetch = (fn) => {
@@ -142,6 +142,23 @@ export declare const directApi: {
142
142
  * @param {{ stable: string[], branches: Array<{ cond?: => any, deps: string[] }> } | null | undefined} [cf]
143
143
  */
144
144
  bindAttr(inst: any, bindingId: any, deps: any, get: any, el: any, name: any, cf: any): void;
145
+ /**
146
+ * Specialized single-field attr bind (0.1.29): compile-time field name, no generic get closure in artifact.
147
+ * @param {object} inst
148
+ * @param {number|string|null} bindingId
149
+ * @param {string} fieldName
150
+ * @param {Element} el
151
+ * @param {string} name
152
+ */
153
+ specFieldAttr(inst: any, bindingId: any, fieldName: any, el: any, name: any): void;
154
+ /**
155
+ * Specialized single-field text bind (0.1.29): compile-time field name in generated artifact.
156
+ * @param {object} inst
157
+ * @param {number|string|null} bindingId
158
+ * @param {string} fieldName
159
+ * @param {Text} textNode
160
+ */
161
+ specFieldText(inst: any, bindingId: any, fieldName: any, textNode: any): void;
145
162
  setHtml(el: any, value: any): void;
146
163
  /**
147
164
  * Trusted HTML binding (`html={expr}`). Author/plugin responsibility.
package/dist/dom-core.js CHANGED
@@ -404,9 +404,13 @@ export const directApi = {
404
404
  * @param {{ skipFlush?: boolean } | null | undefined} [opts]
405
405
  */
406
406
  onMethod(el, type, methodName, opts) {
407
- const inst = directApi._inst;
407
+ // Prefer each-block owner: item create often runs during flush when `_inst` is unset.
408
+ const eachOwner = directApi._eachCtx && directApi._eachCtx.inst;
409
+ const inst = eachOwner || directApi._inst;
410
+ if (!inst)
411
+ return;
408
412
  let skipFlush = !!(opts && opts.skipFlush);
409
- el.addEventListener(type, (ev) => {
413
+ const invoke = (ev) => {
410
414
  if (type === 'submit' && ev && typeof ev.preventDefault === 'function') {
411
415
  ev.preventDefault();
412
416
  }
@@ -429,7 +433,15 @@ export const directApi = {
429
433
  skipFlush = true;
430
434
  }
431
435
  }
432
- });
436
+ };
437
+ if (directApi._eachCtx) {
438
+ /** @type {Record<string, Function>} */
439
+ const bag = el.__vmzEvt || (el.__vmzEvt = Object.create(null));
440
+ bag[type] = (ev) => invoke(ev);
441
+ directApi._eachCtx.needDelegate(type);
442
+ return;
443
+ }
444
+ el.addEventListener(type, (ev) => invoke(ev));
433
445
  },
434
446
  /**
435
447
  * @param {object} inst
@@ -467,6 +479,44 @@ export const directApi = {
467
479
  }
468
480
  }, cf);
469
481
  },
482
+ /**
483
+ * Specialized single-field attr bind (0.1.29): compile-time field name, no generic get closure in artifact.
484
+ * @param {object} inst
485
+ * @param {number|string|null} bindingId
486
+ * @param {string} fieldName
487
+ * @param {Element} el
488
+ * @param {string} name
489
+ */
490
+ specFieldAttr(inst, bindingId, fieldName, el, name) {
491
+ wireDirectBind(inst, bindingId, [fieldName], function specFieldAttrGet() {
492
+ return this[fieldName];
493
+ }, (raw) => {
494
+ if (name === 'class' || name === 'className') {
495
+ const s = String(raw ?? '');
496
+ if (s)
497
+ el.setAttribute('class', s);
498
+ else if (el.hasAttribute('class'))
499
+ el.removeAttribute('class');
500
+ }
501
+ else {
502
+ applyDomAttr(el, name, raw);
503
+ }
504
+ });
505
+ },
506
+ /**
507
+ * Specialized single-field text bind (0.1.29): compile-time field name in generated artifact.
508
+ * @param {object} inst
509
+ * @param {number|string|null} bindingId
510
+ * @param {string} fieldName
511
+ * @param {Text} textNode
512
+ */
513
+ specFieldText(inst, bindingId, fieldName, textNode) {
514
+ wireDirectBind(inst, bindingId, [fieldName], function specFieldTextGet() {
515
+ return this[fieldName];
516
+ }, (raw) => {
517
+ textNode.textContent = String(raw ?? '');
518
+ });
519
+ },
470
520
  setHtml(el, value) {
471
521
  el.innerHTML = value == null ? '' : String(value);
472
522
  },
@@ -1153,7 +1203,9 @@ export const directApi = {
1153
1203
  delegateTypes.add(type);
1154
1204
  ensureDelegateAttached();
1155
1205
  };
1156
- const eachCtx = { noteItemBind, needDelegate };
1206
+ // `inst` must ride eachCtx: month/list rebuilds call createItem during flush
1207
+ // when `directApi._inst` is null (unlike ifBlock branch create which sets it).
1208
+ const eachCtx = { inst, noteItemBind, needDelegate };
1157
1209
  const clearDomEvt = (root) => {
1158
1210
  if (!root || root.nodeType !== 1)
1159
1211
  return;
@@ -1657,8 +1709,20 @@ export const directApi = {
1657
1709
  attrEls[i] = attrSlots[i].get(root);
1658
1710
  for (let i = 0; i < nOn; i++) {
1659
1711
  const el = onSlots[i].get(root);
1660
- if (!el.__vmzAct) {
1661
- el.__vmzAct = onSlots[i].method;
1712
+ const slot = onSlots[i];
1713
+ if (slot.directMethod) {
1714
+ /** @type {Record<string, Function>} */
1715
+ const bag = el.__vmzEvt || (el.__vmzEvt = Object.create(null));
1716
+ const method = slot.method;
1717
+ bag[slot.type] = (ev) => {
1718
+ const fn = inst[method];
1719
+ if (typeof fn !== 'function')
1720
+ return;
1721
+ runDomEventHandler(inst, method, () => fn.call(inst, ev));
1722
+ };
1723
+ }
1724
+ else if (!el.__vmzAct) {
1725
+ el.__vmzAct = slot.method;
1662
1726
  }
1663
1727
  }
1664
1728
  for (let i = 0; i < nText; i++) {
@@ -1834,6 +1898,17 @@ export const directApi = {
1834
1898
  api.on(el, type, handler);
1835
1899
  recordFailed = true;
1836
1900
  },
1901
+ onMethod(el, type, methodName, opts) {
1902
+ if (!recordFailed) {
1903
+ pending.ons.push({
1904
+ node: el,
1905
+ type,
1906
+ method: methodName,
1907
+ directMethod: true,
1908
+ });
1909
+ }
1910
+ api.onMethod(el, type, methodName, opts);
1911
+ },
1837
1912
  });
1838
1913
  const dom = userCreateItem.call(inst, recordingApi, box);
1839
1914
  if (!recordFailed && dom && dom.nodeType === 1 && root === dom && (pending.texts.length > 0 || pending.attrs.length > 0)) {
@@ -1915,6 +1990,7 @@ export const directApi = {
1915
1990
  path,
1916
1991
  type: p.type,
1917
1992
  method: p.method,
1993
+ directMethod: !!p.directMethod,
1918
1994
  get: makeChildGetter(path),
1919
1995
  });
1920
1996
  }
@@ -2070,8 +2146,10 @@ export const directApi = {
2070
2146
  const patches0 = [];
2071
2147
  const prevPatches = directApi._itemPatches;
2072
2148
  const prevCtx = directApi._eachCtx;
2149
+ const prevInst = directApi._inst;
2073
2150
  directApi._itemPatches = patches0;
2074
2151
  directApi._eachCtx = eachCtx;
2152
+ directApi._inst = inst;
2075
2153
  let dom0 = null;
2076
2154
  try {
2077
2155
  dom0 = createItem(directApi, box0, patches0);
@@ -2079,6 +2157,7 @@ export const directApi = {
2079
2157
  finally {
2080
2158
  directApi._itemPatches = prevPatches;
2081
2159
  directApi._eachCtx = prevCtx;
2160
+ directApi._inst = prevInst;
2082
2161
  }
2083
2162
  if (applied !== gen || inst.__vmzDestroyed)
2084
2163
  return;
@@ -2203,8 +2282,10 @@ export const directApi = {
2203
2282
  const patches = [];
2204
2283
  const prevPatches = directApi._itemPatches;
2205
2284
  const prevCtx = directApi._eachCtx;
2285
+ const prevInst = directApi._inst;
2206
2286
  directApi._itemPatches = patches;
2207
2287
  directApi._eachCtx = eachCtx;
2288
+ directApi._inst = inst;
2208
2289
  let dom = null;
2209
2290
  try {
2210
2291
  dom = createItem(directApi, box, patches);
@@ -2212,6 +2293,7 @@ export const directApi = {
2212
2293
  finally {
2213
2294
  directApi._itemPatches = prevPatches;
2214
2295
  directApi._eachCtx = prevCtx;
2296
+ directApi._inst = prevInst;
2215
2297
  }
2216
2298
  if (applied !== gen || inst.__vmzDestroyed)
2217
2299
  return;
@@ -2326,8 +2408,10 @@ export const directApi = {
2326
2408
  const patches = [];
2327
2409
  const prevPatches = directApi._itemPatches;
2328
2410
  const prevCtx = directApi._eachCtx;
2411
+ const prevInst = directApi._inst;
2329
2412
  directApi._itemPatches = patches;
2330
2413
  directApi._eachCtx = eachCtx;
2414
+ directApi._inst = inst;
2331
2415
  let dom = null;
2332
2416
  try {
2333
2417
  dom = createItem(directApi, box, patches);
@@ -2335,6 +2419,7 @@ export const directApi = {
2335
2419
  finally {
2336
2420
  directApi._itemPatches = prevPatches;
2337
2421
  directApi._eachCtx = prevCtx;
2422
+ directApi._inst = prevInst;
2338
2423
  }
2339
2424
  if (applied !== gen || inst.__vmzDestroyed)
2340
2425
  return;
package/dist/dom-ssr.js CHANGED
@@ -591,6 +591,16 @@ const serializeApi = {
591
591
  }
592
592
  textNode.value = String(raw ?? '');
593
593
  },
594
+ specFieldText(inst, _bindingId, fieldName, textNode) {
595
+ let raw;
596
+ try {
597
+ raw = inst[fieldName];
598
+ }
599
+ catch {
600
+ raw = '';
601
+ }
602
+ textNode.value = String(raw ?? '');
603
+ },
594
604
  bindAttr(inst, bindingId, deps, get, el, name) {
595
605
  let raw;
596
606
  try {
@@ -601,6 +611,16 @@ const serializeApi = {
601
611
  }
602
612
  applySerializeAttr(el, name, raw);
603
613
  },
614
+ specFieldAttr(inst, _bindingId, fieldName, el, name) {
615
+ let raw;
616
+ try {
617
+ raw = inst[fieldName];
618
+ }
619
+ catch {
620
+ raw = null;
621
+ }
622
+ applySerializeAttr(el, name, raw);
623
+ },
604
624
  bindComponentProp() {
605
625
  /* SSR: props already resolved into the child instance at create */
606
626
  },
@@ -49,7 +49,9 @@ export declare function linkRouteAliasesFromUnits(units: Array<{
49
49
  export declare function buildLocaleLinkPlan(artifact: LocaleHrefArtifact | null | undefined): LocaleLinkPlan;
50
50
  /** Apply plan rows to `<a data-vmz-route="RouteId">` markers for one LocaleId. */
51
51
  export declare function applyLocaleLinkPlan(html: string, localeId: string, plan: LocaleLinkPlan, escapeAttr?: (s: string) => string): string;
52
- /** Rewrite `<a data-vmz-route href>` using locale realization plan (Plan-native). */
53
- export declare function localizeBodyLinks(html: string, localeId: string, artifact: LocaleHrefArtifact, escapeAttr?: (s: string) => string): string;
52
+ /** Rewrite `<a data-vmz-route href>` using a frozen locale link plan (preferred) or build once from realization. */
53
+ export declare function localizeBodyLinks(html: string, localeId: string, artifact: LocaleHrefArtifact, escapeAttr?: (s: string) => string, frozenPlan?: LocaleLinkPlan | null): string;
54
+ /** Compact RouteId → LocaleId → href table for browser (no path-prefix algebra). */
55
+ export declare function localeHrefTableFromPlan(plan: LocaleLinkPlan | null | undefined): Record<string, Record<string, string>>;
54
56
  /** Legacy same-app href helper for callers without `data-vmz-route` markers. */
55
57
  export declare function localizeSameAppHref(href: string, localeId: string, artifact: LocaleHrefArtifact): string;
@@ -71,11 +71,28 @@ export function applyLocaleLinkPlan(html, localeId, plan, escapeAttr = (s) => St
71
71
  return `<a${newAttrs}>`;
72
72
  });
73
73
  }
74
- /** Rewrite `<a data-vmz-route href>` using locale realization plan (Plan-native). */
75
- export function localizeBodyLinks(html, localeId, artifact, escapeAttr) {
74
+ /** Rewrite `<a data-vmz-route href>` using a frozen locale link plan (preferred) or build once from realization. */
75
+ export function localizeBodyLinks(html, localeId, artifact, escapeAttr, frozenPlan) {
76
76
  if (!html || !localeId || !artifact)
77
77
  return html;
78
- return applyLocaleLinkPlan(html, localeId, buildLocaleLinkPlan(artifact), escapeAttr);
78
+ const plan = frozenPlan && frozenPlan.schema === LOCALE_LINK_PLAN_SCHEMA && Array.isArray(frozenPlan.rows)
79
+ ? frozenPlan
80
+ : buildLocaleLinkPlan(artifact);
81
+ return applyLocaleLinkPlan(html, localeId, plan, escapeAttr);
82
+ }
83
+ /** Compact RouteId → LocaleId → href table for browser (no path-prefix algebra). */
84
+ export function localeHrefTableFromPlan(plan) {
85
+ /** @type {Record<string, Record<string, string>>} */
86
+ const out = Object.create(null);
87
+ for (const row of plan?.rows || []) {
88
+ if (!row?.routeId || !row?.localeId || !row?.href)
89
+ continue;
90
+ if (/\[[^\]]+\]/.test(row.href) || /\/:[^/]+/.test(row.href))
91
+ continue;
92
+ const byLocale = out[row.routeId] || (out[row.routeId] = Object.create(null));
93
+ byLocale[row.localeId] = row.href;
94
+ }
95
+ return out;
79
96
  }
80
97
  function normalizePath(pathname) {
81
98
  let p = String(pathname || '/');
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
5
5
  *
6
- * Pathname matches `vmz-deployment.json` `pathPattern` (explicit `<router>.path`
7
- * or file-route default). Mini page stems are a different host projection.
6
+ * Pathname matches compiled `_vmz/route-catalog.json` (frozen segs / pathPattern).
7
+ * Hosts must not re-parse deployment pathPattern into a live catalog.
8
8
  * Not an SPA shell.
9
9
  *
10
10
  * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
6
6
  *
7
- * Pathname matches `vmz-deployment.json` `pathPattern` (explicit `<router>.path`
8
- * or file-route default). Mini page stems are a different host projection.
7
+ * Pathname matches compiled `_vmz/route-catalog.json` (frozen segs / pathPattern).
8
+ * Hosts must not re-parse deployment pathPattern into a live catalog.
9
9
  * Not an SPA shell.
10
10
  *
11
11
  * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
@@ -23,11 +23,14 @@ import { createRequire, registerHooks } from 'node:module';
23
23
  import path from 'node:path';
24
24
  import { fileURLToPath, pathToFileURL } from 'node:url';
25
25
  import { listClientComponents } from './list-client-components.js';
26
- import { linkRouteAliasesFromUnits, localizeBodyLinks } from './localize-body-links.js';
26
+ import { LOCALE_LINK_PLAN_SCHEMA, linkRouteAliasesFromUnits, localeHrefTableFromPlan, localizeBodyLinks } from './localize-body-links.js';
27
27
  import { loadNativeAddon } from './native-addon.js';
28
28
  import { createRenderHost } from './render-host.js';
29
29
  import { resolveRouteLayoutChain } from './route-layout-chain.js';
30
30
  import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
31
+ const ROUTE_CATALOG_SCHEMA = 'vmz.route.catalog.v0';
32
+ const ROUTE_CATALOG_REL = '_vmz/route-catalog.json';
33
+ const LOCALE_LINK_PLAN_REL = '_vmz/locale-link-plan.json';
31
34
  const require = createRequire(import.meta.url);
32
35
  const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
33
36
  /** App project root for bare import / JSON resolve (dev SSR ≡ build node_modules). */
@@ -154,6 +157,8 @@ let styleBundleHash = null;
154
157
  let styleTheme = null;
155
158
  /** Locale route realization artifact from `_vmz/locale-route-realization.json` (optional). */
156
159
  let localeArtifact = null;
160
+ /** Frozen locale link plan from `_vmz/locale-link-plan.json` (0.1.30 authority). */
161
+ let localeLinkPlan = null;
157
162
  /** @type {Set<import('node:http').ServerResponse>} */
158
163
  const sseClients = new Set();
159
164
  /** In-flight HTTP requests (graceful shutdown drain). */
@@ -575,6 +580,10 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
575
580
  defaultLocale: localeArtifact.defaultLocale,
576
581
  locales: (localeArtifact.locales || []).map((l) => l.id),
577
582
  }));
583
+ const hrefTable = localeHrefTableFromPlan(localeLinkPlan);
584
+ if (hrefTable && Object.keys(hrefTable).length) {
585
+ htmlExtraAttrs.push('data-vmz-locale-hrefs', JSON.stringify(hrefTable));
586
+ }
578
587
  }
579
588
  const pageDocMeta = resolvePageDocumentMeta(Page);
580
589
  const prevLocaleHint = globalThis.__vmzLocaleIdHint;
@@ -605,9 +614,9 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
605
614
  if (signal?.aborted)
606
615
  return;
607
616
  }
608
- // Locale discipline: same-app Links retain current LocaleId (realization authority).
617
+ // Locale discipline: apply frozen link plan rows (0.1.30) — no path algebra.
609
618
  if (localeArtifact && localeId) {
610
- bodyHtml = localizeBodyLinks(bodyHtml, localeId, localeArtifact);
619
+ bodyHtml = localizeBodyLinks(bodyHtml, localeId, localeArtifact, undefined, localeLinkPlan);
611
620
  }
612
621
  }
613
622
  finally {
@@ -816,6 +825,13 @@ async function softReload(opts = {}) {
816
825
  catch {
817
826
  localeArtifact = null;
818
827
  }
828
+ try {
829
+ const rawPlan = JSON.parse(await readFile(path.join(distDir, ...LOCALE_LINK_PLAN_REL.split('/')), 'utf8'));
830
+ localeLinkPlan = rawPlan?.schema === LOCALE_LINK_PLAN_SCHEMA && Array.isArray(rawPlan.rows) ? rawPlan : null;
831
+ }
832
+ catch {
833
+ localeLinkPlan = null;
834
+ }
819
835
  const componentEntries = await listClientComponents(distDir, { strict: !isDev });
820
836
  ssrRenderHost = await createRenderHost(distDir, {
821
837
  strictDeployment: !isDev,
@@ -1108,54 +1124,51 @@ async function loadPageCtor(chunkId) {
1108
1124
  return mod.default;
1109
1125
  }
1110
1126
  /**
1111
- * Discover compiled page modules from Route Graph `pathPattern` in
1112
- * `vmz-deployment.json` only (plan-only host — no `pages/**` walk).
1127
+ * Load compiled page catalog from `_vmz/route-catalog.json` (0.1.30 authority).
1128
+ * Hosts must not re-parse deployment pathPattern into a live catalog.
1113
1129
  * @param {string} dir
1114
1130
  */
1115
1131
  async function listPageClientFiles(dir) {
1116
- const fromDep = await listPagesFromDeployment(dir);
1117
- if (!fromDep.length) {
1132
+ const fromCatalog = await listPagesFromRouteCatalog(dir);
1133
+ if (!fromCatalog.length) {
1118
1134
  if (isDev) {
1119
- console.warn(`vmz dev: no page units with pathPattern in ${path.join(dir, 'vmz-deployment.json')}`);
1135
+ console.warn(`vmz dev: missing compiled ${ROUTE_CATALOG_SCHEMA} at ${path.join(dir, ...ROUTE_CATALOG_REL.split('/'))}`);
1120
1136
  return [];
1121
1137
  }
1122
- throw new Error(`vmz serve: no page units with pathPattern in ${path.join(dir, 'vmz-deployment.json')} (plan-only host)`);
1138
+ throw new Error(`vmz serve: missing compiled ${ROUTE_CATALOG_SCHEMA} at ${path.join(dir, ...ROUTE_CATALOG_REL.split('/'))}`);
1123
1139
  }
1124
- return fromDep;
1140
+ return fromCatalog;
1125
1141
  }
1126
1142
  /**
1127
1143
  * @param {string} dir
1128
1144
  */
1129
- async function listPagesFromDeployment(dir) {
1130
- /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parsePathPattern> }>} */
1145
+ async function listPagesFromRouteCatalog(dir) {
1146
+ /** @type {Array<{ chunkId: string, pageRel: string, routeId?: string, pathPattern?: string, segs: ReturnType<typeof parsePathPattern> }>} */
1131
1147
  const out = [];
1132
1148
  try {
1133
- const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
1134
- const dep = JSON.parse(raw);
1135
- for (const unit of dep.units || []) {
1136
- if (unit?.kind !== 'page')
1137
- continue;
1138
- const chunkId = String(unit.chunkId || '').replace(/\\/g, '/');
1149
+ const raw = await readFile(path.join(dir, ...ROUTE_CATALOG_REL.split('/')), 'utf8');
1150
+ const catalog = JSON.parse(raw);
1151
+ if (catalog?.schema !== ROUTE_CATALOG_SCHEMA || !Array.isArray(catalog.pages))
1152
+ return [];
1153
+ for (const page of catalog.pages) {
1154
+ const chunkId = String(page?.chunkId || '').replace(/\\/g, '/');
1139
1155
  if (!chunkId.startsWith('pages/'))
1140
1156
  continue;
1141
1157
  const stem = chunkId.split('/').pop() || '';
1142
1158
  if (isRouteBoundaryStem(stem))
1143
1159
  continue;
1144
- const pattern = String(unit.pathPattern || '').trim();
1145
- if (!pattern) {
1146
- throw new Error(`vmz serve: page unit ${chunkId} missing pathPattern in vmz-deployment.json (plan-only host)`);
1147
- }
1148
- const pageRel = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
1160
+ const segs = Array.isArray(page?.segs) && page.segs.length ? page.segs : parsePathPattern(String(page?.pathPattern || ''));
1161
+ const pageRel = String(page?.pageRel || `${chunkId}.client.js`).replace(/\\/g, '/');
1149
1162
  out.push({
1150
1163
  chunkId,
1151
1164
  pageRel,
1152
- segs: parsePathPattern(pattern),
1165
+ routeId: typeof page?.routeId === 'string' ? page.routeId : chunkId,
1166
+ pathPattern: typeof page?.pathPattern === 'string' ? page.pathPattern : undefined,
1167
+ segs,
1153
1168
  });
1154
1169
  }
1155
1170
  }
1156
- catch (err) {
1157
- if (err && typeof err.message === 'string' && err.message.includes('plan-only host'))
1158
- throw err;
1171
+ catch {
1159
1172
  return [];
1160
1173
  }
1161
1174
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/core",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
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.28",
70
- "@vmz/vmz-win32-arm64": "0.1.28",
71
- "@vmz/vmz-darwin-x64": "0.1.28",
72
- "@vmz/vmz-darwin-arm64": "0.1.28",
73
- "@vmz/vmz-linux-x64": "0.1.28",
74
- "@vmz/vmz-linux-arm64": "0.1.28"
69
+ "@vmz/vmz-win32-x64": "0.1.30",
70
+ "@vmz/vmz-win32-arm64": "0.1.30",
71
+ "@vmz/vmz-darwin-x64": "0.1.30",
72
+ "@vmz/vmz-darwin-arm64": "0.1.30",
73
+ "@vmz/vmz-linux-x64": "0.1.30",
74
+ "@vmz/vmz-linux-arm64": "0.1.30"
75
75
  },
76
76
  "publishConfig": {
77
77
  "access": "public"