@vmz/vmz 0.1.11 → 0.1.13

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 (43) hide show
  1. package/dist/build-assemble.js +3 -4
  2. package/dist/cdn-policy.js +4 -5
  3. package/dist/cli.js +15 -50
  4. package/dist/content-addressed-assets.d.ts +2 -8
  5. package/dist/content-addressed-assets.js +34 -110
  6. package/dist/delivery-profile.d.ts +1 -11
  7. package/dist/delivery-profile.js +1 -70
  8. package/dist/dev-session.d.ts +0 -1
  9. package/dist/dev-session.js +64 -220
  10. package/dist/document-build.js +34 -61
  11. package/dist/document-cmd.js +9 -2
  12. package/dist/document-enrich.d.ts +1 -2
  13. package/dist/document-enrich.js +4 -15
  14. package/dist/document-integrate.js +21 -28
  15. package/dist/embedded-packaging.js +1 -2
  16. package/dist/index.d.ts +1 -15
  17. package/dist/index.js +3 -58
  18. package/dist/locale-check.js +61 -28
  19. package/dist/locale-cmd.js +42 -10
  20. package/dist/locale-route-emit.d.ts +1 -4
  21. package/dist/locale-route-emit.js +32 -7
  22. package/dist/locale-router.js +8 -26
  23. package/dist/pack.js +2 -3
  24. package/dist/production-observability.js +2 -3
  25. package/dist/production-test-pack.js +3 -4
  26. package/dist/release-pack.js +18 -5
  27. package/dist/server-artifact.js +3 -4
  28. package/dist/site-delivery.js +3 -4
  29. package/dist/static-emit.js +78 -116
  30. package/dist/test-cmd.js +1 -2
  31. package/package.json +12 -12
  32. package/dist/dev-watch-roots.d.ts +0 -67
  33. package/dist/dev-watch-roots.js +0 -220
  34. package/dist/document-host-chrome.d.ts +0 -28
  35. package/dist/document-host-chrome.js +0 -128
  36. package/dist/native-addon.d.ts +0 -9
  37. package/dist/native-addon.js +0 -84
  38. package/dist/pretty-json.d.ts +0 -19
  39. package/dist/pretty-json.js +0 -43
  40. package/dist/route-path.d.ts +0 -35
  41. package/dist/route-path.js +0 -77
  42. package/dist/wechat-packaging.d.ts +0 -22
  43. package/dist/wechat-packaging.js +0 -59
@@ -7,9 +7,34 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { checkLocales, localeHasErrors } from './locale-check.js';
9
9
  import { buildLocalePageMeta, buildLocaleRouteRealizationTable } from './locale-router.js';
10
- import { writePrettyJsonFile } from './pretty-json.js';
11
- import { listPublicPageUnits, unitBrowserPathPattern } from './route-path.js';
12
10
  export const LOCALE_ROUTE_REALIZATION_ARTIFACT_SCHEMA = 'vmz.locale.route_realization.v0';
11
+ /**
12
+ * @param {string} chunkId
13
+ */
14
+ function pathPatternFromChunk(chunkId) {
15
+ const rel = String(chunkId || '').replace(/^pages\//, '');
16
+ const parts = rel.split('/').filter(Boolean);
17
+ const segs = [];
18
+ for (let i = 0; i < parts.length; i++) {
19
+ const p = parts[i];
20
+ if (p === 'index' && i === parts.length - 1)
21
+ continue;
22
+ segs.push(p);
23
+ }
24
+ return segs.length ? `/${segs.join('/')}` : '/';
25
+ }
26
+ /**
27
+ * Layouts are not public RouteNodes for locale realization / SEO.
28
+ * @param {string} chunkId
29
+ */
30
+ function isPublicPageChunk(chunkId) {
31
+ const id = String(chunkId || '');
32
+ if (!id.startsWith('pages/'))
33
+ return false;
34
+ if (/(^|\/)Layout$/.test(id))
35
+ return false;
36
+ return true;
37
+ }
13
38
  /**
14
39
  * @param {string} projectRoot
15
40
  * @param {string} distDir
@@ -34,10 +59,10 @@ export function emitLocaleRouteRealization(projectRoot, distDir, opts = {}) {
34
59
  };
35
60
  }
36
61
  const deployment = JSON.parse(fs.readFileSync(deploymentPath, 'utf8'));
37
- const pages = listPublicPageUnits(deployment);
62
+ const pages = (deployment.units || []).filter((u) => u.kind === 'page' && isPublicPageChunk(String(u.chunkId)));
38
63
  const routes = pages.map((u) => ({
39
64
  routeId: String(u.chunkId),
40
- path: unitBrowserPathPattern(u),
65
+ path: pathPatternFromChunk(String(u.chunkId)),
41
66
  }));
42
67
  const localeEntries = report.manifest?.locales || [];
43
68
  const locales = localeEntries.map((l) => l.id);
@@ -92,14 +117,14 @@ export function emitLocaleRouteRealization(projectRoot, distDir, opts = {}) {
92
117
  const vmzDir = path.join(distDir, '_vmz');
93
118
  fs.mkdirSync(vmzDir, { recursive: true });
94
119
  const outPath = path.join(vmzDir, 'locale-route-realization.json');
95
- writePrettyJsonFile(outPath, artifact);
120
+ fs.writeFileSync(outPath, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
96
121
  const manifestOut = path.join(vmzDir, 'locale-manifest.json');
97
- writePrettyJsonFile(manifestOut, {
122
+ fs.writeFileSync(manifestOut, `${JSON.stringify({
98
123
  schema: 'vmz.locale.manifest.v0',
99
124
  defaultLocale,
100
125
  locales: artifact.locales,
101
126
  routing: artifact.routing,
102
- });
127
+ }, null, 2)}\n`, 'utf8');
103
128
  return {
104
129
  ok: true,
105
130
  written: ['_vmz/locale-route-realization.json', '_vmz/locale-manifest.json'],
@@ -90,34 +90,16 @@ export function buildLocaleRouteRealizationTable(input) {
90
90
  pathPattern: normalizePath(route.path),
91
91
  };
92
92
  realizations.push(entry);
93
- // `none` / `domain`: LocaleId is Host preference — many locales share one URL path.
94
- // Collision only when *different RouteIds* claim the same path.
95
- if (routing.strategy === 'none' || routing.strategy === 'domain') {
96
- const prevRoute = pathOwners.get(entry.path);
97
- if (prevRoute && prevRoute !== route.routeId) {
98
- diagnostics.push({
99
- code: DIAG_LOCALE_ROUTE_COLLISION,
100
- severity: 'error',
101
- message: `path ${entry.path} claimed by ${prevRoute} and ${route.routeId}`,
102
- });
103
- }
104
- else {
105
- pathOwners.set(entry.path, route.routeId);
106
- }
93
+ const prev = pathOwners.get(entry.path);
94
+ if (prev && prev !== `${route.routeId}@${localeId}`) {
95
+ diagnostics.push({
96
+ code: DIAG_LOCALE_ROUTE_COLLISION,
97
+ severity: 'error',
98
+ message: `path ${entry.path} claimed by ${prev} and ${route.routeId}@${localeId}`,
99
+ });
107
100
  }
108
101
  else {
109
- const owner = `${route.routeId}@${localeId}`;
110
- const prev = pathOwners.get(entry.path);
111
- if (prev && prev !== owner) {
112
- diagnostics.push({
113
- code: DIAG_LOCALE_ROUTE_COLLISION,
114
- severity: 'error',
115
- message: `path ${entry.path} claimed by ${prev} and ${owner}`,
116
- });
117
- }
118
- else {
119
- pathOwners.set(entry.path, owner);
120
- }
102
+ pathOwners.set(entry.path, `${route.routeId}@${localeId}`);
121
103
  }
122
104
  }
123
105
  }
package/dist/pack.js CHANGED
@@ -5,11 +5,10 @@
5
5
  */
6
6
  // @ts-nocheck
7
7
  import crypto from 'node:crypto';
8
- import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { loadDeploymentIr, planBundleInputs } from './bundler-adapter.js';
11
11
  import { packClientBareImports } from './pack-client-packages.js';
12
- import { writePrettyJsonFile } from './pretty-json.js';
13
12
  export const PACK_MANIFEST_SCHEMA = 'vmz.pack.manifest.v0';
14
13
  /**
15
14
  * Ensure dom split companions sit next to vmz-dom.js (barrel imports ./dom-core.js).
@@ -101,7 +100,7 @@ export function packFromDeploymentIr(outDir, opts = {}) {
101
100
  const vmzDir = path.join(outDir, '_vmz');
102
101
  mkdirSync(vmzDir, { recursive: true });
103
102
  const file = path.join(vmzDir, 'pack-manifest.json');
104
- writePrettyJsonFile(file, body);
103
+ writeFileSync(file, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
105
104
  return { manifest: body, path: file, clientPackages };
106
105
  }
107
106
  function stableStringify(value) {
@@ -7,7 +7,6 @@ import crypto from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { canonicalJson, sha256Hex } from './release-pack.js';
10
- import { writePrettyJsonFile } from './pretty-json.js';
11
10
  export const PRODUCTION_OBSERVABILITY_SCHEMA = 'vmz.production.observability.v0';
12
11
  export const PRODUCTION_TRACE_SCHEMA = 'vmz.production.trace.v0';
13
12
  /** Facets that production traces must be able to carry (08 A5). */
@@ -458,8 +457,8 @@ export function emitProductionObservability(distDir, overrides = {}, meta = {})
458
457
  fs.mkdirSync(vmzDir, { recursive: true });
459
458
  const contractPath = path.join(vmzDir, 'production-observability.json');
460
459
  const tracePath = path.join(vmzDir, 'production-trace.sample.json');
461
- writePrettyJsonFile(contractPath, contract);
462
- writePrettyJsonFile(tracePath, trace);
460
+ fs.writeFileSync(contractPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
461
+ fs.writeFileSync(tracePath, `${JSON.stringify(trace, null, 2)}\n`, 'utf8');
463
462
  return { contract, trace, contractPath, tracePath };
464
463
  }
465
464
  export function observabilityDigest(contract) {
@@ -8,7 +8,6 @@ import crypto from 'node:crypto';
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { canonicalJson, sha256Hex } from './release-pack.js';
11
- import { writePrettyJsonFile } from './pretty-json.js';
12
11
  export const PRODUCTION_SCENARIO_PACK_SCHEMA = 'vmz.production.scenario_pack.v0';
13
12
  export const PRODUCTION_CI_PROFILE_SCHEMA = 'vmz.production.ci_profile.v0';
14
13
  export const PRODUCTION_TEST_REPORT_SCHEMA = 'vmz.production.test_report.v0';
@@ -432,9 +431,9 @@ export function emitProductionTestArtifacts(root, report, pack, profile) {
432
431
  const reportPath = path.join(dir, 'report.json');
433
432
  const packPath = path.join(dir, 'scenario-pack.json');
434
433
  const profilePath = path.join(dir, 'ci-profile.json');
435
- writePrettyJsonFile(reportPath, stamped);
436
- writePrettyJsonFile(packPath, pack);
437
- writePrettyJsonFile(profilePath, profile);
434
+ fs.writeFileSync(reportPath, `${JSON.stringify(stamped, null, 2)}\n`, 'utf8');
435
+ fs.writeFileSync(packPath, `${JSON.stringify(pack, null, 2)}\n`, 'utf8');
436
+ fs.writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
438
437
  return { reportPath, packPath, profilePath, report: stamped };
439
438
  }
440
439
  /** Assert CI profile forbids JS test-runner disguise. */
@@ -9,8 +9,6 @@
9
9
  import crypto from 'node:crypto';
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
- import { writePrettyJsonFile } from './pretty-json.js';
13
- import { listPublicPageUnits, unitBrowserPathPattern } from './route-path.js';
14
12
  export const RELEASE_ENVELOPE_SCHEMA = 'vmz.release.envelope.v0';
15
13
  export const APPLICATION_ARTIFACT_SCHEMA = 'vmz.application.artifact.v0';
16
14
  export const DELIVERY_ARTIFACT_MANIFEST_SCHEMA = 'vmz.profile.delivery_artifact_manifest.v0';
@@ -90,6 +88,21 @@ function listContentFiles(distDir) {
90
88
  out.sort();
91
89
  return out;
92
90
  }
91
+ /**
92
+ * @param {string} chunkId
93
+ */
94
+ function pathPatternFromChunk(chunkId) {
95
+ const rel = chunkId.replace(/^pages\//, '');
96
+ const parts = rel.split('/').filter(Boolean);
97
+ const segs = [];
98
+ for (let i = 0; i < parts.length; i++) {
99
+ const p = parts[i];
100
+ if (p === 'index' && i === parts.length - 1)
101
+ continue;
102
+ segs.push(p);
103
+ }
104
+ return segs.length ? `/${segs.join('/')}` : '/';
105
+ }
93
106
  /**
94
107
  * Pack `dist/` into `_vmz` manifests + release envelope (filesystem Delivery Profile).
95
108
  * @param {string} distDir
@@ -111,13 +124,13 @@ export function packRelease(distDir, opts = {}) {
111
124
  for (const rel of files) {
112
125
  fileDigests[rel] = sha256File(path.join(abs, ...rel.split('/')));
113
126
  }
114
- const pages = listPublicPageUnits(deployment);
127
+ const pages = (deployment.units || []).filter((u) => u.kind === 'page');
115
128
  const routeRealization = {
116
129
  schema: ROUTE_REALIZATION_TABLE_SCHEMA,
117
130
  routes: pages.map((u) => ({
118
131
  routeId: String(u.chunkId),
119
132
  chunkId: String(u.chunkId),
120
- pathPattern: unitBrowserPathPattern(u),
133
+ pathPattern: pathPatternFromChunk(String(u.chunkId)),
121
134
  clientEntry: u.clientEntry || null,
122
135
  programIr: u.programIr || null,
123
136
  })),
@@ -187,7 +200,7 @@ export function packRelease(distDir, opts = {}) {
187
200
  * @param {unknown} value
188
201
  */
189
202
  function writeJson(file, value) {
190
- writePrettyJsonFile(file, value);
203
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
191
204
  }
192
205
  /**
193
206
  * @param {string} pointerPath
@@ -4,10 +4,9 @@
4
4
  */
5
5
  // @ts-nocheck
6
6
  import crypto from 'node:crypto';
7
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { SERVER_RUNTIMES } from './delivery-profile.js';
10
- import { writePrettyJsonFile } from './pretty-json.js';
11
10
  export const SERVER_ARTIFACT_SCHEMA = 'vmz.server.artifact.v0';
12
11
  export const HTTP_CONTRACT_SCHEMA = 'vmz.http.contract.v0';
13
12
  export const SERVER_RUNTIME_ADAPTER_SCHEMA = 'vmz.server.runtime_adapter.v0';
@@ -117,14 +116,14 @@ export function emitServerArtifact(outDir, opts = {}) {
117
116
  const vmzDir = path.join(outDir, '_vmz');
118
117
  mkdirSync(vmzDir, { recursive: true });
119
118
  const file = path.join(vmzDir, 'server-artifact.json');
120
- writePrettyJsonFile(file, artifact);
119
+ writeFileSync(file, `${JSON.stringify(artifact, null, 2)}\n`, 'utf8');
121
120
  const adapterDir = path.join(vmzDir, 'adapters');
122
121
  mkdirSync(adapterDir, { recursive: true });
123
122
  for (const adapterId of ['worker', 'rust-host']) {
124
123
  const projection = projectServerRuntimeAdapter(artifact, adapterId);
125
124
  const dir = path.join(adapterDir, adapterId);
126
125
  mkdirSync(dir, { recursive: true });
127
- writePrettyJsonFile(path.join(dir, 'adapter.json'), projection);
126
+ writeFileSync(path.join(dir, 'adapter.json'), `${JSON.stringify(projection, null, 2)}\n`, 'utf8');
128
127
  }
129
128
  return { artifact, path: file, httpContractDigest };
130
129
  }
@@ -6,7 +6,6 @@
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';
10
9
  export const SITE_DELIVERY_CONTRACT_SCHEMA = 'vmz.site.delivery_contract.v0';
11
10
  export const SITE_DELIVERY_RESOLUTION_SCHEMA = 'vmz.site.delivery_resolution.v0';
12
11
  /**
@@ -136,7 +135,7 @@ export function normalizeSiteDelivery(raw, opts = {}) {
136
135
  artifact: String(d.artifact),
137
136
  expectedCompatibility: d.expectedCompatibility || {
138
137
  runtime: 'vmz',
139
- deliveryProfiles: ['static', 'filesystem'],
138
+ deliveryProfiles: ['web-static', 'filesystem'],
140
139
  },
141
140
  sources,
142
141
  resolutionPolicy: {
@@ -319,11 +318,11 @@ export function emitSiteDelivery(outDir, deliveryRaw, opts = {}) {
319
318
  }
320
319
  const vmzDir = path.join(outDir, '_vmz');
321
320
  fs.mkdirSync(vmzDir, { recursive: true });
322
- writePrettyJsonFile(path.join(vmzDir, 'site-delivery-contract.json'), norm.contract);
321
+ fs.writeFileSync(path.join(vmzDir, 'site-delivery-contract.json'), `${JSON.stringify(norm.contract, null, 2)}\n`, 'utf8');
323
322
  let resolution = null;
324
323
  if (opts.probes) {
325
324
  resolution = resolveSiteRelease(norm.contract, opts.probes);
326
- writePrettyJsonFile(path.join(vmzDir, 'site-delivery-resolution.json'), resolution);
325
+ fs.writeFileSync(path.join(vmzDir, 'site-delivery-resolution.json'), `${JSON.stringify(resolution, null, 2)}\n`, 'utf8');
327
326
  }
328
327
  return { contract: norm.contract, resolution };
329
328
  }
@@ -7,14 +7,9 @@ import crypto from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
- import { createRenderHost } from '@vmz/core/render-host';
11
- import { listClientComponentsSync } from '@vmz/core/component-registry';
12
10
  import { emitCdnPolicy } from './cdn-policy.js';
13
11
  import { emitContentAddressedAssets } from './content-addressed-assets.js';
14
12
  import { absoluteUrl, buildLocalePageMeta, localizeBodyLinks } from './locale-router.js';
15
- import { requireNativeAddon } from './native-addon.js';
16
- import { writePrettyJsonFile } from './pretty-json.js';
17
- import { filePathPatternFromChunk, isRouteBoundaryStem, listPublicPageUnits, parsePathPattern, unitBrowserPathPattern } from './route-path.js';
18
13
  export const STATIC_DELIVERY_MANIFEST_SCHEMA = 'vmz.static.delivery_manifest.v0';
19
14
  /**
20
15
  * @param {string} distDir
@@ -31,8 +26,7 @@ export async function emitWebStatic(distDir, opts = {}) {
31
26
  if (!fs.existsSync(domPath)) {
32
27
  throw new Error(`emitWebStatic: missing ${domPath} — run vmz build first`);
33
28
  }
34
- const host = await createRenderHost(distDir, { strictDeployment: true, preload: 'none' });
35
- const { renderToString, renderToStream } = host;
29
+ const { renderToString, renderToStream } = await import(pathToFileURL(domPath).href);
36
30
  const pageCatalog = listPageClientFiles(distDir);
37
31
  /** @type {Array<{
38
32
  * routeId: string,
@@ -49,7 +43,7 @@ export async function emitWebStatic(distDir, opts = {}) {
49
43
  /** @type {Array<{ routeId: string, path: string, chunkId: string, classification: string, reason: string }>} */
50
44
  const skipped = [];
51
45
  for (const page of pageCatalog) {
52
- const pattern = page.pathPattern || patternFromSegs(page.segs);
46
+ const pattern = patternFromSegs(page.segs);
53
47
  const routeId = guessRouteId(distDir, page.chunkId);
54
48
  if (page.segs.some((s) => s.kind === 'param' || s.kind === 'catch')) {
55
49
  skipped.push({
@@ -101,7 +95,6 @@ export async function emitWebStatic(distDir, opts = {}) {
101
95
  }
102
96
  const meta = await resolvePageMeta(Page, { params, props, pathname: pattern, origin });
103
97
  const layoutChain = resolveLayoutChain(distDir, page.chunkId);
104
- await host.ensureComponents([page.chunkId, ...layoutChain]);
105
98
  let bodyHtml = '';
106
99
  for await (const chunk of renderToStream(Page, props, {})) {
107
100
  bodyHtml += chunk;
@@ -169,11 +162,7 @@ export async function emitWebStatic(distDir, opts = {}) {
169
162
  fs.writeFileSync(path.join(distDir, '404.html'), notFoundHtml, 'utf8');
170
163
  const sitemap = buildSitemap(origin, generations);
171
164
  fs.writeFileSync(path.join(distDir, 'sitemap.xml'), sitemap, 'utf8');
172
- const nativeRobots = requireNativeAddon();
173
- if (typeof nativeRobots.generateRobotsTxt !== 'function') {
174
- throw new Error('vmz native addon missing generateRobotsTxt — rebuild with `pnpm napi:build`');
175
- }
176
- const robots = nativeRobots.generateRobotsTxt(`${origin}/sitemap.xml`);
165
+ const robots = `User-agent: *\nAllow: /\nDisallow: /404\nSitemap: ${origin}/sitemap.xml\n`;
177
166
  fs.writeFileSync(path.join(distDir, 'robots.txt'), robots, 'utf8');
178
167
  // Hard rule: no SPA fallback shim in artifact.
179
168
  for (const bad of ['_redirects', 'vercel.json', 'netlify.toml']) {
@@ -190,7 +179,7 @@ export async function emitWebStatic(distDir, opts = {}) {
190
179
  const manifest = {
191
180
  schema: STATIC_DELIVERY_MANIFEST_SCHEMA,
192
181
  applicationId,
193
- deliveryProfile: 'static',
182
+ deliveryProfile: 'web-static',
194
183
  origin,
195
184
  generatedAt: new Date().toISOString(),
196
185
  spaFallback: false,
@@ -218,8 +207,7 @@ export async function emitWebStatic(distDir, opts = {}) {
218
207
  };
219
208
  const digest = sha256Hex(canonicalJson(manifest));
220
209
  manifest.manifestDigest = digest;
221
- writePrettyJsonFile(path.join(vmzDir, 'static-delivery-manifest.json'), manifest);
222
- emitStaticClientEntries(distDir, pageCatalog);
210
+ fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
223
211
  const assets = emitContentAddressedAssets(distDir);
224
212
  manifest.contentAddressedAssets = {
225
213
  schema: assets.manifest.schema,
@@ -230,7 +218,7 @@ export async function emitWebStatic(distDir, opts = {}) {
230
218
  // Re-stamp static manifest after linking asset digest (HTML already rewritten on disk).
231
219
  delete manifest.manifestDigest;
232
220
  manifest.manifestDigest = sha256Hex(canonicalJson(manifest));
233
- writePrettyJsonFile(path.join(vmzDir, 'static-delivery-manifest.json'), manifest);
221
+ fs.writeFileSync(path.join(vmzDir, 'static-delivery-manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
234
222
  const cdn = emitCdnPolicy(distDir, manifest);
235
223
  return {
236
224
  manifest,
@@ -270,11 +258,8 @@ function sortKeys(value) {
270
258
  * @param {string} distDir
271
259
  */
272
260
  function listPageClientFiles(distDir) {
273
- const fromDep = listPagesFromDeployment(distDir);
274
- if (fromDep.length)
275
- return fromDep;
276
261
  const root = path.join(distDir, 'pages');
277
- /** @type {Array<{ chunkId: string, segs: ReturnType<typeof parsePathPattern>, pathPattern: string }>} */
262
+ /** @type {Array<{ chunkId: string, segs: ReturnType<typeof parseChunkSegments> }>} */
278
263
  const out = [];
279
264
  function walk(abs, relParts) {
280
265
  let ents;
@@ -289,11 +274,10 @@ function listPageClientFiles(distDir) {
289
274
  walk(path.join(abs, e.name), [...relParts, e.name]);
290
275
  else if (e.isFile() && e.name.endsWith('.client.js')) {
291
276
  const stem = e.name.replace(/\.client\.js$/, '');
292
- if (isRouteBoundaryStem(stem))
277
+ if (stem === 'Layout' || stem === 'Loading' || stem === 'Error' || stem === 'NotFound')
293
278
  continue;
294
279
  const chunkId = ['pages', ...relParts, stem].join('/');
295
- const pathPattern = filePathPatternFromChunk(chunkId);
296
- out.push({ chunkId, pathPattern, segs: parsePathPattern(pathPattern) });
280
+ out.push({ chunkId, segs: parseChunkSegments(chunkId) });
297
281
  }
298
282
  }
299
283
  }
@@ -301,26 +285,32 @@ function listPageClientFiles(distDir) {
301
285
  return out;
302
286
  }
303
287
  /**
304
- * @param {string} distDir
288
+ * @param {string} chunkId
305
289
  */
306
- function listPagesFromDeployment(distDir) {
307
- const deploymentPath = path.join(distDir, 'vmz-deployment.json');
308
- if (!fs.existsSync(deploymentPath))
309
- return [];
310
- try {
311
- const deployment = JSON.parse(fs.readFileSync(deploymentPath, 'utf8'));
312
- return listPublicPageUnits(deployment).map((u) => {
313
- const chunkId = String(u.chunkId || '').replace(/\\/g, '/');
314
- const pathPattern = unitBrowserPathPattern(u);
315
- return { chunkId, pathPattern, segs: parsePathPattern(pathPattern) };
316
- });
317
- }
318
- catch {
319
- return [];
290
+ function parseChunkSegments(chunkId) {
291
+ const rel = chunkId.replace(/^pages\//, '');
292
+ const parts = rel.split('/').filter(Boolean);
293
+ /** @type {Array<{ kind: 'static' | 'param' | 'catch', value?: string, name?: string }>} */
294
+ const segs = [];
295
+ for (let i = 0; i < parts.length; i++) {
296
+ const p = parts[i];
297
+ if (p.startsWith('(') && p.endsWith(')') && p.length > 2)
298
+ continue;
299
+ if (p === 'index' && i === parts.length - 1)
300
+ continue;
301
+ const catchAll = /^\[\.\.\.([^\]]+)\]$/.exec(p);
302
+ const param = /^\[([^\]]+)\]$/.exec(p);
303
+ if (catchAll)
304
+ segs.push({ kind: 'catch', name: catchAll[1] });
305
+ else if (param)
306
+ segs.push({ kind: 'param', name: param[1] });
307
+ else
308
+ segs.push({ kind: 'static', value: p.toLowerCase() });
320
309
  }
310
+ return segs;
321
311
  }
322
312
  /**
323
- * @param {ReturnType<typeof parsePathPattern>} segs
313
+ * @param {ReturnType<typeof parseChunkSegments>} segs
324
314
  */
325
315
  function patternFromSegs(segs) {
326
316
  if (!segs.length)
@@ -399,8 +389,8 @@ async function resolvePageMeta(Page, ctx) {
399
389
  else if (Page.meta && typeof Page.meta === 'object') {
400
390
  raw = Page.meta;
401
391
  }
402
- const title = String(raw.title || guessTitle(ctx.pathname) || 'App');
403
- const description = String(raw.description || '');
392
+ const title = String(raw.title || `${guessTitle(ctx.pathname)} · VMZ`);
393
+ const description = String(raw.description || `VMZ page ${ctx.pathname}`);
404
394
  const canonical = String(raw.canonical || `${ctx.origin}${ctx.pathname === '/' ? '/' : ctx.pathname}`);
405
395
  const robots = String(raw.robots || 'index,follow');
406
396
  const lang = String(raw.lang || 'en');
@@ -524,87 +514,59 @@ function readCssEntry(distDir) {
524
514
  */
525
515
  function wrapDocument(input) {
526
516
  const propsJson = JSON.stringify(input.props ?? {});
517
+ const layoutAttr = input.layoutChain.length ? ` data-vmz-layout="${escapeAttr(input.layoutChain.join(','))}"` : '';
518
+ const pageAttr = input.chunkId ? ` data-vmz-page="${escapeAttr(input.chunkId)}"` : '';
527
519
  const localeId = input.meta.lang || 'en';
528
520
  const dir = input.meta.dir || 'ltr';
529
- const native = requireNativeAddon();
530
- if (typeof native.generatePageShell !== 'function') {
531
- throw new Error('vmz native addon missing generatePageShell rebuild with `pnpm napi:build`');
532
- }
533
- return native.generatePageShell({
534
- bodyHtml: input.bodyHtml,
535
- chunkId: input.chunkId || '',
536
- layoutChain: input.layoutChain || [],
537
- propsJson,
538
- meta: {
539
- title: input.meta.title,
540
- description: input.meta.description,
541
- canonical: input.meta.canonical,
542
- robots: input.meta.robots,
543
- lang: localeId,
544
- dir,
545
- alternates: input.meta.alternates || [],
546
- },
547
- // napi Option<String>: omit/undefined = None; null is rejected as String
548
- ...(input.cssEntry ? { cssEntry: String(input.cssEntry) } : {}),
549
- isErrorDocument: !!input.isErrorDocument,
550
- });
521
+ const localeAttr = ` data-vmz-locale="${escapeAttr(localeId)}" data-vmz-dir="${escapeAttr(dir)}"`;
522
+ const cssLink = input.cssEntry ? ` <link rel="stylesheet" href="/${String(input.cssEntry).replace(/^\/+/, '')}" />\n` : '';
523
+ const entry = input.isErrorDocument ? '' : ` <script type="module" src="/entry-client.js"></script>\n`;
524
+ const hreflang = (input.meta.alternates || [])
525
+ .map((a) => ` <link rel="alternate" hreflang="${escapeAttr(a.hreflang)}" href="${escapeAttr(a.href)}" />`)
526
+ .join('\n');
527
+ const hreflangBlock = hreflang ? `${hreflang}\n` : '';
528
+ return `<!DOCTYPE html>
529
+ <html lang="${escapeAttr(localeId)}" data-locale="${escapeAttr(localeId)}" dir="${escapeAttr(dir)}">
530
+ <head>
531
+ <meta charset="utf-8" />
532
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
533
+ <title>${escapeHtml(input.meta.title)}</title>
534
+ <meta name="description" content="${escapeAttr(input.meta.description)}" />
535
+ <meta name="robots" content="${escapeAttr(input.meta.robots)}" />
536
+ <link rel="canonical" href="${escapeAttr(input.meta.canonical)}" />
537
+ ${hreflangBlock} <meta property="og:title" content="${escapeAttr(input.meta.title)}" />
538
+ <meta property="og:description" content="${escapeAttr(input.meta.description)}" />
539
+ <meta property="og:url" content="${escapeAttr(input.meta.canonical)}" />
540
+ ${cssLink}</head>
541
+ <body>
542
+ <div id="app"${pageAttr}${layoutAttr}${localeAttr} data-vmz-props="${escapeAttr(propsJson)}">${input.bodyHtml}</div>
543
+ ${entry}</body>
544
+ </html>
545
+ `;
551
546
  }
552
547
  /**
553
548
  * @param {string} origin
554
549
  * @param {Array<{ canonical: string, robots: string }>} generations
555
550
  */
556
- function buildSitemap(_origin, generations) {
557
- const urls = generations.filter((g) => !String(g.robots).includes('noindex')).map((g) => ({ loc: g.canonical }));
558
- const native = requireNativeAddon();
559
- if (typeof native.generateSitemapXml !== 'function') {
560
- throw new Error('vmz native addon missing generateSitemapXml — rebuild with `pnpm napi:build`');
561
- }
562
- return native.generateSitemapXml(urls);
551
+ function buildSitemap(origin, generations) {
552
+ const urls = generations
553
+ .filter((g) => !String(g.robots).includes('noindex'))
554
+ .map((g) => ` <url>
555
+ <loc>${escapeXml(g.canonical)}</loc>
556
+ </url>`)
557
+ .join('\n');
558
+ return `<?xml version="1.0" encoding="UTF-8"?>
559
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
560
+ ${urls}
561
+ </urlset>
562
+ `;
563
563
  }
564
- /**
565
- * Static CDN must ship entry-client/event like serve-host (content-addressed + HTML rewrite).
566
- * @param {string} distDir
567
- * @param {Array<{ chunkId: string }>} pageCatalog
568
- */
569
- function emitStaticClientEntries(distDir, pageCatalog) {
570
- const componentEntries = listClientComponentsSync(distDir, { strict: true });
571
- const indexChunk = pageCatalog.find((p) => p.chunkId === 'pages/index')?.chunkId || pageCatalog[0]?.chunkId || 'pages/index';
572
- const resumeEntries = loadPageResumeEntriesSync(distDir, indexChunk);
573
- const lazySet = new Set(resumeEntries
574
- .filter((e) => isEventResumeStrategy(e.strategy))
575
- .map((e) => e.component)
576
- .filter(Boolean));
577
- const eager = componentEntries.filter((e) => !lazySet.has(e.name));
578
- const lazy = componentEntries.filter((e) => lazySet.has(e.name));
579
- const native = requireNativeAddon();
580
- if (typeof native.generateServeEntryClient !== 'function') {
581
- throw new Error('vmz native addon missing generateServeEntryClient — rebuild with `pnpm napi:build`');
582
- }
583
- fs.writeFileSync(path.join(distDir, 'entry-client.js'), native.generateServeEntryClient(eager, lazy, ''), 'utf8');
584
- if (typeof native.generateServeEntryEvent === 'function') {
585
- fs.writeFileSync(path.join(distDir, 'entry-event.js'), native.generateServeEntryEvent(''), 'utf8');
586
- }
564
+ function escapeHtml(s) {
565
+ return String(s).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
587
566
  }
588
- /**
589
- * @param {string} distDir
590
- * @param {string} chunkId
591
- */
592
- function loadPageResumeEntriesSync(distDir, chunkId) {
593
- try {
594
- const dep = JSON.parse(fs.readFileSync(path.join(distDir, 'vmz-deployment.json'), 'utf8'));
595
- const units = Array.isArray(dep.units) ? dep.units : [];
596
- const page = units.find((u) => u.chunkId === chunkId) || units.find((u) => u.chunkId === 'pages/index') || units.find((u) => u.kind === 'page');
597
- const entries = Array.isArray(page?.resumeEntries) ? page.resumeEntries : [];
598
- return entries.map((e) => ({
599
- component: String(e.component || ''),
600
- strategy: String(e.strategy || ''),
601
- }));
602
- }
603
- catch {
604
- return [];
605
- }
567
+ function escapeAttr(s) {
568
+ return escapeHtml(s).replaceAll('"', '&quot;');
606
569
  }
607
- /** @param {string} strategy */
608
- function isEventResumeStrategy(strategy) {
609
- return strategy === 'event' || strategy === 'click' || String(strategy).startsWith('event:');
570
+ function escapeXml(s) {
571
+ return escapeAttr(s).replaceAll("'", '&apos;');
610
572
  }
package/dist/test-cmd.js CHANGED
@@ -6,7 +6,6 @@
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';
10
9
  /**
11
10
  * @returns {Promise<typeof import('@vmz/test')>}
12
11
  */
@@ -318,7 +317,7 @@ export async function cmdTest(args) {
318
317
  return errors.length ? 2 : 0;
319
318
  }
320
319
  if (wantJson) {
321
- const text = `${generatePrettyJson(report)}\n`;
320
+ const text = `${JSON.stringify(report, null, 2)}\n`;
322
321
  if (typeof args.json === 'string' && args.json !== 'true') {
323
322
  const { writeFileSync } = await import('node:fs');
324
323
  writeFileSync(path.resolve(String(args.json)), text);