@vmz/core 0.0.4 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,78 @@
1
1
  // @ts-nocheck
2
2
  /**
3
- * Generic VMZ Node host — SSR file-route pages + dist static + RPC/REST.
3
+ * Generic VMZ Node host — SSR Route Graph pages + dist static + RPC/REST.
4
4
  *
5
5
  * Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
6
6
  *
7
- * Pathname `pages/**` (PascalCase stem → lowercase URL; `index` parent;
8
- * `[Param]` / `[...rest]` dynamic). Not an SPA shell.
7
+ * Pathname matches `vmz-deployment.json` `pathPattern` (explicit `<router>.path`
8
+ * or file-route default). Mini page stems are a different host projection.
9
+ * Not an SPA shell.
9
10
  *
10
11
  * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
11
12
  * GET `/__vmz/events` SSE notifies the browser:
12
13
  * - island HMR → re-import `entry-client.js` (no full document reload)
13
14
  * - otherwise → `location.reload`
15
+ *
16
+ * Dev resolve hook propagates `?t=` onto nested relative `file:` imports under
17
+ * dist so soft reload does not keep a stale `lib/*.js` ESM cache entry.
14
18
  */
19
+ import { existsSync } from 'node:fs';
20
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
15
21
  import http from 'node:http';
22
+ import { createRequire, registerHooks } from 'node:module';
16
23
  import path from 'node:path';
17
- import { readdir, writeFile, readFile } from 'node:fs/promises';
18
- import { existsSync } from 'node:fs';
19
24
  import { fileURLToPath, pathToFileURL } from 'node:url';
20
- import { setServerModuleResolver, setRoutes, handleNodeRequest } from './vmz-runtime.js';
21
25
  import { registerComponents, renderToStream, renderToString } from './vmz-dom.js';
26
+ import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
27
+ const require = createRequire(import.meta.url);
22
28
  const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
23
29
  const host = process.env.VMZ_HOST || '127.0.0.1';
24
30
  const port = Number(process.env.VMZ_PORT || process.env.PORT || 5173);
25
31
  const isDev = process.env.VMZ_DEV === '1' || process.env.VMZ_DEV === 'true';
32
+ // Absolute origin for in-process client graphs that fall back to HTTP RPC
33
+ // (separate `dist/vmz-runtime.js` instance without setServerModuleResolver).
34
+ globalThis.__VMZ_RPC_ORIGIN = `http://${host}:${port}`;
35
+ /**
36
+ * Soft reload only busts the top-level `import(page?t=token)`. Nested relative
37
+ * imports (`../../lib/units.js`) keep the first-loaded ESM cache entry — so a
38
+ * page can demand exports that the stale dep never had (or vice versa).
39
+ * Propagate `t` from parentURL onto file: children under this dist.
40
+ */
41
+ if (isDev) {
42
+ const distUrlPrefix = pathToFileURL(distDir.endsWith(path.sep) ? distDir : `${distDir}${path.sep}`).href;
43
+ registerHooks({
44
+ resolve(specifier, context, nextResolve) {
45
+ const result = nextResolve(specifier, context);
46
+ if (!specifier.startsWith('.') || !context.parentURL || !result?.url)
47
+ return result;
48
+ let token = '';
49
+ try {
50
+ token = new URL(context.parentURL).searchParams.get('t') || '';
51
+ }
52
+ catch {
53
+ return result;
54
+ }
55
+ if (!token)
56
+ return result;
57
+ if (!result.url.startsWith('file:'))
58
+ return result;
59
+ if (!result.url.startsWith(distUrlPrefix)) {
60
+ try {
61
+ if (!fileURLToPath(result.url).startsWith(distDir))
62
+ return result;
63
+ }
64
+ catch {
65
+ return result;
66
+ }
67
+ }
68
+ const u = new URL(result.url);
69
+ if (u.searchParams.get('t') === token)
70
+ return result;
71
+ u.searchParams.set('t', token);
72
+ return { ...result, url: u.href, shortCircuit: true };
73
+ },
74
+ });
75
+ }
26
76
  /** @type {number} */
27
77
  let reloadToken = Date.now();
28
78
  /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
@@ -33,6 +83,8 @@ const pageCtors = new Map();
33
83
  let cssEntry = null;
34
84
  /** @type {{ defaultThemeId: string, themeIds: string[], activationAttr: string, contentHash: string|null } | null} */
35
85
  let styleTheme = null;
86
+ /** Locale route realization artifact from `_vmz/locale-route-realization.json` (optional). */
87
+ let localeArtifact = null;
36
88
  /** @type {Set<import('node:http').ServerResponse>} */
37
89
  const sseClients = new Set();
38
90
  /** In-flight HTTP requests (graceful shutdown drain). */
@@ -42,6 +94,12 @@ let shuttingDown = false;
42
94
  let ready = false;
43
95
  /** @type {{ message: string, stack?: string, at: number } | null} */
44
96
  let lastDevError = null;
97
+ /**
98
+ * Native CodeGenerators handle — must be declared before top-level `await softReload()`
99
+ * (TDZ: requireNativeGenerator may run during that await).
100
+ * @type {any}
101
+ */
102
+ let _nativeGen;
45
103
  setServerModuleResolver((moduleId) => {
46
104
  const rel = moduleId.replace(/^#server\//, '') + '.js';
47
105
  return bustUrl(pathToFileURL(path.join(distDir, '#server', rel)).href);
@@ -94,9 +152,14 @@ async function renderPageStream(pathname, opts = {}) {
94
152
  if (isDev && lastDevError && pageCtors.size === 0) {
95
153
  return { status: 500, stream: emitDevErrorHtml(lastDevError) };
96
154
  }
97
- let match = matchFileRoute(pathname, pageCatalog);
155
+ const localePlan = resolveLocalePath(pathname);
156
+ if (localePlan.redirectTo) {
157
+ return { status: 302, redirect: localePlan.redirectTo, headers: { Location: localePlan.redirectTo } };
158
+ }
159
+ const routePath = localePlan.restPath || pathname;
160
+ let match = matchFileRoute(routePath, pageCatalog);
98
161
  let status = 200;
99
- const gated = await runRouteGate(pathname, match?.chunkId);
162
+ const gated = await runRouteGate(routePath, match?.chunkId);
100
163
  if (gated === 'not_found') {
101
164
  match = findRootCatchAll(pageCatalog);
102
165
  status = 404;
@@ -121,16 +184,24 @@ async function renderPageStream(pathname, opts = {}) {
121
184
  }
122
185
  return null;
123
186
  }
124
- const params = extractRouteParams(match.segs, pathname);
187
+ const params = extractRouteParams(match.segs, routePath);
125
188
  const method = String(opts.method || 'GET').toUpperCase();
189
+ const localeCtx = {
190
+ localeId: localePlan.localeId,
191
+ dir: localePlan.dir,
192
+ pathname,
193
+ routePath,
194
+ alternates: pageMetaAlternates(match.chunkId, localePlan.localeId),
195
+ };
126
196
  if (typeof Page.access === 'function') {
127
197
  const access = await Page.access({
128
198
  params,
129
- pathname,
199
+ pathname: routePath,
130
200
  chunkId: match.chunkId,
131
201
  signal: opts.signal,
132
202
  searchParams: opts.searchParams,
133
203
  method,
204
+ localeId: localeCtx.localeId,
134
205
  });
135
206
  const closed = normalizeAccessResult(access);
136
207
  if (closed.kind === 'redirect') {
@@ -148,7 +219,7 @@ async function renderPageStream(pathname, opts = {}) {
148
219
  const eventOnlyShell = isEventOnlyShell(resumeEntries.map((e) => e.strategy));
149
220
  return {
150
221
  status: 404,
151
- stream: emitPageHtml(NotFound, catchAll.chunkId, eventOnlyShell, { ...params }, opts),
222
+ stream: emitPageHtml(NotFound, catchAll.chunkId, eventOnlyShell, { ...params }, opts, [], localeCtx),
152
223
  };
153
224
  }
154
225
  }
@@ -159,12 +230,13 @@ async function renderPageStream(pathname, opts = {}) {
159
230
  if (method === 'POST' && typeof Page.action === 'function') {
160
231
  const acted = await Page.action({
161
232
  params,
162
- pathname,
233
+ pathname: routePath,
163
234
  chunkId: match.chunkId,
164
235
  signal: opts.signal,
165
236
  searchParams: opts.searchParams,
166
237
  body: opts.body,
167
238
  method,
239
+ localeId: localeCtx.localeId,
168
240
  });
169
241
  const actionClosed = normalizeActionResult(acted);
170
242
  if (actionClosed.kind === 'redirect') {
@@ -183,10 +255,11 @@ async function renderPageStream(pathname, opts = {}) {
183
255
  if (typeof Page.load === 'function') {
184
256
  const loaded = await Page.load({
185
257
  params,
186
- pathname,
258
+ pathname: routePath,
187
259
  chunkId: match.chunkId,
188
260
  signal: opts.signal,
189
261
  searchParams: opts.searchParams,
262
+ localeId: localeCtx.localeId,
190
263
  });
191
264
  if (opts.signal?.aborted) {
192
265
  return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
@@ -204,7 +277,7 @@ async function renderPageStream(pathname, opts = {}) {
204
277
  const layoutChain = resolveLayoutChain(match.chunkId);
205
278
  return {
206
279
  status,
207
- stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, props, opts, layoutChain),
280
+ stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, props, opts, layoutChain, localeCtx),
208
281
  };
209
282
  }
210
283
  /**
@@ -260,11 +333,36 @@ function normalizeActionResult(acted) {
260
333
  * @param {string} marker
261
334
  */
262
335
  async function* emitAccessShell(marker) {
263
- yield `<!DOCTYPE html>
264
- <html lang="en">
265
- <head><meta charset="utf-8" /><title>VMZ</title></head>
266
- <body><p>${marker}</p></body>
267
- </html>`;
336
+ const native = requireNativeGenerator();
337
+ if (typeof native.generateHtmlShell !== 'function') {
338
+ throw new Error('vmz native addon missing generateHtmlShell — rebuild with `pnpm napi:build`');
339
+ }
340
+ yield native.generateHtmlShell({
341
+ title: 'App',
342
+ lang: 'en',
343
+ cssHrefs: [],
344
+ bodyHtml: `<p>${marker}</p>`,
345
+ bodyAttrs: [],
346
+ });
347
+ }
348
+ /**
349
+ * Prefer page `static meta()` for document title/description — never brand the framework in business HTML.
350
+ * @param {any} Page
351
+ */
352
+ function resolvePageDocumentMeta(Page) {
353
+ try {
354
+ let raw = {};
355
+ if (typeof Page?.meta === 'function')
356
+ raw = Page.meta() || {};
357
+ else if (Page?.meta && typeof Page.meta === 'object')
358
+ raw = Page.meta;
359
+ const title = String(raw.title || '').trim();
360
+ const description = String(raw.description || '').trim();
361
+ return { title: title || 'App', description };
362
+ }
363
+ catch {
364
+ return { title: 'App', description: '' };
365
+ }
268
366
  }
269
367
  /**
270
368
  * @param {any} Page
@@ -274,12 +372,18 @@ async function* emitAccessShell(marker) {
274
372
  * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
275
373
  * @param {string[]} [layoutChain] layout chunk ids outer→inner
276
374
  */
277
- async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = []) {
375
+ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = [], localeCtx = {}) {
278
376
  const signal = opts.signal;
279
377
  const live = isDev
280
378
  ? `\n <script>
281
379
  (() => {
282
380
  const es = new EventSource("/__vmz/events");
381
+ let sawDisconnect = false;
382
+ es.onerror = () => { sawDisconnect = true; };
383
+ es.onopen = () => {
384
+ // Host respawn drops SSE — reload once the new process is up (no manual restart).
385
+ if (sawDisconnect) location.reload();
386
+ };
283
387
  function showOverlay(err) {
284
388
  let el = document.getElementById("vmz-dev-overlay");
285
389
  if (!el) {
@@ -298,7 +402,7 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
298
402
  const stack = (err && err.stack) || "";
299
403
  const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
300
404
  el.innerHTML = "<div style=\\"max-width:56rem;margin:0 auto\\">"
301
- + "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">VMZ Dev Error</p>"
405
+ + "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">Dev Error</p>"
302
406
  + "<pre style=\\"white-space:pre-wrap;margin:0 0 1rem;font-size:13px;line-height:1.45\\">" + esc(msg) + "</pre>"
303
407
  + (stack ? "<pre style=\\"white-space:pre-wrap;opacity:.7;font-size:12px\\">" + esc(stack) + "</pre>" : "")
304
408
  + "<p style=\\"opacity:.65;font-size:12px\\">Fix the file and save — soft reload will clear this overlay.</p>"
@@ -370,50 +474,88 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {
370
474
  `/* paint immediately */` +
371
475
  `var d=document.createElement("div");d.id="vmz-dev-overlay";d.setAttribute("role","alert");` +
372
476
  `Object.assign(d.style,{position:"fixed",inset:"0",zIndex:"2147483646",background:"rgba(15,17,21,0.92)",color:"#f4f4f5",fontFamily:"ui-monospace,monospace",padding:"2rem",overflow:"auto"});` +
373
- `d.innerHTML="<div style='max-width:56rem;margin:0 auto'><p style='color:#f87171;font-weight:700'>VMZ Dev Error</p><pre style='white-space:pre-wrap'>"+String(e.message||e).replace(/[<>&]/g,function(c){return {"<":"&lt;",">":"&gt;","&":"&amp;"}[c]})+"</pre></div>";` +
477
+ `d.innerHTML="<div style='max-width:56rem;margin:0 auto'><p style='color:#f87171;font-weight:700'>Dev Error</p><pre style='white-space:pre-wrap'>"+String(e.message||e).replace(/[<>&]/g,function(c){return {"<":"&lt;",">":"&gt;","&":"&amp;"}[c]})+"</pre></div>";` +
374
478
  `document.documentElement.appendChild(d);})();</script>`
375
479
  : '';
376
480
  if (signal?.aborted)
377
481
  return;
378
482
  const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
379
- const htmlTheme = htmlThemeAttributeForId(themeId);
380
483
  const themeBoot = themeBootstrapScript();
381
- const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
382
484
  const propsJson = JSON.stringify(props ?? {});
383
- const layoutAttr = layoutChain.length ? ` data-vmz-layout="${escapeAttr(layoutChain.join(','))}"` : '';
384
- yield `<!DOCTYPE html>
385
- <html lang="en"${htmlTheme}>
386
- <head>
387
- <meta charset="utf-8" />
388
- <meta name="viewport" content="width=device-width, initial-scale=1" />
389
- <title>VMZ</title>
390
- ${themeBoot}${cssLink}</head>
391
- <body>
392
- <div id="app" data-vmz-page="${escapeAttr(chunkId)}"${layoutAttr} data-vmz-props="${escapeAttr(propsJson)}">`;
485
+ const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
486
+ const dir = localeCtx.dir || 'ltr';
487
+ /** @type {string[]} */
488
+ const htmlExtraAttrs = [...htmlThemeAttrPair(themeId)];
489
+ if (localeArtifact?.routing) {
490
+ htmlExtraAttrs.push('data-vmz-locale-routing', JSON.stringify({
491
+ strategy: localeArtifact.routing.strategy || 'prefix',
492
+ defaultPrefix: localeArtifact.routing.defaultPrefix || 'include',
493
+ defaultLocale: localeArtifact.defaultLocale,
494
+ locales: (localeArtifact.locales || []).map((l) => l.id),
495
+ }));
496
+ }
497
+ const pageDocMeta = resolvePageDocumentMeta(Page);
498
+ const prevLocaleHint = globalThis.__vmzLocaleIdHint;
499
+ globalThis.__vmzLocaleIdHint = localeId;
393
500
  let bodyHtml = '';
394
- for await (const chunk of renderToStream(Page, props, { signal })) {
501
+ try {
502
+ for await (const chunk of renderToStream(Page, props, { signal })) {
503
+ if (signal?.aborted)
504
+ return;
505
+ bodyHtml += chunk;
506
+ }
395
507
  if (signal?.aborted)
396
508
  return;
397
- bodyHtml += chunk;
509
+ // Wrap page HTML in layout chain (outer → inner) via default slot injection.
510
+ for (let i = layoutChain.length - 1; i >= 0; i--) {
511
+ const Layout = await loadPageCtor(layoutChain[i]);
512
+ if (!Layout)
513
+ continue;
514
+ bodyHtml = await renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
515
+ if (signal?.aborted)
516
+ return;
517
+ }
518
+ // Locale discipline: same-app Links retain current LocaleId (realization authority).
519
+ if (localeArtifact && localeId) {
520
+ bodyHtml = localizeBodyLinksInHost(bodyHtml, localeId, localeArtifact);
521
+ }
398
522
  }
399
- if (signal?.aborted)
400
- return;
401
- // Wrap page HTML in layout chain (outer → inner) via default slot injection.
402
- for (let i = layoutChain.length - 1; i >= 0; i--) {
403
- const Layout = await loadPageCtor(layoutChain[i]);
404
- if (!Layout)
405
- continue;
406
- bodyHtml = await renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
407
- if (signal?.aborted)
408
- return;
523
+ finally {
524
+ if (prevLocaleHint === undefined)
525
+ delete globalThis.__vmzLocaleIdHint;
526
+ else
527
+ globalThis.__vmzLocaleIdHint = prevLocaleHint;
409
528
  }
410
- yield bodyHtml;
411
529
  if (signal?.aborted)
412
530
  return;
413
- yield `</div>
414
- <script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}${bootOverlay}
415
- </body>
416
- </html>`;
531
+ const native = requireNativeGenerator();
532
+ if (typeof native.generatePageShell !== 'function') {
533
+ throw new Error('vmz native addon missing generatePageShell — rebuild with `pnpm napi:build`');
534
+ }
535
+ const entrySrc = `/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}`;
536
+ const cssHref = cssEntry ? `${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}` : undefined;
537
+ yield native.generatePageShell({
538
+ bodyHtml,
539
+ chunkId,
540
+ layoutChain,
541
+ propsJson,
542
+ meta: {
543
+ title: pageDocMeta.title,
544
+ description: pageDocMeta.description,
545
+ canonical: '',
546
+ robots: '',
547
+ lang: localeId,
548
+ dir,
549
+ alternates: localeCtx.alternates || [],
550
+ },
551
+ // napi Option<String>: omit/undefined = None; null is rejected as String
552
+ ...(cssHref ? { cssEntry: cssHref } : {}),
553
+ isErrorDocument: false,
554
+ htmlExtraAttrs,
555
+ headExtraHtml: themeBoot,
556
+ moduleScriptSrc: entrySrc,
557
+ bodyTailHtml: `${live}${bootOverlay}`,
558
+ });
417
559
  }
418
560
  const server = http.createServer((req, res) => {
419
561
  const url = new URL(req.url || '/', `http://${host}:${port}`);
@@ -528,17 +670,20 @@ process.on('SIGINT', () => {
528
670
  * Re-import routes / pages / components with a new cache-bust token.
529
671
  * Keeps the HTTP server process alive (no Node restart).
530
672
  * Failed reloads keep the previous in-memory modules (Vite-like resilience).
531
- * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
673
+ * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], emitted?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
532
674
  */
533
675
  async function softReload(opts = {}) {
534
676
  const prevToken = reloadToken;
535
677
  const prevCatalog = pageCatalog;
678
+ const prevCtors = new Map(pageCtors);
536
679
  const nextToken = Date.now();
537
680
  reloadToken = nextToken;
538
681
  const affected = opts.payload?.affectedChunks ?? [];
539
682
  const seeds = opts.payload?.seedChunks ?? [];
683
+ const emitted = opts.payload?.emitted ?? [];
540
684
  const full = opts.payload?.full;
541
685
  const islandHmr = Boolean(opts.payload?.islandHmr);
686
+ const reloadAllPages = shouldReloadAllPages({ full, affected, emitted, islandHmr });
542
687
  try {
543
688
  try {
544
689
  const routes = JSON.parse(await readFile(path.join(distDir, 'vmz-routes.json'), 'utf8'));
@@ -547,6 +692,12 @@ async function softReload(opts = {}) {
547
692
  catch {
548
693
  setRoutes([]);
549
694
  }
695
+ try {
696
+ localeArtifact = JSON.parse(await readFile(path.join(distDir, '_vmz', 'locale-route-realization.json'), 'utf8'));
697
+ }
698
+ catch {
699
+ localeArtifact = null;
700
+ }
550
701
  const componentEntries = await listClientComponents(distDir);
551
702
  const nextCatalog = await listPageClientFiles(distDir);
552
703
  if (!nextCatalog.length) {
@@ -570,7 +721,8 @@ async function softReload(opts = {}) {
570
721
  components[entry.name] = mod.default;
571
722
  }
572
723
  if (!islandHmr) {
573
- for (const p of nextCatalog) {
724
+ const pagesToLoad = reloadAllPages ? nextCatalog : nextCatalog.filter((p) => pageNeedsReload(p.chunkId, affected));
725
+ for (const p of pagesToLoad) {
574
726
  const pageRel = `${p.chunkId}.client.js`;
575
727
  const href = bustUrl(pathToFileURL(path.join(distDir, pageRel)).href);
576
728
  const mod = await import(href);
@@ -579,9 +731,21 @@ async function softReload(opts = {}) {
579
731
  }
580
732
  pageCatalog = nextCatalog;
581
733
  if (!islandHmr) {
582
- pageCtors.clear();
583
- for (const [k, v] of nextCtors)
584
- pageCtors.set(k, v);
734
+ if (reloadAllPages) {
735
+ pageCtors.clear();
736
+ for (const [k, v] of nextCtors)
737
+ pageCtors.set(k, v);
738
+ }
739
+ else {
740
+ // Keep unaffected page constructors; only swap what we re-imported.
741
+ for (const [k, v] of nextCtors)
742
+ pageCtors.set(k, v);
743
+ // Drop ctors for pages that disappeared from catalog.
744
+ for (const id of [...pageCtors.keys()]) {
745
+ if (!nextCatalog.some((p) => p.chunkId === id))
746
+ pageCtors.delete(id);
747
+ }
748
+ }
585
749
  }
586
750
  if (Object.keys(components).length) {
587
751
  registerComponents(components);
@@ -613,7 +777,8 @@ async function softReload(opts = {}) {
613
777
  }));
614
778
  if (!opts.quiet) {
615
779
  const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
616
- console.log(`vmz serve: soft reload ok (mode=${mode}; pages=${pageCatalog.length}; t=${reloadToken}${aff})`);
780
+ const scope = islandHmr ? 'island' : reloadAllPages ? 'all-pages' : `pages=${nextCtors.size}`;
781
+ console.log(`vmz serve: soft reload ok (mode=${mode}; ${scope}; catalog=${pageCatalog.length}; t=${reloadToken}${aff})`);
617
782
  }
618
783
  return {
619
784
  affectedChunks: affected,
@@ -623,15 +788,49 @@ async function softReload(opts = {}) {
623
788
  mode,
624
789
  eventOnlyShell,
625
790
  pageCount: pageCatalog.length,
791
+ reloadedPages: islandHmr ? 0 : nextCtors.size,
792
+ reloadAllPages,
626
793
  };
627
794
  }
628
795
  catch (err) {
629
796
  reloadToken = prevToken;
630
797
  pageCatalog = prevCatalog;
798
+ pageCtors.clear();
799
+ for (const [k, v] of prevCtors)
800
+ pageCtors.set(k, v);
631
801
  lastDevError = normalizeDevError(err);
632
802
  throw err;
633
803
  }
634
804
  }
805
+ /**
806
+ * Shared lib / full rebuild / missing affected list → refresh every page ctor.
807
+ * Otherwise only re-import the dirty page chunks (Vite-like module graph).
808
+ * @param {{ full?: boolean, affected: string[], emitted: string[], islandHmr: boolean }} opts
809
+ */
810
+ function shouldReloadAllPages(opts) {
811
+ if (opts.islandHmr)
812
+ return false;
813
+ if (opts.full)
814
+ return true;
815
+ if (!opts.affected.length)
816
+ return true;
817
+ for (const f of opts.emitted) {
818
+ const n = String(f).replace(/\\/g, '/');
819
+ if (n.includes('/lib/') || /\/Application\.client\.js$/.test(n) || /\/vmz-(dom|runtime|http|client-nav)\.js$/.test(n)) {
820
+ return true;
821
+ }
822
+ }
823
+ return false;
824
+ }
825
+ /** @param {string} chunkId @param {string[]} affected */
826
+ function pageNeedsReload(chunkId, affected) {
827
+ if (chunkId === 'pages/Layout' || chunkId.endsWith('/Layout'))
828
+ return true;
829
+ return affected.some((a) => {
830
+ const id = String(a);
831
+ return id === chunkId || chunkId.startsWith(`${id}/`) || id.startsWith(`${chunkId}/`);
832
+ });
833
+ }
635
834
  /** @param {string} event */
636
835
  function notifySse(event) {
637
836
  for (const client of [...sseClients]) {
@@ -659,23 +858,16 @@ function normalizeDevError(err) {
659
858
  async function* emitDevErrorHtml(err) {
660
859
  const msg = escapeHtml(err.message || 'Unknown error');
661
860
  const stack = err.stack ? escapeHtml(err.stack) : '';
662
- yield `<!DOCTYPE html>
663
- <html lang="en">
664
- <head>
665
- <meta charset="utf-8" />
666
- <meta name="viewport" content="width=device-width, initial-scale=1" />
667
- <title>VMZ Dev Error</title>
668
- <style>
861
+ const style = `<style>
669
862
  body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
670
863
  main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
671
864
  h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
672
865
  pre{white-space:pre-wrap;margin:0 0 1rem}
673
866
  .hint{opacity:.65;font-size:12px}
674
- </style>
675
- </head>
676
- <body>
867
+ </style>`;
868
+ const body = `${style}
677
869
  <main>
678
- <h1>VMZ Dev Error</h1>
870
+ <h1>Dev Error</h1>
679
871
  <pre>${msg}</pre>
680
872
  ${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
681
873
  <p class="hint">Dev host stayed up. Fix the source and save — soft reload will recover.</p>
@@ -689,14 +881,147 @@ async function* emitDevErrorHtml(err) {
689
881
  if (msg && msg.type === "hmr") location.reload();
690
882
  };
691
883
  })();
692
- </script>
693
- </body>
694
- </html>`;
884
+ </script>`;
885
+ const native = requireNativeGenerator();
886
+ if (typeof native.generateHtmlShell !== 'function') {
887
+ throw new Error('vmz native addon missing generateHtmlShell — rebuild with `pnpm napi:build`');
888
+ }
889
+ yield native.generateHtmlShell({
890
+ title: 'Dev Error',
891
+ lang: 'en',
892
+ cssHrefs: [],
893
+ bodyHtml: body,
894
+ bodyAttrs: [],
895
+ });
695
896
  }
696
897
  /** @param {string} s */
697
898
  function escapeHtml(s) {
698
899
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
699
900
  }
901
+ /**
902
+ * Resolve LocaleId from pathname using `_vmz/locale-route-realization.json`.
903
+ * LocaleId is a realization dimension — matching still uses stable route path.
904
+ * @param {string} pathname
905
+ */
906
+ function resolveLocalePath(pathname) {
907
+ const raw = String(pathname || '/');
908
+ const normalized = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw || '/';
909
+ if (!localeArtifact) {
910
+ return { localeId: 'en', dir: 'ltr', restPath: normalized, redirectTo: null };
911
+ }
912
+ const supported = (localeArtifact.locales || []).map((l) => l.id);
913
+ const defaultLocale = localeArtifact.defaultLocale || supported[0] || 'en';
914
+ const directions = Object.fromEntries((localeArtifact.locales || []).map((l) => [l.id, l.direction || 'ltr']));
915
+ const routing = localeArtifact.routing || {};
916
+ const parts = normalized.split('/').filter(Boolean);
917
+ let localeId = null;
918
+ let restPath = normalized;
919
+ if (parts.length && supported.includes(parts[0])) {
920
+ localeId = parts[0];
921
+ const rest = parts.slice(1);
922
+ restPath = rest.length ? `/${rest.join('/')}` : '/';
923
+ }
924
+ // omit defaultPrefix: prefixed defaultLocale URL redirects to unprefixed canonical.
925
+ if (routing.defaultPrefix === 'omit' && localeId === defaultLocale) {
926
+ return {
927
+ localeId: defaultLocale,
928
+ dir: directions[defaultLocale] || 'ltr',
929
+ restPath,
930
+ redirectTo: restPath,
931
+ };
932
+ }
933
+ const contentLocale = localeId || defaultLocale;
934
+ return {
935
+ localeId: contentLocale,
936
+ dir: directions[contentLocale] || 'ltr',
937
+ restPath,
938
+ redirectTo: null,
939
+ };
940
+ }
941
+ /**
942
+ * Realize href for current LocaleId (prefix strategy). Kept local so serve-host
943
+ * stays free of CLI package imports.
944
+ * @param {string} href
945
+ * @param {string} localeId
946
+ * @param {any} artifact
947
+ */
948
+ function localizeSameAppHrefHost(href, localeId, artifact) {
949
+ if (!href || !localeId || !artifact)
950
+ return href;
951
+ if (href.startsWith('#') || /^(mailto|tel|javascript):/i.test(href))
952
+ return href;
953
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('/'))
954
+ return href;
955
+ let pathname = String(href);
956
+ let search = '';
957
+ let hash = '';
958
+ const hashIdx = pathname.indexOf('#');
959
+ if (hashIdx >= 0) {
960
+ hash = pathname.slice(hashIdx);
961
+ pathname = pathname.slice(0, hashIdx);
962
+ }
963
+ const qIdx = pathname.indexOf('?');
964
+ if (qIdx >= 0) {
965
+ search = pathname.slice(qIdx);
966
+ pathname = pathname.slice(0, qIdx);
967
+ }
968
+ if (!pathname)
969
+ pathname = '/';
970
+ const supported = (artifact.locales || []).map((l) => l.id).filter(Boolean);
971
+ const defaultLocale = artifact.defaultLocale || artifact.routing?.defaultLocale;
972
+ const routing = artifact.routing || {};
973
+ const strategy = routing.strategy || 'prefix';
974
+ const defaultPrefix = routing.defaultPrefix || 'include';
975
+ const parts = pathname.split('/').filter(Boolean);
976
+ let rest = pathname;
977
+ if (parts.length && supported.includes(parts[0])) {
978
+ const r = parts.slice(1);
979
+ rest = r.length ? `/${r.join('/')}` : '/';
980
+ }
981
+ if (rest.length > 1 && rest.endsWith('/'))
982
+ rest = rest.slice(0, -1);
983
+ if (!rest.startsWith('/'))
984
+ rest = `/${rest}`;
985
+ if (strategy === 'none' || strategy === 'domain')
986
+ return `${rest}${search}${hash}`;
987
+ const omitDefault = defaultPrefix === 'omit' && localeId === defaultLocale;
988
+ if (omitDefault)
989
+ return `${rest}${search}${hash}`;
990
+ const pathOut = rest === '/' ? `/${localeId}` : `/${localeId}${rest}`;
991
+ return `${pathOut}${search}${hash}`;
992
+ }
993
+ /**
994
+ * @param {string} html
995
+ * @param {string} localeId
996
+ * @param {any} artifact
997
+ */
998
+ function localizeBodyLinksInHost(html, localeId, artifact) {
999
+ if (!html || !localeId || !artifact)
1000
+ return html;
1001
+ return String(html).replace(/<a\b([^>]*)>/gi, (full, attrs) => {
1002
+ if (!/\bdata-vmz-route\s*=/.test(attrs))
1003
+ return full;
1004
+ const hm = attrs.match(/\bhref\s*=\s*"([^"]*)"/i);
1005
+ if (!hm)
1006
+ return full;
1007
+ const next = localizeSameAppHrefHost(hm[1], localeId, artifact);
1008
+ if (next === hm[1])
1009
+ return full;
1010
+ const newAttrs = attrs.replace(/\bhref\s*=\s*"[^"]*"/i, `href="${escapeAttr(next)}"`);
1011
+ return `<a${newAttrs}>`;
1012
+ });
1013
+ }
1014
+ /**
1015
+ * @param {string} chunkId
1016
+ * @param {string} localeId
1017
+ */
1018
+ function pageMetaAlternates(chunkId, localeId) {
1019
+ if (!localeArtifact?.pageMetas)
1020
+ return [];
1021
+ const meta = localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeId) ||
1022
+ localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeArtifact.defaultLocale);
1023
+ return Array.isArray(meta?.alternates) ? meta.alternates : [];
1024
+ }
700
1025
  /** @param {string} href */
701
1026
  function bustUrl(href) {
702
1027
  const u = new URL(href);
@@ -752,10 +1077,14 @@ async function listClientComponents(dir) {
752
1077
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
753
1078
  }
754
1079
  /**
755
- * Discover compiled page modules under dist/pages.
1080
+ * Discover compiled page modules. Prefer Route Graph `pathPattern` from
1081
+ * `vmz-deployment.json`; fall back to walking `pages/**` (file-route only).
756
1082
  * @param {string} dir
757
1083
  */
758
1084
  async function listPageClientFiles(dir) {
1085
+ const fromDep = await listPagesFromDeployment(dir);
1086
+ if (fromDep.length)
1087
+ return fromDep;
759
1088
  const root = path.join(dir, 'pages');
760
1089
  /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
761
1090
  const out = [];
@@ -787,6 +1116,38 @@ async function listPageClientFiles(dir) {
787
1116
  await walk(root, []);
788
1117
  return out;
789
1118
  }
1119
+ /**
1120
+ * @param {string} dir
1121
+ */
1122
+ async function listPagesFromDeployment(dir) {
1123
+ /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
1124
+ const out = [];
1125
+ try {
1126
+ const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
1127
+ const dep = JSON.parse(raw);
1128
+ for (const unit of dep.units || []) {
1129
+ if (unit?.kind !== 'page')
1130
+ continue;
1131
+ const chunkId = String(unit.chunkId || '').replace(/\\/g, '/');
1132
+ if (!chunkId.startsWith('pages/'))
1133
+ continue;
1134
+ const stem = chunkId.split('/').pop() || '';
1135
+ if (isRouteBoundaryStem(stem))
1136
+ continue;
1137
+ const pageRel = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
1138
+ const pattern = String(unit.pathPattern || '').trim();
1139
+ out.push({
1140
+ chunkId,
1141
+ pageRel,
1142
+ segs: pattern ? parsePathPattern(pattern) : parseChunkSegments(chunkId),
1143
+ });
1144
+ }
1145
+ }
1146
+ catch {
1147
+ return [];
1148
+ }
1149
+ return out;
1150
+ }
790
1151
  /**
791
1152
  * File-route segments from chunk id (`pages/Install` → `/install`).
792
1153
  * Skips URL-invisible `(group)` dirs; boundary stems never reach here.
@@ -803,17 +1164,43 @@ function parseChunkSegments(chunkId) {
803
1164
  continue;
804
1165
  if (p === 'index' && i === parts.length - 1)
805
1166
  continue;
806
- const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
807
- const param = /^\[([^\]]+)\]$/.exec(p);
808
- if (catchAll)
809
- segs.push({ kind: 'catch', name: catchAll[1] });
810
- else if (param)
811
- segs.push({ kind: 'param', name: param[1] });
812
- else
813
- segs.push({ kind: 'static', value: p.toLowerCase() });
1167
+ segs.push(parsePathSegment(p));
1168
+ }
1169
+ return segs;
1170
+ }
1171
+ /**
1172
+ * Browser HTTP pattern (`/` / `/home` / `/users/:id` / `/blog/[...slug]`).
1173
+ * @param {string} pattern
1174
+ */
1175
+ function parsePathPattern(pattern) {
1176
+ const raw = String(pattern || '').trim();
1177
+ if (!raw || raw === '/')
1178
+ return [];
1179
+ const parts = raw.replace(/^\/+/, '').split('/').filter(Boolean);
1180
+ /** @type {Array<{ kind: 'static' | 'param' | 'catch', value?: string, name?: string }>} */
1181
+ const segs = [];
1182
+ for (const p of parts) {
1183
+ if (isRouteGroupDir(p))
1184
+ continue;
1185
+ segs.push(parsePathSegment(p));
814
1186
  }
815
1187
  return segs;
816
1188
  }
1189
+ /**
1190
+ * @param {string} p
1191
+ */
1192
+ function parsePathSegment(p) {
1193
+ const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
1194
+ const param = /^\[([^\]]+)\]$/.exec(p);
1195
+ const colon = /^:([A-Za-z_][\w]*)$/.exec(p);
1196
+ if (catchAll)
1197
+ return { kind: 'catch', name: catchAll[1] };
1198
+ if (param)
1199
+ return { kind: 'param', name: param[1] };
1200
+ if (colon)
1201
+ return { kind: 'param', name: colon[1] };
1202
+ return { kind: 'static', value: p.toLowerCase() };
1203
+ }
817
1204
  function isRouteGroupDir(seg) {
818
1205
  return typeof seg === 'string' && seg.startsWith('(') && seg.endsWith(')') && seg.length > 2;
819
1206
  }
@@ -963,50 +1350,11 @@ async function runRouteGate(pathname, chunkId) {
963
1350
  */
964
1351
  function emitEntryClient(eager, lazy, token) {
965
1352
  const q = `?t=${token}`;
966
- const imports = eager.map((e) => `import ${e.name} from ${JSON.stringify(`./${e.entry}${q}`)};`).join('\n');
967
- const map = eager.length ? `registerComponents({ ${eager.map((e) => e.name).join(', ')} });` : '';
968
- const entryByName = Object.fromEntries([...eager, ...lazy].map((e) => [e.name, e.entry]));
969
- const loader = lazy.length
970
- ? `const __vmzComponentEntries = ${JSON.stringify(entryByName)};
971
- globalThis.__vmzLoadComponent = async (name) => {
972
- const entry = __vmzComponentEntries[name] || ("components/" + name + ".client.js");
973
- const mod = await import("./" + entry + "${q}");
974
- return mod.default;
975
- };`
976
- : '';
977
- return `/**
978
- * Generated by vmz serve — hydrate matched file-route page (data-vmz-page) + layout chain + client Link takeover.
979
- */
980
- import { registerComponents, hydrate, hydrateRoute, destroy } from ${JSON.stringify(`./vmz-dom.js${q}`)};
981
- import { installClientNavigation } from ${JSON.stringify(`./vmz-client-nav.js${q}`)};
982
- ${imports}
983
-
984
- ${map}
985
- ${loader}
986
-
987
- const root = document.getElementById("app");
988
- if (!root) throw new Error("vmz: missing #app");
989
- const chunkId = root.getAttribute("data-vmz-page");
990
- if (!chunkId) throw new Error("vmz: missing data-vmz-page");
991
- let props = {};
992
- try {
993
- const raw = root.getAttribute("data-vmz-props");
994
- if (raw) props = JSON.parse(raw);
995
- } catch { /* ignore */ }
996
- const layoutChain = (root.getAttribute("data-vmz-layout") || "").split(",").map((s) => s.trim()).filter(Boolean);
997
- const layoutCtors = [];
998
- for (const id of layoutChain) {
999
- layoutCtors.push((await import("./" + id + ".client.js${q}")).default);
1000
- }
1001
- const Page = (await import("./" + chunkId + ".client.js${q}")).default;
1002
- await hydrateRoute(Page, root, props, layoutCtors);
1003
- installClientNavigation({
1004
- hydrate,
1005
- hydrateRoute,
1006
- destroy,
1007
- importPage: async (id) => (await import("./" + id + ".client.js${q}")).default,
1008
- });
1009
- `;
1353
+ const native = requireNativeGenerator();
1354
+ if (typeof native.generateServeEntryClient !== 'function') {
1355
+ throw new Error('vmz native addon missing generateServeEntryClient — rebuild with `pnpm napi:build`');
1356
+ }
1357
+ return native.generateServeEntryClient(eager, lazy, q);
1010
1358
  }
1011
1359
  /**
1012
1360
  * EventEntry zero-framework bootstrap: no static import of vmz-dom / page / islands.
@@ -1015,35 +1363,80 @@ installClientNavigation({
1015
1363
  */
1016
1364
  function emitEntryEvent(token) {
1017
1365
  const q = `?t=${token}`;
1018
- return `/**
1019
- * Generated by vmz serve — EventEntry zero-framework JS shell.
1366
+ const native = requireNativeGenerator();
1367
+ if (typeof native.generateServeEntryEvent !== 'function') {
1368
+ throw new Error('vmz native addon missing generateServeEntryEvent — rebuild with `pnpm napi:build`');
1369
+ }
1370
+ return native.generateServeEntryEvent(q);
1371
+ }
1372
+ /**
1373
+ * Load vmz N-API CodeGenerators (same discovery as `@vmz/vmz` native-addon).
1374
+ * @returns {any}
1020
1375
  */
1021
- (async () => {
1022
- const roots = [...document.querySelectorAll(
1023
- '[data-vmz-entry="event"], [data-vmz-client="event"], [data-vmz-client^="event:"]',
1024
- )];
1025
- for (const el of roots) {
1026
- if (el.__vmzEventWired) continue;
1027
- el.__vmzEventWired = true;
1028
- const strat = el.getAttribute("data-vmz-client") || "event";
1029
- let type = "click";
1030
- if (strat.startsWith("event:") && strat.length > 6) type = strat.slice(6) || "click";
1031
- else if (strat === "click") type = "click";
1032
- el.addEventListener(
1033
- type,
1034
- async () => {
1035
- const { registerComponents, resume } = await import(${JSON.stringify(`./vmz-dom.js${q}`)});
1036
- const name = el.getAttribute("data-vmz-island");
1037
- if (!name) throw new Error("vmz: EventEntry missing data-vmz-island");
1038
- const Comp = (await import("./components/" + name + ".client.js${q}")).default;
1039
- registerComponents({ [name]: Comp });
1040
- await resume(Comp, el);
1041
- },
1042
- { once: true },
1043
- );
1044
- }
1045
- })();
1046
- `;
1376
+ function requireNativeGenerator() {
1377
+ if (_nativeGen !== undefined) {
1378
+ if (!_nativeGen) {
1379
+ throw new Error('vmz native addon missing — run `pnpm napi:build` (serve entry printers live in vmz-generator via N-API)');
1380
+ }
1381
+ return _nativeGen;
1382
+ }
1383
+ try {
1384
+ const envPath = (typeof process.env.VMZ_NATIVE_NODE === 'string' && process.env.VMZ_NATIVE_NODE.trim()) || '';
1385
+ if (envPath) {
1386
+ _nativeGen = require(path.resolve(envPath));
1387
+ return _nativeGen;
1388
+ }
1389
+ const { platform, arch } = process;
1390
+ let triple = `${platform}-${arch}`;
1391
+ if (platform === 'win32' && arch === 'x64')
1392
+ triple = 'win32-x64-msvc';
1393
+ else if (platform === 'win32' && arch === 'arm64')
1394
+ triple = 'win32-arm64-msvc';
1395
+ else if (platform === 'darwin' && arch === 'arm64')
1396
+ triple = 'darwin-arm64';
1397
+ else if (platform === 'darwin' && arch === 'x64')
1398
+ triple = 'darwin-x64';
1399
+ else if (platform === 'linux' && arch === 'x64')
1400
+ triple = 'linux-x64-gnu';
1401
+ else if (platform === 'linux' && arch === 'arm64')
1402
+ triple = 'linux-arm64-gnu';
1403
+ const short = triple === 'win32-x64-msvc'
1404
+ ? 'win32-x64'
1405
+ : triple === 'win32-arm64-msvc'
1406
+ ? 'win32-arm64'
1407
+ : triple === 'linux-x64-gnu'
1408
+ ? 'linux-x64'
1409
+ : triple === 'linux-arm64-gnu'
1410
+ ? 'linux-arm64'
1411
+ : triple;
1412
+ const name = `@vmz/vmz-${short}`;
1413
+ /** @type {string[]} */
1414
+ const candidates = [];
1415
+ try {
1416
+ const resolved = require.resolve(`${name}/package.json`);
1417
+ const dir = path.dirname(resolved);
1418
+ candidates.push(path.join(dir, `vmz.${triple}.node`), path.join(dir, 'vmz.node'));
1419
+ }
1420
+ catch {
1421
+ /* optional */
1422
+ }
1423
+ const here = path.dirname(fileURLToPath(import.meta.url));
1424
+ candidates.push(path.join(here, 'node_modules', name, `vmz.${triple}.node`), path.join(here, 'node_modules', name, 'vmz.node'), path.join(here, '..', 'node_modules', name, `vmz.${triple}.node`), path.join(here, '..', 'node_modules', name, 'vmz.node'));
1425
+ for (const p of candidates) {
1426
+ if (existsSync(p)) {
1427
+ _nativeGen = require(p);
1428
+ return _nativeGen;
1429
+ }
1430
+ }
1431
+ _nativeGen = null;
1432
+ }
1433
+ catch {
1434
+ _nativeGen = null;
1435
+ }
1436
+ if (!_nativeGen) {
1437
+ throw new Error('vmz native addon missing — run `pnpm napi:build` (serve entry printers live in vmz-generator via N-API)');
1438
+ }
1439
+ return _nativeGen;
1047
1440
  }
1048
1441
  /**
1049
1442
  * Style Theme cookie / localStorage key (host contract, not a second theme API).
@@ -1101,14 +1494,15 @@ function resolveThemeId(searchParams, cookieHeader) {
1101
1494
  /**
1102
1495
  * Always emit activation attr for an explicit theme id (incl. default) so it overrides OS media.
1103
1496
  * @param {string|null} themeId
1497
+ * @returns {[string, string] | []} flattened attr pair for generatePageShell
1104
1498
  */
1105
- function htmlThemeAttributeForId(themeId) {
1499
+ function htmlThemeAttrPair(themeId) {
1106
1500
  if (!styleTheme || !themeId)
1107
- return '';
1501
+ return [];
1108
1502
  const attr = styleTheme.activationAttr || 'data-theme';
1109
1503
  if (!(styleTheme.themeIds || []).includes(themeId))
1110
- return '';
1111
- return ` ${attr}="${escapeAttr(themeId)}"`;
1504
+ return [];
1505
+ return [attr, themeId];
1112
1506
  }
1113
1507
  /**
1114
1508
  * Inline boot when SSR had no query/cookie: apply explicit `localStorage` only.