@vmz/vmz 0.1.10 → 0.1.12

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.
@@ -154,7 +154,8 @@ function ingestCandidate(absDist, rel, rewrites, objects, opts) {
154
154
  else {
155
155
  const existing = sha256Hex(fs.readFileSync(dest));
156
156
  if (existing !== digest) {
157
- throw new Error(`content-address collision at ${assetRel}`);
157
+ // Stale assets/ from a prior partial build can reuse hash filenames with different bytes.
158
+ fs.writeFileSync(dest, buf);
158
159
  }
159
160
  }
160
161
  objects.push({
@@ -17,7 +17,7 @@ import { buildIntegratedDocuments, projectHasDocuments } from './document-integr
17
17
  import { createWorkspace, resolveNativePath } from './index.js';
18
18
  import { emitLocaleRuntimeModules, localeHasErrors } from './locale-check.js';
19
19
  import { log } from './log.js';
20
- import { coalesceRootBurst, collectDevWatchRoots, isDependencyPath, mergeDirtySets } from './dev-watch-roots.js';
20
+ import { coalesceRootBurst, collectDevWatchRoots, classifyWatchRoot, isDependencyPath, mergeDirtySets } from './dev-watch-roots.js';
21
21
  import { diffFingerprints, fileFingerprintMap } from './watch-diff.js';
22
22
  /**
23
23
  * @typedef {object} DevSessionOptions
@@ -190,6 +190,7 @@ export function createDevSession(options) {
190
190
  }
191
191
  const docsRoot = path.join(project, 'documents');
192
192
  const localesRoot = path.join(project, 'locales');
193
+ const designsRoot = path.join(project, 'designs');
193
194
  const watched = collectDevWatchRoots({ project, outDir });
194
195
  /** @type {string[]} */
195
196
  const watchRoots = [...watched.roots];
@@ -218,37 +219,46 @@ export function createDevSession(options) {
218
219
  * Scan all watch roots into a batch. Does not update fingerprints.
219
220
  */
220
221
  function scanBatch() {
221
- /** @type {{ srcChanged: string[], srcDeleted: string[], depChanged: string[], depDeleted: string[], docsDirty: boolean, localesDirty: boolean }} */
222
+ /** @type {{ srcChanged: string[], srcDeleted: string[], depChanged: string[], depDeleted: string[], designsChanged: string[], designsDeleted: string[], docsDirty: boolean, localesDirty: boolean, designsDirty: boolean }} */
222
223
  const batch = {
223
224
  srcChanged: [],
224
225
  srcDeleted: [],
225
226
  depChanged: [],
226
227
  depDeleted: [],
228
+ designsChanged: [],
229
+ designsDeleted: [],
227
230
  docsDirty: false,
228
231
  localesDirty: false,
232
+ designsDirty: false,
229
233
  };
234
+ const watchCtx = { src, docsRoot, localesRoot, designsRoot, dependencyRoots };
230
235
  for (const root of watchRoots) {
231
236
  const prev = fingerprints.get(root) || new Map();
232
237
  const next = fileFingerprintMap(root);
233
238
  const diff = diffFingerprints(prev, next);
234
239
  if (!diff.changed.length && !diff.deleted.length)
235
240
  continue;
236
- if (root === src) {
241
+ const kind = classifyWatchRoot(root, watchCtx);
242
+ if (kind === 'src') {
237
243
  batch.srcChanged.push(...diff.changed);
238
244
  batch.srcDeleted.push(...diff.deleted);
239
245
  }
240
- else if (root === localesRoot) {
246
+ else if (kind === 'locales') {
241
247
  batch.localesDirty = true;
242
248
  }
243
- else if (root === docsRoot) {
249
+ else if (kind === 'docs') {
244
250
  batch.docsDirty = true;
245
251
  }
246
- else if (dependencyRoots.includes(root)) {
252
+ else if (kind === 'designs') {
253
+ batch.designsDirty = true;
254
+ batch.designsChanged.push(...diff.changed);
255
+ batch.designsDeleted.push(...diff.deleted);
256
+ }
257
+ else if (kind === 'dep') {
247
258
  batch.depChanged.push(...diff.changed);
248
259
  batch.depDeleted.push(...diff.deleted);
249
260
  }
250
261
  else {
251
- // designs or other application roots → treat like docs (full reload)
252
262
  batch.docsDirty = true;
253
263
  }
254
264
  }
@@ -280,7 +290,8 @@ export function createDevSession(options) {
280
290
  }
281
291
  const srcDirty = batch.srcChanged.length + batch.srcDeleted.length;
282
292
  const depDirty = batch.depChanged.length + batch.depDeleted.length;
283
- if (!srcDirty && !depDirty && !batch.docsDirty && !batch.localesDirty)
293
+ const designsDirty = batch.designsDirty || batch.designsChanged.length + batch.designsDeleted.length;
294
+ if (!srcDirty && !depDirty && !batch.docsDirty && !batch.localesDirty && !designsDirty)
284
295
  continue;
285
296
  // Coalesce multi-file bursts without dropping the initial dirty set.
286
297
  if (srcDirty > 1) {
@@ -329,6 +340,10 @@ export function createDevSession(options) {
329
340
  batch.depDeleted = depMerged.deleted;
330
341
  batch.docsDirty = batch.docsDirty || residual.docsDirty;
331
342
  batch.localesDirty = batch.localesDirty || residual.localesDirty;
343
+ batch.designsDirty = batch.designsDirty || residual.designsDirty;
344
+ const designsMerged = mergeDirtySets({ changed: batch.designsChanged, deleted: batch.designsDeleted }, { changed: residual.designsChanged, deleted: residual.designsDeleted });
345
+ batch.designsChanged = designsMerged.changed;
346
+ batch.designsDeleted = designsMerged.deleted;
332
347
  }
333
348
  commitFingerprints();
334
349
  if (!batch.srcChanged.length &&
@@ -336,7 +351,8 @@ export function createDevSession(options) {
336
351
  !batch.depChanged.length &&
337
352
  !batch.depDeleted.length &&
338
353
  !batch.docsDirty &&
339
- !batch.localesDirty) {
354
+ !batch.localesDirty &&
355
+ !(batch.designsDirty || batch.designsChanged.length || batch.designsDeleted.length)) {
340
356
  continue;
341
357
  }
342
358
  // Dependency changes: conservative full rebuild + full reload (v0.1.5).
@@ -376,7 +392,7 @@ export function createDevSession(options) {
376
392
  log.info(kind === 'respawn' ? 'reload ok (respawned; deps)' : 'soft reload ok (full page; deps)');
377
393
  continue;
378
394
  }
379
- let needFullReload = batch.docsDirty;
395
+ let needFullReload = batch.docsDirty || batch.designsDirty;
380
396
  if (batch.srcChanged.length || batch.srcDeleted.length) {
381
397
  log.info(`change detected (${batch.srcChanged.length} update, ${batch.srcDeleted.length} delete) — affected rebuild…`);
382
398
  const changes = [
@@ -419,6 +435,37 @@ export function createDevSession(options) {
419
435
  : 'soft reload ok');
420
436
  continue;
421
437
  }
438
+ if (batch.designsChanged.length || batch.designsDeleted.length || batch.designsDirty) {
439
+ log.info(`designs change detected (${batch.designsChanged.length} update, ${batch.designsDeleted.length} delete) — rebuilding styles…`);
440
+ const changes = [
441
+ ...batch.designsChanged.map((p) => ({ path: p, kind: /** @type {'update'} */ ('update') })),
442
+ ...batch.designsDeleted.map((p) => ({ path: p, kind: /** @type {'delete'} */ ('delete') })),
443
+ ];
444
+ const report = rebuild(changes.length ? changes : undefined);
445
+ if (!printReport(report, 'rebuild')) {
446
+ log.warn('designs rebuild failed — keeping previous styles');
447
+ continue;
448
+ }
449
+ if (batch.docsDirty || projectHasDocuments(project)) {
450
+ const docs = await buildIntegratedDocuments({ projectRoot: project, outDir });
451
+ if (!docs.ok)
452
+ log.warn('document mount rebuild failed — keeping previous docs');
453
+ }
454
+ if (wechatPreview) {
455
+ if (!packWechatPreview())
456
+ log.warn('wechat pack failed — keeping previous dist/wechat');
457
+ continue;
458
+ }
459
+ const kind = await reloadAfterBuild({
460
+ affectedChunks: report.affectedChunks ?? [],
461
+ seedChunks: report.seedChunks ?? [],
462
+ emitted: report.emitted ?? [],
463
+ full: true,
464
+ islandHmr: false,
465
+ });
466
+ log.info(kind === 'respawn' ? 'reload ok (respawned; designs)' : 'soft reload ok (full page; designs)');
467
+ continue;
468
+ }
422
469
  if (batch.localesDirty) {
423
470
  log.info('locales change detected — re-emitting #locales runtime…');
424
471
  if (!emitLocales()) {
@@ -59,6 +59,19 @@ export declare function collectDevWatchRoots(opts: any): {
59
59
  dependencyRoots: unknown[];
60
60
  applicationRoots: any[];
61
61
  };
62
+ /**
63
+ * Classify which watch bucket a root belongs to.
64
+ * @param {string} root
65
+ * @param {{
66
+ * src: string,
67
+ * docsRoot: string,
68
+ * localesRoot: string,
69
+ * designsRoot: string,
70
+ * dependencyRoots: string[],
71
+ * }} ctx
72
+ * @returns {'src' | 'locales' | 'docs' | 'designs' | 'dep' | 'other'}
73
+ */
74
+ export declare function classifyWatchRoot(root: any, ctx: any): "src" | "locales" | "docs" | "designs" | "dep" | "other";
62
75
  /**
63
76
  * Classify whether a changed file lives under a dependency watch root (not app src).
64
77
  * @param {string} file
@@ -209,6 +209,31 @@ export function collectDevWatchRoots(opts) {
209
209
  }
210
210
  return { roots, dependencyRoots, applicationRoots };
211
211
  }
212
+ /**
213
+ * Classify which watch bucket a root belongs to.
214
+ * @param {string} root
215
+ * @param {{
216
+ * src: string,
217
+ * docsRoot: string,
218
+ * localesRoot: string,
219
+ * designsRoot: string,
220
+ * dependencyRoots: string[],
221
+ * }} ctx
222
+ * @returns {'src' | 'locales' | 'docs' | 'designs' | 'dep' | 'other'}
223
+ */
224
+ export function classifyWatchRoot(root, ctx) {
225
+ if (root === ctx.src)
226
+ return 'src';
227
+ if (root === ctx.localesRoot)
228
+ return 'locales';
229
+ if (root === ctx.docsRoot)
230
+ return 'docs';
231
+ if (root === ctx.designsRoot)
232
+ return 'designs';
233
+ if ((ctx.dependencyRoots || []).includes(root))
234
+ return 'dep';
235
+ return 'other';
236
+ }
212
237
  /**
213
238
  * Classify whether a changed file lives under a dependency watch root (not app src).
214
239
  * @param {string} file
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @param {{ projectRoot: string, outDir?: string, strict?: boolean, engines?: { markdown?: string } }} opts
2
+ * @param {{ projectRoot: string, outDir?: string, appDistDir?: string, strict?: boolean, engines?: { markdown?: string } }} opts
3
3
  */
4
4
  export declare function buildDocuments(opts: any): Promise<{
5
5
  ok: boolean;
@@ -57,6 +57,7 @@ export declare function buildDocuments(opts: any): Promise<{
57
57
  outDir: string;
58
58
  designs: string;
59
59
  designsCss: any;
60
+ hostShell: string;
60
61
  pages: any[];
61
62
  evidence: string;
62
63
  search: string;
@@ -9,23 +9,27 @@ import { resolveDocumentDesignsCss } from './document-designs.js';
9
9
  import { enrichDocumentContent, pageHtmlRel } from './document-enrich.js';
10
10
  import { enrichDocumentEvidence } from './document-evidence.js';
11
11
  import { artifactHrefFromHtml, buildDocumentIslands, buildDocumentSearch, collectFenceBodies, renderIslandShellsHtml, } from './document-interactive.js';
12
+ import { assertIntegratedDistReady, renderCompiledDocumentLayout } from './document-layout-render.js';
12
13
  import { resolveMarkdownEngine } from './document-markdown.js';
14
+ import { loadLocalesRouting } from './document-routing-config.js';
13
15
  import { DOCUMENT_VIEW_SCHEMA } from './document-schema.js';
14
16
  import { createWorkspace } from './index.js';
15
17
  import { requireNativeAddon } from './native-addon.js';
16
18
  import { writePrettyJsonFile } from './pretty-json.js';
17
19
  /**
18
- * @param {{ projectRoot: string, outDir?: string, strict?: boolean, engines?: { markdown?: string } }} opts
20
+ * @param {{ projectRoot: string, outDir?: string, appDistDir?: string, strict?: boolean, engines?: { markdown?: string } }} opts
19
21
  */
20
22
  export async function buildDocuments(opts) {
21
23
  const projectRoot = path.resolve(opts.projectRoot);
22
24
  const outDir = path.resolve(opts.outDir || path.join(projectRoot, 'dist', 'documents'));
23
25
  const strict = Boolean(opts.strict);
24
26
  const manifest = checkDocuments({ projectRoot, strict });
27
+ const routing = loadLocalesRouting(projectRoot) || { strategy: 'prefix' };
25
28
  const engine = await resolveMarkdownEngine({ engines: opts.engines, projectRoot });
26
29
  const enriched = enrichDocumentContent(manifest, {
27
30
  analyzeMarkdown: engine.analyzeMarkdown,
28
31
  projectRoot,
32
+ routing,
29
33
  });
30
34
  manifest.diagnostics = enriched.diagnostics;
31
35
  const evidence = await enrichDocumentEvidence(manifest, {
@@ -60,8 +64,12 @@ export async function buildDocuments(opts) {
60
64
  });
61
65
  manifest.search = search;
62
66
  manifest.islands = islands;
63
- const hostChrome = resolveHostSiteChrome(projectRoot);
64
- const useHostShell = Boolean(hostChrome) && (manifest.mounts || []).some((m) => m.mode === 'integrated');
67
+ const integratedMount = (manifest.mounts || []).some((m) => m.mode === 'integrated');
68
+ const appDistDir = resolveAppDistDir(opts, outDir);
69
+ const useCompiledShell = Boolean(integratedMount && appDistDir);
70
+ if (integratedMount && appDistDir) {
71
+ assertIntegratedDistReady(appDistDir);
72
+ }
65
73
  fs.mkdirSync(outDir, { recursive: true });
66
74
  const designs = resolveDocumentDesignsCss(projectRoot);
67
75
  /** @type {string | null} */
@@ -92,6 +100,20 @@ export async function buildDocuments(opts) {
92
100
  pageKey: page.identity.pageKey,
93
101
  locale: page.identity.locale,
94
102
  });
103
+ const slotHtml = buildDocumentSlotHtml({
104
+ nav,
105
+ bodyHtml: info.html,
106
+ headings: info.headings,
107
+ htmlRel,
108
+ route: info.route,
109
+ routing,
110
+ searchShellHtml: shells.searchHtml,
111
+ playgroundShellHtml: shells.playgroundHtml,
112
+ });
113
+ let compiledLayoutHtml = null;
114
+ if (useCompiledShell) {
115
+ compiledLayoutHtml = await renderCompiledDocumentLayout(appDistDir, page.identity.locale, slotHtml);
116
+ }
95
117
  const view = {
96
118
  schema: DOCUMENT_VIEW_SCHEMA,
97
119
  pageKey: page.identity.pageKey,
@@ -105,7 +127,7 @@ export async function buildDocuments(opts) {
105
127
  designsCss: designsHref,
106
128
  noJsReadable: true,
107
129
  hydrate: 'island-only',
108
- hostShell: useHostShell,
130
+ hostShell: useCompiledShell ? 'compiled-layout' : false,
109
131
  islands: ['DocumentSearch'].concat((islands.islands || [])
110
132
  .filter((isl) => isl.kind === 'playground' &&
111
133
  isl.fence?.locale === page.identity.locale &&
@@ -127,7 +149,9 @@ export async function buildDocuments(opts) {
127
149
  htmlRel,
128
150
  searchShellHtml: shells.searchHtml,
129
151
  playgroundShellHtml: shells.playgroundHtml,
130
- hostChrome: useHostShell ? hostChrome : null,
152
+ routing,
153
+ compiledLayoutHtml,
154
+ useCompiledShell,
131
155
  });
132
156
  fs.writeFileSync(htmlAbs, html, 'utf8');
133
157
  written.push({ route: info.route, htmlPath: htmlRel, viewPath: viewRel });
@@ -143,6 +167,7 @@ export async function buildDocuments(opts) {
143
167
  outDir: path.relative(projectRoot, outDir).replace(/\\/g, '/') || '.',
144
168
  designs: designs.source,
145
169
  designsCss: designsHref,
170
+ hostShell: useCompiledShell ? 'compiled-layout' : 'standalone',
146
171
  pages: written,
147
172
  evidence: 'document.evidence.json',
148
173
  search: 'document.search.json',
@@ -156,25 +181,24 @@ export async function buildDocuments(opts) {
156
181
  return { ok: true, manifest: manifestOut, outDir, pages: written, search, islands };
157
182
  }
158
183
  /**
159
- * No-JS readable static HTML: nav + main landmarks, Island shells without scripts.
160
- * Integrated mounts reuse host SiteHeader/SiteFooter templates when present.
184
+ * @param {{ appDistDir?: string }} opts
185
+ * @param {string} outDir
161
186
  */
162
- function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, designsHref, htmlRel, searchShellHtml = '', playgroundShellHtml = '', hostChrome = null, }) {
163
- const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
164
- const depth = htmlRel.split('/').length - 1;
165
- const prefix = depth > 0 ? '../'.repeat(depth) : './';
166
- /** @type {string[]} */
167
- const cssHrefs = [];
168
- if (hostChrome) {
169
- // Integrated documents are served with pretty directory URLs. Root
170
- // absolute assets remain correct for both emitted files and rewrites.
171
- cssHrefs.push('/vmz.css');
187
+ function resolveAppDistDir(opts, outDir) {
188
+ if (opts.appDistDir) {
189
+ const p = path.resolve(opts.appDistDir);
190
+ return fs.existsSync(path.join(p, 'vmz-dom.js')) ? p : null;
172
191
  }
173
- if (designsHref)
174
- cssHrefs.push(hostChrome ? `/${designsHref}` : prefix + designsHref);
192
+ return fs.existsSync(path.join(outDir, 'vmz-dom.js')) ? outDir : null;
193
+ }
194
+ /**
195
+ * Document main column + sidebar (injected into DocumentLayout slot).
196
+ */
197
+ function buildDocumentSlotHtml({ nav, bodyHtml, headings, htmlRel, route, routing, searchShellHtml = '', playgroundShellHtml = '' }) {
198
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
175
199
  const navItems = nav
176
200
  .map((n) => {
177
- const href = relativeHref(htmlRel, n.href, route);
201
+ const href = routing.strategy === 'none' || routing.strategy === 'domain' ? n.href : relativeHref(htmlRel, n.href, route);
178
202
  const current = n.href === route ? ' aria-current="page"' : '';
179
203
  return ` <li><a href="${esc(href)}"${current}>${esc(n.title)}</a></li>`;
180
204
  })
@@ -189,15 +213,7 @@ function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, desig
189
213
  ${navItems}
190
214
  </ul>
191
215
  </nav>`;
192
- /** @type {string} */
193
- let bodyInner;
194
- if (hostChrome) {
195
- const header = hostChrome.header.replace(/(<a\s+href="\/d\/?")([^>]*>文档<\/a>)/, '$1 aria-current="page"$2');
196
- bodyInner = ` <div class="site site--docs">
197
- <a class="skip-link" href="#main">Skip to content</a>
198
- ${header}
199
- <div class="doc-body">
200
- <aside class="doc-sidebar">
216
+ return ` <aside class="doc-sidebar">
201
217
  ${docsNav}
202
218
  ${searchShellHtml}
203
219
  </aside>
@@ -206,15 +222,47 @@ ${searchShellHtml}
206
222
  ${bodyHtml}
207
223
  ${playgroundShellHtml}
208
224
  </main>
209
- </div>
210
- </div>
211
- ${hostChrome.footer}
212
- </div>
213
- `;
225
+ </div>`;
226
+ }
227
+ /**
228
+ * No-JS readable static HTML: nav + main landmarks, Island shells without scripts.
229
+ * Integrated mounts wrap content in compiled DocumentLayout (SiteHeader/SiteFooter SSR).
230
+ */
231
+ function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, designsHref, htmlRel, searchShellHtml = '', playgroundShellHtml = '', routing = { strategy: 'prefix' }, compiledLayoutHtml, useCompiledShell = false, }) {
232
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
233
+ const depth = htmlRel.split('/').length - 1;
234
+ const prefix = depth > 0 ? '../'.repeat(depth) : './';
235
+ /** @type {string[]} */
236
+ const cssHrefs = [];
237
+ if (useCompiledShell) {
238
+ cssHrefs.push('/vmz.css');
239
+ }
240
+ if (designsHref)
241
+ cssHrefs.push(useCompiledShell ? `/${designsHref}` : prefix + designsHref);
242
+ /** @type {string} */
243
+ let bodyInner;
244
+ if (useCompiledShell) {
245
+ bodyInner = compiledLayoutHtml;
214
246
  }
215
247
  else {
248
+ const navItems = nav
249
+ .map((n) => {
250
+ const href = routing.strategy === 'none' || routing.strategy === 'domain' ? n.href : relativeHref(htmlRel, n.href, route);
251
+ const current = n.href === route ? ' aria-current="page"' : '';
252
+ return ` <li><a href="${esc(href)}"${current}>${esc(n.title)}</a></li>`;
253
+ })
254
+ .join('\n');
255
+ const toc = headings.length > 1
256
+ ? `<nav aria-label="On this page" class="toc">\n <ol>\n${headings
257
+ .map((h) => ` <li class="h${h.level}"><a href="#${esc(h.id)}">${esc(h.text)}</a></li>`)
258
+ .join('\n')}\n </ol>\n </nav>\n`
259
+ : '';
216
260
  bodyInner = ` <a class="skip-link" href="#main">Skip to content</a>
217
- ${docsNav}
261
+ <nav aria-label="Documents" class="doc-subnav">
262
+ <ul>
263
+ ${navItems}
264
+ </ul>
265
+ </nav>
218
266
  ${searchShellHtml}
219
267
  ${toc}<main id="main">
220
268
  ${bodyHtml}
@@ -234,28 +282,6 @@ ${playgroundShellHtml}
234
282
  bodyAttrs: ['data-vmz-hydrate', 'island-only'],
235
283
  });
236
284
  }
237
- /**
238
- * Integrated DocumentMount: reuse host SiteHeader / SiteFooter .vmz templates.
239
- * @param {string} projectRoot
240
- * @returns {{ header: string, footer: string } | null}
241
- */
242
- function resolveHostSiteChrome(projectRoot) {
243
- const headerPath = path.join(projectRoot, 'src', 'components', 'SiteHeader.vmz');
244
- const footerPath = path.join(projectRoot, 'src', 'components', 'SiteFooter.vmz');
245
- if (!fs.existsSync(headerPath) || !fs.existsSync(footerPath))
246
- return null;
247
- const header = extractVmzTemplateHtml(headerPath);
248
- const footer = extractVmzTemplateHtml(footerPath);
249
- if (!header || !footer)
250
- return null;
251
- return { header, footer };
252
- }
253
- /** @param {string} filePath */
254
- function extractVmzTemplateHtml(filePath) {
255
- const src = fs.readFileSync(filePath, 'utf8');
256
- const m = src.match(/<template>([\s\S]*?)<\/template>/);
257
- return m ? m[1].trim() : '';
258
- }
259
285
  function relativeHref(fromHtmlRel, toRoute, _fromRoute) {
260
286
  const toParts = String(toRoute).replace(/^\//, '').split('/').filter(Boolean);
261
287
  let toRel;
@@ -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;
@@ -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-routing-config.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
  /**
@@ -31,6 +33,7 @@ export async function buildIntegratedDocuments(opts) {
31
33
  const result = await buildDocuments({
32
34
  projectRoot,
33
35
  outDir,
36
+ appDistDir: outDir,
34
37
  strict: Boolean(opts.strict),
35
38
  });
36
39
  if (!result.ok) {
@@ -40,7 +43,7 @@ export async function buildIntegratedDocuments(opts) {
40
43
  }
41
44
  return { ok: false, error: 'document diagnostics', pages: 0 };
42
45
  }
43
- writeMountRootRedirects(result.manifest, outDir);
46
+ writeMountRootRedirects(result.manifest, outDir, projectRoot);
44
47
  log.info(`document mount: pages=${result.pages.length} → ${path.relative(process.cwd(), outDir) || '.'}`);
45
48
  return { ok: true, pages: result.pages.length };
46
49
  }
@@ -51,22 +54,34 @@ export async function buildIntegratedDocuments(opts) {
51
54
  }
52
55
  }
53
56
  /**
54
- * Emit `{routeBase}/index.html` → defaultLocale landing (for /d/ and /docs/).
57
+ * Emit `{routeBase}/index.html` for integrated mounts.
58
+ * `routing.strategy: none` → copy default-locale docs index (LocaleId is Host state).
59
+ * prefix strategy → redirect HTML to `{routeBase}/{defaultLocale}/`.
55
60
  * @param {import('./document-schema.js').DocumentManifest} manifest
56
61
  * @param {string} outDir
62
+ * @param {string} projectRoot
57
63
  */
58
- function writeMountRootRedirects(manifest, outDir) {
64
+ function writeMountRootRedirects(manifest, outDir, projectRoot) {
59
65
  const defaultLocale = manifest.defaultLocale || manifest.locales?.[0];
60
66
  if (!defaultLocale)
61
67
  return;
68
+ const routing = loadLocalesRouting(projectRoot) || { strategy: 'prefix' };
62
69
  for (const mount of manifest.mounts || []) {
63
70
  if (!mount?.routeBase || mount.routeBase === '/')
64
71
  continue;
65
72
  const base = String(mount.routeBase).replace(/\/$/, '');
66
- const target = `${base}/${defaultLocale}/`;
67
73
  const relDir = base.replace(/^\//, '');
68
74
  const abs = path.join(outDir, relDir, 'index.html');
69
75
  fs.mkdirSync(path.dirname(abs), { recursive: true });
76
+ if (routing.strategy === 'none' || routing.strategy === 'domain') {
77
+ const srcRel = pageHtmlRel(base, defaultLocale, 'index');
78
+ const srcAbs = path.join(outDir, srcRel);
79
+ if (fs.existsSync(srcAbs)) {
80
+ fs.copyFileSync(srcAbs, abs);
81
+ }
82
+ continue;
83
+ }
84
+ const target = `${base}/${defaultLocale}/`;
70
85
  const native = requireNativeAddon();
71
86
  if (typeof native.generateRedirectHtml !== 'function') {
72
87
  throw new Error('vmz native addon missing generateRedirectHtml — rebuild with `pnpm napi:build`');
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Integrated DocumentMount — compile host chrome via DocumentLayout + createRenderHost.
3
+ * Replaces the removed regex template lowering in document-host-chrome.ts.
4
+ */
5
+ /** @param {string} distDir */
6
+ export declare function resolveDocumentLayoutChunkId(distDir: any): string;
7
+ /**
8
+ * @param {string} distDir
9
+ */
10
+ export declare function assertIntegratedDistReady(distDir: any): string;
11
+ /**
12
+ * @param {string} html
13
+ */
14
+ export declare function assertCompiledShellHtml(html: any): void;
15
+ /**
16
+ * @param {string} distDir
17
+ * @param {string} localeId
18
+ * @param {string} slotHtml
19
+ */
20
+ export declare function renderCompiledDocumentLayout(distDir: any, localeId: any, slotHtml: any): Promise<any>;
@@ -0,0 +1,87 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Integrated DocumentMount — compile host chrome via DocumentLayout + createRenderHost.
4
+ * Replaces the removed regex template lowering in document-host-chrome.ts.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { pathToFileURL } from 'node:url';
9
+ import { createRenderHost } from '@vmz/core/render-host';
10
+ /** @param {string} distDir */
11
+ export function resolveDocumentLayoutChunkId(distDir) {
12
+ for (const chunkId of ['layouts/DocumentLayout', 'components/DocumentLayout']) {
13
+ if (fs.existsSync(path.join(distDir, `${chunkId}.client.js`)))
14
+ return chunkId;
15
+ }
16
+ return null;
17
+ }
18
+ /**
19
+ * @param {string} distDir
20
+ */
21
+ export function assertIntegratedDistReady(distDir) {
22
+ const dom = path.join(distDir, 'vmz-dom.js');
23
+ if (!fs.existsSync(dom)) {
24
+ throw new Error('integrated document mount requires vmz build output (vmz-dom.js in app dist). Run `vmz build` before document emit.');
25
+ }
26
+ const chunkId = resolveDocumentLayoutChunkId(distDir);
27
+ if (!chunkId) {
28
+ throw new Error('integrated document mount requires compiled DocumentLayout (add src/layouts/DocumentLayout.vmz and rebuild)');
29
+ }
30
+ return chunkId;
31
+ }
32
+ /**
33
+ * @param {string} html
34
+ */
35
+ export function assertCompiledShellHtml(html) {
36
+ const header = html.match(/<header[^>]*data-vmz-fixture="site-header"[\s\S]*?<\/header>/i)?.[0] ?? '';
37
+ const footer = html.match(/<footer[^>]*data-vmz-fixture="site-footer"[\s\S]*?<\/footer>/i)?.[0] ?? '';
38
+ for (const part of [header, footer]) {
39
+ const leak = part.match(/\{[A-Za-z][A-Za-z0-9]*\}/);
40
+ if (leak) {
41
+ throw new Error(`document layout SSR leaked binding placeholder ${leak[0]}`);
42
+ }
43
+ if (/<(?:Link|Button|Icon)\b/.test(part)) {
44
+ throw new Error('document layout SSR leaked uncompiled VMZ component tag in chrome');
45
+ }
46
+ }
47
+ if (!header || !footer) {
48
+ throw new Error('document layout SSR missing compiled SiteHeader/SiteFooter fixtures');
49
+ }
50
+ }
51
+ /**
52
+ * @param {string} distDir
53
+ * @param {string} chunkId
54
+ */
55
+ async function loadCtor(distDir, chunkId) {
56
+ const href = pathToFileURL(path.join(distDir, `${chunkId}.client.js`)).href;
57
+ const mod = await import(`${href}?t=${Date.now()}`);
58
+ return mod.default;
59
+ }
60
+ /**
61
+ * @param {string} distDir
62
+ * @param {string} localeId
63
+ * @param {string} slotHtml
64
+ */
65
+ export async function renderCompiledDocumentLayout(distDir, localeId, slotHtml) {
66
+ const chunkId = assertIntegratedDistReady(distDir);
67
+ const prevHint = globalThis.__vmzLocaleIdHint;
68
+ globalThis.__vmzLocaleIdHint = localeId;
69
+ try {
70
+ if (typeof globalThis.document !== 'undefined' && globalThis.document?.documentElement) {
71
+ globalThis.document.documentElement.setAttribute('data-locale', localeId);
72
+ globalThis.document.documentElement.setAttribute('lang', localeId);
73
+ }
74
+ const host = await createRenderHost(distDir, { strictDeployment: true, preload: 'none' });
75
+ await host.ensureComponents([chunkId]);
76
+ const Layout = await loadCtor(distDir, chunkId);
77
+ const html = await host.renderToString(Layout, {}, { slotHtml });
78
+ assertCompiledShellHtml(html);
79
+ return html;
80
+ }
81
+ finally {
82
+ if (prevHint === undefined)
83
+ delete globalThis.__vmzLocaleIdHint;
84
+ else
85
+ globalThis.__vmzLocaleIdHint = prevHint;
86
+ }
87
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Project locale routing config for document mount (locales/locales.json5).
3
+ */
4
+ /**
5
+ * @param {string} projectRoot
6
+ * @returns {{ strategy?: string, defaultLocale?: string } | null}
7
+ */
8
+ export declare function loadLocalesRouting(projectRoot: any): any;
9
+ /**
10
+ * @param {string} routeBase
11
+ * @param {string} pageKey
12
+ */
13
+ export declare function docsRouteNone(routeBase: any, pageKey: any): string;
@@ -0,0 +1,40 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Project locale routing config for document mount (locales/locales.json5).
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ /**
8
+ * @param {string} projectRoot
9
+ * @returns {{ strategy?: string, defaultLocale?: string } | null}
10
+ */
11
+ export function loadLocalesRouting(projectRoot) {
12
+ const p = path.join(projectRoot, 'locales', 'locales.json5');
13
+ if (!fs.existsSync(p))
14
+ return null;
15
+ try {
16
+ const raw = fs.readFileSync(p, 'utf8');
17
+ let s = raw.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
18
+ const m = s.match(/routing\s*:\s*\{([\s\S]*?)\}/);
19
+ if (!m)
20
+ return null;
21
+ let block = `{${m[1]}}`;
22
+ block = block.replace(/([,{]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
23
+ block = block.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_, inner) => JSON.stringify(inner));
24
+ block = block.replace(/,\s*([}\]])/g, '$1');
25
+ return JSON.parse(block);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /**
32
+ * @param {string} routeBase
33
+ * @param {string} pageKey
34
+ */
35
+ export function docsRouteNone(routeBase, pageKey) {
36
+ const base = String(routeBase || '/').replace(/\/$/, '') || '';
37
+ const key = pageKey === 'index' ? '' : pageKey.replace(/\\/g, '/');
38
+ const parts = [base.replace(/^\//, ''), key].filter((p) => p !== '');
39
+ return '/' + (parts.length ? parts.join('/') : '');
40
+ }
package/dist/index.js CHANGED
@@ -150,6 +150,7 @@ export const SERVE_HOST_RUNTIME_FILES = [
150
150
  ['list-client-components.js', 'list-client-components.js'],
151
151
  ['deployment-registry.js', 'deployment-registry.js'],
152
152
  ['render-host.js', 'render-host.js'],
153
+ ['route-layout-chain.js', 'route-layout-chain.js'],
153
154
  ];
154
155
  /**
155
156
  * Copy serve-host + registry bootstrap modules from `@vmz/core` into app outDir.
@@ -8,6 +8,7 @@ import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { pathToFileURL } from 'node:url';
10
10
  import { createRenderHost } from '@vmz/core/render-host';
11
+ import { resolveRouteLayoutChain } from '@vmz/core/route-layout-chain';
11
12
  import { listClientComponentsSync } from '@vmz/core/component-registry';
12
13
  import { emitCdnPolicy } from './cdn-policy.js';
13
14
  import { emitContentAddressedAssets } from './content-addressed-assets.js';
@@ -100,7 +101,7 @@ export async function emitWebStatic(distDir, opts = {}) {
100
101
  }
101
102
  }
102
103
  const meta = await resolvePageMeta(Page, { params, props, pathname: pattern, origin });
103
- const layoutChain = resolveLayoutChain(distDir, page.chunkId);
104
+ const layoutChain = resolveRouteLayoutChain(distDir, page.chunkId);
104
105
  await host.ensureComponents([page.chunkId, ...layoutChain]);
105
106
  let bodyHtml = '';
106
107
  for await (const chunk of renderToStream(Page, props, {})) {
@@ -353,24 +354,6 @@ async function loadCtor(distDir, chunkId) {
353
354
  const mod = await import(`${href}?t=${Date.now()}`);
354
355
  return mod.default;
355
356
  }
356
- /**
357
- * @param {string} distDir
358
- * @param {string} pageChunkId
359
- */
360
- function resolveLayoutChain(distDir, pageChunkId) {
361
- const rel = pageChunkId.replace(/^pages\//, '');
362
- const parts = rel.split('/').filter(Boolean);
363
- parts.pop();
364
- /** @type {string[]} */
365
- const chain = [];
366
- for (let i = parts.length; i >= 0; i--) {
367
- const dirParts = parts.slice(0, i);
368
- const layoutChunk = ['pages', ...dirParts, 'Layout'].join('/');
369
- if (fs.existsSync(path.join(distDir, `${layoutChunk}.client.js`)))
370
- chain.unshift(layoutChunk);
371
- }
372
- return chain;
373
- }
374
357
  /**
375
358
  * @param {string} distDir
376
359
  * @param {string} chunkId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vmz/vmz",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
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.10",
52
- "@vmz/plugin": "0.1.10",
53
- "@vmz/protocol": "0.1.10",
51
+ "@vmz/core": "0.1.12",
52
+ "@vmz/plugin": "0.1.12",
53
+ "@vmz/protocol": "0.1.12",
54
54
  "jiti": "^2.6.1",
55
55
  "json5": "^2.2.3"
56
56
  },
57
57
  "peerDependencies": {
58
- "@vmz/plugin-markdown-it": "0.1.10",
59
- "@vmz/test": "0.1.10",
58
+ "@vmz/plugin-markdown-it": "0.1.12",
59
+ "@vmz/test": "0.1.12",
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.10",
94
- "@vmz/vmz-win32-arm64": "0.1.10",
95
- "@vmz/vmz-darwin-x64": "0.1.10",
96
- "@vmz/vmz-darwin-arm64": "0.1.10",
97
- "@vmz/vmz-linux-x64": "0.1.10",
98
- "@vmz/vmz-linux-arm64": "0.1.10"
93
+ "@vmz/vmz-win32-x64": "0.1.12",
94
+ "@vmz/vmz-win32-arm64": "0.1.12",
95
+ "@vmz/vmz-darwin-x64": "0.1.12",
96
+ "@vmz/vmz-darwin-arm64": "0.1.12",
97
+ "@vmz/vmz-linux-x64": "0.1.12",
98
+ "@vmz/vmz-linux-arm64": "0.1.12"
99
99
  },
100
100
  "publishConfig": {
101
101
  "access": "public"