@vmz/vmz 0.1.11 → 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;
@@ -8,15 +8,16 @@ 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';
12
11
  import { artifactHrefFromHtml, buildDocumentIslands, buildDocumentSearch, collectFenceBodies, renderIslandShellsHtml, } from './document-interactive.js';
12
+ import { assertIntegratedDistReady, renderCompiledDocumentLayout } from './document-layout-render.js';
13
13
  import { resolveMarkdownEngine } from './document-markdown.js';
14
+ import { loadLocalesRouting } from './document-routing-config.js';
14
15
  import { DOCUMENT_VIEW_SCHEMA } from './document-schema.js';
15
16
  import { createWorkspace } from './index.js';
16
17
  import { requireNativeAddon } from './native-addon.js';
17
18
  import { writePrettyJsonFile } from './pretty-json.js';
18
19
  /**
19
- * @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
20
21
  */
21
22
  export async function buildDocuments(opts) {
22
23
  const projectRoot = path.resolve(opts.projectRoot);
@@ -63,8 +64,12 @@ export async function buildDocuments(opts) {
63
64
  });
64
65
  manifest.search = search;
65
66
  manifest.islands = islands;
66
- const hostChromeRaw = resolveHostSiteChromeRaw(projectRoot);
67
- const useHostShell = Boolean(hostChromeRaw) && (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
+ }
68
73
  fs.mkdirSync(outDir, { recursive: true });
69
74
  const designs = resolveDocumentDesignsCss(projectRoot);
70
75
  /** @type {string | null} */
@@ -95,6 +100,20 @@ export async function buildDocuments(opts) {
95
100
  pageKey: page.identity.pageKey,
96
101
  locale: page.identity.locale,
97
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
+ }
98
117
  const view = {
99
118
  schema: DOCUMENT_VIEW_SCHEMA,
100
119
  pageKey: page.identity.pageKey,
@@ -108,7 +127,7 @@ export async function buildDocuments(opts) {
108
127
  designsCss: designsHref,
109
128
  noJsReadable: true,
110
129
  hydrate: 'island-only',
111
- hostShell: useHostShell,
130
+ hostShell: useCompiledShell ? 'compiled-layout' : false,
112
131
  islands: ['DocumentSearch'].concat((islands.islands || [])
113
132
  .filter((isl) => isl.kind === 'playground' &&
114
133
  isl.fence?.locale === page.identity.locale &&
@@ -130,12 +149,9 @@ export async function buildDocuments(opts) {
130
149
  htmlRel,
131
150
  searchShellHtml: shells.searchHtml,
132
151
  playgroundShellHtml: shells.playgroundHtml,
133
- hostChrome: useHostShell
134
- ? renderHostChromeForLocale(projectRoot, page.identity.locale, routing, enriched.routeBase, hostChromeRaw)
135
- : null,
136
152
  routing,
137
- routeBase: enriched.routeBase,
138
- pageKey: page.identity.pageKey,
153
+ compiledLayoutHtml,
154
+ useCompiledShell,
139
155
  });
140
156
  fs.writeFileSync(htmlAbs, html, 'utf8');
141
157
  written.push({ route: info.route, htmlPath: htmlRel, viewPath: viewRel });
@@ -151,6 +167,7 @@ export async function buildDocuments(opts) {
151
167
  outDir: path.relative(projectRoot, outDir).replace(/\\/g, '/') || '.',
152
168
  designs: designs.source,
153
169
  designsCss: designsHref,
170
+ hostShell: useCompiledShell ? 'compiled-layout' : 'standalone',
154
171
  pages: written,
155
172
  evidence: 'document.evidence.json',
156
173
  search: 'document.search.json',
@@ -164,22 +181,21 @@ export async function buildDocuments(opts) {
164
181
  return { ok: true, manifest: manifestOut, outDir, pages: written, search, islands };
165
182
  }
166
183
  /**
167
- * No-JS readable static HTML: nav + main landmarks, Island shells without scripts.
168
- * Integrated mounts reuse host SiteHeader/SiteFooter templates when present.
184
+ * @param {{ appDistDir?: string }} opts
185
+ * @param {string} outDir
169
186
  */
170
- function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, designsHref, htmlRel, searchShellHtml = '', playgroundShellHtml = '', hostChrome = null, routing = { strategy: 'prefix' }, routeBase = '/docs', pageKey = 'index', }) {
171
- const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
172
- const depth = htmlRel.split('/').length - 1;
173
- const prefix = depth > 0 ? '../'.repeat(depth) : './';
174
- /** @type {string[]} */
175
- const cssHrefs = [];
176
- if (hostChrome) {
177
- // Integrated documents are served with pretty directory URLs. Root
178
- // absolute assets remain correct for both emitted files and rewrites.
179
- 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;
180
191
  }
181
- if (designsHref)
182
- 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;');
183
199
  const navItems = nav
184
200
  .map((n) => {
185
201
  const href = routing.strategy === 'none' || routing.strategy === 'domain' ? n.href : relativeHref(htmlRel, n.href, route);
@@ -197,15 +213,7 @@ function renderStaticHtml({ title, locale, route, nav, bodyHtml, headings, desig
197
213
  ${navItems}
198
214
  </ul>
199
215
  </nav>`;
200
- /** @type {string} */
201
- let bodyInner;
202
- if (hostChrome) {
203
- const header = hostChrome.header.replace(/(<a\s+href="\/d\/?")([^>]*>文档<\/a>)/, '$1 aria-current="page"$2');
204
- bodyInner = ` <div class="site site--docs">
205
- <a class="skip-link" href="#main">Skip to content</a>
206
- ${header}
207
- <div class="doc-body">
208
- <aside class="doc-sidebar">
216
+ return ` <aside class="doc-sidebar">
209
217
  ${docsNav}
210
218
  ${searchShellHtml}
211
219
  </aside>
@@ -214,16 +222,47 @@ ${searchShellHtml}
214
222
  ${bodyHtml}
215
223
  ${playgroundShellHtml}
216
224
  </main>
217
- </div>
218
- </div>
219
- ${hostChrome.footer}
220
- </div>
221
- ${routing.strategy === 'none' && hostChrome ? localeNonePickerScript() : ''}
222
- `;
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;
223
246
  }
224
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
+ : '';
225
260
  bodyInner = ` <a class="skip-link" href="#main">Skip to content</a>
226
- ${docsNav}
261
+ <nav aria-label="Documents" class="doc-subnav">
262
+ <ul>
263
+ ${navItems}
264
+ </ul>
265
+ </nav>
227
266
  ${searchShellHtml}
228
267
  ${toc}<main id="main">
229
268
  ${bodyHtml}
@@ -243,50 +282,6 @@ ${playgroundShellHtml}
243
282
  bodyAttrs: ['data-vmz-hydrate', 'island-only'],
244
283
  });
245
284
  }
246
- /**
247
- * Integrated DocumentMount: reuse host SiteHeader / SiteFooter .vmz templates.
248
- * @param {string} projectRoot
249
- * @returns {{ header: string, footer: string } | null}
250
- */
251
- function resolveHostSiteChromeRaw(projectRoot) {
252
- const headerPath = path.join(projectRoot, 'src', 'components', 'SiteHeader.vmz');
253
- const footerPath = path.join(projectRoot, 'src', 'components', 'SiteFooter.vmz');
254
- if (!fs.existsSync(headerPath) || !fs.existsSync(footerPath))
255
- return null;
256
- const header = extractVmzTemplateHtml(headerPath);
257
- const footer = extractVmzTemplateHtml(footerPath);
258
- if (!header || !footer)
259
- return null;
260
- return { header, footer };
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
- }
284
- /** @param {string} filePath */
285
- function extractVmzTemplateHtml(filePath) {
286
- const src = fs.readFileSync(filePath, 'utf8');
287
- const m = src.match(/<template>([\s\S]*?)<\/template>/);
288
- return m ? m[1].trim() : '';
289
- }
290
285
  function relativeHref(fromHtmlRel, toRoute, _fromRoute) {
291
286
  const toParts = String(toRoute).replace(/^\//, '').split('/').filter(Boolean);
292
287
  let toRel;
@@ -7,7 +7,7 @@ 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';
10
+ import { loadLocalesRouting } from './document-routing-config.js';
11
11
  import { pageHtmlRel } from './document-enrich.js';
12
12
  import { log } from './log.js';
13
13
  import { requireNativeAddon } from './native-addon.js';
@@ -33,6 +33,7 @@ export async function buildIntegratedDocuments(opts) {
33
33
  const result = await buildDocuments({
34
34
  projectRoot,
35
35
  outDir,
36
+ appDistDir: outDir,
36
37
  strict: Boolean(opts.strict),
37
38
  });
38
39
  if (!result.ok) {
@@ -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.11",
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.11",
52
- "@vmz/plugin": "0.1.11",
53
- "@vmz/protocol": "0.1.11",
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.11",
59
- "@vmz/test": "0.1.11",
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.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"
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"
@@ -1,28 +0,0 @@
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;
@@ -1,128 +0,0 @@
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
- }