@vmz/core 0.0.3 → 0.1.0
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.
- package/dist/client-nav.d.ts +64 -0
- package/dist/client-nav.js +696 -0
- package/dist/dom-core.d.ts +337 -0
- package/dist/dom-core.js +4565 -0
- package/dist/dom-ssr.d.ts +78 -0
- package/dist/dom-ssr.js +970 -0
- package/dist/dom.client.d.ts +4 -0
- package/dist/dom.client.js +5 -0
- package/dist/dom.d.ts +3 -200
- package/dist/dom.js +3 -3055
- package/dist/serve-host.d.ts +13 -0
- package/dist/serve-host.mjs +718 -94
- package/dist/server.d.ts +8 -0
- package/dist/server.js +313 -19
- package/dist/vmz-dom.d.ts +5 -0
- package/dist/vmz-dom.js +6 -0
- package/dist/vmz-runtime.d.ts +5 -0
- package/dist/vmz-runtime.js +6 -0
- package/package.json +12 -1
package/dist/serve-host.mjs
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
* - island HMR → re-import `entry-client.js` (no full document reload)
|
|
13
13
|
* - otherwise → `location.reload`
|
|
14
14
|
*/
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
|
15
17
|
import http from 'node:http';
|
|
16
18
|
import path from 'node:path';
|
|
17
|
-
import { readdir, writeFile, readFile } from 'node:fs/promises';
|
|
18
19
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
20
|
+
import { registerComponents, renderToStream, renderToString } from './vmz-dom.js';
|
|
21
|
+
import { handleNodeRequest, setRoutes, setServerModuleResolver } from './vmz-runtime.js';
|
|
21
22
|
const distDir = process.env.VMZ_DIST ? path.resolve(process.env.VMZ_DIST) : path.dirname(fileURLToPath(import.meta.url));
|
|
22
23
|
const host = process.env.VMZ_HOST || '127.0.0.1';
|
|
23
24
|
const port = Number(process.env.VMZ_PORT || process.env.PORT || 5173);
|
|
@@ -32,13 +33,30 @@ const pageCtors = new Map();
|
|
|
32
33
|
let cssEntry = null;
|
|
33
34
|
/** @type {{ defaultThemeId: string, themeIds: string[], activationAttr: string, contentHash: string|null } | null} */
|
|
34
35
|
let styleTheme = null;
|
|
36
|
+
/** Locale route realization artifact from `_vmz/locale-route-realization.json` (optional). */
|
|
37
|
+
let localeArtifact = null;
|
|
35
38
|
/** @type {Set<import('node:http').ServerResponse>} */
|
|
36
39
|
const sseClients = new Set();
|
|
40
|
+
/** In-flight HTTP requests (graceful shutdown drain). */
|
|
41
|
+
let inFlight = 0;
|
|
42
|
+
/** When true, refuse new work except health. */
|
|
43
|
+
let shuttingDown = false;
|
|
44
|
+
let ready = false;
|
|
45
|
+
/** @type {{ message: string, stack?: string, at: number } | null} */
|
|
46
|
+
let lastDevError = null;
|
|
37
47
|
setServerModuleResolver((moduleId) => {
|
|
38
48
|
const rel = moduleId.replace(/^#server\//, '') + '.js';
|
|
39
49
|
return bustUrl(pathToFileURL(path.join(distDir, '#server', rel)).href);
|
|
40
50
|
});
|
|
41
|
-
|
|
51
|
+
try {
|
|
52
|
+
await softReload({ quiet: true });
|
|
53
|
+
ready = true;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
lastDevError = normalizeDevError(err);
|
|
57
|
+
ready = true; // still accept HTTP — serve error page / recover on next reload
|
|
58
|
+
console.error('vmz serve: initial load failed (dev host stays up)', lastDevError.message);
|
|
59
|
+
}
|
|
42
60
|
/**
|
|
43
61
|
* @param {import('node:http').IncomingMessage} req
|
|
44
62
|
* @returns {Promise<string>}
|
|
@@ -68,14 +86,24 @@ async function renderPage(pathname, opts = {}) {
|
|
|
68
86
|
}
|
|
69
87
|
/**
|
|
70
88
|
* Stream shell + Direct serialize body for the matched file-route page.
|
|
89
|
+
* Runs Page.access (closed allow/redirect/not-found/deny) before load;
|
|
90
|
+
* POST may run Page.action before re-render.
|
|
71
91
|
* @param {string} pathname
|
|
72
|
-
* @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
|
|
73
|
-
* @returns {Promise<{ status: number, stream
|
|
92
|
+
* @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string, method?: string, body?: unknown }} [opts]
|
|
93
|
+
* @returns {Promise<{ status: number, stream?: AsyncGenerator<string, void, void>, redirect?: string, headers?: Record<string, string> } | null>}
|
|
74
94
|
*/
|
|
75
95
|
async function renderPageStream(pathname, opts = {}) {
|
|
76
|
-
|
|
96
|
+
if (isDev && lastDevError && pageCtors.size === 0) {
|
|
97
|
+
return { status: 500, stream: emitDevErrorHtml(lastDevError) };
|
|
98
|
+
}
|
|
99
|
+
const localePlan = resolveLocalePath(pathname);
|
|
100
|
+
if (localePlan.redirectTo) {
|
|
101
|
+
return { status: 302, redirect: localePlan.redirectTo, headers: { Location: localePlan.redirectTo } };
|
|
102
|
+
}
|
|
103
|
+
const routePath = localePlan.restPath || pathname;
|
|
104
|
+
let match = matchFileRoute(routePath, pageCatalog);
|
|
77
105
|
let status = 200;
|
|
78
|
-
const gated = await runRouteGate(
|
|
106
|
+
const gated = await runRouteGate(routePath, match?.chunkId);
|
|
79
107
|
if (gated === 'not_found') {
|
|
80
108
|
match = findRootCatchAll(pageCatalog);
|
|
81
109
|
status = 404;
|
|
@@ -87,38 +115,228 @@ async function renderPageStream(pathname, opts = {}) {
|
|
|
87
115
|
else if (isRootCatchAll(match)) {
|
|
88
116
|
status = 404;
|
|
89
117
|
}
|
|
90
|
-
if (!match)
|
|
118
|
+
if (!match) {
|
|
119
|
+
if (isDev && lastDevError) {
|
|
120
|
+
return { status: 500, stream: emitDevErrorHtml(lastDevError) };
|
|
121
|
+
}
|
|
91
122
|
return null;
|
|
123
|
+
}
|
|
92
124
|
const Page = await loadPageCtor(match.chunkId);
|
|
93
|
-
if (!Page)
|
|
125
|
+
if (!Page) {
|
|
126
|
+
if (isDev && lastDevError) {
|
|
127
|
+
return { status: 500, stream: emitDevErrorHtml(lastDevError) };
|
|
128
|
+
}
|
|
94
129
|
return null;
|
|
130
|
+
}
|
|
131
|
+
const params = extractRouteParams(match.segs, routePath);
|
|
132
|
+
const method = String(opts.method || 'GET').toUpperCase();
|
|
133
|
+
const localeCtx = {
|
|
134
|
+
localeId: localePlan.localeId,
|
|
135
|
+
dir: localePlan.dir,
|
|
136
|
+
pathname,
|
|
137
|
+
routePath,
|
|
138
|
+
alternates: pageMetaAlternates(match.chunkId, localePlan.localeId),
|
|
139
|
+
};
|
|
140
|
+
if (typeof Page.access === 'function') {
|
|
141
|
+
const access = await Page.access({
|
|
142
|
+
params,
|
|
143
|
+
pathname: routePath,
|
|
144
|
+
chunkId: match.chunkId,
|
|
145
|
+
signal: opts.signal,
|
|
146
|
+
searchParams: opts.searchParams,
|
|
147
|
+
method,
|
|
148
|
+
localeId: localeCtx.localeId,
|
|
149
|
+
});
|
|
150
|
+
const closed = normalizeAccessResult(access);
|
|
151
|
+
if (closed.kind === 'redirect') {
|
|
152
|
+
return { status: 302, redirect: closed.location, headers: { Location: closed.location } };
|
|
153
|
+
}
|
|
154
|
+
if (closed.kind === 'deny') {
|
|
155
|
+
return { status: 403, stream: emitAccessShell('route-access-deny') };
|
|
156
|
+
}
|
|
157
|
+
if (closed.kind === 'not-found') {
|
|
158
|
+
const catchAll = findRootCatchAll(pageCatalog);
|
|
159
|
+
if (catchAll) {
|
|
160
|
+
const NotFound = await loadPageCtor(catchAll.chunkId);
|
|
161
|
+
if (NotFound) {
|
|
162
|
+
const resumeEntries = await loadPageResumeEntries(distDir, catchAll.chunkId);
|
|
163
|
+
const eventOnlyShell = isEventOnlyShell(resumeEntries.map((e) => e.strategy));
|
|
164
|
+
return {
|
|
165
|
+
status: 404,
|
|
166
|
+
stream: emitPageHtml(NotFound, catchAll.chunkId, eventOnlyShell, { ...params }, opts, [], localeCtx),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { status: 404, stream: emitAccessShell('route-access-not-found') };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
let props = { ...params };
|
|
174
|
+
if (method === 'POST' && typeof Page.action === 'function') {
|
|
175
|
+
const acted = await Page.action({
|
|
176
|
+
params,
|
|
177
|
+
pathname: routePath,
|
|
178
|
+
chunkId: match.chunkId,
|
|
179
|
+
signal: opts.signal,
|
|
180
|
+
searchParams: opts.searchParams,
|
|
181
|
+
body: opts.body,
|
|
182
|
+
method,
|
|
183
|
+
localeId: localeCtx.localeId,
|
|
184
|
+
});
|
|
185
|
+
const actionClosed = normalizeActionResult(acted);
|
|
186
|
+
if (actionClosed.kind === 'redirect') {
|
|
187
|
+
return { status: 302, redirect: actionClosed.location, headers: { Location: actionClosed.location } };
|
|
188
|
+
}
|
|
189
|
+
if (actionClosed.kind === 'deny') {
|
|
190
|
+
return { status: 403, stream: emitAccessShell('route-action-deny') };
|
|
191
|
+
}
|
|
192
|
+
if (actionClosed.kind === 'not-found') {
|
|
193
|
+
return { status: 404, stream: emitAccessShell('route-action-not-found') };
|
|
194
|
+
}
|
|
195
|
+
if (actionClosed.props) {
|
|
196
|
+
props = { ...props, ...actionClosed.props };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (typeof Page.load === 'function') {
|
|
200
|
+
const loaded = await Page.load({
|
|
201
|
+
params,
|
|
202
|
+
pathname: routePath,
|
|
203
|
+
chunkId: match.chunkId,
|
|
204
|
+
signal: opts.signal,
|
|
205
|
+
searchParams: opts.searchParams,
|
|
206
|
+
localeId: localeCtx.localeId,
|
|
207
|
+
});
|
|
208
|
+
if (opts.signal?.aborted) {
|
|
209
|
+
return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
|
|
210
|
+
}
|
|
211
|
+
if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
|
|
212
|
+
props = { ...props, ...loaded };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (opts.signal?.aborted) {
|
|
216
|
+
return { status: 499, stream: emitAccessShell('route-nav-cancelled') };
|
|
217
|
+
}
|
|
95
218
|
const resumeEntries = await loadPageResumeEntries(distDir, match.chunkId);
|
|
96
219
|
const strategies = resumeEntries.map((e) => e.strategy);
|
|
97
220
|
const eventOnlyShell = isEventOnlyShell(strategies);
|
|
221
|
+
const layoutChain = resolveLayoutChain(match.chunkId);
|
|
98
222
|
return {
|
|
99
223
|
status,
|
|
100
|
-
stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, opts),
|
|
224
|
+
stream: emitPageHtml(Page, match.chunkId, eventOnlyShell, props, opts, layoutChain, localeCtx),
|
|
101
225
|
};
|
|
102
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* @param {unknown} access
|
|
229
|
+
* @returns {{ kind: 'allow' } | { kind: 'redirect', location: string } | { kind: 'deny' } | { kind: 'not-found' }}
|
|
230
|
+
*/
|
|
231
|
+
function normalizeAccessResult(access) {
|
|
232
|
+
if (access == null || access === true)
|
|
233
|
+
return { kind: 'allow' };
|
|
234
|
+
if (typeof access === 'string') {
|
|
235
|
+
const k = access.toLowerCase();
|
|
236
|
+
if (k === 'allow')
|
|
237
|
+
return { kind: 'allow' };
|
|
238
|
+
if (k === 'deny')
|
|
239
|
+
return { kind: 'deny' };
|
|
240
|
+
if (k === 'not-found' || k === 'notfound')
|
|
241
|
+
return { kind: 'not-found' };
|
|
242
|
+
}
|
|
243
|
+
if (typeof access === 'object') {
|
|
244
|
+
const kind = String(access.kind || access.type || 'allow').toLowerCase();
|
|
245
|
+
if (kind === 'redirect') {
|
|
246
|
+
const location = String(access.location || access.to || access.href || '');
|
|
247
|
+
if (!location)
|
|
248
|
+
return { kind: 'deny' };
|
|
249
|
+
return { kind: 'redirect', location };
|
|
250
|
+
}
|
|
251
|
+
if (kind === 'deny')
|
|
252
|
+
return { kind: 'deny' };
|
|
253
|
+
if (kind === 'not-found' || kind === 'notfound')
|
|
254
|
+
return { kind: 'not-found' };
|
|
255
|
+
return { kind: 'allow' };
|
|
256
|
+
}
|
|
257
|
+
return { kind: 'allow' };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* @param {unknown} acted
|
|
261
|
+
* @returns {{ kind: 'allow', props?: Record<string, unknown> } | { kind: 'redirect', location: string } | { kind: 'deny' } | { kind: 'not-found' }}
|
|
262
|
+
*/
|
|
263
|
+
function normalizeActionResult(acted) {
|
|
264
|
+
const base = normalizeAccessResult(acted);
|
|
265
|
+
if (base.kind !== 'allow')
|
|
266
|
+
return base;
|
|
267
|
+
if (acted && typeof acted === 'object' && acted.props && typeof acted.props === 'object') {
|
|
268
|
+
return { kind: 'allow', props: acted.props };
|
|
269
|
+
}
|
|
270
|
+
if (acted && typeof acted === 'object' && !('kind' in acted) && !('type' in acted) && !Array.isArray(acted)) {
|
|
271
|
+
return { kind: 'allow', props: acted };
|
|
272
|
+
}
|
|
273
|
+
return { kind: 'allow' };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Minimal HTML for closed access/action results when no NotFound page exists.
|
|
277
|
+
* @param {string} marker
|
|
278
|
+
*/
|
|
279
|
+
async function* emitAccessShell(marker) {
|
|
280
|
+
yield `<!DOCTYPE html>
|
|
281
|
+
<html lang="en">
|
|
282
|
+
<head><meta charset="utf-8" /><title>VMZ</title></head>
|
|
283
|
+
<body><p>${marker}</p></body>
|
|
284
|
+
</html>`;
|
|
285
|
+
}
|
|
103
286
|
/**
|
|
104
287
|
* @param {any} Page
|
|
105
288
|
* @param {string} chunkId
|
|
106
289
|
* @param {boolean} eventOnlyShell
|
|
290
|
+
* @param {Record<string, unknown>} props
|
|
107
291
|
* @param {{ signal?: AbortSignal, searchParams?: URLSearchParams, cookieHeader?: string }} [opts]
|
|
292
|
+
* @param {string[]} [layoutChain] layout chunk ids outer→inner
|
|
108
293
|
*/
|
|
109
|
-
async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
|
|
294
|
+
async function* emitPageHtml(Page, chunkId, eventOnlyShell, props = {}, opts = {}, layoutChain = [], localeCtx = {}) {
|
|
110
295
|
const signal = opts.signal;
|
|
111
296
|
const live = isDev
|
|
112
297
|
? `\n <script>
|
|
113
298
|
(() => {
|
|
114
299
|
const es = new EventSource("/__vmz/events");
|
|
300
|
+
function showOverlay(err) {
|
|
301
|
+
let el = document.getElementById("vmz-dev-overlay");
|
|
302
|
+
if (!el) {
|
|
303
|
+
el = document.createElement("div");
|
|
304
|
+
el.id = "vmz-dev-overlay";
|
|
305
|
+
el.setAttribute("role", "alert");
|
|
306
|
+
Object.assign(el.style, {
|
|
307
|
+
position: "fixed", inset: "0", zIndex: "2147483646",
|
|
308
|
+
background: "rgba(15,17,21,0.92)", color: "#f4f4f5",
|
|
309
|
+
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
310
|
+
padding: "2rem", overflow: "auto",
|
|
311
|
+
});
|
|
312
|
+
document.documentElement.appendChild(el);
|
|
313
|
+
}
|
|
314
|
+
const msg = (err && err.message) || String(err || "Unknown error");
|
|
315
|
+
const stack = (err && err.stack) || "";
|
|
316
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({"&":"&","<":"<",">":">"}[c]));
|
|
317
|
+
el.innerHTML = "<div style=\\"max-width:56rem;margin:0 auto\\">"
|
|
318
|
+
+ "<p style=\\"margin:0 0 .5rem;color:#f87171;font-weight:700\\">VMZ Dev Error</p>"
|
|
319
|
+
+ "<pre style=\\"white-space:pre-wrap;margin:0 0 1rem;font-size:13px;line-height:1.45\\">" + esc(msg) + "</pre>"
|
|
320
|
+
+ (stack ? "<pre style=\\"white-space:pre-wrap;opacity:.7;font-size:12px\\">" + esc(stack) + "</pre>" : "")
|
|
321
|
+
+ "<p style=\\"opacity:.65;font-size:12px\\">Fix the file and save — soft reload will clear this overlay.</p>"
|
|
322
|
+
+ "</div>";
|
|
323
|
+
}
|
|
324
|
+
function hideOverlay() {
|
|
325
|
+
const el = document.getElementById("vmz-dev-overlay");
|
|
326
|
+
if (el) el.remove();
|
|
327
|
+
}
|
|
115
328
|
es.onmessage = async (ev) => {
|
|
116
329
|
let msg = null;
|
|
117
330
|
try { msg = JSON.parse(ev.data); } catch { /* plain string */ }
|
|
331
|
+
if (msg && msg.type === "error") {
|
|
332
|
+
showOverlay(msg);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
118
335
|
if (!msg || msg.type !== "hmr") {
|
|
119
336
|
if (ev.data === "reload") location.reload();
|
|
120
337
|
return;
|
|
121
338
|
}
|
|
339
|
+
hideOverlay();
|
|
122
340
|
if (msg.mode === "island") {
|
|
123
341
|
try {
|
|
124
342
|
const { registerComponents, hydrate } = await import("/vmz-dom.js?t=" + msg.token);
|
|
@@ -142,13 +360,18 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
|
|
|
142
360
|
const pageChunk = root && root.getAttribute("data-vmz-page");
|
|
143
361
|
if (root && pageChunk) {
|
|
144
362
|
const pageMod = await import("/" + pageChunk + ".client.js?t=" + msg.token);
|
|
145
|
-
|
|
363
|
+
let hmrProps = {};
|
|
364
|
+
try {
|
|
365
|
+
const raw = root.getAttribute("data-vmz-props");
|
|
366
|
+
if (raw) hmrProps = JSON.parse(raw);
|
|
367
|
+
} catch { /* ignore */ }
|
|
368
|
+
await hydrate(pageMod.default, root, hmrProps, { preserveState: true, skipOnMount: true });
|
|
146
369
|
} else {
|
|
147
370
|
location.reload();
|
|
148
371
|
}
|
|
149
372
|
} catch (err) {
|
|
150
373
|
console.error("vmz island HMR failed", err);
|
|
151
|
-
|
|
374
|
+
showOverlay({ message: String(err && err.message || err), stack: err && err.stack });
|
|
152
375
|
}
|
|
153
376
|
return;
|
|
154
377
|
}
|
|
@@ -157,35 +380,110 @@ async function* emitPageHtml(Page, chunkId, eventOnlyShell, opts = {}) {
|
|
|
157
380
|
})();
|
|
158
381
|
</script>`
|
|
159
382
|
: '';
|
|
383
|
+
const bootOverlay = isDev && lastDevError
|
|
384
|
+
? `\n <script>window.__VMZ_DEV_ERROR__=${JSON.stringify(lastDevError)};` +
|
|
385
|
+
`(function(){var e=window.__VMZ_DEV_ERROR__;if(!e)return;` +
|
|
386
|
+
`var ev=new Event("message");ev.data=JSON.stringify({type:"error",message:e.message,stack:e.stack});` +
|
|
387
|
+
`/* paint immediately */` +
|
|
388
|
+
`var d=document.createElement("div");d.id="vmz-dev-overlay";d.setAttribute("role","alert");` +
|
|
389
|
+
`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"});` +
|
|
390
|
+
`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 {"<":"<",">":">","&":"&"}[c]})+"</pre></div>";` +
|
|
391
|
+
`document.documentElement.appendChild(d);})();</script>`
|
|
392
|
+
: '';
|
|
160
393
|
if (signal?.aborted)
|
|
161
394
|
return;
|
|
162
395
|
const themeId = resolveThemeId(opts.searchParams, opts.cookieHeader);
|
|
163
396
|
const htmlTheme = htmlThemeAttributeForId(themeId);
|
|
164
397
|
const themeBoot = themeBootstrapScript();
|
|
165
398
|
const cssLink = cssEntry ? ` <link rel="stylesheet" href="/${String(cssEntry).replace(/^\/+/, '')}?t=${reloadToken}" />\n` : '';
|
|
399
|
+
const propsJson = JSON.stringify(props ?? {});
|
|
400
|
+
const layoutAttr = layoutChain.length ? ` data-vmz-layout="${escapeAttr(layoutChain.join(','))}"` : '';
|
|
401
|
+
const localeId = localeCtx.localeId || localeArtifact?.defaultLocale || 'en';
|
|
402
|
+
const dir = localeCtx.dir || 'ltr';
|
|
403
|
+
const localeAttr = ` data-vmz-locale="${escapeAttr(localeId)}" data-vmz-dir="${escapeAttr(dir)}"`;
|
|
404
|
+
const routingJson = localeArtifact?.routing
|
|
405
|
+
? escapeAttr(JSON.stringify({
|
|
406
|
+
strategy: localeArtifact.routing.strategy || 'prefix',
|
|
407
|
+
defaultPrefix: localeArtifact.routing.defaultPrefix || 'include',
|
|
408
|
+
defaultLocale: localeArtifact.defaultLocale,
|
|
409
|
+
locales: (localeArtifact.locales || []).map((l) => l.id),
|
|
410
|
+
}))
|
|
411
|
+
: '';
|
|
412
|
+
const routingAttr = routingJson ? ` data-vmz-locale-routing="${routingJson}"` : '';
|
|
413
|
+
const hreflangLinks = (localeCtx.alternates || [])
|
|
414
|
+
.map((a) => ` <link rel="alternate" hreflang="${escapeAttr(a.hreflang)}" href="${escapeAttr(a.href)}" />`)
|
|
415
|
+
.join('\n');
|
|
416
|
+
const hreflangBlock = hreflangLinks ? `${hreflangLinks}\n` : '';
|
|
166
417
|
yield `<!DOCTYPE html>
|
|
167
|
-
<html lang="
|
|
418
|
+
<html lang="${escapeAttr(localeId)}" data-locale="${escapeAttr(localeId)}" dir="${escapeAttr(dir)}"${routingAttr}${htmlTheme}>
|
|
168
419
|
<head>
|
|
169
420
|
<meta charset="utf-8" />
|
|
170
421
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
171
422
|
<title>VMZ</title>
|
|
172
|
-
${themeBoot}${cssLink}</head>
|
|
423
|
+
${hreflangBlock}${themeBoot}${cssLink}</head>
|
|
173
424
|
<body>
|
|
174
|
-
<div id="app" data-vmz-page="${escapeAttr(chunkId)}">`;
|
|
175
|
-
|
|
425
|
+
<div id="app" data-vmz-page="${escapeAttr(chunkId)}"${layoutAttr}${localeAttr} data-vmz-props="${escapeAttr(propsJson)}">`;
|
|
426
|
+
let bodyHtml = '';
|
|
427
|
+
for await (const chunk of renderToStream(Page, props, { signal })) {
|
|
176
428
|
if (signal?.aborted)
|
|
177
429
|
return;
|
|
178
|
-
|
|
430
|
+
bodyHtml += chunk;
|
|
179
431
|
}
|
|
432
|
+
if (signal?.aborted)
|
|
433
|
+
return;
|
|
434
|
+
// Wrap page HTML in layout chain (outer → inner) via default slot injection.
|
|
435
|
+
for (let i = layoutChain.length - 1; i >= 0; i--) {
|
|
436
|
+
const Layout = await loadPageCtor(layoutChain[i]);
|
|
437
|
+
if (!Layout)
|
|
438
|
+
continue;
|
|
439
|
+
bodyHtml = await renderToString(Layout, {}, { signal, slotHtml: bodyHtml });
|
|
440
|
+
if (signal?.aborted)
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
// Locale discipline: same-app Links retain current LocaleId (realization authority).
|
|
444
|
+
if (localeArtifact && localeId) {
|
|
445
|
+
bodyHtml = localizeBodyLinksInHost(bodyHtml, localeId, localeArtifact);
|
|
446
|
+
}
|
|
447
|
+
yield bodyHtml;
|
|
180
448
|
if (signal?.aborted)
|
|
181
449
|
return;
|
|
182
450
|
yield `</div>
|
|
183
|
-
<script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}
|
|
451
|
+
<script type="module" src="/${eventOnlyShell ? 'entry-event.js' : 'entry-client.js'}?t=${reloadToken}"></script>${live}${bootOverlay}
|
|
184
452
|
</body>
|
|
185
453
|
</html>`;
|
|
186
454
|
}
|
|
187
455
|
const server = http.createServer((req, res) => {
|
|
188
456
|
const url = new URL(req.url || '/', `http://${host}:${port}`);
|
|
457
|
+
if (url.pathname === '/__vmz/health' && req.method === 'GET') {
|
|
458
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
459
|
+
res.end(JSON.stringify({ status: 'ok', shuttingDown, inFlight }));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (url.pathname === '/__vmz/ready' && req.method === 'GET') {
|
|
463
|
+
if (!ready || shuttingDown) {
|
|
464
|
+
res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
465
|
+
res.end(JSON.stringify({ status: 'not-ready', ready, shuttingDown }));
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
469
|
+
res.end(JSON.stringify({ status: 'ready', ready: true, inFlight }));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (shuttingDown) {
|
|
473
|
+
res.writeHead(503, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
474
|
+
res.end(JSON.stringify({ status: 'shutting-down' }));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
inFlight += 1;
|
|
478
|
+
let settled = false;
|
|
479
|
+
const done = () => {
|
|
480
|
+
if (settled)
|
|
481
|
+
return;
|
|
482
|
+
settled = true;
|
|
483
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
484
|
+
};
|
|
485
|
+
res.on('finish', done);
|
|
486
|
+
res.on('close', done);
|
|
189
487
|
if (url.pathname === '/__vmz/reload' && req.method === 'POST') {
|
|
190
488
|
readRequestBody(req)
|
|
191
489
|
.then((raw) => {
|
|
@@ -204,8 +502,15 @@ const server = http.createServer((req, res) => {
|
|
|
204
502
|
})
|
|
205
503
|
.catch((err) => {
|
|
206
504
|
console.error('vmz serve: soft reload failed', err);
|
|
505
|
+
lastDevError = normalizeDevError(err);
|
|
506
|
+
notifySse(JSON.stringify({
|
|
507
|
+
type: 'error',
|
|
508
|
+
message: lastDevError.message,
|
|
509
|
+
stack: lastDevError.stack,
|
|
510
|
+
at: lastDevError.at,
|
|
511
|
+
}));
|
|
207
512
|
res.writeHead(500, { 'content-type': 'application/json' });
|
|
208
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
513
|
+
res.end(JSON.stringify({ ok: false, error: lastDevError.message }));
|
|
209
514
|
});
|
|
210
515
|
return;
|
|
211
516
|
}
|
|
@@ -227,91 +532,148 @@ const server = http.createServer((req, res) => {
|
|
|
227
532
|
server.listen(port, host, () => {
|
|
228
533
|
console.log(`vmz serve http://${host}:${port} (dist=${distDir}${isDev ? ', dev' : ''})`);
|
|
229
534
|
});
|
|
535
|
+
const SHUTDOWN_TIMEOUT_MS = Number(process.env.VMZ_SHUTDOWN_TIMEOUT_MS || 10000);
|
|
536
|
+
async function gracefulShutdown(signal) {
|
|
537
|
+
if (shuttingDown)
|
|
538
|
+
return;
|
|
539
|
+
shuttingDown = true;
|
|
540
|
+
ready = false;
|
|
541
|
+
console.log(`vmz serve: ${signal} — draining in-flight=${inFlight} timeout=${SHUTDOWN_TIMEOUT_MS}ms`);
|
|
542
|
+
server.close();
|
|
543
|
+
const start = Date.now();
|
|
544
|
+
while (inFlight > 0 && Date.now() - start < SHUTDOWN_TIMEOUT_MS) {
|
|
545
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
546
|
+
}
|
|
547
|
+
for (const client of sseClients) {
|
|
548
|
+
try {
|
|
549
|
+
client.end();
|
|
550
|
+
}
|
|
551
|
+
catch {
|
|
552
|
+
/* ignore */
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
sseClients.clear();
|
|
556
|
+
process.exit(inFlight > 0 ? 1 : 0);
|
|
557
|
+
}
|
|
558
|
+
process.on('SIGTERM', () => {
|
|
559
|
+
void gracefulShutdown('SIGTERM');
|
|
560
|
+
});
|
|
561
|
+
process.on('SIGINT', () => {
|
|
562
|
+
void gracefulShutdown('SIGINT');
|
|
563
|
+
});
|
|
230
564
|
/**
|
|
231
565
|
* Re-import routes / pages / components with a new cache-bust token.
|
|
232
566
|
* Keeps the HTTP server process alive (no Node restart).
|
|
567
|
+
* Failed reloads keep the previous in-memory modules (Vite-like resilience).
|
|
233
568
|
* @param {{ quiet?: boolean, payload?: { affectedChunks?: string[], seedChunks?: string[], full?: boolean, islandHmr?: boolean } }} [opts]
|
|
234
569
|
*/
|
|
235
570
|
async function softReload(opts = {}) {
|
|
236
|
-
|
|
571
|
+
const prevToken = reloadToken;
|
|
572
|
+
const prevCatalog = pageCatalog;
|
|
573
|
+
const nextToken = Date.now();
|
|
574
|
+
reloadToken = nextToken;
|
|
237
575
|
const affected = opts.payload?.affectedChunks ?? [];
|
|
238
576
|
const seeds = opts.payload?.seedChunks ?? [];
|
|
239
577
|
const full = opts.payload?.full;
|
|
240
578
|
const islandHmr = Boolean(opts.payload?.islandHmr);
|
|
241
579
|
try {
|
|
242
|
-
|
|
243
|
-
|
|
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;
|
|
580
|
+
try {
|
|
581
|
+
const routes = JSON.parse(await readFile(path.join(distDir, 'vmz-routes.json'), 'utf8'));
|
|
582
|
+
setRoutes(routes);
|
|
265
583
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
584
|
+
catch {
|
|
585
|
+
setRoutes([]);
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
localeArtifact = JSON.parse(await readFile(path.join(distDir, '_vmz', 'locale-route-realization.json'), 'utf8'));
|
|
589
|
+
}
|
|
590
|
+
catch {
|
|
591
|
+
localeArtifact = null;
|
|
592
|
+
}
|
|
593
|
+
const componentEntries = await listClientComponents(distDir);
|
|
594
|
+
const nextCatalog = await listPageClientFiles(distDir);
|
|
595
|
+
if (!nextCatalog.length) {
|
|
596
|
+
throw new Error(`vmz serve: no pages/**/*.client.js in ${distDir}`);
|
|
597
|
+
}
|
|
598
|
+
/** @type {Record<string, any>} */
|
|
599
|
+
const components = {};
|
|
600
|
+
/** @type {Map<string, any>} */
|
|
601
|
+
const nextCtors = new Map();
|
|
602
|
+
const affectedNames = new Set(affected
|
|
603
|
+
.map((c) => String(c))
|
|
604
|
+
.filter((c) => c.startsWith('components/') || !c.includes('/'))
|
|
605
|
+
.map((c) => c.split('/').pop())
|
|
606
|
+
.filter(Boolean));
|
|
607
|
+
for (const entry of componentEntries) {
|
|
608
|
+
if (islandHmr && affectedNames.size > 0 && !affectedNames.has(entry.name)) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const href = bustUrl(pathToFileURL(path.join(distDir, entry.entry)).href);
|
|
612
|
+
const mod = await import(href);
|
|
613
|
+
components[entry.name] = mod.default;
|
|
276
614
|
}
|
|
615
|
+
if (!islandHmr) {
|
|
616
|
+
for (const p of nextCatalog) {
|
|
617
|
+
const pageRel = `${p.chunkId}.client.js`;
|
|
618
|
+
const href = bustUrl(pathToFileURL(path.join(distDir, pageRel)).href);
|
|
619
|
+
const mod = await import(href);
|
|
620
|
+
nextCtors.set(p.chunkId, mod.default);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
pageCatalog = nextCatalog;
|
|
624
|
+
if (!islandHmr) {
|
|
625
|
+
pageCtors.clear();
|
|
626
|
+
for (const [k, v] of nextCtors)
|
|
627
|
+
pageCtors.set(k, v);
|
|
628
|
+
}
|
|
629
|
+
if (Object.keys(components).length) {
|
|
630
|
+
registerComponents(components);
|
|
631
|
+
}
|
|
632
|
+
const indexChunk = pageCatalog.find((p) => p.chunkId === 'pages/index')?.chunkId || pageCatalog[0].chunkId;
|
|
633
|
+
const resumeEntries = await loadPageResumeEntries(distDir, indexChunk);
|
|
634
|
+
const styleMeta = await loadDeploymentStyle(distDir);
|
|
635
|
+
cssEntry = styleMeta.cssEntry;
|
|
636
|
+
styleTheme = styleMeta.styleTheme;
|
|
637
|
+
const lazyEventNames = resumeEntries
|
|
638
|
+
.filter((e) => isEventStrategy(e.strategy))
|
|
639
|
+
.map((e) => e.component)
|
|
640
|
+
.filter(Boolean);
|
|
641
|
+
const lazySet = new Set(lazyEventNames);
|
|
642
|
+
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');
|
|
643
|
+
const strategies = resumeEntries.map((e) => e.strategy);
|
|
644
|
+
const eventOnlyShell = isEventOnlyShell(strategies);
|
|
645
|
+
await writeFile(path.join(distDir, 'entry-event.js'), emitEntryEvent(reloadToken), 'utf8');
|
|
646
|
+
lastDevError = null;
|
|
647
|
+
const mode = islandHmr ? 'island' : eventOnlyShell ? 'event-shell' : 'full';
|
|
648
|
+
notifySse(JSON.stringify({
|
|
649
|
+
type: 'hmr',
|
|
650
|
+
mode,
|
|
651
|
+
affectedChunks: affected,
|
|
652
|
+
seedChunks: seeds,
|
|
653
|
+
token: reloadToken,
|
|
654
|
+
full: Boolean(full),
|
|
655
|
+
eventOnlyShell,
|
|
656
|
+
}));
|
|
657
|
+
if (!opts.quiet) {
|
|
658
|
+
const aff = affected.length > 0 ? ` affected=[${affected.join(', ')}]` : full === false ? ' affected=[]' : '';
|
|
659
|
+
console.log(`vmz serve: soft reload ok (mode=${mode}; pages=${pageCatalog.length}; t=${reloadToken}${aff})`);
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
affectedChunks: affected,
|
|
663
|
+
seedChunks: seeds,
|
|
664
|
+
full: Boolean(full),
|
|
665
|
+
islandHmr,
|
|
666
|
+
mode,
|
|
667
|
+
eventOnlyShell,
|
|
668
|
+
pageCount: pageCatalog.length,
|
|
669
|
+
};
|
|
277
670
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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})`);
|
|
671
|
+
catch (err) {
|
|
672
|
+
reloadToken = prevToken;
|
|
673
|
+
pageCatalog = prevCatalog;
|
|
674
|
+
lastDevError = normalizeDevError(err);
|
|
675
|
+
throw err;
|
|
305
676
|
}
|
|
306
|
-
return {
|
|
307
|
-
affectedChunks: affected,
|
|
308
|
-
seedChunks: seeds,
|
|
309
|
-
full: Boolean(full),
|
|
310
|
-
islandHmr,
|
|
311
|
-
mode,
|
|
312
|
-
eventOnlyShell,
|
|
313
|
-
pageCount: pageCatalog.length,
|
|
314
|
-
};
|
|
315
677
|
}
|
|
316
678
|
/** @param {string} event */
|
|
317
679
|
function notifySse(event) {
|
|
@@ -324,6 +686,184 @@ function notifySse(event) {
|
|
|
324
686
|
}
|
|
325
687
|
}
|
|
326
688
|
}
|
|
689
|
+
/** @param {unknown} err */
|
|
690
|
+
function normalizeDevError(err) {
|
|
691
|
+
if (err && typeof err === 'object') {
|
|
692
|
+
const e = /** @type {{ message?: string, stack?: string }} */ (err);
|
|
693
|
+
return {
|
|
694
|
+
message: e.message ? String(e.message) : String(err),
|
|
695
|
+
stack: e.stack ? String(e.stack) : undefined,
|
|
696
|
+
at: Date.now(),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
return { message: String(err), at: Date.now() };
|
|
700
|
+
}
|
|
701
|
+
/** @param {{ message: string, stack?: string }} err */
|
|
702
|
+
async function* emitDevErrorHtml(err) {
|
|
703
|
+
const msg = escapeHtml(err.message || 'Unknown error');
|
|
704
|
+
const stack = err.stack ? escapeHtml(err.stack) : '';
|
|
705
|
+
yield `<!DOCTYPE html>
|
|
706
|
+
<html lang="en">
|
|
707
|
+
<head>
|
|
708
|
+
<meta charset="utf-8" />
|
|
709
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
710
|
+
<title>VMZ Dev Error</title>
|
|
711
|
+
<style>
|
|
712
|
+
body{margin:0;background:#0f1115;color:#f4f4f5;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
|
713
|
+
main{max-width:56rem;margin:0 auto;padding:2rem 1.25rem}
|
|
714
|
+
h1{margin:0 0 .75rem;color:#f87171;font-size:1.1rem}
|
|
715
|
+
pre{white-space:pre-wrap;margin:0 0 1rem}
|
|
716
|
+
.hint{opacity:.65;font-size:12px}
|
|
717
|
+
</style>
|
|
718
|
+
</head>
|
|
719
|
+
<body>
|
|
720
|
+
<main>
|
|
721
|
+
<h1>VMZ Dev Error</h1>
|
|
722
|
+
<pre>${msg}</pre>
|
|
723
|
+
${stack ? `<pre style="opacity:.7;font-size:12px">${stack}</pre>` : ''}
|
|
724
|
+
<p class="hint">Dev host stayed up. Fix the source and save — soft reload will recover.</p>
|
|
725
|
+
</main>
|
|
726
|
+
<script>
|
|
727
|
+
(() => {
|
|
728
|
+
const es = new EventSource("/__vmz/events");
|
|
729
|
+
es.onmessage = (ev) => {
|
|
730
|
+
let msg = null;
|
|
731
|
+
try { msg = JSON.parse(ev.data); } catch {}
|
|
732
|
+
if (msg && msg.type === "hmr") location.reload();
|
|
733
|
+
};
|
|
734
|
+
})();
|
|
735
|
+
</script>
|
|
736
|
+
</body>
|
|
737
|
+
</html>`;
|
|
738
|
+
}
|
|
739
|
+
/** @param {string} s */
|
|
740
|
+
function escapeHtml(s) {
|
|
741
|
+
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Resolve LocaleId from pathname using `_vmz/locale-route-realization.json`.
|
|
745
|
+
* LocaleId is a realization dimension — matching still uses stable route path.
|
|
746
|
+
* @param {string} pathname
|
|
747
|
+
*/
|
|
748
|
+
function resolveLocalePath(pathname) {
|
|
749
|
+
const raw = String(pathname || '/');
|
|
750
|
+
const normalized = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw || '/';
|
|
751
|
+
if (!localeArtifact) {
|
|
752
|
+
return { localeId: 'en', dir: 'ltr', restPath: normalized, redirectTo: null };
|
|
753
|
+
}
|
|
754
|
+
const supported = (localeArtifact.locales || []).map((l) => l.id);
|
|
755
|
+
const defaultLocale = localeArtifact.defaultLocale || supported[0] || 'en';
|
|
756
|
+
const directions = Object.fromEntries((localeArtifact.locales || []).map((l) => [l.id, l.direction || 'ltr']));
|
|
757
|
+
const routing = localeArtifact.routing || {};
|
|
758
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
759
|
+
let localeId = null;
|
|
760
|
+
let restPath = normalized;
|
|
761
|
+
if (parts.length && supported.includes(parts[0])) {
|
|
762
|
+
localeId = parts[0];
|
|
763
|
+
const rest = parts.slice(1);
|
|
764
|
+
restPath = rest.length ? `/${rest.join('/')}` : '/';
|
|
765
|
+
}
|
|
766
|
+
// omit defaultPrefix: prefixed defaultLocale URL redirects to unprefixed canonical.
|
|
767
|
+
if (routing.defaultPrefix === 'omit' && localeId === defaultLocale) {
|
|
768
|
+
return {
|
|
769
|
+
localeId: defaultLocale,
|
|
770
|
+
dir: directions[defaultLocale] || 'ltr',
|
|
771
|
+
restPath,
|
|
772
|
+
redirectTo: restPath,
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const contentLocale = localeId || defaultLocale;
|
|
776
|
+
return {
|
|
777
|
+
localeId: contentLocale,
|
|
778
|
+
dir: directions[contentLocale] || 'ltr',
|
|
779
|
+
restPath,
|
|
780
|
+
redirectTo: null,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Realize href for current LocaleId (prefix strategy). Kept local so serve-host
|
|
785
|
+
* stays free of CLI package imports.
|
|
786
|
+
* @param {string} href
|
|
787
|
+
* @param {string} localeId
|
|
788
|
+
* @param {any} artifact
|
|
789
|
+
*/
|
|
790
|
+
function localizeSameAppHrefHost(href, localeId, artifact) {
|
|
791
|
+
if (!href || !localeId || !artifact)
|
|
792
|
+
return href;
|
|
793
|
+
if (href.startsWith('#') || /^(mailto|tel|javascript):/i.test(href))
|
|
794
|
+
return href;
|
|
795
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('/'))
|
|
796
|
+
return href;
|
|
797
|
+
let pathname = String(href);
|
|
798
|
+
let search = '';
|
|
799
|
+
let hash = '';
|
|
800
|
+
const hashIdx = pathname.indexOf('#');
|
|
801
|
+
if (hashIdx >= 0) {
|
|
802
|
+
hash = pathname.slice(hashIdx);
|
|
803
|
+
pathname = pathname.slice(0, hashIdx);
|
|
804
|
+
}
|
|
805
|
+
const qIdx = pathname.indexOf('?');
|
|
806
|
+
if (qIdx >= 0) {
|
|
807
|
+
search = pathname.slice(qIdx);
|
|
808
|
+
pathname = pathname.slice(0, qIdx);
|
|
809
|
+
}
|
|
810
|
+
if (!pathname)
|
|
811
|
+
pathname = '/';
|
|
812
|
+
const supported = (artifact.locales || []).map((l) => l.id).filter(Boolean);
|
|
813
|
+
const defaultLocale = artifact.defaultLocale || artifact.routing?.defaultLocale;
|
|
814
|
+
const routing = artifact.routing || {};
|
|
815
|
+
const strategy = routing.strategy || 'prefix';
|
|
816
|
+
const defaultPrefix = routing.defaultPrefix || 'include';
|
|
817
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
818
|
+
let rest = pathname;
|
|
819
|
+
if (parts.length && supported.includes(parts[0])) {
|
|
820
|
+
const r = parts.slice(1);
|
|
821
|
+
rest = r.length ? `/${r.join('/')}` : '/';
|
|
822
|
+
}
|
|
823
|
+
if (rest.length > 1 && rest.endsWith('/'))
|
|
824
|
+
rest = rest.slice(0, -1);
|
|
825
|
+
if (!rest.startsWith('/'))
|
|
826
|
+
rest = `/${rest}`;
|
|
827
|
+
if (strategy === 'none' || strategy === 'domain')
|
|
828
|
+
return `${rest}${search}${hash}`;
|
|
829
|
+
const omitDefault = defaultPrefix === 'omit' && localeId === defaultLocale;
|
|
830
|
+
if (omitDefault)
|
|
831
|
+
return `${rest}${search}${hash}`;
|
|
832
|
+
const pathOut = rest === '/' ? `/${localeId}` : `/${localeId}${rest}`;
|
|
833
|
+
return `${pathOut}${search}${hash}`;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* @param {string} html
|
|
837
|
+
* @param {string} localeId
|
|
838
|
+
* @param {any} artifact
|
|
839
|
+
*/
|
|
840
|
+
function localizeBodyLinksInHost(html, localeId, artifact) {
|
|
841
|
+
if (!html || !localeId || !artifact)
|
|
842
|
+
return html;
|
|
843
|
+
return String(html).replace(/<a\b([^>]*)>/gi, (full, attrs) => {
|
|
844
|
+
if (!/\bdata-vmz-route\s*=/.test(attrs))
|
|
845
|
+
return full;
|
|
846
|
+
const hm = attrs.match(/\bhref\s*=\s*"([^"]*)"/i);
|
|
847
|
+
if (!hm)
|
|
848
|
+
return full;
|
|
849
|
+
const next = localizeSameAppHrefHost(hm[1], localeId, artifact);
|
|
850
|
+
if (next === hm[1])
|
|
851
|
+
return full;
|
|
852
|
+
const newAttrs = attrs.replace(/\bhref\s*=\s*"[^"]*"/i, `href="${escapeAttr(next)}"`);
|
|
853
|
+
return `<a${newAttrs}>`;
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* @param {string} chunkId
|
|
858
|
+
* @param {string} localeId
|
|
859
|
+
*/
|
|
860
|
+
function pageMetaAlternates(chunkId, localeId) {
|
|
861
|
+
if (!localeArtifact?.pageMetas)
|
|
862
|
+
return [];
|
|
863
|
+
const meta = localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeId) ||
|
|
864
|
+
localeArtifact.pageMetas.find((m) => m.routeId === chunkId && m.locale === localeArtifact.defaultLocale);
|
|
865
|
+
return Array.isArray(meta?.alternates) ? meta.alternates : [];
|
|
866
|
+
}
|
|
327
867
|
/** @param {string} href */
|
|
328
868
|
function bustUrl(href) {
|
|
329
869
|
const u = new URL(href);
|
|
@@ -400,6 +940,8 @@ async function listPageClientFiles(dir) {
|
|
|
400
940
|
}
|
|
401
941
|
else if (e.isFile() && e.name.endsWith('.client.js')) {
|
|
402
942
|
const stem = e.name.replace(/\.client\.js$/, '');
|
|
943
|
+
if (isRouteBoundaryStem(stem))
|
|
944
|
+
continue;
|
|
403
945
|
const chunkId = ['pages', ...relParts, stem].join('/');
|
|
404
946
|
out.push({
|
|
405
947
|
chunkId,
|
|
@@ -414,6 +956,7 @@ async function listPageClientFiles(dir) {
|
|
|
414
956
|
}
|
|
415
957
|
/**
|
|
416
958
|
* File-route segments from chunk id (`pages/Install` → `/install`).
|
|
959
|
+
* Skips URL-invisible `(group)` dirs; boundary stems never reach here.
|
|
417
960
|
* @param {string} chunkId
|
|
418
961
|
*/
|
|
419
962
|
function parseChunkSegments(chunkId) {
|
|
@@ -423,6 +966,8 @@ function parseChunkSegments(chunkId) {
|
|
|
423
966
|
const segs = [];
|
|
424
967
|
for (let i = 0; i < parts.length; i++) {
|
|
425
968
|
const p = parts[i];
|
|
969
|
+
if (isRouteGroupDir(p))
|
|
970
|
+
continue;
|
|
426
971
|
if (p === 'index' && i === parts.length - 1)
|
|
427
972
|
continue;
|
|
428
973
|
const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
|
|
@@ -436,6 +981,38 @@ function parseChunkSegments(chunkId) {
|
|
|
436
981
|
}
|
|
437
982
|
return segs;
|
|
438
983
|
}
|
|
984
|
+
function isRouteGroupDir(seg) {
|
|
985
|
+
return typeof seg === 'string' && seg.startsWith('(') && seg.endsWith(')') && seg.length > 2;
|
|
986
|
+
}
|
|
987
|
+
function isRouteBoundaryStem(stem) {
|
|
988
|
+
return stem === 'Layout' || stem === 'Loading' || stem === 'Error' || stem === 'NotFound';
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Nearest `Layout.client.js` walking up from the page chunk (outer→inner).
|
|
992
|
+
* @param {string} pageChunkId
|
|
993
|
+
* @returns {string[]}
|
|
994
|
+
*/
|
|
995
|
+
function resolveLayoutChain(pageChunkId) {
|
|
996
|
+
const rel = pageChunkId.replace(/^pages\//, '');
|
|
997
|
+
const parts = rel.split('/').filter(Boolean);
|
|
998
|
+
parts.pop(); // page stem
|
|
999
|
+
/** @type {string[]} */
|
|
1000
|
+
const chain = [];
|
|
1001
|
+
for (let i = parts.length; i >= 0; i--) {
|
|
1002
|
+
const dirParts = parts.slice(0, i);
|
|
1003
|
+
const layoutChunk = ['pages', ...dirParts, 'Layout'].join('/');
|
|
1004
|
+
const abs = path.join(distDir, `${layoutChunk}.client.js`);
|
|
1005
|
+
try {
|
|
1006
|
+
// sync existence — layouts are compile artifacts next to pages
|
|
1007
|
+
if (existsSync(abs))
|
|
1008
|
+
chain.unshift(layoutChunk);
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
/* ignore */
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return chain;
|
|
1015
|
+
}
|
|
439
1016
|
/**
|
|
440
1017
|
* @param {string} pathname
|
|
441
1018
|
* @param {typeof pageCatalog} catalog
|
|
@@ -459,6 +1036,35 @@ function matchFileRoute(pathname, catalog) {
|
|
|
459
1036
|
}
|
|
460
1037
|
return best;
|
|
461
1038
|
}
|
|
1039
|
+
/**
|
|
1040
|
+
* @param {ReturnType<typeof parseChunkSegments>} segs
|
|
1041
|
+
* @param {string} pathname
|
|
1042
|
+
* @returns {Record<string, string>}
|
|
1043
|
+
*/
|
|
1044
|
+
function extractRouteParams(segs, pathname) {
|
|
1045
|
+
const pathParts = decodeURIComponent(pathname.split('?')[0] || '/')
|
|
1046
|
+
.replace(/\/+$/, '')
|
|
1047
|
+
.split('/')
|
|
1048
|
+
.filter(Boolean);
|
|
1049
|
+
/** @type {Record<string, string>} */
|
|
1050
|
+
const params = {};
|
|
1051
|
+
let j = 0;
|
|
1052
|
+
for (let i = 0; i < segs.length; i++) {
|
|
1053
|
+
const s = segs[i];
|
|
1054
|
+
if (s.kind === 'catch') {
|
|
1055
|
+
if (s.name)
|
|
1056
|
+
params[s.name] = pathParts.slice(j).join('/');
|
|
1057
|
+
return params;
|
|
1058
|
+
}
|
|
1059
|
+
if (j >= pathParts.length)
|
|
1060
|
+
break;
|
|
1061
|
+
if (s.kind === 'param' && s.name) {
|
|
1062
|
+
params[s.name] = pathParts[j];
|
|
1063
|
+
}
|
|
1064
|
+
j++;
|
|
1065
|
+
}
|
|
1066
|
+
return params;
|
|
1067
|
+
}
|
|
462
1068
|
/**
|
|
463
1069
|
* @param {ReturnType<typeof parseChunkSegments>} segs
|
|
464
1070
|
* @param {string[]} pathParts
|
|
@@ -536,9 +1142,10 @@ globalThis.__vmzLoadComponent = async (name) => {
|
|
|
536
1142
|
};`
|
|
537
1143
|
: '';
|
|
538
1144
|
return `/**
|
|
539
|
-
* Generated by vmz serve — hydrate matched file-route page (data-vmz-page).
|
|
1145
|
+
* Generated by vmz serve — hydrate matched file-route page (data-vmz-page) + layout chain + client Link takeover.
|
|
540
1146
|
*/
|
|
541
|
-
import { registerComponents, hydrate } from ${JSON.stringify(`./vmz-dom.js${q}`)};
|
|
1147
|
+
import { registerComponents, hydrate, hydrateRoute, hydrateRoutePage, destroy } from ${JSON.stringify(`./vmz-dom.js${q}`)};
|
|
1148
|
+
import { installClientNavigation } from ${JSON.stringify(`./vmz-client-nav.js${q}`)};
|
|
542
1149
|
${imports}
|
|
543
1150
|
|
|
544
1151
|
${map}
|
|
@@ -548,8 +1155,25 @@ const root = document.getElementById("app");
|
|
|
548
1155
|
if (!root) throw new Error("vmz: missing #app");
|
|
549
1156
|
const chunkId = root.getAttribute("data-vmz-page");
|
|
550
1157
|
if (!chunkId) throw new Error("vmz: missing data-vmz-page");
|
|
1158
|
+
let props = {};
|
|
1159
|
+
try {
|
|
1160
|
+
const raw = root.getAttribute("data-vmz-props");
|
|
1161
|
+
if (raw) props = JSON.parse(raw);
|
|
1162
|
+
} catch { /* ignore */ }
|
|
1163
|
+
const layoutChain = (root.getAttribute("data-vmz-layout") || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1164
|
+
const layoutCtors = [];
|
|
1165
|
+
for (const id of layoutChain) {
|
|
1166
|
+
layoutCtors.push((await import("./" + id + ".client.js${q}")).default);
|
|
1167
|
+
}
|
|
551
1168
|
const Page = (await import("./" + chunkId + ".client.js${q}")).default;
|
|
552
|
-
await
|
|
1169
|
+
await hydrateRoute(Page, root, props, layoutCtors);
|
|
1170
|
+
installClientNavigation({
|
|
1171
|
+
hydrate,
|
|
1172
|
+
hydrateRoute,
|
|
1173
|
+
hydrateRoutePage,
|
|
1174
|
+
destroy,
|
|
1175
|
+
importPage: async (id) => (await import("./" + id + ".client.js${q}")).default,
|
|
1176
|
+
});
|
|
553
1177
|
`;
|
|
554
1178
|
}
|
|
555
1179
|
/**
|