@vmz/vmz 0.0.4 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/build-assemble.d.ts +52 -0
  2. package/dist/build-assemble.js +192 -0
  3. package/dist/cdn-policy.d.ts +22 -4
  4. package/dist/cdn-policy.js +109 -13
  5. package/dist/cli.js +122 -27
  6. package/dist/content-addressed-assets.js +2 -1
  7. package/dist/delivery-profile.d.ts +84 -0
  8. package/dist/delivery-profile.js +348 -0
  9. package/dist/dev-session.d.ts +6 -0
  10. package/dist/dev-session.js +167 -40
  11. package/dist/document-build.js +33 -32
  12. package/dist/document-cmd.js +2 -9
  13. package/dist/document-enrich.js +8 -0
  14. package/dist/document-integrate.js +10 -17
  15. package/dist/embedded-packaging.d.ts +22 -0
  16. package/dist/embedded-packaging.js +109 -0
  17. package/dist/index.d.ts +26 -1
  18. package/dist/index.js +69 -2
  19. package/dist/locale-check.js +39 -66
  20. package/dist/locale-cmd.js +12 -44
  21. package/dist/locale-route-emit.d.ts +37 -0
  22. package/dist/locale-route-emit.js +109 -0
  23. package/dist/locale-router.d.ts +20 -0
  24. package/dist/locale-router.js +68 -0
  25. package/dist/log.d.ts +2 -2
  26. package/dist/log.js +11 -3
  27. package/dist/mini-host.d.ts +47 -0
  28. package/dist/mini-host.js +202 -0
  29. package/dist/native-addon.d.ts +9 -0
  30. package/dist/native-addon.js +84 -0
  31. package/dist/pack-client-packages.d.ts +25 -0
  32. package/dist/pack-client-packages.js +399 -0
  33. package/dist/pack.d.ts +58 -0
  34. package/dist/pack.js +123 -0
  35. package/dist/plugin-host.d.ts +1 -1
  36. package/dist/plugin-host.js +2 -2
  37. package/dist/pretty-json.d.ts +19 -0
  38. package/dist/pretty-json.js +43 -0
  39. package/dist/production-observability.js +3 -2
  40. package/dist/production-test-pack.d.ts +0 -14
  41. package/dist/production-test-pack.js +27 -31
  42. package/dist/release-pack.js +17 -21
  43. package/dist/route-path.d.ts +35 -0
  44. package/dist/route-path.js +77 -0
  45. package/dist/server-artifact.d.ts +140 -0
  46. package/dist/server-artifact.js +204 -0
  47. package/dist/server-language-backend.d.ts +89 -0
  48. package/dist/server-language-backend.js +121 -0
  49. package/dist/site-delivery.js +3 -2
  50. package/dist/static-emit.d.ts +10 -1
  51. package/dist/static-emit.js +192 -97
  52. package/dist/test-cmd.js +2 -1
  53. package/dist/wechat-packaging.d.ts +22 -0
  54. package/dist/wechat-packaging.js +59 -0
  55. package/package.json +12 -12
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Server Language DSL backends (lang on `<script server>`).
3
+ * Author surface is a VMZ DSL flavor — not full target-language source.
4
+ */
5
+ /** @typedef {'ts' | 'rust' | 'python' | 'java'} ServerLangId */
6
+ export declare const SERVER_LANG_IDS: readonly string[];
7
+ /** @type {Record<string, ServerLangId>} */
8
+ export declare const SERVER_LANG_ALIASES: Readonly<{
9
+ ts: "ts";
10
+ typescript: "ts";
11
+ rust: "rust";
12
+ python: "python";
13
+ java: "java";
14
+ }>;
15
+ /**
16
+ * @typedef {{
17
+ * langId: ServerLangId,
18
+ * aliases: string[],
19
+ * compatibleRuntimes: string[],
20
+ * implemented: boolean,
21
+ * artifactRoot: string,
22
+ * }} ServerLanguageBackendMeta
23
+ */
24
+ /** @type {Record<ServerLangId, ServerLanguageBackendMeta>} */
25
+ export declare const SERVER_LANGUAGE_BACKENDS: Readonly<{
26
+ ts: {
27
+ langId: string;
28
+ aliases: string[];
29
+ compatibleRuntimes: string[];
30
+ implemented: boolean;
31
+ artifactRoot: string;
32
+ };
33
+ rust: {
34
+ langId: string;
35
+ aliases: string[];
36
+ compatibleRuntimes: string[];
37
+ implemented: boolean;
38
+ artifactRoot: string;
39
+ };
40
+ python: {
41
+ langId: string;
42
+ aliases: string[];
43
+ compatibleRuntimes: string[];
44
+ implemented: boolean;
45
+ artifactRoot: string;
46
+ };
47
+ java: {
48
+ langId: string;
49
+ aliases: string[];
50
+ compatibleRuntimes: string[];
51
+ implemented: boolean;
52
+ artifactRoot: string;
53
+ };
54
+ }>;
55
+ /**
56
+ * Resolve author `lang` attr (or null/undefined for default TS).
57
+ * @param {string | null | undefined} raw
58
+ * @returns {{
59
+ * ok: true, langId: ServerLangId, backend: ServerLanguageBackendMeta
60
+ * } | {
61
+ * ok: false, code: string, message: string
62
+ * }}
63
+ */
64
+ export declare function resolveServerLanguage(raw: any): {
65
+ ok: boolean;
66
+ code: string;
67
+ message: string;
68
+ langId?: undefined;
69
+ backend?: undefined;
70
+ } | {
71
+ ok: boolean;
72
+ langId: any;
73
+ backend: any;
74
+ code?: undefined;
75
+ message?: undefined;
76
+ };
77
+ /**
78
+ * @param {ServerLangId} langId
79
+ * @param {string | null | undefined} serverRuntime
80
+ */
81
+ export declare function assertLangRuntimePair(langId: any, serverRuntime: any): {
82
+ ok: boolean;
83
+ code: string;
84
+ message: string;
85
+ } | {
86
+ ok: boolean;
87
+ code?: undefined;
88
+ message?: undefined;
89
+ };
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Server Language DSL backends (lang on `<script server>`).
3
+ * Author surface is a VMZ DSL flavor — not full target-language source.
4
+ */
5
+ // @ts-nocheck
6
+ /** @typedef {'ts' | 'rust' | 'python' | 'java'} ServerLangId */
7
+ export const SERVER_LANG_IDS = Object.freeze(['ts', 'rust', 'python', 'java']);
8
+ /** @type {Record<string, ServerLangId>} */
9
+ export const SERVER_LANG_ALIASES = Object.freeze({
10
+ ts: 'ts',
11
+ typescript: 'ts',
12
+ rust: 'rust',
13
+ python: 'python',
14
+ java: 'java',
15
+ });
16
+ /**
17
+ * @typedef {{
18
+ * langId: ServerLangId,
19
+ * aliases: string[],
20
+ * compatibleRuntimes: string[],
21
+ * implemented: boolean,
22
+ * artifactRoot: string,
23
+ * }} ServerLanguageBackendMeta
24
+ */
25
+ /** @type {Record<ServerLangId, ServerLanguageBackendMeta>} */
26
+ export const SERVER_LANGUAGE_BACKENDS = Object.freeze({
27
+ ts: {
28
+ langId: 'ts',
29
+ aliases: ['ts', 'typescript'],
30
+ compatibleRuntimes: ['node', 'worker', 'deno', 'bun'],
31
+ implemented: true,
32
+ artifactRoot: 'dist/#server',
33
+ },
34
+ rust: {
35
+ langId: 'rust',
36
+ aliases: ['rust'],
37
+ compatibleRuntimes: ['rust-host'],
38
+ implemented: true,
39
+ artifactRoot: 'target/vmz/server-rust',
40
+ },
41
+ python: {
42
+ langId: 'python',
43
+ aliases: ['python'],
44
+ compatibleRuntimes: ['python-host'],
45
+ implemented: false,
46
+ artifactRoot: 'target/vmz/server-python',
47
+ },
48
+ java: {
49
+ langId: 'java',
50
+ aliases: ['java'],
51
+ compatibleRuntimes: ['jvm-host'],
52
+ implemented: false,
53
+ artifactRoot: 'target/vmz/server-java',
54
+ },
55
+ });
56
+ /**
57
+ * Resolve author `lang` attr (or null/undefined for default TS).
58
+ * @param {string | null | undefined} raw
59
+ * @returns {{
60
+ * ok: true, langId: ServerLangId, backend: ServerLanguageBackendMeta
61
+ * } | {
62
+ * ok: false, code: string, message: string
63
+ * }}
64
+ */
65
+ export function resolveServerLanguage(raw) {
66
+ const trimmed = raw == null ? '' : String(raw).trim();
67
+ if (!trimmed) {
68
+ return { ok: true, langId: 'ts', backend: SERVER_LANGUAGE_BACKENDS.ts };
69
+ }
70
+ const langId = SERVER_LANG_ALIASES[trimmed];
71
+ if (!langId) {
72
+ return {
73
+ ok: false,
74
+ code: 'vmz::server::unknown_language',
75
+ message: `unknown script language \`${trimmed}\`; use ts|typescript|rust|python|java`,
76
+ };
77
+ }
78
+ const backend = SERVER_LANGUAGE_BACKENDS[langId];
79
+ if (!backend.implemented) {
80
+ return {
81
+ ok: false,
82
+ code: 'vmz::server::language_backend_unimplemented',
83
+ message: `\`<script server lang="${langId}">\` is registered but not implemented yet`,
84
+ };
85
+ }
86
+ return { ok: true, langId, backend };
87
+ }
88
+ /**
89
+ * @param {ServerLangId} langId
90
+ * @param {string | null | undefined} serverRuntime
91
+ */
92
+ export function assertLangRuntimePair(langId, serverRuntime) {
93
+ const backend = SERVER_LANGUAGE_BACKENDS[langId];
94
+ if (!backend) {
95
+ return {
96
+ ok: false,
97
+ code: 'vmz::server::unknown_language',
98
+ message: `unknown langId ${langId}`,
99
+ };
100
+ }
101
+ if (langId === 'ts') {
102
+ // TS stays compatible with existing node/worker defaults; rust-host rejects ts.
103
+ if (serverRuntime === 'rust-host' || serverRuntime === 'python-host' || serverRuntime === 'jvm-host') {
104
+ return {
105
+ ok: false,
106
+ code: 'vmz::server::lang_runtime_mismatch',
107
+ message: `lang=ts is incompatible with serverRuntime=${serverRuntime}`,
108
+ };
109
+ }
110
+ return { ok: true };
111
+ }
112
+ const rt = String(serverRuntime || '').trim();
113
+ if (!backend.compatibleRuntimes.includes(rt)) {
114
+ return {
115
+ ok: false,
116
+ code: 'vmz::server::lang_runtime_mismatch',
117
+ message: `lang=${langId} requires serverRuntime in [${backend.compatibleRuntimes.join('|')}] (got ${rt || '(none)'})`,
118
+ };
119
+ }
120
+ return { ok: true };
121
+ }
@@ -6,6 +6,7 @@
6
6
  import crypto from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { writePrettyJsonFile } from './pretty-json.js';
9
10
  export const SITE_DELIVERY_CONTRACT_SCHEMA = 'vmz.site.delivery_contract.v0';
10
11
  export const SITE_DELIVERY_RESOLUTION_SCHEMA = 'vmz.site.delivery_resolution.v0';
11
12
  /**
@@ -318,11 +319,11 @@ export function emitSiteDelivery(outDir, deliveryRaw, opts = {}) {
318
319
  }
319
320
  const vmzDir = path.join(outDir, '_vmz');
320
321
  fs.mkdirSync(vmzDir, { recursive: true });
321
- fs.writeFileSync(path.join(vmzDir, 'site-delivery-contract.json'), `${JSON.stringify(norm.contract, null, 2)}\n`, 'utf8');
322
+ writePrettyJsonFile(path.join(vmzDir, 'site-delivery-contract.json'), norm.contract);
322
323
  let resolution = null;
323
324
  if (opts.probes) {
324
325
  resolution = resolveSiteRelease(norm.contract, opts.probes);
325
- fs.writeFileSync(path.join(vmzDir, 'site-delivery-resolution.json'), `${JSON.stringify(resolution, null, 2)}\n`, 'utf8');
326
+ writePrettyJsonFile(path.join(vmzDir, 'site-delivery-resolution.json'), resolution);
326
327
  }
327
328
  return { contract: norm.contract, resolution };
328
329
  }
@@ -29,11 +29,13 @@ export declare function emitWebStatic(distDir: any, opts?: {}): Promise<{
29
29
  chunkId: any;
30
30
  htmlPath: any;
31
31
  classification: any;
32
+ localeId: any;
32
33
  seo: {
33
34
  title: any;
34
35
  description: any;
35
36
  canonical: any;
36
37
  robots: any;
38
+ alternates: any;
37
39
  };
38
40
  }[];
39
41
  skipped: any[];
@@ -75,7 +77,14 @@ export declare function emitWebStatic(distDir: any, opts?: {}): Promise<{
75
77
  };
76
78
  })[];
77
79
  errorDocuments: any;
78
- routes: any;
80
+ routes: any[];
81
+ localeCache: {
82
+ strategy: string;
83
+ varyAcceptLanguage: boolean;
84
+ defaultLocale: any;
85
+ routeCount: number;
86
+ locales: any[];
87
+ };
79
88
  };
80
89
  cdnAdapters: {
81
90
  'local-static': {
@@ -9,6 +9,10 @@ import path from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
10
  import { emitCdnPolicy } from './cdn-policy.js';
11
11
  import { emitContentAddressedAssets } from './content-addressed-assets.js';
12
+ import { absoluteUrl, buildLocalePageMeta, localizeBodyLinks } from './locale-router.js';
13
+ import { requireNativeAddon } from './native-addon.js';
14
+ import { writePrettyJsonFile } from './pretty-json.js';
15
+ import { filePathPatternFromChunk, isRouteBoundaryStem, listPublicPageUnits, parsePathPattern, unitBrowserPathPattern, } from './route-path.js';
12
16
  export const STATIC_DELIVERY_MANIFEST_SCHEMA = 'vmz.static.delivery_manifest.v0';
13
17
  /**
14
18
  * @param {string} distDir
@@ -42,7 +46,7 @@ export async function emitWebStatic(distDir, opts = {}) {
42
46
  /** @type {Array<{ routeId: string, path: string, chunkId: string, classification: string, reason: string }>} */
43
47
  const skipped = [];
44
48
  for (const page of pageCatalog) {
45
- const pattern = patternFromSegs(page.segs);
49
+ const pattern = page.pathPattern || patternFromSegs(page.segs);
46
50
  const routeId = guessRouteId(distDir, page.chunkId);
47
51
  if (page.segs.some((s) => s.kind === 'param' || s.kind === 'catch')) {
48
52
  skipped.push({
@@ -104,29 +108,44 @@ export async function emitWebStatic(distDir, opts = {}) {
104
108
  continue;
105
109
  bodyHtml = await renderToString(Layout, {}, { slotHtml: bodyHtml });
106
110
  }
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,
111
+ const localeArt = loadLocaleArtifact(distDir);
112
+ // Locale artifact routeId is chunkId (`pages/about`), not the page class name.
113
+ const localeWrites = expandLocaleStaticGenerations({
114
+ localeArt,
115
+ routeId: page.chunkId,
112
116
  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,
117
+ pattern,
118
+ origin,
119
+ baseMeta: meta,
129
120
  });
121
+ for (const gen of localeWrites) {
122
+ const absHtml = path.join(distDir, gen.htmlPath);
123
+ fs.mkdirSync(path.dirname(absHtml), { recursive: true });
124
+ // Each LocaleId HTML must retain locale on same-app Links (realization authority).
125
+ const localizedBody = gen.localeId && localeArt ? localizeBodyLinks(bodyHtml, gen.localeId, localeArt) : bodyHtml;
126
+ const html = wrapDocument({
127
+ bodyHtml: localizedBody,
128
+ chunkId: page.chunkId,
129
+ layoutChain,
130
+ props,
131
+ meta: gen.meta,
132
+ cssEntry: readCssEntry(distDir),
133
+ });
134
+ fs.writeFileSync(absHtml, html, 'utf8');
135
+ generations.push({
136
+ routeId,
137
+ path: gen.path,
138
+ chunkId: page.chunkId,
139
+ htmlPath: gen.htmlPath.replaceAll('\\', '/'),
140
+ classification: 'Static',
141
+ title: gen.meta.title,
142
+ description: meta.description,
143
+ canonical: gen.meta.canonical,
144
+ robots: gen.meta.robots,
145
+ localeId: gen.localeId || null,
146
+ alternates: Array.isArray(gen.meta.alternates) ? gen.meta.alternates : [],
147
+ });
148
+ }
130
149
  }
131
150
  const notFoundHtml = wrapDocument({
132
151
  bodyHtml: '<main><h1>Not Found</h1><p>route-static-404</p></main>',
@@ -146,7 +165,11 @@ export async function emitWebStatic(distDir, opts = {}) {
146
165
  fs.writeFileSync(path.join(distDir, '404.html'), notFoundHtml, 'utf8');
147
166
  const sitemap = buildSitemap(origin, generations);
148
167
  fs.writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap, 'utf8');
149
- const robots = `User-agent: *\nAllow: /\nDisallow: /404\nSitemap: ${origin}/sitemap.xml\n`;
168
+ const nativeRobots = requireNativeAddon();
169
+ if (typeof nativeRobots.generateRobotsTxt !== 'function') {
170
+ throw new Error('vmz native addon missing generateRobotsTxt — rebuild with `pnpm napi:build`');
171
+ }
172
+ const robots = nativeRobots.generateRobotsTxt(`${origin}/sitemap.xml`);
150
173
  fs.writeFileSync(path.join(distDir, 'robots.txt'), robots, 'utf8');
151
174
  // Hard rule: no SPA fallback shim in artifact.
152
175
  for (const bad of ['_redirects', 'vercel.json', 'netlify.toml']) {
@@ -174,11 +197,13 @@ export async function emitWebStatic(distDir, opts = {}) {
174
197
  chunkId: g.chunkId,
175
198
  htmlPath: g.htmlPath,
176
199
  classification: g.classification,
200
+ localeId: g.localeId || null,
177
201
  seo: {
178
202
  title: g.title,
179
203
  description: g.description,
180
204
  canonical: g.canonical,
181
205
  robots: g.robots,
206
+ alternates: g.alternates || [],
182
207
  },
183
208
  })),
184
209
  skipped,
@@ -189,7 +214,7 @@ export async function emitWebStatic(distDir, opts = {}) {
189
214
  };
190
215
  const digest = sha256Hex(canonicalJson(manifest));
191
216
  manifest.manifestDigest = digest;
192
- fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
217
+ writePrettyJsonFile(path.join(vmzDir, 'static-delivery-manifest.json'), manifest);
193
218
  const assets = emitContentAddressedAssets(distDir);
194
219
  manifest.contentAddressedAssets = {
195
220
  schema: assets.manifest.schema,
@@ -200,7 +225,7 @@ export async function emitWebStatic(distDir, opts = {}) {
200
225
  // Re-stamp static manifest after linking asset digest (HTML already rewritten on disk).
201
226
  delete manifest.manifestDigest;
202
227
  manifest.manifestDigest = sha256Hex(canonicalJson(manifest));
203
- fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
228
+ writePrettyJsonFile(path.join(vmzDir, 'static-delivery-manifest.json'), manifest);
204
229
  const cdn = emitCdnPolicy(distDir, manifest);
205
230
  return {
206
231
  manifest,
@@ -240,8 +265,11 @@ function sortKeys(value) {
240
265
  * @param {string} distDir
241
266
  */
242
267
  function listPageClientFiles(distDir) {
268
+ const fromDep = listPagesFromDeployment(distDir);
269
+ if (fromDep.length)
270
+ return fromDep;
243
271
  const root = path.join(distDir, 'pages');
244
- /** @type {Array<{ chunkId: string, segs: ReturnType<typeof parseChunkSegments> }>} */
272
+ /** @type {Array<{ chunkId: string, segs: ReturnType<typeof parsePathPattern>, pathPattern: string }>} */
245
273
  const out = [];
246
274
  function walk(abs, relParts) {
247
275
  let ents;
@@ -256,10 +284,11 @@ function listPageClientFiles(distDir) {
256
284
  walk(path.join(abs, e.name), [...relParts, e.name]);
257
285
  else if (e.isFile() && e.name.endsWith('.client.js')) {
258
286
  const stem = e.name.replace(/\.client\.js$/, '');
259
- if (stem === 'Layout' || stem === 'Loading' || stem === 'Error' || stem === 'NotFound')
287
+ if (isRouteBoundaryStem(stem))
260
288
  continue;
261
289
  const chunkId = ['pages', ...relParts, stem].join('/');
262
- out.push({ chunkId, segs: parseChunkSegments(chunkId) });
290
+ const pathPattern = filePathPatternFromChunk(chunkId);
291
+ out.push({ chunkId, pathPattern, segs: parsePathPattern(pathPattern) });
263
292
  }
264
293
  }
265
294
  }
@@ -267,32 +296,26 @@ function listPageClientFiles(distDir) {
267
296
  return out;
268
297
  }
269
298
  /**
270
- * @param {string} chunkId
299
+ * @param {string} distDir
271
300
  */
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() });
301
+ function listPagesFromDeployment(distDir) {
302
+ const deploymentPath = path.join(distDir, 'vmz-deployment.json');
303
+ if (!fs.existsSync(deploymentPath))
304
+ return [];
305
+ try {
306
+ const deployment = JSON.parse(fs.readFileSync(deploymentPath, 'utf8'));
307
+ return listPublicPageUnits(deployment).map((u) => {
308
+ const chunkId = String(u.chunkId || '').replace(/\\/g, '/');
309
+ const pathPattern = unitBrowserPathPattern(u);
310
+ return { chunkId, pathPattern, segs: parsePathPattern(pathPattern) };
311
+ });
312
+ }
313
+ catch {
314
+ return [];
291
315
  }
292
- return segs;
293
316
  }
294
317
  /**
295
- * @param {ReturnType<typeof parseChunkSegments>} segs
318
+ * @param {ReturnType<typeof parsePathPattern>} segs
296
319
  */
297
320
  function patternFromSegs(segs) {
298
321
  if (!segs.length)
@@ -371,12 +394,96 @@ async function resolvePageMeta(Page, ctx) {
371
394
  else if (Page.meta && typeof Page.meta === 'object') {
372
395
  raw = Page.meta;
373
396
  }
374
- const title = String(raw.title || `${guessTitle(ctx.pathname)} · VMZ`);
375
- const description = String(raw.description || `VMZ page ${ctx.pathname}`);
397
+ const title = String(raw.title || guessTitle(ctx.pathname) || 'App');
398
+ const description = String(raw.description || '');
376
399
  const canonical = String(raw.canonical || `${ctx.origin}${ctx.pathname === '/' ? '/' : ctx.pathname}`);
377
400
  const robots = String(raw.robots || 'index,follow');
378
401
  const lang = String(raw.lang || 'en');
379
- return { title, description, canonical, robots, lang };
402
+ return { title, description, canonical, robots, lang, alternates: [] };
403
+ }
404
+ /**
405
+ * @param {string} distDir
406
+ */
407
+ function loadLocaleArtifact(distDir) {
408
+ const p = path.join(distDir, '_vmz', 'locale-route-realization.json');
409
+ if (!fs.existsSync(p))
410
+ return null;
411
+ try {
412
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
413
+ }
414
+ catch {
415
+ return null;
416
+ }
417
+ }
418
+ /**
419
+ * Expand one Static route across LocaleId realizations (hreflang seed + prefixed HTML).
420
+ * @param {{
421
+ * localeArt: any,
422
+ * routeId: string,
423
+ * chunkId: string,
424
+ * pattern: string,
425
+ * origin: string,
426
+ * baseMeta: { title: string, description: string, canonical: string, robots: string, lang: string, alternates?: any[] },
427
+ * }} input
428
+ */
429
+ function expandLocaleStaticGenerations(input) {
430
+ const { localeArt, routeId, pattern, origin, baseMeta } = input;
431
+ if (!localeArt?.realizations?.length) {
432
+ return [
433
+ {
434
+ path: pattern,
435
+ htmlPath: htmlPathForRoute(pattern),
436
+ localeId: null,
437
+ meta: baseMeta,
438
+ },
439
+ ];
440
+ }
441
+ const locales = (localeArt.locales || []).map((l) => l.id);
442
+ const directions = Object.fromEntries((localeArt.locales || []).map((l) => [l.id, l.direction || 'ltr']));
443
+ const defaultLocale = localeArt.defaultLocale || locales[0];
444
+ const forRoute = (localeArt.realizations || []).filter((r) => r.routeId === routeId || r.routeId === input.chunkId || r.pathPattern === pattern || (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
+ ];
380
487
  }
381
488
  function guessTitle(pathname) {
382
489
  if (pathname === '/')
@@ -405,59 +512,47 @@ function readCssEntry(distDir) {
405
512
  * chunkId: string,
406
513
  * layoutChain: string[],
407
514
  * props: Record<string, unknown>,
408
- * meta: { title: string, description: string, canonical: string, robots: string, lang: string },
515
+ * meta: { title: string, description: string, canonical: string, robots: string, lang: string, dir?: string, alternates?: Array<{ hreflang: string, href: string }> },
409
516
  * cssEntry: string | null,
410
517
  * isErrorDocument?: boolean,
411
518
  * }} input
412
519
  */
413
520
  function wrapDocument(input) {
414
521
  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
- `;
522
+ const localeId = input.meta.lang || 'en';
523
+ const dir = input.meta.dir || 'ltr';
524
+ const native = requireNativeAddon();
525
+ if (typeof native.generatePageShell !== 'function') {
526
+ throw new Error('vmz native addon missing generatePageShell — rebuild with `pnpm napi:build`');
527
+ }
528
+ return native.generatePageShell({
529
+ bodyHtml: input.bodyHtml,
530
+ chunkId: input.chunkId || '',
531
+ layoutChain: input.layoutChain || [],
532
+ propsJson,
533
+ meta: {
534
+ title: input.meta.title,
535
+ description: input.meta.description,
536
+ canonical: input.meta.canonical,
537
+ robots: input.meta.robots,
538
+ lang: localeId,
539
+ dir,
540
+ alternates: input.meta.alternates || [],
541
+ },
542
+ // napi Option<String>: omit/undefined = None; null is rejected as String
543
+ ...(input.cssEntry ? { cssEntry: String(input.cssEntry) } : {}),
544
+ isErrorDocument: !!input.isErrorDocument,
545
+ });
437
546
  }
438
547
  /**
439
548
  * @param {string} origin
440
549
  * @param {Array<{ canonical: string, robots: string }>} generations
441
550
  */
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;');
551
+ function buildSitemap(_origin, generations) {
552
+ const urls = generations.filter((g) => !String(g.robots).includes('noindex')).map((g) => ({ loc: g.canonical }));
553
+ const native = requireNativeAddon();
554
+ if (typeof native.generateSitemapXml !== 'function') {
555
+ throw new Error('vmz native addon missing generateSitemapXml — rebuild with `pnpm napi:build`');
556
+ }
557
+ return native.generateSitemapXml(urls);
463
558
  }
package/dist/test-cmd.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import path from 'node:path';
7
7
  import { createWorkspace } from './index.js';
8
8
  import { log } from './log.js';
9
+ import { generatePrettyJson } from './pretty-json.js';
9
10
  /**
10
11
  * @returns {Promise<typeof import('@vmz/test')>}
11
12
  */
@@ -317,7 +318,7 @@ export async function cmdTest(args) {
317
318
  return errors.length ? 2 : 0;
318
319
  }
319
320
  if (wantJson) {
320
- const text = `${JSON.stringify(report, null, 2)}\n`;
321
+ const text = `${generatePrettyJson(report)}\n`;
321
322
  if (typeof args.json === 'string' && args.json !== 'true') {
322
323
  const { writeFileSync } = await import('node:fs');
323
324
  writeFileSync(path.resolve(String(args.json)), text);