@vmz/vmz 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.
Files changed (52) hide show
  1. package/README.md +6 -4
  2. package/dist/build-assemble.d.ts +52 -0
  3. package/dist/build-assemble.js +191 -0
  4. package/dist/cdn-policy.d.ts +196 -0
  5. package/dist/cdn-policy.js +443 -0
  6. package/dist/cli.js +152 -11
  7. package/dist/content-addressed-assets.d.ts +69 -0
  8. package/dist/content-addressed-assets.js +206 -0
  9. package/dist/delivery-profile.d.ts +74 -0
  10. package/dist/delivery-profile.js +279 -0
  11. package/dist/dev-session.js +49 -13
  12. package/dist/document-build.js +9 -4
  13. package/dist/document-designs.js +30 -2
  14. package/dist/document-enrich.js +8 -0
  15. package/dist/embedded-packaging.d.ts +22 -0
  16. package/dist/embedded-packaging.js +113 -0
  17. package/dist/index.d.ts +19 -3
  18. package/dist/index.js +35 -15
  19. package/dist/invocation.d.ts +8 -31
  20. package/dist/invocation.js +12 -33
  21. package/dist/locale-check.d.ts +16 -0
  22. package/dist/locale-check.js +131 -3
  23. package/dist/locale-cmd.js +2 -2
  24. package/dist/locale-route-emit.d.ts +34 -0
  25. package/dist/locale-route-emit.js +134 -0
  26. package/dist/locale-router.d.ts +20 -0
  27. package/dist/locale-router.js +68 -0
  28. package/dist/log.d.ts +2 -2
  29. package/dist/log.js +11 -3
  30. package/dist/pack.d.ts +40 -0
  31. package/dist/pack.js +108 -0
  32. package/dist/plugin-host.d.ts +10 -1
  33. package/dist/plugin-host.js +19 -2
  34. package/dist/port.d.ts +10 -0
  35. package/dist/port.js +46 -0
  36. package/dist/production-observability.d.ts +286 -0
  37. package/dist/production-observability.js +469 -0
  38. package/dist/production-test-pack.d.ts +144 -0
  39. package/dist/production-test-pack.js +447 -0
  40. package/dist/release-cmd.d.ts +8 -0
  41. package/dist/release-cmd.js +126 -0
  42. package/dist/release-pack.d.ts +96 -0
  43. package/dist/release-pack.js +346 -0
  44. package/dist/server-artifact.d.ts +140 -0
  45. package/dist/server-artifact.js +205 -0
  46. package/dist/server-language-backend.d.ts +89 -0
  47. package/dist/server-language-backend.js +121 -0
  48. package/dist/site-delivery.d.ts +134 -0
  49. package/dist/site-delivery.js +345 -0
  50. package/dist/static-emit.d.ts +145 -0
  51. package/dist/static-emit.js +577 -0
  52. package/package.json +13 -13
@@ -0,0 +1,577 @@
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
+ import { absoluteUrl, buildLocalePageMeta, localizeBodyLinks } from './locale-router.js';
13
+ export const STATIC_DELIVERY_MANIFEST_SCHEMA = 'vmz.static.delivery_manifest.v0';
14
+ /**
15
+ * @param {string} distDir
16
+ * @param {{
17
+ * origin?: string,
18
+ * applicationId?: string,
19
+ * staticParams?: Record<string, Array<Record<string, string>>>,
20
+ * }} [opts]
21
+ */
22
+ export async function emitWebStatic(distDir, opts = {}) {
23
+ const origin = String(opts.origin || process.env.VMZ_SITE_ORIGIN || 'https://example.test').replace(/\/$/, '');
24
+ const applicationId = opts.applicationId || path.basename(path.dirname(distDir));
25
+ const domPath = path.join(distDir, 'vmz-dom.js');
26
+ if (!fs.existsSync(domPath)) {
27
+ throw new Error(`emitWebStatic: missing ${domPath} — run vmz build first`);
28
+ }
29
+ const { renderToString, renderToStream } = await import(pathToFileURL(domPath).href);
30
+ const pageCatalog = listPageClientFiles(distDir);
31
+ /** @type {Array<{
32
+ * routeId: string,
33
+ * path: string,
34
+ * chunkId: string,
35
+ * htmlPath: string,
36
+ * classification: string,
37
+ * title: string,
38
+ * description: string,
39
+ * canonical: string,
40
+ * robots: string,
41
+ * }>} */
42
+ const generations = [];
43
+ /** @type {Array<{ routeId: string, path: string, chunkId: string, classification: string, reason: string }>} */
44
+ const skipped = [];
45
+ for (const page of pageCatalog) {
46
+ const pattern = patternFromSegs(page.segs);
47
+ const routeId = guessRouteId(distDir, page.chunkId);
48
+ if (page.segs.some((s) => s.kind === 'param' || s.kind === 'catch')) {
49
+ skipped.push({
50
+ routeId,
51
+ path: pattern,
52
+ chunkId: page.chunkId,
53
+ classification: 'ServerRequired',
54
+ reason: 'dynamic params require explicit StaticRouteSource (not in this thin slice)',
55
+ });
56
+ continue;
57
+ }
58
+ const Page = await loadCtor(distDir, page.chunkId);
59
+ if (!Page) {
60
+ skipped.push({
61
+ routeId,
62
+ path: pattern,
63
+ chunkId: page.chunkId,
64
+ classification: 'UnsupportedForStatic',
65
+ reason: 'missing page ctor',
66
+ });
67
+ continue;
68
+ }
69
+ const params = {};
70
+ if (typeof Page.access === 'function') {
71
+ const access = await Page.access({ params, pathname: pattern, chunkId: page.chunkId, method: 'GET' });
72
+ const kind = access && typeof access === 'object' ? String(access.kind || 'allow') : 'allow';
73
+ if (kind !== 'allow') {
74
+ skipped.push({
75
+ routeId,
76
+ path: pattern,
77
+ chunkId: page.chunkId,
78
+ classification: 'ServerRequired',
79
+ reason: `access result ${kind} is request-bound`,
80
+ });
81
+ continue;
82
+ }
83
+ }
84
+ let props = { ...params };
85
+ if (typeof Page.load === 'function') {
86
+ const loaded = await Page.load({
87
+ params,
88
+ pathname: pattern,
89
+ chunkId: page.chunkId,
90
+ searchParams: new URLSearchParams(),
91
+ });
92
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
93
+ props = { ...props, ...loaded };
94
+ }
95
+ }
96
+ const meta = await resolvePageMeta(Page, { params, props, pathname: pattern, origin });
97
+ const layoutChain = resolveLayoutChain(distDir, page.chunkId);
98
+ let bodyHtml = '';
99
+ for await (const chunk of renderToStream(Page, props, {})) {
100
+ bodyHtml += chunk;
101
+ }
102
+ for (let i = layoutChain.length - 1; i >= 0; i--) {
103
+ const Layout = await loadCtor(distDir, layoutChain[i]);
104
+ if (!Layout)
105
+ continue;
106
+ bodyHtml = await renderToString(Layout, {}, { slotHtml: bodyHtml });
107
+ }
108
+ const localeArt = loadLocaleArtifact(distDir);
109
+ // Locale artifact routeId is chunkId (`pages/about`), not the page class name.
110
+ const localeWrites = expandLocaleStaticGenerations({
111
+ localeArt,
112
+ routeId: page.chunkId,
113
+ chunkId: page.chunkId,
114
+ pattern,
115
+ origin,
116
+ baseMeta: meta,
117
+ });
118
+ for (const gen of localeWrites) {
119
+ const absHtml = path.join(distDir, gen.htmlPath);
120
+ fs.mkdirSync(path.dirname(absHtml), { recursive: true });
121
+ // Each LocaleId HTML must retain locale on same-app Links (realization authority).
122
+ const localizedBody = gen.localeId && localeArt
123
+ ? localizeBodyLinks(bodyHtml, gen.localeId, localeArt)
124
+ : bodyHtml;
125
+ const html = wrapDocument({
126
+ bodyHtml: localizedBody,
127
+ chunkId: page.chunkId,
128
+ layoutChain,
129
+ props,
130
+ meta: gen.meta,
131
+ cssEntry: readCssEntry(distDir),
132
+ });
133
+ fs.writeFileSync(absHtml, html, 'utf8');
134
+ generations.push({
135
+ routeId,
136
+ path: gen.path,
137
+ chunkId: page.chunkId,
138
+ htmlPath: gen.htmlPath.replaceAll('\\', '/'),
139
+ classification: 'Static',
140
+ title: gen.meta.title,
141
+ description: meta.description,
142
+ canonical: gen.meta.canonical,
143
+ robots: gen.meta.robots,
144
+ localeId: gen.localeId || null,
145
+ alternates: Array.isArray(gen.meta.alternates) ? gen.meta.alternates : [],
146
+ });
147
+ }
148
+ }
149
+ const notFoundHtml = wrapDocument({
150
+ bodyHtml: '<main><h1>Not Found</h1><p>route-static-404</p></main>',
151
+ chunkId: '',
152
+ layoutChain: [],
153
+ props: {},
154
+ meta: {
155
+ title: 'Not Found',
156
+ description: 'Page not found',
157
+ canonical: `${origin}/404`,
158
+ robots: 'noindex,nofollow',
159
+ lang: 'en',
160
+ },
161
+ cssEntry: readCssEntry(distDir),
162
+ isErrorDocument: true,
163
+ });
164
+ fs.writeFileSync(path.join(distDir, '404.html'), notFoundHtml, 'utf8');
165
+ const sitemap = buildSitemap(origin, generations);
166
+ fs.writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap, 'utf8');
167
+ const robots = `User-agent: *\nAllow: /\nDisallow: /404\nSitemap: ${origin}/sitemap.xml\n`;
168
+ fs.writeFileSync(path.join(distDir, 'robots.txt'), robots, 'utf8');
169
+ // Hard rule: no SPA fallback shim in artifact.
170
+ for (const bad of ['_redirects', 'vercel.json', 'netlify.toml']) {
171
+ const p = path.join(distDir, bad);
172
+ if (fs.existsSync(p)) {
173
+ const text = fs.readFileSync(p, 'utf8');
174
+ if (/\/\*|\bspa\b|index\.html/i.test(text) && /fallback|rewrite|redirects/i.test(text)) {
175
+ throw new Error(`emitWebStatic: forbidden SPA fallback config present: ${bad}`);
176
+ }
177
+ }
178
+ }
179
+ const vmzDir = path.join(distDir, '_vmz');
180
+ fs.mkdirSync(vmzDir, { recursive: true });
181
+ const manifest = {
182
+ schema: STATIC_DELIVERY_MANIFEST_SCHEMA,
183
+ applicationId,
184
+ deliveryProfile: 'web-static',
185
+ origin,
186
+ generatedAt: new Date().toISOString(),
187
+ spaFallback: false,
188
+ errorDocuments: [{ status: 404, path: '404.html' }],
189
+ routes: generations.map((g) => ({
190
+ routeId: g.routeId,
191
+ path: g.path,
192
+ chunkId: g.chunkId,
193
+ htmlPath: g.htmlPath,
194
+ classification: g.classification,
195
+ localeId: g.localeId || null,
196
+ seo: {
197
+ title: g.title,
198
+ description: g.description,
199
+ canonical: g.canonical,
200
+ robots: g.robots,
201
+ alternates: g.alternates || [],
202
+ },
203
+ })),
204
+ skipped,
205
+ seoArtifacts: {
206
+ sitemap: 'sitemap.xml',
207
+ robots: 'robots.txt',
208
+ },
209
+ };
210
+ const digest = sha256Hex(canonicalJson(manifest));
211
+ manifest.manifestDigest = digest;
212
+ fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
213
+ const assets = emitContentAddressedAssets(distDir);
214
+ manifest.contentAddressedAssets = {
215
+ schema: assets.manifest.schema,
216
+ manifestDigest: assets.manifest.manifestDigest,
217
+ objectCount: assets.manifest.objectCount,
218
+ layout: assets.manifest.layout,
219
+ };
220
+ // Re-stamp static manifest after linking asset digest (HTML already rewritten on disk).
221
+ delete manifest.manifestDigest;
222
+ manifest.manifestDigest = sha256Hex(canonicalJson(manifest));
223
+ fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
224
+ const cdn = emitCdnPolicy(distDir, manifest);
225
+ return {
226
+ manifest,
227
+ htmlFiles: generations.map((g) => g.htmlPath),
228
+ skipped,
229
+ digest: manifest.manifestDigest,
230
+ assets: assets.manifest,
231
+ cdnPolicy: cdn.policy,
232
+ cdnAdapters: cdn.adapters,
233
+ };
234
+ }
235
+ /**
236
+ * @param {string} data
237
+ */
238
+ function sha256Hex(data) {
239
+ return crypto.createHash('sha256').update(data).digest('hex');
240
+ }
241
+ /**
242
+ * @param {unknown} value
243
+ */
244
+ function canonicalJson(value) {
245
+ return JSON.stringify(sortKeys(value));
246
+ }
247
+ function sortKeys(value) {
248
+ if (Array.isArray(value))
249
+ return value.map(sortKeys);
250
+ if (value && typeof value === 'object') {
251
+ /** @type {Record<string, unknown>} */
252
+ const out = {};
253
+ for (const k of Object.keys(value).sort())
254
+ out[k] = sortKeys(value[k]);
255
+ return out;
256
+ }
257
+ return value;
258
+ }
259
+ /**
260
+ * @param {string} distDir
261
+ */
262
+ function listPageClientFiles(distDir) {
263
+ const root = path.join(distDir, 'pages');
264
+ /** @type {Array<{ chunkId: string, segs: ReturnType<typeof parseChunkSegments> }>} */
265
+ const out = [];
266
+ function walk(abs, relParts) {
267
+ let ents;
268
+ try {
269
+ ents = fs.readdirSync(abs, { withFileTypes: true });
270
+ }
271
+ catch {
272
+ return;
273
+ }
274
+ for (const e of ents) {
275
+ if (e.isDirectory())
276
+ walk(path.join(abs, e.name), [...relParts, e.name]);
277
+ else if (e.isFile() && e.name.endsWith('.client.js')) {
278
+ const stem = e.name.replace(/\.client\.js$/, '');
279
+ if (stem === 'Layout' || stem === 'Loading' || stem === 'Error' || stem === 'NotFound')
280
+ continue;
281
+ const chunkId = ['pages', ...relParts, stem].join('/');
282
+ out.push({ chunkId, segs: parseChunkSegments(chunkId) });
283
+ }
284
+ }
285
+ }
286
+ walk(root, []);
287
+ return out;
288
+ }
289
+ /**
290
+ * @param {string} chunkId
291
+ */
292
+ function parseChunkSegments(chunkId) {
293
+ const rel = chunkId.replace(/^pages\//, '');
294
+ const parts = rel.split('/').filter(Boolean);
295
+ /** @type {Array<{ kind: 'static' | 'param' | 'catch', value?: string, name?: string }>} */
296
+ const segs = [];
297
+ for (let i = 0; i < parts.length; i++) {
298
+ const p = parts[i];
299
+ if (p.startsWith('(') && p.endsWith(')') && p.length > 2)
300
+ continue;
301
+ if (p === 'index' && i === parts.length - 1)
302
+ continue;
303
+ const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
304
+ const param = /^\[([^\]]+)\]$/.exec(p);
305
+ if (catchAll)
306
+ segs.push({ kind: 'catch', name: catchAll[1] });
307
+ else if (param)
308
+ segs.push({ kind: 'param', name: param[1] });
309
+ else
310
+ segs.push({ kind: 'static', value: p.toLowerCase() });
311
+ }
312
+ return segs;
313
+ }
314
+ /**
315
+ * @param {ReturnType<typeof parseChunkSegments>} segs
316
+ */
317
+ function patternFromSegs(segs) {
318
+ if (!segs.length)
319
+ return '/';
320
+ return `/${segs
321
+ .map((s) => {
322
+ if (s.kind === 'static')
323
+ return s.value;
324
+ if (s.kind === 'param')
325
+ return `[${s.name}]`;
326
+ return `[...${s.name}]`;
327
+ })
328
+ .join('/')}`;
329
+ }
330
+ /**
331
+ * @param {string} pathname
332
+ */
333
+ function htmlPathForRoute(pathname) {
334
+ const p = pathname === '/' ? '' : pathname.replace(/^\//, '').replace(/\/+$/, '');
335
+ if (!p)
336
+ return 'index.html';
337
+ return path.join(...p.split('/'), 'index.html');
338
+ }
339
+ /**
340
+ * @param {string} distDir
341
+ * @param {string} chunkId
342
+ */
343
+ async function loadCtor(distDir, chunkId) {
344
+ const href = pathToFileURL(path.join(distDir, `${chunkId}.client.js`)).href;
345
+ const mod = await import(`${href}?t=${Date.now()}`);
346
+ return mod.default;
347
+ }
348
+ /**
349
+ * @param {string} distDir
350
+ * @param {string} pageChunkId
351
+ */
352
+ function resolveLayoutChain(distDir, pageChunkId) {
353
+ const rel = pageChunkId.replace(/^pages\//, '');
354
+ const parts = rel.split('/').filter(Boolean);
355
+ parts.pop();
356
+ /** @type {string[]} */
357
+ const chain = [];
358
+ for (let i = parts.length; i >= 0; i--) {
359
+ const dirParts = parts.slice(0, i);
360
+ const layoutChunk = ['pages', ...dirParts, 'Layout'].join('/');
361
+ if (fs.existsSync(path.join(distDir, `${layoutChunk}.client.js`)))
362
+ chain.unshift(layoutChunk);
363
+ }
364
+ return chain;
365
+ }
366
+ /**
367
+ * @param {string} distDir
368
+ * @param {string} chunkId
369
+ */
370
+ function guessRouteId(distDir, chunkId) {
371
+ try {
372
+ const js = fs.readFileSync(path.join(distDir, `${chunkId}.client.js`), 'utf8');
373
+ const m = /export default class (\w+)/.exec(js);
374
+ if (m)
375
+ return m[1];
376
+ }
377
+ catch {
378
+ /* ignore */
379
+ }
380
+ return chunkId.split('/').pop() || chunkId;
381
+ }
382
+ /**
383
+ * @param {any} Page
384
+ * @param {{ params: Record<string, string>, props: Record<string, unknown>, pathname: string, origin: string }} ctx
385
+ */
386
+ async function resolvePageMeta(Page, ctx) {
387
+ let raw = {};
388
+ if (typeof Page.meta === 'function') {
389
+ raw = (await Page.meta(ctx)) || {};
390
+ }
391
+ else if (Page.meta && typeof Page.meta === 'object') {
392
+ raw = Page.meta;
393
+ }
394
+ const title = String(raw.title || `${guessTitle(ctx.pathname)} · VMZ`);
395
+ const description = String(raw.description || `VMZ page ${ctx.pathname}`);
396
+ const canonical = String(raw.canonical || `${ctx.origin}${ctx.pathname === '/' ? '/' : ctx.pathname}`);
397
+ const robots = String(raw.robots || 'index,follow');
398
+ const lang = String(raw.lang || 'en');
399
+ return { title, description, canonical, robots, lang, alternates: [] };
400
+ }
401
+ /**
402
+ * @param {string} distDir
403
+ */
404
+ function loadLocaleArtifact(distDir) {
405
+ const p = path.join(distDir, '_vmz', 'locale-route-realization.json');
406
+ if (!fs.existsSync(p))
407
+ return null;
408
+ try {
409
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
410
+ }
411
+ catch {
412
+ return null;
413
+ }
414
+ }
415
+ /**
416
+ * Expand one Static route across LocaleId realizations (hreflang seed + prefixed HTML).
417
+ * @param {{
418
+ * localeArt: any,
419
+ * routeId: string,
420
+ * chunkId: string,
421
+ * pattern: string,
422
+ * origin: string,
423
+ * baseMeta: { title: string, description: string, canonical: string, robots: string, lang: string, alternates?: any[] },
424
+ * }} input
425
+ */
426
+ function expandLocaleStaticGenerations(input) {
427
+ const { localeArt, routeId, pattern, origin, baseMeta } = input;
428
+ if (!localeArt?.realizations?.length) {
429
+ return [
430
+ {
431
+ path: pattern,
432
+ htmlPath: htmlPathForRoute(pattern),
433
+ localeId: null,
434
+ meta: baseMeta,
435
+ },
436
+ ];
437
+ }
438
+ const locales = (localeArt.locales || []).map((l) => l.id);
439
+ const directions = Object.fromEntries((localeArt.locales || []).map((l) => [l.id, l.direction || 'ltr']));
440
+ const defaultLocale = localeArt.defaultLocale || locales[0];
441
+ const forRoute = (localeArt.realizations || []).filter((r) => r.routeId === routeId ||
442
+ r.routeId === input.chunkId ||
443
+ r.pathPattern === pattern ||
444
+ (r.path === pattern && !r.prefixed));
445
+ /** @type {any[]} */
446
+ const out = [];
447
+ for (const loc of locales) {
448
+ const hit = forRoute.find((r) => r.localeId === loc);
449
+ if (!hit)
450
+ continue;
451
+ const built = buildLocalePageMeta({
452
+ routeId,
453
+ localeId: loc,
454
+ direction: directions[loc],
455
+ title: baseMeta.title,
456
+ description: baseMeta.description,
457
+ origin,
458
+ realizations: localeArt.realizations,
459
+ locales,
460
+ defaultLocale,
461
+ });
462
+ out.push({
463
+ path: hit.path,
464
+ htmlPath: htmlPathForRoute(hit.path),
465
+ localeId: loc,
466
+ meta: {
467
+ title: baseMeta.title,
468
+ description: baseMeta.description,
469
+ canonical: built.canonical || absoluteUrl(origin, hit.path),
470
+ robots: baseMeta.robots,
471
+ lang: loc,
472
+ dir: directions[loc] || 'ltr',
473
+ alternates: built.alternates || [],
474
+ },
475
+ });
476
+ }
477
+ return out.length
478
+ ? out
479
+ : [
480
+ {
481
+ path: pattern,
482
+ htmlPath: htmlPathForRoute(pattern),
483
+ localeId: null,
484
+ meta: baseMeta,
485
+ },
486
+ ];
487
+ }
488
+ function guessTitle(pathname) {
489
+ if (pathname === '/')
490
+ return 'Home';
491
+ return pathname
492
+ .split('/')
493
+ .filter(Boolean)
494
+ .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
495
+ .join(' / ');
496
+ }
497
+ /**
498
+ * @param {string} distDir
499
+ */
500
+ function readCssEntry(distDir) {
501
+ try {
502
+ const dep = JSON.parse(fs.readFileSync(path.join(distDir, 'vmz-deployment.json'), 'utf8'));
503
+ return dep.cssEntry || null;
504
+ }
505
+ catch {
506
+ return null;
507
+ }
508
+ }
509
+ /**
510
+ * @param {{
511
+ * bodyHtml: string,
512
+ * chunkId: string,
513
+ * layoutChain: string[],
514
+ * props: Record<string, unknown>,
515
+ * meta: { title: string, description: string, canonical: string, robots: string, lang: string, dir?: string, alternates?: Array<{ hreflang: string, href: string }> },
516
+ * cssEntry: string | null,
517
+ * isErrorDocument?: boolean,
518
+ * }} input
519
+ */
520
+ function wrapDocument(input) {
521
+ const propsJson = JSON.stringify(input.props ?? {});
522
+ const layoutAttr = input.layoutChain.length ? ` data-vmz-layout="${escapeAttr(input.layoutChain.join(','))}"` : '';
523
+ const pageAttr = input.chunkId ? ` data-vmz-page="${escapeAttr(input.chunkId)}"` : '';
524
+ const localeId = input.meta.lang || 'en';
525
+ const dir = input.meta.dir || 'ltr';
526
+ const localeAttr = ` data-vmz-locale="${escapeAttr(localeId)}" data-vmz-dir="${escapeAttr(dir)}"`;
527
+ const cssLink = input.cssEntry ? ` <link rel="stylesheet" href="/${String(input.cssEntry).replace(/^\/+/, '')}" />\n` : '';
528
+ const entry = input.isErrorDocument ? '' : ` <script type="module" src="/entry-client.js"></script>\n`;
529
+ const hreflang = (input.meta.alternates || [])
530
+ .map((a) => ` <link rel="alternate" hreflang="${escapeAttr(a.hreflang)}" href="${escapeAttr(a.href)}" />`)
531
+ .join('\n');
532
+ const hreflangBlock = hreflang ? `${hreflang}\n` : '';
533
+ return `<!DOCTYPE html>
534
+ <html lang="${escapeAttr(localeId)}" data-locale="${escapeAttr(localeId)}" dir="${escapeAttr(dir)}">
535
+ <head>
536
+ <meta charset="utf-8" />
537
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
538
+ <title>${escapeHtml(input.meta.title)}</title>
539
+ <meta name="description" content="${escapeAttr(input.meta.description)}" />
540
+ <meta name="robots" content="${escapeAttr(input.meta.robots)}" />
541
+ <link rel="canonical" href="${escapeAttr(input.meta.canonical)}" />
542
+ ${hreflangBlock} <meta property="og:title" content="${escapeAttr(input.meta.title)}" />
543
+ <meta property="og:description" content="${escapeAttr(input.meta.description)}" />
544
+ <meta property="og:url" content="${escapeAttr(input.meta.canonical)}" />
545
+ ${cssLink}</head>
546
+ <body>
547
+ <div id="app"${pageAttr}${layoutAttr}${localeAttr} data-vmz-props="${escapeAttr(propsJson)}">${input.bodyHtml}</div>
548
+ ${entry}</body>
549
+ </html>
550
+ `;
551
+ }
552
+ /**
553
+ * @param {string} origin
554
+ * @param {Array<{ canonical: string, robots: string }>} generations
555
+ */
556
+ function buildSitemap(origin, generations) {
557
+ const urls = generations
558
+ .filter((g) => !String(g.robots).includes('noindex'))
559
+ .map((g) => ` <url>
560
+ <loc>${escapeXml(g.canonical)}</loc>
561
+ </url>`)
562
+ .join('\n');
563
+ return `<?xml version="1.0" encoding="UTF-8"?>
564
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
565
+ ${urls}
566
+ </urlset>
567
+ `;
568
+ }
569
+ function escapeHtml(s) {
570
+ return String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
571
+ }
572
+ function escapeAttr(s) {
573
+ return escapeHtml(s).replaceAll('"', '&quot;');
574
+ }
575
+ function escapeXml(s) {
576
+ return escapeAttr(s).replaceAll("'", '&apos;');
577
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vmz/vmz",
3
- "version": "0.0.3",
3
+ "version": "0.1.0",
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.1.0",
52
+ "@vmz/plugin": "0.1.0",
53
+ "@vmz/protocol": "0.1.0",
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.1.0",
59
+ "@vmz/test": "0.1.0",
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.1.0",
94
+ "@vmz/vmz-win32-arm64": "0.1.0",
95
+ "@vmz/vmz-darwin-x64": "0.1.0",
96
+ "@vmz/vmz-darwin-arm64": "0.1.0",
97
+ "@vmz/vmz-linux-x64": "0.1.0",
98
+ "@vmz/vmz-linux-arm64": "0.1.0"
99
99
  },
100
100
  "publishConfig": {
101
101
  "access": "public"