@vmz/core 0.0.1 → 0.0.2

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.
@@ -0,0 +1,736 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Generic VMZ Node host — SSR file-route pages + dist static + RPC/REST.
4
+ *
5
+ * Invoked by `vmz serve` / `vmz dev` (or: node dist/vmz-serve-host.mjs).
6
+ *
7
+ * Pathname → `pages/**` (PascalCase stem → lowercase URL; `index` → parent;
8
+ * `[Param]` / `[...rest]` dynamic). Not an SPA shell.
9
+ *
10
+ * `VMZ_DEV=1`: POST `/__vmz/reload` soft-reloads modules (cache-bust import);
11
+ * GET `/__vmz/events` SSE notifies the browser:
12
+ * - island HMR → re-import `entry-client.js` (no full document reload)
13
+ * - otherwise → `location.reload()`
14
+ */
15
+ import http from 'node:http';
16
+ import path from 'node:path';
17
+ import { readdir, writeFile, readFile } from 'node:fs/promises';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
19
+ import { setServerModuleResolver, setRoutes, handleNodeRequest } from './vmz-runtime.js';
20
+ import { registerComponents, renderToStream } from './vmz-dom.js';
21
+ const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
22
+ const host = process.env.VMZ_HOST || '127.0.0.1';
23
+ const port = Number(process.env.VMZ_PORT || process.env.PORT || 5173);
24
+ const isDev = process.env.VMZ_DEV === '1' || process.env.VMZ_DEV === 'true';
25
+ /** @type {number} */
26
+ let reloadToken = Date.now();
27
+ /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
28
+ let pageCatalog = [];
29
+ /** @type {Map<string, any>} */
30
+ const pageCtors = new Map();
31
+ /** Stylesheet from deployment `cssEntry` (e.g. vmz.css). */
32
+ let cssEntry = null;
33
+ /** @type {{ defaultThemeId: string, themeIds: string[], activationAttr: string, contentHash: string|null } | null} */
34
+ let styleTheme = null;
35
+ /** @type {Set<import('node:http').ServerResponse>} */
36
+ const sseClients = new Set();
37
+ setServerModuleResolver((moduleId) => {
38
+ const rel = moduleId.replace(/^#server\//, '') + '.js';
39
+ return bustUrl(pathToFileURL(path.join(distDir, '#server', rel)).href);
40
+ });
41
+ await softReload({ quiet: true });
42
+ /**
43
+ * @param {import('node:http').IncomingMessage} req
44
+ * @returns {Promise<string>}
45
+ */
46
+ function readRequestBody(req) {
47
+ return new Promise((resolve, reject) => {
48
+ const chunks = [];
49
+ req.on('data', (c) => chunks.push(c));
50
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
51
+ req.on('error', reject);
52
+ });
53
+ }
54
+ /**
55
+ * @param {string} pathname
56
+ * @returns {Promise<string | null>}
57
+ */
58
+ async function renderPage(pathname, opts = {}) {
59
+ const rendered = await renderPageStream(pathname, opts);
60
+ if (!rendered)
61
+ return null;
62
+ const stream = rendered.stream ?? rendered;
63
+ let html = '';
64
+ for await (const chunk of stream) {
65
+ html += chunk;
66
+ }
67
+ return html;
68
+ }
69
+ /**
70
+ * Stream shell + Direct serialize body for the matched file-route page.
71
+ * @param {string} pathname
72
+ * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
73
+ * @returns {Promise<{ status: number, stream: AsyncGenerator<string, void, void> } | null>}
74
+ */
75
+ async function renderPageStream(pathname, opts = {}) {
76
+ let match = matchFileRoute(pathname, pageCatalog);
77
+ let status = 200;
78
+ const gated = await runRouteGate(pathname, match?.chunkId);
79
+ if (gated === 'not_found') {
80
+ match = findRootCatchAll(pageCatalog);
81
+ status = 404;
82
+ }
83
+ else if (!match) {
84
+ match = findRootCatchAll(pageCatalog);
85
+ status = 404;
86
+ }
87
+ else if (isRootCatchAll(match)) {
88
+ status = 404;
89
+ }
90
+ if (!match)
91
+ return null;
92
+ const Page = await loadPageCtor(match.chunkId);
93
+ if (!Page)
94
+ return null;
95
+ const resumeEntries = await loadPageResumeEntries(distDir, match.chunkId);
96
+ const strategies = resumeEntries.map((e) => e.strategy);
97
+ const eventOnlyShell = isEventOnlyShell(strategies);
98
+ return {
99
+ status,
100
+ stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, opts),
101
+ };
102
+ }
103
+ /**
104
+ * @param {any} Page
105
+ * @param {string} chunkId
106
+ * @param {boolean} eventOnlyShell
107
+ * @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
108
+ */
109
+ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
110
+ const signal = opts.signal;
111
+ const live = isDev
112
+ ? `\n <script>
113
+ (() => {
114
+ const es = new EventSource("/__vmz/events");
115
+ es.onmessage = async (ev) => {
116
+ let msg = null;
117
+ try { msg = JSON.parse(ev.data); } catch { /* plain string */ }
118
+ if (!msg || msg.type !== "hmr") {
119
+ if (ev.data === "reload") location.reload();
120
+ return;
121
+ }
122
+ if (msg.mode === "island") {
123
+ try {
124
+ const { registerComponents, hydrate } = await import("/vmz-dom.js?t=" + msg.token);
125
+ const names = (msg.affectedChunks || [])
126
+ .map((c) => String(c))
127
+ .filter((c) => c.startsWith("components/") || !c.includes("/"))
128
+ .map((c) => c.split("/").pop())
129
+ .filter(Boolean);
130
+ const map = {};
131
+ for (const name of names) {
132
+ let mod;
133
+ try {
134
+ mod = await import("/components/" + name + ".client.js?t=" + msg.token);
135
+ } catch {
136
+ mod = await import("/" + name + ".client.js?t=" + msg.token);
137
+ }
138
+ map[name] = mod.default;
139
+ }
140
+ if (Object.keys(map).length) registerComponents(map);
141
+ const root = document.getElementById("app");
142
+ const pageChunk = root && root.getAttribute("data-vmz-page");
143
+ if (root && pageChunk) {
144
+ const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
145
+ await hydrate(pageMod.default, root, {}, { preserveState: true, skipOnMount: true });
146
+ } else {
147
+ location.reload();
148
+ }
149
+ } catch (err) {
150
+ console.error("vmz island HMR failed", err);
151
+ location.reload();
152
+ }
153
+ return;
154
+ }
155
+ location.reload();
156
+ };
157
+ })();
158
+ </script>`
159
+ : '';
160
+ if (signal?.aborted)
161
+ return;
162
+ const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
163
+ const htmlTheme = htmlThemeAttributeForId(themeId);
164
+ const themeBoot = themeBootstrapScript();
165
+ const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
166
+ yield `<!DOCTYPE html>
167
+ <html lang="en"${htmlTheme}>
168
+ <head>
169
+ <meta charset="utf-8" />
170
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
171
+ <title>VMZ</title>
172
+ ${themeBoot}${cssLink}</head>
173
+ <body>
174
+ <div id="app" data-vmz-page="${escapeAttr(chunkId)}">`;
175
+ for await (const chunk of renderToStream(Page, {}, { signal })) {
176
+ if (signal?.aborted)
177
+ return;
178
+ yield chunk;
179
+ }
180
+ if (signal?.aborted)
181
+ return;
182
+ yield `</div>
183
+ <script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}
184
+ </body>
185
+ </html>`;
186
+ }
187
+ const server = http.createServer((req, res) => {
188
+ const url = new URL(req.url || '/', `http://${host}:${port}`);
189
+ if (url.pathname === '/__vmz/reload' && req.method === 'POST') {
190
+ readRequestBody(req)
191
+ .then((raw) => {
192
+ let payload = {};
193
+ try {
194
+ payload = raw ? JSON.parse(raw) : {};
195
+ }
196
+ catch {
197
+ payload = {};
198
+ }
199
+ return softReload({ payload });
200
+ })
201
+ .then((info) => {
202
+ res.writeHead(200, { 'content-type': 'application/json' });
203
+ res.end(JSON.stringify({ ok: true, token: reloadToken, ...info }));
204
+ })
205
+ .catch((err) => {
206
+ console.error('vmz serve: soft reload failed', err);
207
+ res.writeHead(500, { 'content-type': 'application/json' });
208
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
209
+ });
210
+ return;
211
+ }
212
+ if (url.pathname === '/__vmz/events' && req.method === 'GET') {
213
+ res.writeHead(200, {
214
+ 'content-type': 'text/event-stream',
215
+ 'cache-control': 'no-cache',
216
+ connection: 'keep-alive',
217
+ });
218
+ res.write(': connected\n\n');
219
+ sseClients.add(res);
220
+ req.on('close', () => {
221
+ sseClients.delete(res);
222
+ });
223
+ return;
224
+ }
225
+ handleNodeRequest(req, res, { distDir, renderPage, renderPageStream });
226
+ });
227
+ server.listen(port, host, () => {
228
+ console.log(`vmz serve http://${host}:${port} (dist=${distDir}${isDev ? ', dev' : ''})`);
229
+ });
230
+ /**
231
+ * Re-import routes / pages / components with a new cache-bust token.
232
+ * Keeps the HTTP server process alive (no Node restart).
233
+ * @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
234
+ */
235
+ async function softReload(opts = {}) {
236
+ reloadToken = Date.now();
237
+ const affected = opts.payload?.affectedChunks ?? [];
238
+ const seeds = opts.payload?.seedChunks ?? [];
239
+ const full = opts.payload?.full;
240
+ const islandHmr = Boolean(opts.payload?.islandHmr);
241
+ try {
242
+ const routes = JSON.parse(await readFile(path.join(distDir, 'vmz-routes.json'), 'utf8'));
243
+ setRoutes(routes);
244
+ }
245
+ catch {
246
+ setRoutes([]);
247
+ }
248
+ const componentEntries = await listClientComponents(distDir);
249
+ const componentNames = componentEntries.map((e) => e.name);
250
+ pageCatalog = await listPageClientFiles(distDir);
251
+ if (!pageCatalog.length) {
252
+ throw new Error(`vmz serve: no pages/**/*.client.js in ${distDir}`);
253
+ }
254
+ pageCtors.clear();
255
+ /** @type {Record<string, any>} */
256
+ const components = {};
257
+ const affectedNames = new Set(affected
258
+ .map((c) => String(c))
259
+ .filter((c) => c.startsWith('components/') || !c.includes('/'))
260
+ .map((c) => c.split('/').pop())
261
+ .filter(Boolean));
262
+ for (const entry of componentEntries) {
263
+ if (islandHmr && affectedNames.size > 0 && !affectedNames.has(entry.name)) {
264
+ continue;
265
+ }
266
+ const href = bustUrl(pathToFileURL(path.join(distDir, entry.entry)).href);
267
+ const mod = await import(href);
268
+ components[entry.name] = mod.default;
269
+ }
270
+ if (Object.keys(components).length) {
271
+ registerComponents(components);
272
+ }
273
+ if (!islandHmr) {
274
+ for (const p of pageCatalog) {
275
+ await loadPageCtor(p.chunkId);
276
+ }
277
+ }
278
+ const indexChunk = pageCatalog.find((p) => p.chunkId === 'pages/index')?.chunkId || pageCatalog[0].chunkId;
279
+ const resumeEntries = await loadPageResumeEntries(distDir, indexChunk);
280
+ const styleMeta = await loadDeploymentStyle(distDir);
281
+ cssEntry = styleMeta.cssEntry;
282
+ styleTheme = styleMeta.styleTheme;
283
+ const lazyEventNames = resumeEntries
284
+ .filter((e) => isEventStrategy(e.strategy))
285
+ .map((e) => e.component)
286
+ .filter(Boolean);
287
+ const lazySet = new Set(lazyEventNames);
288
+ await writeFile(path.join(distDir, 'entry-client.js'), emitEntryClient(componentEntries.filter((e) => !lazySet.has(e.name)), componentEntries.filter((e) => lazySet.has(e.name)), reloadToken), 'utf8');
289
+ const strategies = resumeEntries.map((e) => e.strategy);
290
+ const eventOnlyShell = isEventOnlyShell(strategies);
291
+ await writeFile(path.join(distDir, 'entry-event.js'), emitEntryEvent(reloadToken), 'utf8');
292
+ const mode = islandHmr ? 'island' : eventOnlyShell ? 'event-shell' : 'full';
293
+ notifySse(JSON.stringify({
294
+ type: 'hmr',
295
+ mode,
296
+ affectedChunks: affected,
297
+ seedChunks: seeds,
298
+ token: reloadToken,
299
+ full: Boolean(full),
300
+ eventOnlyShell,
301
+ }));
302
+ if (!opts.quiet) {
303
+ const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
304
+ console.log(`vmz serve: soft reload ok (mode=${mode}; pages=${pageCatalog.length}; t=${reloadToken}${aff})`);
305
+ }
306
+ return {
307
+ affectedChunks: affected,
308
+ seedChunks: seeds,
309
+ full: Boolean(full),
310
+ islandHmr,
311
+ mode,
312
+ eventOnlyShell,
313
+ pageCount: pageCatalog.length,
314
+ };
315
+ }
316
+ /** @param {string} event */
317
+ function notifySse(event) {
318
+ for (const client of [...sseClients]) {
319
+ try {
320
+ client.write(`data: ${event}\n\n`);
321
+ }
322
+ catch {
323
+ sseClients.delete(client);
324
+ }
325
+ }
326
+ }
327
+ /** @param {string} href */
328
+ function bustUrl(href) {
329
+ const u = new URL(href);
330
+ u.searchParams.set('t', String(reloadToken));
331
+ return u.href;
332
+ }
333
+ /** @param {string} chunkId */
334
+ async function loadPageCtor(chunkId) {
335
+ const pageRel = `${chunkId}.client.js`;
336
+ const href = bustUrl(pathToFileURL(path.join(distDir, pageRel)).href);
337
+ const mod = await import(href);
338
+ pageCtors.set(chunkId, mod.default);
339
+ return mod.default;
340
+ }
341
+ /**
342
+ * @param {string} dir
343
+ * @returns {Promise<Array<{ name: string, entry: string }>>}
344
+ */
345
+ async function listClientComponents(dir) {
346
+ /** @type {Map<string, { name: string, entry: string }>} */
347
+ const byName = new Map();
348
+ try {
349
+ const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
350
+ const dep = JSON.parse(raw);
351
+ for (const unit of dep.units || []) {
352
+ if (unit?.kind !== 'component')
353
+ continue;
354
+ const chunkId = String(unit.chunkId || '');
355
+ const name = chunkId.split('/').pop();
356
+ if (!name)
357
+ continue;
358
+ const entry = String(unit.clientEntry || `${chunkId}.client.js`).replace(/\\/g, '/');
359
+ byName.set(name, { name, entry });
360
+ }
361
+ }
362
+ catch {
363
+ /* fall through to directory scan */
364
+ }
365
+ if (byName.size === 0) {
366
+ const folder = path.join(dir, 'components');
367
+ let files = [];
368
+ try {
369
+ files = await readdir(folder);
370
+ }
371
+ catch {
372
+ return [];
373
+ }
374
+ for (const f of files.filter((name) => name.endsWith('.client.js'))) {
375
+ const name = f.replace(/\.client\.js$/, '');
376
+ byName.set(name, { name, entry: `components/${name}.client.js` });
377
+ }
378
+ }
379
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
380
+ }
381
+ /**
382
+ * Discover compiled page modules under dist/pages.
383
+ * @param {string} dir
384
+ */
385
+ async function listPageClientFiles(dir) {
386
+ const root = path.join(dir, 'pages');
387
+ /** @type {Array<{ chunkId: string, pageRel: string, segs: ReturnType<typeof parseChunkSegments> }>} */
388
+ const out = [];
389
+ async function walk(abs, relParts) {
390
+ let ents;
391
+ try {
392
+ ents = await readdir(abs, { withFileTypes: true });
393
+ }
394
+ catch {
395
+ return;
396
+ }
397
+ for (const e of ents) {
398
+ if (e.isDirectory()) {
399
+ await walk(path.join(abs, e.name), [...relParts, e.name]);
400
+ }
401
+ else if (e.isFile() && e.name.endsWith('.client.js')) {
402
+ const stem = e.name.replace(/\.client\.js$/, '');
403
+ const chunkId = ['pages', ...relParts, stem].join('/');
404
+ out.push({
405
+ chunkId,
406
+ pageRel: `${chunkId}.client.js`,
407
+ segs: parseChunkSegments(chunkId),
408
+ });
409
+ }
410
+ }
411
+ }
412
+ await walk(root, []);
413
+ return out;
414
+ }
415
+ /**
416
+ * File-route segments from chunk id (`pages/Install` → `/install`).
417
+ * @param {string} chunkId
418
+ */
419
+ function parseChunkSegments(chunkId) {
420
+ const rel = chunkId.replace(/^pages\//, '');
421
+ const parts = rel.split('/').filter(Boolean);
422
+ /** @type {Array<{ kind: 'static' | 'param' | 'catch', value?: string, name?: string }>} */
423
+ const segs = [];
424
+ for (let i = 0; i < parts.length; i++) {
425
+ const p = parts[i];
426
+ if (p === 'index' && i === parts.length - 1)
427
+ continue;
428
+ const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
429
+ const param = /^\[([^\]]+)\]$/.exec(p);
430
+ if (catchAll)
431
+ segs.push({ kind: 'catch', name: catchAll[1] });
432
+ else if (param)
433
+ segs.push({ kind: 'param', name: param[1] });
434
+ else
435
+ segs.push({ kind: 'static', value: p.toLowerCase() });
436
+ }
437
+ return segs;
438
+ }
439
+ /**
440
+ * @param {string} pathname
441
+ * @param {typeof pageCatalog} catalog
442
+ */
443
+ function matchFileRoute(pathname, catalog) {
444
+ const pathParts = decodeURIComponent(pathname.split('?')[0] || '/')
445
+ .replace(/\/+$/, '')
446
+ .split('/')
447
+ .filter(Boolean)
448
+ .map((p) => p.toLowerCase());
449
+ let best = null;
450
+ let bestScore = -1;
451
+ for (const page of catalog) {
452
+ const score = scoreRoute(page.segs, pathParts);
453
+ if (score == null)
454
+ continue;
455
+ if (score > bestScore) {
456
+ bestScore = score;
457
+ best = page;
458
+ }
459
+ }
460
+ return best;
461
+ }
462
+ /**
463
+ * @param {ReturnType<typeof parseChunkSegments>} segs
464
+ * @param {string[]} pathParts
465
+ * @returns {number | null}
466
+ */
467
+ function scoreRoute(segs, pathParts) {
468
+ let i = 0;
469
+ let j = 0;
470
+ let score = 0;
471
+ while (i < segs.length) {
472
+ const s = segs[i];
473
+ if (s.kind === 'catch') {
474
+ // Required catch-all `[...slug]` needs ≥1 remaining segment (not `/`).
475
+ if (j >= pathParts.length)
476
+ return null;
477
+ score += 1;
478
+ return score;
479
+ }
480
+ if (j >= pathParts.length)
481
+ return null;
482
+ if (s.kind === 'static') {
483
+ if (s.value !== pathParts[j])
484
+ return null;
485
+ score += 1000;
486
+ }
487
+ else if (s.kind === 'param') {
488
+ score += 100;
489
+ }
490
+ i++;
491
+ j++;
492
+ }
493
+ if (j !== pathParts.length)
494
+ return null;
495
+ return score + segs.length;
496
+ }
497
+ function isRootCatchAll(page) {
498
+ return page?.segs?.length === 1 && page.segs[0].kind === 'catch';
499
+ }
500
+ function findRootCatchAll(catalog) {
501
+ return catalog.find((p) => isRootCatchAll(p)) || null;
502
+ }
503
+ /**
504
+ * Optional app gate: `dist/vmz-route-gate.mjs` → `{ check(pathname, chunkId) => 'not_found' | null }`.
505
+ * @param {string} pathname
506
+ * @param {string | undefined} chunkId
507
+ */
508
+ async function runRouteGate(pathname, chunkId) {
509
+ try {
510
+ const href = bustUrl(pathToFileURL(path.join(distDir, 'vmz-route-gate.mjs')).href);
511
+ const mod = await import(href);
512
+ if (typeof mod.check !== 'function')
513
+ return null;
514
+ return await mod.check(pathname, chunkId ?? null);
515
+ }
516
+ catch {
517
+ return null;
518
+ }
519
+ }
520
+ /**
521
+ * @param {Array<{ name: string, entry: string }>} eager
522
+ * @param {Array<{ name: string, entry: string }>} lazy
523
+ * @param {number} token
524
+ */
525
+ function emitEntryClient(eager, lazy, token) {
526
+ const q = `?t=${token}`;
527
+ const imports = eager.map((e) => `import ${e.name} from ${JSON.stringify(`./${e.entry}${q}`)};`).join('\n');
528
+ const map = eager.length ? `registerComponents({ ${eager.map((e) => e.name).join(', ')} });` : '';
529
+ const entryByName = Object.fromEntries([...eager, ...lazy].map((e) => [e.name, e.entry]));
530
+ const loader = lazy.length
531
+ ? `const __vmzComponentEntries = ${JSON.stringify(entryByName)};
532
+ globalThis.__vmzLoadComponent = async (name) => {
533
+ const entry = __vmzComponentEntries[name] || ("components/" + name + ".client.js");
534
+ const mod = await import("./" + entry + "${q}");
535
+ return mod.default;
536
+ };`
537
+ : '';
538
+ return `/**
539
+ * Generated by vmz serve — hydrate matched file-route page (data-vmz-page).
540
+ */
541
+ import { registerComponents, hydrate } from ${JSON.stringify(`./vmz-dom.js${q}`)};
542
+ ${imports}
543
+
544
+ ${map}
545
+ ${loader}
546
+
547
+ const root = document.getElementById("app");
548
+ if (!root) throw new Error("vmz: missing #app");
549
+ const chunkId = root.getAttribute("data-vmz-page");
550
+ if (!chunkId) throw new Error("vmz: missing data-vmz-page");
551
+ const Page = (await import("./" + chunkId + ".client.js${q}")).default;
552
+ await hydrate(Page, root);
553
+ `;
554
+ }
555
+ /**
556
+ * EventEntry zero-framework bootstrap: no static import of vmz-dom / page / islands.
557
+ * Framework bytes load only inside the first matching DOM event handler.
558
+ * @param {number} token
559
+ */
560
+ function emitEntryEvent(token) {
561
+ const q = `?t=${token}`;
562
+ return `/**
563
+ * Generated by vmz serve — EventEntry zero-framework JS shell.
564
+ */
565
+ (async () => {
566
+ const roots = [...document.querySelectorAll(
567
+ '[data-vmz-entry="event"], [data-vmz-client="event"], [data-vmz-client^="event:"]',
568
+ )];
569
+ for (const el of roots) {
570
+ if (el.__vmzEventWired) continue;
571
+ el.__vmzEventWired = true;
572
+ const strat = el.getAttribute("data-vmz-client") || "event";
573
+ let type = "click";
574
+ if (strat.startsWith("event:") && strat.length > 6) type = strat.slice(6) || "click";
575
+ else if (strat === "click") type = "click";
576
+ el.addEventListener(
577
+ type,
578
+ async () => {
579
+ const { registerComponents, resume } = await import(${JSON.stringify(`./vmz-dom.js${q}`)});
580
+ const name = el.getAttribute("data-vmz-island");
581
+ if (!name) throw new Error("vmz: EventEntry missing data-vmz-island");
582
+ const Comp = (await import("./components/" + name + ".client.js${q}")).default;
583
+ registerComponents({ [name]: Comp });
584
+ await resume(Comp, el);
585
+ },
586
+ { once: true },
587
+ );
588
+ }
589
+ })();
590
+ `;
591
+ }
592
+ /**
593
+ * Style Theme cookie / localStorage key (host contract, not a second theme API).
594
+ */
595
+ const THEME_STORE_KEY = 'vmz-theme';
596
+ /**
597
+ * @param {string} dir
598
+ * @returns {Promise<{ cssEntry: string|null, styleTheme: typeof styleTheme, styleBundleHash: string|null }>}
599
+ */
600
+ async function loadDeploymentStyle(dir) {
601
+ try {
602
+ const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
603
+ const dep = JSON.parse(raw);
604
+ const entry = dep.cssEntry;
605
+ const css = typeof entry === 'string' && entry.trim() ? entry.trim().replace(/^\/+/, '') : null;
606
+ const st = dep.styleTheme;
607
+ let theme = null;
608
+ if (st && typeof st === 'object') {
609
+ theme = {
610
+ defaultThemeId: String(st.defaultThemeId || 'default'),
611
+ themeIds: Array.isArray(st.themeIds) ? st.themeIds.map(String) : [],
612
+ activationAttr: String(st.activationAttr || 'data-theme'),
613
+ prefersColorScheme: st.prefersColorScheme && typeof st.prefersColorScheme === 'object'
614
+ ? Object.fromEntries(Object.entries(st.prefersColorScheme).map(([k, v]) => [String(k), String(v)]))
615
+ : {},
616
+ contentHash: st.contentHash ? String(st.contentHash) : null,
617
+ };
618
+ }
619
+ const bundleHash = typeof dep.styleBundleHash === 'string' && dep.styleBundleHash.trim() ? dep.styleBundleHash.trim() : null;
620
+ return { cssEntry: css, styleTheme: theme, styleBundleHash: bundleHash };
621
+ }
622
+ catch {
623
+ return { cssEntry: null, styleTheme: null, styleBundleHash: null };
624
+ }
625
+ }
626
+ /**
627
+ * Priority: `?theme=` → cookie → none (CSS `:root` + prefers-color-scheme media).
628
+ * Explicit ids (including default) always win over OS preference via activation attr.
629
+ * @param {URLSearchParams | undefined} searchParams
630
+ * @param {string | undefined} cookieHeader
631
+ * @returns {string|null}
632
+ */
633
+ function resolveThemeId(searchParams, cookieHeader) {
634
+ if (!styleTheme)
635
+ return null;
636
+ const ids = styleTheme.themeIds || [];
637
+ const q = searchParams && typeof searchParams.get === 'function' ? searchParams.get('theme') : null;
638
+ if (q && ids.includes(q))
639
+ return q;
640
+ const fromCookie = readCookie(cookieHeader, THEME_STORE_KEY);
641
+ if (fromCookie && ids.includes(fromCookie))
642
+ return fromCookie;
643
+ return null;
644
+ }
645
+ /**
646
+ * Always emit activation attr for an explicit theme id (incl. default) so it overrides OS media.
647
+ * @param {string|null} themeId
648
+ */
649
+ function htmlThemeAttributeForId(themeId) {
650
+ if (!styleTheme || !themeId)
651
+ return '';
652
+ const attr = styleTheme.activationAttr || 'data-theme';
653
+ if (!(styleTheme.themeIds || []).includes(themeId))
654
+ return '';
655
+ return ` ${attr}="${escapeAttr(themeId)}"`;
656
+ }
657
+ /**
658
+ * Inline boot when SSR had no query/cookie: apply explicit `localStorage` only.
659
+ * No stored choice → leave bare `<html>` so CSS `@media (prefers-color-scheme)` follows OS live.
660
+ * Explicit ids (incl. default) always set the activation attr so they override OS media.
661
+ */
662
+ function themeBootstrapScript() {
663
+ if (!styleTheme)
664
+ return '';
665
+ const attr = JSON.stringify(styleTheme.activationAttr || 'data-theme');
666
+ const ids = JSON.stringify(styleTheme.themeIds || []);
667
+ const key = JSON.stringify(THEME_STORE_KEY);
668
+ return ` <script>(function(){try{var k=${key},attr=${attr},ids=${ids};var id=localStorage.getItem(k);if(!id||ids.indexOf(id)<0)return;document.documentElement.setAttribute(attr,id);}catch(e){}})();</script>\n`;
669
+ }
670
+ /**
671
+ * @param {string|undefined} header
672
+ * @param {string} name
673
+ */
674
+ function readCookie(header, name) {
675
+ if (!header)
676
+ return null;
677
+ const parts = String(header).split(';');
678
+ for (const part of parts) {
679
+ const idx = part.indexOf('=');
680
+ if (idx < 0)
681
+ continue;
682
+ const k = part.slice(0, idx).trim();
683
+ if (k !== name)
684
+ continue;
685
+ try {
686
+ return decodeURIComponent(part.slice(idx + 1).trim());
687
+ }
688
+ catch {
689
+ return part.slice(idx + 1).trim();
690
+ }
691
+ }
692
+ return null;
693
+ }
694
+ /**
695
+ * @param {string} dir
696
+ * @returns {Promise<string|null>}
697
+ */
698
+ async function loadCssEntry(dir) {
699
+ const meta = await loadDeploymentStyle(dir);
700
+ return meta.cssEntry;
701
+ }
702
+ /**
703
+ * @param {string} dir
704
+ * @param {string} chunkId
705
+ * @returns {Promise<Array<{ component: string, strategy: string }>>}
706
+ */
707
+ async function loadPageResumeEntries(dir, chunkId) {
708
+ try {
709
+ const raw = await readFile(path.join(dir, 'vmz-deployment.json'), 'utf8');
710
+ const dep = JSON.parse(raw);
711
+ const units = Array.isArray(dep.units) ? dep.units : [];
712
+ const page = units.find((u) => u.chunkId === chunkId) || units.find((u) => u.chunkId === 'pages/index') || units.find((u) => u.kind === 'page');
713
+ const entries = Array.isArray(page?.resumeEntries) ? page.resumeEntries : [];
714
+ return entries.map((e) => ({
715
+ component: String(e.component || ''),
716
+ strategy: String(e.strategy || ''),
717
+ }));
718
+ }
719
+ catch {
720
+ return [];
721
+ }
722
+ }
723
+ /** @param {string} strategy */
724
+ function isEventStrategy(strategy) {
725
+ return strategy === 'event' || strategy === 'click' || strategy.startsWith('event:');
726
+ }
727
+ /** @param {string[]} strategies */
728
+ function isEventOnlyShell(strategies) {
729
+ if (!strategies.length)
730
+ return false;
731
+ return strategies.every((s) => isEventStrategy(s));
732
+ }
733
+ /** @param {string} s */
734
+ function escapeAttr(s) {
735
+ return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
736
+ }