@vmz/vmz 0.0.3 → 0.0.4

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,463 @@
1
+ /**
2
+ * A3-static: emit per-route HTML + 404 + SEO head + StaticDeliveryManifest.
3
+ * Reuses the same Direct SSR path as serve-host (no second SSG runtime).
4
+ */
5
+ // @ts-nocheck
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { pathToFileURL } from 'node:url';
10
+ import { emitCdnPolicy } from './cdn-policy.js';
11
+ import { emitContentAddressedAssets } from './content-addressed-assets.js';
12
+ export const STATIC_DELIVERY_MANIFEST_SCHEMA = 'vmz.static.delivery_manifest.v0';
13
+ /**
14
+ * @param {string} distDir
15
+ * @param {{
16
+ * origin?: string,
17
+ * applicationId?: string,
18
+ * staticParams?: Record<string, Array<Record<string, string>>>,
19
+ * }} [opts]
20
+ */
21
+ export async function emitWebStatic(distDir, opts = {}) {
22
+ const origin = String(opts.origin || process.env.VMZ_SITE_ORIGIN || 'https://example.test').replace(/\/$/, '');
23
+ const applicationId = opts.applicationId || path.basename(path.dirname(distDir));
24
+ const domPath = path.join(distDir, 'vmz-dom.js');
25
+ if (!fs.existsSync(domPath)) {
26
+ throw new Error(`emitWebStatic: missing ${domPath} — run vmz build first`);
27
+ }
28
+ const { renderToString, renderToStream } = await import(pathToFileURL(domPath).href);
29
+ const pageCatalog = listPageClientFiles(distDir);
30
+ /** @type {Array<{
31
+ * routeId: string,
32
+ * path: string,
33
+ * chunkId: string,
34
+ * htmlPath: string,
35
+ * classification: string,
36
+ * title: string,
37
+ * description: string,
38
+ * canonical: string,
39
+ * robots: string,
40
+ * }>} */
41
+ const generations = [];
42
+ /** @type {Array<{ routeId: string, path: string, chunkId: string, classification: string, reason: string }>} */
43
+ const skipped = [];
44
+ for (const page of pageCatalog) {
45
+ const pattern = patternFromSegs(page.segs);
46
+ const routeId = guessRouteId(distDir, page.chunkId);
47
+ if (page.segs.some((s) => s.kind === 'param' || s.kind === 'catch')) {
48
+ skipped.push({
49
+ routeId,
50
+ path: pattern,
51
+ chunkId: page.chunkId,
52
+ classification: 'ServerRequired',
53
+ reason: 'dynamic params require explicit StaticRouteSource (not in this thin slice)',
54
+ });
55
+ continue;
56
+ }
57
+ const Page = await loadCtor(distDir, page.chunkId);
58
+ if (!Page) {
59
+ skipped.push({
60
+ routeId,
61
+ path: pattern,
62
+ chunkId: page.chunkId,
63
+ classification: 'UnsupportedForStatic',
64
+ reason: 'missing page ctor',
65
+ });
66
+ continue;
67
+ }
68
+ const params = {};
69
+ if (typeof Page.access === 'function') {
70
+ const access = await Page.access({ params, pathname: pattern, chunkId: page.chunkId, method: 'GET' });
71
+ const kind = access && typeof access === 'object' ? String(access.kind || 'allow') : 'allow';
72
+ if (kind !== 'allow') {
73
+ skipped.push({
74
+ routeId,
75
+ path: pattern,
76
+ chunkId: page.chunkId,
77
+ classification: 'ServerRequired',
78
+ reason: `access result ${kind} is request-bound`,
79
+ });
80
+ continue;
81
+ }
82
+ }
83
+ let props = { ...params };
84
+ if (typeof Page.load === 'function') {
85
+ const loaded = await Page.load({
86
+ params,
87
+ pathname: pattern,
88
+ chunkId: page.chunkId,
89
+ searchParams: new URLSearchParams(),
90
+ });
91
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
92
+ props = { ...props, ...loaded };
93
+ }
94
+ }
95
+ const meta = await resolvePageMeta(Page, { params, props, pathname: pattern, origin });
96
+ const layoutChain = resolveLayoutChain(distDir, page.chunkId);
97
+ let bodyHtml = '';
98
+ for await (const chunk of renderToStream(Page, props, {})) {
99
+ bodyHtml += chunk;
100
+ }
101
+ for (let i = layoutChain.length - 1; i >= 0; i--) {
102
+ const Layout = await loadCtor(distDir, layoutChain[i]);
103
+ if (!Layout)
104
+ continue;
105
+ bodyHtml = await renderToString(Layout, {}, { slotHtml: bodyHtml });
106
+ }
107
+ const htmlPath = htmlPathForRoute(pattern);
108
+ const absHtml = path.join(distDir, htmlPath);
109
+ fs.mkdirSync(path.dirname(absHtml), { recursive: true });
110
+ const html = wrapDocument({
111
+ bodyHtml,
112
+ chunkId: page.chunkId,
113
+ layoutChain,
114
+ props,
115
+ meta,
116
+ cssEntry: readCssEntry(distDir),
117
+ });
118
+ fs.writeFileSync(absHtml, html, 'utf8');
119
+ generations.push({
120
+ routeId,
121
+ path: pattern,
122
+ chunkId: page.chunkId,
123
+ htmlPath: htmlPath.replaceAll('\\', '/'),
124
+ classification: 'Static',
125
+ title: meta.title,
126
+ description: meta.description,
127
+ canonical: meta.canonical,
128
+ robots: meta.robots,
129
+ });
130
+ }
131
+ const notFoundHtml = wrapDocument({
132
+ bodyHtml: '<main><h1>Not Found</h1><p>route-static-404</p></main>',
133
+ chunkId: '',
134
+ layoutChain: [],
135
+ props: {},
136
+ meta: {
137
+ title: 'Not Found',
138
+ description: 'Page not found',
139
+ canonical: `${origin}/404`,
140
+ robots: 'noindex,nofollow',
141
+ lang: 'en',
142
+ },
143
+ cssEntry: readCssEntry(distDir),
144
+ isErrorDocument: true,
145
+ });
146
+ fs.writeFileSync(path.join(distDir, '404.html'), notFoundHtml, 'utf8');
147
+ const sitemap = buildSitemap(origin, generations);
148
+ fs.writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap, 'utf8');
149
+ const robots = `User-agent: *\nAllow: /\nDisallow: /404\nSitemap: ${origin}/sitemap.xml\n`;
150
+ fs.writeFileSync(path.join(distDir, 'robots.txt'), robots, 'utf8');
151
+ // Hard rule: no SPA fallback shim in artifact.
152
+ for (const bad of ['_redirects', 'vercel.json', 'netlify.toml']) {
153
+ const p = path.join(distDir, bad);
154
+ if (fs.existsSync(p)) {
155
+ const text = fs.readFileSync(p, 'utf8');
156
+ if (/\/\*|\bspa\b|index\.html/i.test(text) && /fallback|rewrite|redirects/i.test(text)) {
157
+ throw new Error(`emitWebStatic: forbidden SPA fallback config present: ${bad}`);
158
+ }
159
+ }
160
+ }
161
+ const vmzDir = path.join(distDir, '_vmz');
162
+ fs.mkdirSync(vmzDir, { recursive: true });
163
+ const manifest = {
164
+ schema: STATIC_DELIVERY_MANIFEST_SCHEMA,
165
+ applicationId,
166
+ deliveryProfile: 'web-static',
167
+ origin,
168
+ generatedAt: new Date().toISOString(),
169
+ spaFallback: false,
170
+ errorDocuments: [{ status: 404, path: '404.html' }],
171
+ routes: generations.map((g) => ({
172
+ routeId: g.routeId,
173
+ path: g.path,
174
+ chunkId: g.chunkId,
175
+ htmlPath: g.htmlPath,
176
+ classification: g.classification,
177
+ seo: {
178
+ title: g.title,
179
+ description: g.description,
180
+ canonical: g.canonical,
181
+ robots: g.robots,
182
+ },
183
+ })),
184
+ skipped,
185
+ seoArtifacts: {
186
+ sitemap: 'sitemap.xml',
187
+ robots: 'robots.txt',
188
+ },
189
+ };
190
+ const digest = sha256Hex(canonicalJson(manifest));
191
+ manifest.manifestDigest = digest;
192
+ fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
193
+ const assets = emitContentAddressedAssets(distDir);
194
+ manifest.contentAddressedAssets = {
195
+ schema: assets.manifest.schema,
196
+ manifestDigest: assets.manifest.manifestDigest,
197
+ objectCount: assets.manifest.objectCount,
198
+ layout: assets.manifest.layout,
199
+ };
200
+ // Re-stamp static manifest after linking asset digest (HTML already rewritten on disk).
201
+ delete manifest.manifestDigest;
202
+ manifest.manifestDigest = sha256Hex(canonicalJson(manifest));
203
+ fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
204
+ const cdn = emitCdnPolicy(distDir, manifest);
205
+ return {
206
+ manifest,
207
+ htmlFiles: generations.map((g) => g.htmlPath),
208
+ skipped,
209
+ digest: manifest.manifestDigest,
210
+ assets: assets.manifest,
211
+ cdnPolicy: cdn.policy,
212
+ cdnAdapters: cdn.adapters,
213
+ };
214
+ }
215
+ /**
216
+ * @param {string} data
217
+ */
218
+ function sha256Hex(data) {
219
+ return crypto.createHash('sha256').update(data).digest('hex');
220
+ }
221
+ /**
222
+ * @param {unknown} value
223
+ */
224
+ function canonicalJson(value) {
225
+ return JSON.stringify(sortKeys(value));
226
+ }
227
+ function sortKeys(value) {
228
+ if (Array.isArray(value))
229
+ return value.map(sortKeys);
230
+ if (value && typeof value === 'object') {
231
+ /** @type {Record<string, unknown>} */
232
+ const out = {};
233
+ for (const k of Object.keys(value).sort())
234
+ out[k] = sortKeys(value[k]);
235
+ return out;
236
+ }
237
+ return value;
238
+ }
239
+ /**
240
+ * @param {string} distDir
241
+ */
242
+ function listPageClientFiles(distDir) {
243
+ const root = path.join(distDir, 'pages');
244
+ /** @type {Array<{ chunkId: string, segs: ReturnType<typeof parseChunkSegments> }>} */
245
+ const out = [];
246
+ function walk(abs, relParts) {
247
+ let ents;
248
+ try {
249
+ ents = fs.readdirSync(abs, { withFileTypes: true });
250
+ }
251
+ catch {
252
+ return;
253
+ }
254
+ for (const e of ents) {
255
+ if (e.isDirectory())
256
+ walk(path.join(abs, e.name), [...relParts, e.name]);
257
+ else if (e.isFile() && e.name.endsWith('.client.js')) {
258
+ const stem = e.name.replace(/\.client\.js$/, '');
259
+ if (stem === 'Layout' || stem === 'Loading' || stem === 'Error' || stem === 'NotFound')
260
+ continue;
261
+ const chunkId = ['pages', ...relParts, stem].join('/');
262
+ out.push({ chunkId, segs: parseChunkSegments(chunkId) });
263
+ }
264
+ }
265
+ }
266
+ walk(root, []);
267
+ return out;
268
+ }
269
+ /**
270
+ * @param {string} chunkId
271
+ */
272
+ function parseChunkSegments(chunkId) {
273
+ const rel = chunkId.replace(/^pages\//, '');
274
+ const parts = rel.split('/').filter(Boolean);
275
+ /** @type {Array<{ kind: 'static' | 'param' | 'catch', value?: string, name?: string }>} */
276
+ const segs = [];
277
+ for (let i = 0; i < parts.length; i++) {
278
+ const p = parts[i];
279
+ if (p.startsWith('(') && p.endsWith(')') && p.length > 2)
280
+ continue;
281
+ if (p === 'index' && i === parts.length - 1)
282
+ continue;
283
+ const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
284
+ const param = /^\[([^\]]+)\]$/.exec(p);
285
+ if (catchAll)
286
+ segs.push({ kind: 'catch', name: catchAll[1] });
287
+ else if (param)
288
+ segs.push({ kind: 'param', name: param[1] });
289
+ else
290
+ segs.push({ kind: 'static', value: p.toLowerCase() });
291
+ }
292
+ return segs;
293
+ }
294
+ /**
295
+ * @param {ReturnType<typeof parseChunkSegments>} segs
296
+ */
297
+ function patternFromSegs(segs) {
298
+ if (!segs.length)
299
+ return '/';
300
+ return `/${segs
301
+ .map((s) => {
302
+ if (s.kind === 'static')
303
+ return s.value;
304
+ if (s.kind === 'param')
305
+ return `[${s.name}]`;
306
+ return `[...${s.name}]`;
307
+ })
308
+ .join('/')}`;
309
+ }
310
+ /**
311
+ * @param {string} pathname
312
+ */
313
+ function htmlPathForRoute(pathname) {
314
+ const p = pathname === '/' ? '' : pathname.replace(/^\//, '').replace(/\/+$/, '');
315
+ if (!p)
316
+ return 'index.html';
317
+ return path.join(...p.split('/'), 'index.html');
318
+ }
319
+ /**
320
+ * @param {string} distDir
321
+ * @param {string} chunkId
322
+ */
323
+ async function loadCtor(distDir, chunkId) {
324
+ const href = pathToFileURL(path.join(distDir, `${chunkId}.client.js`)).href;
325
+ const mod = await import(`${href}?t=${Date.now()}`);
326
+ return mod.default;
327
+ }
328
+ /**
329
+ * @param {string} distDir
330
+ * @param {string} pageChunkId
331
+ */
332
+ function resolveLayoutChain(distDir, pageChunkId) {
333
+ const rel = pageChunkId.replace(/^pages\//, '');
334
+ const parts = rel.split('/').filter(Boolean);
335
+ parts.pop();
336
+ /** @type {string[]} */
337
+ const chain = [];
338
+ for (let i = parts.length; i >= 0; i--) {
339
+ const dirParts = parts.slice(0, i);
340
+ const layoutChunk = ['pages', ...dirParts, 'Layout'].join('/');
341
+ if (fs.existsSync(path.join(distDir, `${layoutChunk}.client.js`)))
342
+ chain.unshift(layoutChunk);
343
+ }
344
+ return chain;
345
+ }
346
+ /**
347
+ * @param {string} distDir
348
+ * @param {string} chunkId
349
+ */
350
+ function guessRouteId(distDir, chunkId) {
351
+ try {
352
+ const js = fs.readFileSync(path.join(distDir, `${chunkId}.client.js`), 'utf8');
353
+ const m = /export default class (\w+)/.exec(js);
354
+ if (m)
355
+ return m[1];
356
+ }
357
+ catch {
358
+ /* ignore */
359
+ }
360
+ return chunkId.split('/').pop() || chunkId;
361
+ }
362
+ /**
363
+ * @param {any} Page
364
+ * @param {{ params: Record<string, string>, props: Record<string, unknown>, pathname: string, origin: string }} ctx
365
+ */
366
+ async function resolvePageMeta(Page, ctx) {
367
+ let raw = {};
368
+ if (typeof Page.meta === 'function') {
369
+ raw = (await Page.meta(ctx)) || {};
370
+ }
371
+ else if (Page.meta && typeof Page.meta === 'object') {
372
+ raw = Page.meta;
373
+ }
374
+ const title = String(raw.title || `${guessTitle(ctx.pathname)} · VMZ`);
375
+ const description = String(raw.description || `VMZ page ${ctx.pathname}`);
376
+ const canonical = String(raw.canonical || `${ctx.origin}${ctx.pathname === '/' ? '/' : ctx.pathname}`);
377
+ const robots = String(raw.robots || 'index,follow');
378
+ const lang = String(raw.lang || 'en');
379
+ return { title, description, canonical, robots, lang };
380
+ }
381
+ function guessTitle(pathname) {
382
+ if (pathname === '/')
383
+ return 'Home';
384
+ return pathname
385
+ .split('/')
386
+ .filter(Boolean)
387
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
388
+ .join(' / ');
389
+ }
390
+ /**
391
+ * @param {string} distDir
392
+ */
393
+ function readCssEntry(distDir) {
394
+ try {
395
+ const dep = JSON.parse(fs.readFileSync(path.join(distDir, 'vmz-deployment.json'), 'utf8'));
396
+ return dep.cssEntry || null;
397
+ }
398
+ catch {
399
+ return null;
400
+ }
401
+ }
402
+ /**
403
+ * @param {{
404
+ * bodyHtml: string,
405
+ * chunkId: string,
406
+ * layoutChain: string[],
407
+ * props: Record<string, unknown>,
408
+ * meta: { title: string, description: string, canonical: string, robots: string, lang: string },
409
+ * cssEntry: string | null,
410
+ * isErrorDocument?: boolean,
411
+ * }} input
412
+ */
413
+ function wrapDocument(input) {
414
+ const propsJson = JSON.stringify(input.props ?? {});
415
+ const layoutAttr = input.layoutChain.length ? ` data-vmz-layout="${escapeAttr(input.layoutChain.join(','))}"` : '';
416
+ const pageAttr = input.chunkId ? ` data-vmz-page="${escapeAttr(input.chunkId)}"` : '';
417
+ const cssLink = input.cssEntry ? ` <link rel="stylesheet" href="/${String(input.cssEntry).replace(/^\/+/, '')}" />\n` : '';
418
+ const entry = input.isErrorDocument ? '' : ` <script type="module" src="/entry-client.js"></script>\n`;
419
+ return `<!DOCTYPE html>
420
+ <html lang="${escapeAttr(input.meta.lang)}">
421
+ <head>
422
+ <meta charset="utf-8" />
423
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
424
+ <title>${escapeHtml(input.meta.title)}</title>
425
+ <meta name="description" content="${escapeAttr(input.meta.description)}" />
426
+ <meta name="robots" content="${escapeAttr(input.meta.robots)}" />
427
+ <link rel="canonical" href="${escapeAttr(input.meta.canonical)}" />
428
+ <meta property="og:title" content="${escapeAttr(input.meta.title)}" />
429
+ <meta property="og:description" content="${escapeAttr(input.meta.description)}" />
430
+ <meta property="og:url" content="${escapeAttr(input.meta.canonical)}" />
431
+ ${cssLink}</head>
432
+ <body>
433
+ <div id="app"${pageAttr}${layoutAttr} data-vmz-props="${escapeAttr(propsJson)}">${input.bodyHtml}</div>
434
+ ${entry}</body>
435
+ </html>
436
+ `;
437
+ }
438
+ /**
439
+ * @param {string} origin
440
+ * @param {Array<{ canonical: string, robots: string }>} generations
441
+ */
442
+ function buildSitemap(origin, generations) {
443
+ const urls = generations
444
+ .filter((g) => !String(g.robots).includes('noindex'))
445
+ .map((g) => ` <url>
446
+ <loc>${escapeXml(g.canonical)}</loc>
447
+ </url>`)
448
+ .join('\n');
449
+ return `<?xml version="1.0" encoding="UTF-8"?>
450
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
451
+ ${urls}
452
+ </urlset>
453
+ `;
454
+ }
455
+ function escapeHtml(s) {
456
+ return String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
457
+ }
458
+ function escapeAttr(s) {
459
+ return escapeHtml(s).replaceAll('"', '&quot;');
460
+ }
461
+ function escapeXml(s) {
462
+ return escapeAttr(s).replaceAll("'", '&apos;');
463
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vmz/vmz",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
- "description": "VMZ Node toolchain — N-API workspace session + CLI",
5
+ "description": "VMZ Node toolchain — N-API workspace session + CLI (publish name @vmz/vmz)",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "vmz": "./bin/vmz.js"
@@ -48,15 +48,15 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@vmz/core": "0.0.3",
52
- "@vmz/plugin": "0.0.3",
53
- "@vmz/protocol": "0.0.3",
51
+ "@vmz/core": "0.0.4",
52
+ "@vmz/plugin": "0.0.4",
53
+ "@vmz/protocol": "0.0.4",
54
54
  "jiti": "^2.6.1",
55
55
  "json5": "^2.2.3"
56
56
  },
57
57
  "peerDependencies": {
58
- "@vmz/plugin-markdown-it": "0.0.3",
59
- "@vmz/test": "0.0.3",
58
+ "@vmz/plugin-markdown-it": "0.0.4",
59
+ "@vmz/test": "0.0.4",
60
60
  "typescript": "^5.8.3"
61
61
  },
62
62
  "peerDependenciesMeta": {
@@ -90,12 +90,12 @@
90
90
  "cli"
91
91
  ],
92
92
  "optionalDependencies": {
93
- "@vmz/vmz-win32-x64": "0.0.3",
94
- "@vmz/vmz-win32-arm64": "0.0.3",
95
- "@vmz/vmz-darwin-x64": "0.0.3",
96
- "@vmz/vmz-darwin-arm64": "0.0.3",
97
- "@vmz/vmz-linux-x64": "0.0.3",
98
- "@vmz/vmz-linux-arm64": "0.0.3"
93
+ "@vmz/vmz-win32-x64": "0.0.4",
94
+ "@vmz/vmz-win32-arm64": "0.0.4",
95
+ "@vmz/vmz-darwin-x64": "0.0.4",
96
+ "@vmz/vmz-darwin-arm64": "0.0.4",
97
+ "@vmz/vmz-linux-x64": "0.0.4",
98
+ "@vmz/vmz-linux-arm64": "0.0.4"
99
99
  },
100
100
  "publishConfig": {
101
101
  "access": "public"