@vmz/vmz 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -3,9 +3,9 @@
3
3
  * Node CLI command implementations .
4
4
  */
5
5
  import { spawn } from 'node:child_process';
6
- import { copyFileSync, existsSync } from 'node:fs';
6
+ import { existsSync } from 'node:fs';
7
7
  import path from 'node:path';
8
- import { HOST_PROTOCOL, createWorkspace, getProtocolVersions, resolveCoreRuntimeDist, resolveNativePath } from './index.js';
8
+ import { HOST_PROTOCOL, createWorkspace, getProtocolVersions, materializeServeHostRuntime, resolveCoreRuntimeDist, resolveNativePath, } from './index.js';
9
9
  import { createDevSession } from './dev-session.js';
10
10
  import { gateGlobalProjectCommand, getInvocationContext, isGlobalAllowedCommand } from './invocation.js';
11
11
  import { log } from './log.js';
@@ -501,14 +501,12 @@ async function cmdServe(args) {
501
501
  });
502
502
  const hostJs = path.join(outDir, 'vmz-serve-host.mjs');
503
503
  if (!existsSync(hostJs)) {
504
- const coreDist = resolveCoreRuntimeDist();
505
- const src = coreDist ? path.join(coreDist, 'serve-host.mjs') : null;
506
- if (src && existsSync(src)) {
507
- copyFileSync(src, hostJs);
504
+ try {
505
+ materializeServeHostRuntime(outDir);
508
506
  log.info(`materialized ${hostJs} from @vmz/core (release builds omit it)`);
509
507
  }
510
- else {
511
- log.error(`missing ${hostJs} — run \`vmz build\` (without --release) or ensure @vmz/core is installed`);
508
+ catch (err) {
509
+ log.error(`missing ${hostJs} — run \`vmz build\` (without --release) or ensure @vmz/core is installed (${err instanceof Error ? err.message : err})`);
512
510
  return 1;
513
511
  }
514
512
  }
@@ -1,9 +1,15 @@
1
1
  /**
2
2
  * A3: content-addressed assets/<hash> layout for immutable CDN objects.
3
3
  * Logical paths stay available for serve/dev; static HTML rewrites to hashed URLs.
4
- * Identical bytes identical asset path (cross-release / cross-source reuse by digest).
4
+ * CSS aggregators (vmz.css) rewrite `@import` to hashed sibling paths under assets/.
5
5
  */
6
6
  export declare const CONTENT_ADDRESSED_ASSETS_SCHEMA = "vmz.content_addressed_assets.v0";
7
+ /**
8
+ * Rewrite relative `@import "./foo.css"` to hashed paths under assets/.
9
+ * @param {string} cssText
10
+ * @param {Record<string, string>} rewrites logical (no leading slash) or `/logical` → `assets/hash.ext`
11
+ */
12
+ export declare function rewriteCssImports(cssText: any, rewrites: any): any;
7
13
  /**
8
14
  * Emit `assets/<sha256>.<ext>` copies and rewrite HTML href/src to hashed URLs.
9
15
  * @param {string} distDir
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * A3: content-addressed assets/<hash> layout for immutable CDN objects.
3
3
  * Logical paths stay available for serve/dev; static HTML rewrites to hashed URLs.
4
- * Identical bytes identical asset path (cross-release / cross-source reuse by digest).
4
+ * CSS aggregators (vmz.css) rewrite `@import` to hashed sibling paths under assets/.
5
5
  */
6
6
  // @ts-nocheck
7
7
  import crypto from 'node:crypto';
@@ -16,11 +16,33 @@ const DEFAULT_CANDIDATES = [
16
16
  'entry-event.js',
17
17
  'vmz.css',
18
18
  'vmz-designs.css',
19
+ 'vmz-style.css',
19
20
  'vmz-dom.js',
20
21
  'vmz-runtime.js',
21
22
  'vmz-http.js',
22
23
  'vmz-client-nav.js',
23
24
  ];
25
+ /** CSS files that may @import other logical CSS; processed after leaf CSS is hashed. */
26
+ const CSS_AGGREGATORS = new Set(['vmz.css']);
27
+ const CSS_IMPORT_RE = /@import\s*(?:url\()?['"]?(\.\/)?([^'")\s;]+)['"]?\)?/gi;
28
+ /**
29
+ * Rewrite relative `@import "./foo.css"` to hashed paths under assets/.
30
+ * @param {string} cssText
31
+ * @param {Record<string, string>} rewrites logical (no leading slash) or `/logical` → `assets/hash.ext`
32
+ */
33
+ export function rewriteCssImports(cssText, rewrites) {
34
+ return cssText.replace(CSS_IMPORT_RE, (match, _dot, target) => {
35
+ const logical = String(target || '').replace(/^\.\//, '');
36
+ if (!logical)
37
+ return match;
38
+ const hashed = rewrites[logical] || rewrites[`/${logical}`] || rewrites[`assets/${logical}`];
39
+ if (!hashed)
40
+ return match;
41
+ const rel = hashed.startsWith('/') ? hashed.slice(1) : hashed;
42
+ const sibling = rel.startsWith('assets/') ? `./${path.basename(rel)}` : `./${rel}`;
43
+ return `@import"${sibling}"`;
44
+ });
45
+ }
24
46
  /**
25
47
  * Emit `assets/<sha256>.<ext>` copies and rewrite HTML href/src to hashed URLs.
26
48
  * @param {string} distDir
@@ -38,36 +60,24 @@ export function emitContentAddressedAssets(distDir, opts = {}) {
38
60
  const objects = [];
39
61
  /** @type {Record<string, string>} */
40
62
  const rewrites = {};
41
- for (const rel of candidates) {
42
- const logical = String(rel).replace(/\\/g, '/').replace(/^\//, '');
43
- const src = path.join(abs, ...logical.split('/'));
44
- if (!fs.existsSync(src) || !fs.statSync(src).isFile())
63
+ const ordered = orderCandidates(candidates);
64
+ for (const rel of ordered) {
65
+ ingestCandidate(abs, rel, rewrites, objects, { transform: null });
66
+ }
67
+ // Aggregator CSS (vmz.css) must import hashed leaf files — rewrite then hash.
68
+ for (const rel of ordered) {
69
+ if (!CSS_AGGREGATORS.has(rel))
45
70
  continue;
46
- const buf = fs.readFileSync(src);
47
- const digest = sha256Hex(buf);
48
- const ext = path.extname(logical) || '';
49
- const assetRel = `assets/${digest}${ext}`;
50
- const dest = path.join(abs, ...assetRel.split('/'));
51
- if (!fs.existsSync(dest)) {
52
- fs.mkdirSync(path.dirname(dest), { recursive: true });
53
- fs.writeFileSync(dest, buf);
54
- }
55
- else {
56
- // Cross-release reuse: identical digest must not be rewritten.
57
- const existing = sha256Hex(fs.readFileSync(dest));
58
- if (existing !== digest) {
59
- throw new Error(`content-address collision at ${assetRel}`);
60
- }
61
- }
62
- objects.push({
63
- logicalPath: logical,
64
- assetPath: assetRel,
65
- digest,
66
- bytes: buf.length,
67
- immutable: true,
71
+ const src = path.join(abs, rel);
72
+ if (!fs.existsSync(src))
73
+ continue;
74
+ const rewritten = rewriteCssImports(fs.readFileSync(src, 'utf8'), rewrites);
75
+ removeLogicalObject(objects, rel);
76
+ delete rewrites[`/${rel}`];
77
+ delete rewrites[rel];
78
+ ingestCandidate(abs, rel, rewrites, objects, {
79
+ transform: () => Buffer.from(rewritten, 'utf8'),
68
80
  });
69
- rewrites[`/${logical}`] = `/${assetRel}`;
70
- rewrites[logical] = assetRel;
71
81
  }
72
82
  objects.sort((a, b) => (a.logicalPath < b.logicalPath ? -1 : a.logicalPath > b.logicalPath ? 1 : 0));
73
83
  let rewrittenHtml = 0;
@@ -89,6 +99,74 @@ export function emitContentAddressedAssets(distDir, opts = {}) {
89
99
  writePrettyJsonFile(outPath, manifest);
90
100
  return { manifest, assetsDir, rewrites, manifestPath: outPath };
91
101
  }
102
+ /**
103
+ * @param {string[]} candidates
104
+ */
105
+ function orderCandidates(candidates) {
106
+ const set = new Set(candidates.map((c) => String(c).replace(/\\/g, '/').replace(/^\//, '')));
107
+ /** @type {string[]} */
108
+ const out = [];
109
+ for (const name of DEFAULT_CANDIDATES) {
110
+ if (set.has(name) && !CSS_AGGREGATORS.has(name))
111
+ out.push(name);
112
+ }
113
+ for (const name of [...set].sort()) {
114
+ if (!CSS_AGGREGATORS.has(name) && !out.includes(name))
115
+ out.push(name);
116
+ }
117
+ if (set.has('vmz.css'))
118
+ out.push('vmz.css');
119
+ return out;
120
+ }
121
+ /**
122
+ * @param {Array<Record<string, any>>} objects
123
+ * @param {string} logical
124
+ */
125
+ function removeLogicalObject(objects, logical) {
126
+ const idx = objects.findIndex((o) => o.logicalPath === logical);
127
+ if (idx >= 0)
128
+ objects.splice(idx, 1);
129
+ }
130
+ /**
131
+ * @param {string} absDist
132
+ * @param {string} rel
133
+ * @param {Record<string, string>} rewrites
134
+ * @param {Array<Record<string, any>>} objects
135
+ * @param {{ transform?: ((buf: Buffer) => Buffer) | null }} opts
136
+ */
137
+ function ingestCandidate(absDist, rel, rewrites, objects, opts) {
138
+ const logical = String(rel).replace(/\\/g, '/').replace(/^\//, '');
139
+ const src = path.join(absDist, ...logical.split('/'));
140
+ if (!fs.existsSync(src) || !fs.statSync(src).isFile())
141
+ return;
142
+ let buf = fs.readFileSync(src);
143
+ if (typeof opts.transform === 'function') {
144
+ buf = opts.transform(buf);
145
+ }
146
+ const digest = sha256Hex(buf);
147
+ const ext = path.extname(logical) || '';
148
+ const assetRel = `assets/${digest}${ext}`;
149
+ const dest = path.join(absDist, ...assetRel.split('/'));
150
+ if (!fs.existsSync(dest)) {
151
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
152
+ fs.writeFileSync(dest, buf);
153
+ }
154
+ else {
155
+ const existing = sha256Hex(fs.readFileSync(dest));
156
+ if (existing !== digest) {
157
+ throw new Error(`content-address collision at ${assetRel}`);
158
+ }
159
+ }
160
+ objects.push({
161
+ logicalPath: logical,
162
+ assetPath: assetRel,
163
+ digest,
164
+ bytes: buf.length,
165
+ immutable: true,
166
+ });
167
+ rewrites[`/${logical}`] = `/${assetRel}`;
168
+ rewrites[logical] = assetRel;
169
+ }
92
170
  /**
93
171
  * Resolve an immutable object by digest under dist/assets (cross-source reuse).
94
172
  * @param {string} distDir
@@ -124,7 +202,6 @@ export function assertSharedAssetPath(distDir, a, b, ext = '.js') {
124
202
  const rel = `assets/${da}${ext}`;
125
203
  const dest = path.join(distDir, ...rel.split('/'));
126
204
  fs.writeFileSync(dest, typeof a === 'string' ? Buffer.from(a) : a);
127
- // Second write of identical bytes must be reuse, not fork.
128
205
  fs.writeFileSync(dest, typeof b === 'string' ? Buffer.from(b) : b);
129
206
  const again = resolveAssetByDigest(distDir, da, ext);
130
207
  if (!again || again.assetPath !== rel) {
@@ -139,7 +216,6 @@ function collectCandidates(distDir) {
139
216
  if (fs.existsSync(path.join(distDir, name)))
140
217
  out.push(name);
141
218
  }
142
- // Include top-level *.client.js and pages/**/*.client.js referenced by resume.
143
219
  walk(distDir, distDir, (rel) => {
144
220
  if (/\.client\.js$/i.test(rel))
145
221
  out.push(rel);
@@ -173,7 +249,6 @@ function rewriteHtmlReferences(distDir, rewrites) {
173
249
  let text = fs.readFileSync(file, 'utf8');
174
250
  let next = text;
175
251
  for (const [from, to] of pairs) {
176
- // href="/x" src="/x" and unquoted variants in attributes
177
252
  next = next.split(from).join(to);
178
253
  }
179
254
  if (next !== text) {
@@ -8,6 +8,7 @@ import { checkDocuments, manifestHasErrors } from './document-check.js';
8
8
  import { resolveDocumentDesignsCss } from './document-designs.js';
9
9
  import { enrichDocumentContent, pageHtmlRel } from './document-enrich.js';
10
10
  import { enrichDocumentEvidence } from './document-evidence.js';
11
+ import { docsRouteNone, loadLocaleCommonMessages, loadLocalesRouting, localeNonePickerScript, readSiteGithubUrl, renderHostChromeTemplate, } from './document-host-chrome.js';
11
12
  import { artifactHrefFromHtml, buildDocumentIslands, buildDocumentSearch, collectFenceBodies, renderIslandShellsHtml, } from './document-interactive.js';
12
13
  import { resolveMarkdownEngine } from './document-markdown.js';
13
14
  import { DOCUMENT_VIEW_SCHEMA } from './document-schema.js';
@@ -22,10 +23,12 @@ export async function buildDocuments(opts) {
22
23
  const outDir = path.resolve(opts.outDir || path.join(projectRoot, 'dist', 'documents'));
23
24
  const strict = Boolean(opts.strict);
24
25
  const manifest = checkDocuments({ projectRoot, strict });
26
+ const routing = loadLocalesRouting(projectRoot) || { strategy: 'prefix' };
25
27
  const engine = await resolveMarkdownEngine({ engines: opts.engines, projectRoot });
26
28
  const enriched = enrichDocumentContent(manifest, {
27
29
  analyzeMarkdown: engine.analyzeMarkdown,
28
30
  projectRoot,
31
+ routing,
29
32
  });
30
33
  manifest.diagnostics = enriched.diagnostics;
31
34
  const evidence = await enrichDocumentEvidence(manifest, {
@@ -60,8 +63,8 @@ export async function buildDocuments(opts) {
60
63
  });
61
64
  manifest.search = search;
62
65
  manifest.islands = islands;
63
- const hostChrome = resolveHostSiteChrome(projectRoot);
64
- const useHostShell = Boolean(hostChrome) && (manifest.mounts || []).some((m) => m.mode === 'integrated');
66
+ const hostChromeRaw = resolveHostSiteChromeRaw(projectRoot);
67
+ const useHostShell = Boolean(hostChromeRaw) && (manifest.mounts || []).some((m) => m.mode === 'integrated');
65
68
  fs.mkdirSync(outDir, { recursive: true });
66
69
  const designs = resolveDocumentDesignsCss(projectRoot);
67
70
  /** @type {string | null} */
@@ -127,7 +130,12 @@ export async function buildDocuments(opts) {
127
130
  htmlRel,
128
131
  searchShellHtml: shells.searchHtml,
129
132
  playgroundShellHtml: shells.playgroundHtml,
130
- hostChrome: useHostShell ? hostChrome : null,
133
+ hostChrome: useHostShell
134
+ ? renderHostChromeForLocale(projectRoot, page.identity.locale, routing, enriched.routeBase, hostChromeRaw)
135
+ : null,
136
+ routing,
137
+ routeBase: enriched.routeBase,
138
+ pageKey: page.identity.pageKey,
131
139
  });
132
140
  fs.writeFileSync(htmlAbs, html, 'utf8');
133
141
  written.push({ route: info.route, htmlPath: htmlRel, viewPath: viewRel });
@@ -159,7 +167,7 @@ export async function buildDocuments(opts) {
159
167
  * No-JS readable static HTML: nav + main landmarks, Island shells without scripts.
160
168
  * Integrated mounts reuse host SiteHeader/SiteFooter templates when present.
161
169
  */
162
- function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, designsHref, htmlRel, searchShellHtml = '', playgroundShellHtml = '', hostChrome = null, }) {
170
+ function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, designsHref, htmlRel, searchShellHtml = '', playgroundShellHtml = '', hostChrome = null, routing = { strategy: 'prefix' }, routeBase = '/docs', pageKey = 'index', }) {
163
171
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
164
172
  const depth = htmlRel.split('/').length - 1;
165
173
  const prefix = depth > 0 ? '../'.repeat(depth) : './';
@@ -174,7 +182,7 @@ function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, desig
174
182
  cssHrefs.push(hostChrome ? `/${designsHref}` : prefix + designsHref);
175
183
  const navItems = nav
176
184
  .map((n) => {
177
- const href = relativeHref(htmlRel, n.href, route);
185
+ const href = routing.strategy === 'none' || routing.strategy === 'domain' ? n.href : relativeHref(htmlRel, n.href, route);
178
186
  const current = n.href === route ? ' aria-current="page"' : '';
179
187
  return ` <li><a href="${esc(href)}"${current}>${esc(n.title)}</a></li>`;
180
188
  })
@@ -210,6 +218,7 @@ ${playgroundShellHtml}
210
218
  </div>
211
219
  ${hostChrome.footer}
212
220
  </div>
221
+ ${routing.strategy === 'none' && hostChrome ? localeNonePickerScript() : ''}
213
222
  `;
214
223
  }
215
224
  else {
@@ -239,7 +248,7 @@ ${playgroundShellHtml}
239
248
  * @param {string} projectRoot
240
249
  * @returns {{ header: string, footer: string } | null}
241
250
  */
242
- function resolveHostSiteChrome(projectRoot) {
251
+ function resolveHostSiteChromeRaw(projectRoot) {
243
252
  const headerPath = path.join(projectRoot, 'src', 'components', 'SiteHeader.vmz');
244
253
  const footerPath = path.join(projectRoot, 'src', 'components', 'SiteFooter.vmz');
245
254
  if (!fs.existsSync(headerPath) || !fs.existsSync(footerPath))
@@ -250,6 +259,28 @@ function resolveHostSiteChrome(projectRoot) {
250
259
  return null;
251
260
  return { header, footer };
252
261
  }
262
+ /**
263
+ * @param {string} projectRoot
264
+ * @param {string} localeId
265
+ * @param {{ strategy?: string }} routing
266
+ * @param {string} routeBase
267
+ * @param {{ header: string, footer: string }} raw
268
+ */
269
+ function renderHostChromeForLocale(projectRoot, localeId, routing, routeBase, raw) {
270
+ const messages = loadLocaleCommonMessages(projectRoot, localeId);
271
+ const githubUrl = readSiteGithubUrl(projectRoot);
272
+ const docsRootHref = routing.strategy === 'none' || routing.strategy === 'domain'
273
+ ? `${routeBase.replace(/\/$/, '')}/`
274
+ : `${routeBase.replace(/\/$/, '')}/${localeId}/`;
275
+ const guideHref = routing.strategy === 'none' || routing.strategy === 'domain'
276
+ ? docsRouteNone(routeBase, 'guide/getting-started')
277
+ : `${docsRootHref}guide/getting-started`;
278
+ const opts = { docsRootHref, guideHref, githubUrl, routing };
279
+ return {
280
+ header: renderHostChromeTemplate(raw.header, localeId, messages, opts),
281
+ footer: renderHostChromeTemplate(raw.footer, localeId, messages, opts),
282
+ };
283
+ }
253
284
  /** @param {string} filePath */
254
285
  function extractVmzTemplateHtml(filePath) {
255
286
  const src = fs.readFileSync(filePath, 'utf8');
@@ -2,8 +2,9 @@
2
2
  * @param {string} routeBase e.g. /docs
3
3
  * @param {string} locale
4
4
  * @param {string} pageKey
5
+ * @param {{ strategy?: string }} [routing]
5
6
  */
6
- export declare function pageRoute(routeBase: any, locale: any, pageKey: any): string;
7
+ export declare function pageRoute(routeBase: any, locale: any, pageKey: any, routing?: {}): string;
7
8
  /**
8
9
  * Static file path relative to out dir (posix).
9
10
  * @param {string} routeBase
@@ -9,10 +9,16 @@ import { DIAG } from './document-schema.js';
9
9
  * @param {string} routeBase e.g. /docs
10
10
  * @param {string} locale
11
11
  * @param {string} pageKey
12
+ * @param {{ strategy?: string }} [routing]
12
13
  */
13
- export function pageRoute(routeBase, locale, pageKey) {
14
+ export function pageRoute(routeBase, locale, pageKey, routing = {}) {
14
15
  const base = String(routeBase || '/').replace(/\/$/, '') || '';
15
16
  const key = pageKey === 'index' ? '' : pageKey.replace(/\\/g, '/');
17
+ const strategy = routing.strategy || 'prefix';
18
+ if (strategy === 'none' || strategy === 'domain') {
19
+ const parts = [base.replace(/^\//, ''), key].filter((p) => p !== '');
20
+ return '/' + (parts.length ? parts.join('/') : '');
21
+ }
16
22
  const parts = [base.replace(/^\//, ''), locale, key].filter((p) => p !== '');
17
23
  return '/' + parts.join('/');
18
24
  }
@@ -35,6 +41,7 @@ export function pageHtmlRel(routeBase, locale, pageKey) {
35
41
  */
36
42
  export function enrichDocumentContent(manifest, ctx) {
37
43
  const routeBase = manifest.mounts?.[0]?.routeBase || '/docs';
44
+ const routing = ctx.routing || { strategy: 'prefix' };
38
45
  /** @type {Map<string, { html: string, headings: any[], links: any[], title: string, route: string, anchors: string[] }>} */
39
46
  const byId = new Map();
40
47
  /** @type {import('./document-schema.js').DocumentDiagnostic[]} */
@@ -45,7 +52,7 @@ export function enrichDocumentContent(manifest, ctx) {
45
52
  const abs = path.isAbsolute(page.sourcePath) ? page.sourcePath : path.join(manifest.root, page.sourcePath);
46
53
  const source = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : '';
47
54
  const analyzed = ctx.analyzeMarkdown(source);
48
- const route = pageRoute(routeBase, page.identity.locale, page.identity.pageKey);
55
+ const route = pageRoute(routeBase, page.identity.locale, page.identity.pageKey, routing);
49
56
  const anchors = analyzed.headings.map((h) => h.id);
50
57
  const title = analyzed.headings.find((h) => h.level === 1)?.text || analyzed.headings[0]?.text || page.identity.pageKey;
51
58
  // Duplicate anchors on page
@@ -61,7 +68,11 @@ export function enrichDocumentContent(manifest, ctx) {
61
68
  }
62
69
  seen.add(id);
63
70
  }
64
- if (routeOwners.has(route)) {
71
+ const owner = `${page.identity.locale}:${page.identity.pageKey}`;
72
+ if (routing.strategy === 'none' || routing.strategy === 'domain') {
73
+ routeOwners.set(route, owner);
74
+ }
75
+ else if (routeOwners.has(route)) {
65
76
  diagnostics.push({
66
77
  code: DIAG.ROUTE_DUPLICATE,
67
78
  severity: 'error',
@@ -70,7 +81,7 @@ export function enrichDocumentContent(manifest, ctx) {
70
81
  });
71
82
  }
72
83
  else {
73
- routeOwners.set(route, `${page.identity.locale}:${page.identity.pageKey}`);
84
+ routeOwners.set(route, owner);
74
85
  }
75
86
  page.route = route;
76
87
  page.anchors = anchors;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * @param {string} projectRoot
3
+ * @returns {{ strategy?: string, defaultLocale?: string } | null}
4
+ */
5
+ export declare function loadLocalesRouting(projectRoot: any): any;
6
+ /**
7
+ * @param {string} projectRoot
8
+ * @param {string} localeId
9
+ */
10
+ export declare function loadLocaleCommonMessages(projectRoot: any, localeId: any): any;
11
+ /**
12
+ * @param {string} projectRoot
13
+ */
14
+ export declare function readSiteGithubUrl(projectRoot: any): string;
15
+ /**
16
+ * @param {string} template
17
+ * @param {string} localeId
18
+ * @param {Record<string, string>} messages
19
+ * @param {{ docsRootHref: string, guideHref: string, githubUrl: string, routing?: { strategy?: string } }} opts
20
+ */
21
+ export declare function renderHostChromeTemplate(template: any, localeId: any, messages: any, opts: any): any;
22
+ /** Inline locale preference picker for strategy `none` (Host state, not URL). */
23
+ export declare function localeNonePickerScript(): string;
24
+ /**
25
+ * @param {string} routeBase
26
+ * @param {string} pageKey
27
+ */
28
+ export declare function docsRouteNone(routeBase: any, pageKey: any): string;
@@ -0,0 +1,128 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Integrated DocumentMount — lower host SiteHeader/SiteFooter .vmz templates to
4
+ * static HTML with locale-resolved copy (LocaleId is Host preference, not URL).
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ /**
9
+ * @param {string} projectRoot
10
+ * @returns {{ strategy?: string, defaultLocale?: string } | null}
11
+ */
12
+ export function loadLocalesRouting(projectRoot) {
13
+ const p = path.join(projectRoot, 'locales', 'locales.json5');
14
+ if (!fs.existsSync(p))
15
+ return null;
16
+ try {
17
+ const raw = fs.readFileSync(p, 'utf8');
18
+ let s = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
19
+ const m = s.match(/routing\s*:\s*\{([\s\S]*?)\}/);
20
+ if (!m)
21
+ return null;
22
+ let block = `{${m[1]}}`;
23
+ block = block.replace(/([,{]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
24
+ block = block.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_, inner) => JSON.stringify(inner));
25
+ block = block.replace(/,\s*([}\]])/g, '$1');
26
+ return JSON.parse(block);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * @param {string} projectRoot
34
+ * @param {string} localeId
35
+ */
36
+ export function loadLocaleCommonMessages(projectRoot, localeId) {
37
+ const p = path.join(projectRoot, 'locales', localeId, 'common.json5');
38
+ if (!fs.existsSync(p))
39
+ return {};
40
+ try {
41
+ let s = fs.readFileSync(p, 'utf8');
42
+ s = s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
43
+ s = s.replace(/([,{]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
44
+ s = s.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_, inner) => JSON.stringify(inner));
45
+ s = s.replace(/,\s*([}\]])/g, '$1');
46
+ return JSON.parse(s);
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ /**
53
+ * @param {string} projectRoot
54
+ */
55
+ export function readSiteGithubUrl(projectRoot) {
56
+ const p = path.join(projectRoot, 'src', 'lib', 'site.ts');
57
+ if (!fs.existsSync(p))
58
+ return 'https://github.com/voml/iris-orm';
59
+ const m = fs.readFileSync(p, 'utf8').match(/githubUrl\s*=\s*["']([^"']+)["']/);
60
+ return m?.[1] || 'https://github.com/voml/iris-orm';
61
+ }
62
+ /**
63
+ * @param {string} template
64
+ * @param {string} localeId
65
+ * @param {Record<string, string>} messages
66
+ * @param {{ docsRootHref: string, guideHref: string, githubUrl: string, routing?: { strategy?: string } }} opts
67
+ */
68
+ export function renderHostChromeTemplate(template, localeId, messages, opts) {
69
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
70
+ const bindings = {
71
+ brandLabel: messages.brand || 'Iris',
72
+ brandFullLabel: `${messages.brand || 'Iris'} ORM`,
73
+ navHomeLabel: messages.navHome || 'Home',
74
+ navDocsLabel: messages.navDocs || 'Docs',
75
+ navPlaygroundLabel: messages.navPlayground || 'Playground',
76
+ navGithubLabel: messages.navGithub || 'GitHub',
77
+ selectLanguageLabel: messages.selectLanguage || 'Language',
78
+ langZhLabel: messages.langZh || '中文',
79
+ langEnLabel: messages.langEn || 'English',
80
+ footerTagLabel: messages.footerTag || messages.brand || 'Iris',
81
+ footerScopeLabel: messages.footerScopeNote || '',
82
+ footerCopyrightLabel: messages.footerCopyright || '',
83
+ footerGuideLabel: messages.footerGuide || messages.ctaInstall || 'Getting started',
84
+ footerColProductLabel: messages.footerColProduct || 'Product',
85
+ footerColProjectLabel: messages.footerColProject || 'Project',
86
+ ctaInstallLabel: messages.ctaInstall || messages.ctaDocs || 'Docs',
87
+ docsRootHref: opts.docsRootHref,
88
+ guideHref: opts.guideHref,
89
+ githubUrl: opts.githubUrl,
90
+ };
91
+ let html = template;
92
+ html = html.replace(/data-home=\{home \? 'true' : 'false'\}/g, 'data-home="false"');
93
+ html = html.replace(/href=\{docsRootHref\}/g, `href="${esc(bindings.docsRootHref)}"`);
94
+ html = html.replace(/href=\{guideHref\}/g, `href="${esc(bindings.guideHref)}"`);
95
+ html = html.replace(/href=\{githubUrl\}/g, `href="${esc(bindings.githubUrl)}"`);
96
+ for (const [key, val] of Object.entries(bindings)) {
97
+ html = html.replace(new RegExp(`\\{${key}\\}`, 'g'), esc(String(val)));
98
+ }
99
+ html = html.replace(/<Link(\s)/g, '<a class="vmz-ui-link"$1');
100
+ html = html.replace(/<\/Link>/g, '</a>');
101
+ html = html.replace(/<Icon[^>]*\/>/g, '');
102
+ html = html.replace(/<div class="locale-switch"[\s\S]*?<\/div>/g, () => {
103
+ const zh = `<button type="button" class="locale-switch__btn${localeId === 'zh-hans' ? ' is-active' : ''}" data-vmz-locale-pick="zh-hans"${localeId === 'zh-hans' ? ' aria-current="true"' : ''}>${esc(bindings.langZhLabel)}</button>`;
104
+ const en = `<button type="button" class="locale-switch__btn${localeId === 'en-us' ? ' is-active' : ''}" data-vmz-locale-pick="en-us"${localeId === 'en-us' ? ' aria-current="true"' : ''}>${esc(bindings.langEnLabel)}</button>`;
105
+ return `<div class="locale-switch" role="group" aria-label="${esc(bindings.selectLanguageLabel)}">${zh}${en}</div>`;
106
+ });
107
+ html = html.replace(/<Button[\s\S]*?onClick=\{\(\) => this\.switchLocale\('zh-hans'\)\}[\s\S]*?>\s*[\s\S]*?<\/Button>/g, `<button type="button" class="locale-switch__btn${localeId === 'zh-hans' ? ' is-active' : ''}" data-vmz-locale-pick="zh-hans"${localeId === 'zh-hans' ? ' aria-current="true"' : ''}>${esc(bindings.langZhLabel)}</button>`);
108
+ html = html.replace(/<Button[\s\S]*?onClick=\{\(\) => this\.switchLocale\('en-us'\)\}[\s\S]*?>\s*[\s\S]*?<\/Button>/g, `<button type="button" class="locale-switch__btn${localeId === 'en-us' ? ' is-active' : ''}" data-vmz-locale-pick="en-us"${localeId === 'en-us' ? ' aria-current="true"' : ''}>${esc(bindings.langEnLabel)}</button>`);
109
+ html = html.replace(/<Button[\s\S]*?<\/Button>/g, '');
110
+ html = html.replace(/aria-current=\{localeId === '[^']+' \? 'true' : null\}/g, '');
111
+ html = html.replace(/aria-label=\{selectLanguageLabel\}/g, `aria-label="${esc(bindings.selectLanguageLabel)}"`);
112
+ html = html.replace(/label=\{[^}]+\}/g, '');
113
+ return html;
114
+ }
115
+ /** Inline locale preference picker for strategy `none` (Host state, not URL). */
116
+ export function localeNonePickerScript() {
117
+ return `<script>(function(){try{document.querySelectorAll("[data-vmz-locale-pick]").forEach(function(btn){btn.addEventListener("click",function(){var id=btn.getAttribute("data-vmz-locale-pick");if(!id)return;try{localStorage.setItem("vmz.locale",id);}catch(e){}try{document.cookie="vmz.locale="+encodeURIComponent(id)+"; path=/; max-age=31536000; SameSite=Lax";}catch(e){}location.reload();});});}catch(e){}})();</script>`;
118
+ }
119
+ /**
120
+ * @param {string} routeBase
121
+ * @param {string} pageKey
122
+ */
123
+ export function docsRouteNone(routeBase, pageKey) {
124
+ const base = String(routeBase || '/').replace(/\/$/, '') || '';
125
+ const key = pageKey === 'index' ? '' : pageKey.replace(/\\/g, '/');
126
+ const parts = [base.replace(/^\//, ''), key].filter((p) => p !== '');
127
+ return '/' + (parts.length ? parts.join('/') : '');
128
+ }
@@ -7,6 +7,8 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { buildDocuments } from './document-build.js';
9
9
  import { resolveDocumentsRoot } from './document-check.js';
10
+ import { loadLocalesRouting } from './document-host-chrome.js';
11
+ import { pageHtmlRel } from './document-enrich.js';
10
12
  import { log } from './log.js';
11
13
  import { requireNativeAddon } from './native-addon.js';
12
14
  /**
@@ -40,7 +42,7 @@ export async function buildIntegratedDocuments(opts) {
40
42
  }
41
43
  return { ok: false, error: 'document diagnostics', pages: 0 };
42
44
  }
43
- writeMountRootRedirects(result.manifest, outDir);
45
+ writeMountRootRedirects(result.manifest, outDir, projectRoot);
44
46
  log.info(`document mount: pages=${result.pages.length} → ${path.relative(process.cwd(), outDir) || '.'}`);
45
47
  return { ok: true, pages: result.pages.length };
46
48
  }
@@ -51,22 +53,34 @@ export async function buildIntegratedDocuments(opts) {
51
53
  }
52
54
  }
53
55
  /**
54
- * Emit `{routeBase}/index.html` → defaultLocale landing (for /d/ and /docs/).
56
+ * Emit `{routeBase}/index.html` for integrated mounts.
57
+ * `routing.strategy: none` → copy default-locale docs index (LocaleId is Host state).
58
+ * prefix strategy → redirect HTML to `{routeBase}/{defaultLocale}/`.
55
59
  * @param {import('./document-schema.js').DocumentManifest} manifest
56
60
  * @param {string} outDir
61
+ * @param {string} projectRoot
57
62
  */
58
- function writeMountRootRedirects(manifest, outDir) {
63
+ function writeMountRootRedirects(manifest, outDir, projectRoot) {
59
64
  const defaultLocale = manifest.defaultLocale || manifest.locales?.[0];
60
65
  if (!defaultLocale)
61
66
  return;
67
+ const routing = loadLocalesRouting(projectRoot) || { strategy: 'prefix' };
62
68
  for (const mount of manifest.mounts || []) {
63
69
  if (!mount?.routeBase || mount.routeBase === '/')
64
70
  continue;
65
71
  const base = String(mount.routeBase).replace(/\/$/, '');
66
- const target = `${base}/${defaultLocale}/`;
67
72
  const relDir = base.replace(/^\//, '');
68
73
  const abs = path.join(outDir, relDir, 'index.html');
69
74
  fs.mkdirSync(path.dirname(abs), { recursive: true });
75
+ if (routing.strategy === 'none' || routing.strategy === 'domain') {
76
+ const srcRel = pageHtmlRel(base, defaultLocale, 'index');
77
+ const srcAbs = path.join(outDir, srcRel);
78
+ if (fs.existsSync(srcAbs)) {
79
+ fs.copyFileSync(srcAbs, abs);
80
+ }
81
+ continue;
82
+ }
83
+ const target = `${base}/${defaultLocale}/`;
70
84
  const native = requireNativeAddon();
71
85
  if (typeof native.generateRedirectHtml !== 'function') {
72
86
  throw new Error('vmz native addon missing generateRedirectHtml — rebuild with `pnpm napi:build`');
package/dist/index.d.ts CHANGED
@@ -53,6 +53,14 @@ export declare function handshake(host?: {
53
53
  * @returns {string | null}
54
54
  */
55
55
  export declare function resolveCoreRuntimeDist(): string;
56
+ /** Runtime companions required by dist/vmz-serve-host.mjs relative imports. */
57
+ export declare const SERVE_HOST_RUNTIME_FILES: string[][];
58
+ /**
59
+ * Copy serve-host + registry bootstrap modules from `@vmz/core` into app outDir.
60
+ * @param {string} outDir
61
+ * @param {string} [coreDist]
62
+ */
63
+ export declare function materializeServeHostRuntime(outDir: any, coreDist?: string): void;
56
64
  /**
57
65
  * @typedef {object} WorkspaceOptions
58
66
  * @property {string} root
@@ -234,6 +242,8 @@ declare const _default: {
234
242
  expectedProtocol: typeof expectedProtocol;
235
243
  resolveNativePath: typeof resolveNativePath;
236
244
  resolveCoreRuntimeDist: typeof resolveCoreRuntimeDist;
245
+ materializeServeHostRuntime: typeof materializeServeHostRuntime;
246
+ SERVE_HOST_RUNTIME_FILES: string[][];
237
247
  loadNative: typeof loadNative;
238
248
  getProtocolVersions: typeof getProtocolVersions;
239
249
  handshake: typeof handshake;
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * Coarse-grained only — no transform hooks / per-AST callbacks.
5
5
  */
6
6
  import { createRequire } from 'node:module';
7
- import { existsSync } from 'node:fs';
7
+ import { copyFileSync, existsSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import { materializeWechatPackaging } from './wechat-packaging.js';
@@ -144,6 +144,31 @@ export function resolveCoreRuntimeDist() {
144
144
  return nested;
145
145
  return null;
146
146
  }
147
+ /** Runtime companions required by dist/vmz-serve-host.mjs relative imports. */
148
+ export const SERVE_HOST_RUNTIME_FILES = [
149
+ ['serve-host.mjs', 'vmz-serve-host.mjs'],
150
+ ['list-client-components.js', 'list-client-components.js'],
151
+ ['deployment-registry.js', 'deployment-registry.js'],
152
+ ['render-host.js', 'render-host.js'],
153
+ ];
154
+ /**
155
+ * Copy serve-host + registry bootstrap modules from `@vmz/core` into app outDir.
156
+ * @param {string} outDir
157
+ * @param {string} [coreDist]
158
+ */
159
+ export function materializeServeHostRuntime(outDir, coreDist = resolveCoreRuntimeDist()) {
160
+ if (!coreDist) {
161
+ throw new Error('materializeServeHostRuntime: @vmz/core dist not found');
162
+ }
163
+ for (const [srcName, outName] of SERVE_HOST_RUNTIME_FILES) {
164
+ const src = path.join(coreDist, srcName);
165
+ const dst = path.join(outDir, outName);
166
+ if (!existsSync(src)) {
167
+ throw new Error(`materializeServeHostRuntime: missing ${src}`);
168
+ }
169
+ copyFileSync(src, dst);
170
+ }
171
+ }
147
172
  /**
148
173
  * @typedef {object} WorkspaceOptions
149
174
  * @property {string} root
@@ -458,6 +483,8 @@ export default {
458
483
  expectedProtocol,
459
484
  resolveNativePath,
460
485
  resolveCoreRuntimeDist,
486
+ materializeServeHostRuntime,
487
+ SERVE_HOST_RUNTIME_FILES,
461
488
  loadNative,
462
489
  getProtocolVersions,
463
490
  handshake,
@@ -7,7 +7,8 @@ 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 { preloadComponentRegistry } from '@vmz/core/component-registry';
10
+ import { createRenderHost } from '@vmz/core/render-host';
11
+ import { listClientComponentsSync } from '@vmz/core/component-registry';
11
12
  import { emitCdnPolicy } from './cdn-policy.js';
12
13
  import { emitContentAddressedAssets } from './content-addressed-assets.js';
13
14
  import { absoluteUrl, buildLocalePageMeta, localizeBodyLinks } from './locale-router.js';
@@ -30,8 +31,8 @@ export async function emitWebStatic(distDir, opts = {}) {
30
31
  if (!fs.existsSync(domPath)) {
31
32
  throw new Error(`emitWebStatic: missing ${domPath} — run vmz build first`);
32
33
  }
33
- const { renderToString, renderToStream, registerComponents } = await import(pathToFileURL(domPath).href);
34
- await preloadComponentRegistry(distDir, registerComponents);
34
+ const host = await createRenderHost(distDir, { strictDeployment: true, preload: 'none' });
35
+ const { renderToString, renderToStream } = host;
35
36
  const pageCatalog = listPageClientFiles(distDir);
36
37
  /** @type {Array<{
37
38
  * routeId: string,
@@ -100,6 +101,7 @@ export async function emitWebStatic(distDir, opts = {}) {
100
101
  }
101
102
  const meta = await resolvePageMeta(Page, { params, props, pathname: pattern, origin });
102
103
  const layoutChain = resolveLayoutChain(distDir, page.chunkId);
104
+ await host.ensureComponents([page.chunkId, ...layoutChain]);
103
105
  let bodyHtml = '';
104
106
  for await (const chunk of renderToStream(Page, props, {})) {
105
107
  bodyHtml += chunk;
@@ -217,6 +219,7 @@ export async function emitWebStatic(distDir, opts = {}) {
217
219
  const digest = sha256Hex(canonicalJson(manifest));
218
220
  manifest.manifestDigest = digest;
219
221
  writePrettyJsonFile(path.join(vmzDir, 'static-delivery-manifest.json'), manifest);
222
+ emitStaticClientEntries(distDir, pageCatalog);
220
223
  const assets = emitContentAddressedAssets(distDir);
221
224
  manifest.contentAddressedAssets = {
222
225
  schema: assets.manifest.schema,
@@ -558,3 +561,50 @@ function buildSitemap(_origin, generations) {
558
561
  }
559
562
  return native.generateSitemapXml(urls);
560
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
+ }
587
+ }
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
+ }
606
+ }
607
+ /** @param {string} strategy */
608
+ function isEventResumeStrategy(strategy) {
609
+ return strategy === 'event' || strategy === 'click' || String(strategy).startsWith('event:');
610
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/vmz",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "type": "module",
5
5
  "description": "VMZ Node toolchain — N-API workspace session + CLI (publish name @vmz/vmz)",
6
6
  "license": "MIT",
@@ -48,15 +48,15 @@
48
48
  }
49
49
  },
50
50
  "dependencies": {
51
- "@vmz/core": "0.1.9",
52
- "@vmz/plugin": "0.1.9",
53
- "@vmz/protocol": "0.1.9",
51
+ "@vmz/core": "0.1.11",
52
+ "@vmz/plugin": "0.1.11",
53
+ "@vmz/protocol": "0.1.11",
54
54
  "jiti": "^2.6.1",
55
55
  "json5": "^2.2.3"
56
56
  },
57
57
  "peerDependencies": {
58
- "@vmz/plugin-markdown-it": "0.1.9",
59
- "@vmz/test": "0.1.9",
58
+ "@vmz/plugin-markdown-it": "0.1.11",
59
+ "@vmz/test": "0.1.11",
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.1.9",
94
- "@vmz/vmz-win32-arm64": "0.1.9",
95
- "@vmz/vmz-darwin-x64": "0.1.9",
96
- "@vmz/vmz-darwin-arm64": "0.1.9",
97
- "@vmz/vmz-linux-x64": "0.1.9",
98
- "@vmz/vmz-linux-arm64": "0.1.9"
93
+ "@vmz/vmz-win32-x64": "0.1.11",
94
+ "@vmz/vmz-win32-arm64": "0.1.11",
95
+ "@vmz/vmz-darwin-x64": "0.1.11",
96
+ "@vmz/vmz-darwin-arm64": "0.1.11",
97
+ "@vmz/vmz-linux-x64": "0.1.11",
98
+ "@vmz/vmz-linux-arm64": "0.1.11"
99
99
  },
100
100
  "publishConfig": {
101
101
  "access": "public"