@docubook/flame 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.docu/components/Context.tsx +6 -5
  2. package/.docu/components/DocsLayout.tsx +7 -4
  3. package/.docu/components/Menu.tsx +10 -9
  4. package/.docu/components/Navbar.tsx +24 -22
  5. package/.docu/components/Pagination.tsx +3 -2
  6. package/.docu/components/Search.tsx +6 -1
  7. package/.docu/components/Sidebar.tsx +22 -21
  8. package/.docu/components/Sublink.tsx +3 -3
  9. package/.docu/lib/build.deno.js +1 -1
  10. package/.docu/lib/{build.impl-CA7XPLz0.js → build.impl-DK485Nx1.js} +60 -31
  11. package/.docu/lib/build.impl-DK485Nx1.js.map +1 -0
  12. package/.docu/lib/build.impl-Dmybgcj2.js +2 -0
  13. package/.docu/lib/build.node.js +1 -1
  14. package/.docu/lib/clean.js +1 -1
  15. package/.docu/lib/deploy.deno.js +1 -1
  16. package/.docu/lib/deploy.node.js +1 -1
  17. package/.docu/lib/{deploy.shared-DjD3qs7t.js → deploy.shared-BW2KXFXs.js} +27 -8
  18. package/.docu/lib/deploy.shared-BW2KXFXs.js.map +1 -0
  19. package/.docu/lib/{html.shared-CsEFmy_Y.js → html.shared-RPCM1y5Q.js} +264 -73
  20. package/.docu/lib/html.shared-RPCM1y5Q.js.map +1 -0
  21. package/.docu/lib/{logger-_e2TNmEq.js → logger-DYFD47f8.js} +3 -3
  22. package/.docu/lib/{logger-_e2TNmEq.js.map → logger-DYFD47f8.js.map} +1 -1
  23. package/.docu/lib/paths-CHXABfXa.js +332 -0
  24. package/.docu/lib/paths-CHXABfXa.js.map +1 -0
  25. package/.docu/lib/preview.deno.js +1 -1
  26. package/.docu/lib/{preview.impl-DiTu2Coo.js → preview.impl-DMXVsGs0.js} +4 -4
  27. package/.docu/lib/preview.impl-DMXVsGs0.js.map +1 -0
  28. package/.docu/lib/preview.node.js +1 -1
  29. package/.docu/lib/{utils-B_CoyKie.js → server-utils-BYWB7A6T.js} +3 -21
  30. package/.docu/lib/server-utils-BYWB7A6T.js.map +1 -0
  31. package/.docu/lib/server.deno.js +1 -1
  32. package/.docu/lib/{server.impl-DPBnKdIh.js → server.impl-DcyZhPA_.js} +24 -19
  33. package/.docu/lib/server.impl-DcyZhPA_.js.map +1 -0
  34. package/.docu/lib/server.node.js +1 -1
  35. package/.docu/node/base-path.ts +306 -0
  36. package/.docu/node/build.impl.ts +50 -17
  37. package/.docu/node/build.ts +50 -17
  38. package/.docu/node/cache-key.ts +92 -4
  39. package/.docu/node/client-routes.ts +16 -1
  40. package/.docu/node/deploy.shared.ts +51 -15
  41. package/.docu/node/deploy.ts +7 -3
  42. package/.docu/node/html.shared.ts +33 -3
  43. package/.docu/node/html.ts +18 -3
  44. package/.docu/node/hydrate.node.ts +6 -0
  45. package/.docu/node/hydrate.ts +16 -1
  46. package/.docu/node/mdx.ts +102 -23
  47. package/.docu/node/paths.ts +115 -0
  48. package/.docu/node/preview.impl.ts +1 -1
  49. package/.docu/node/preview.ts +1 -1
  50. package/.docu/node/search-indexer.ts +20 -3
  51. package/.docu/node/seo.ts +41 -7
  52. package/.docu/node/server-routes.ts +34 -11
  53. package/.docu/node/server-utils.ts +152 -0
  54. package/.docu/node/server.impl.ts +22 -12
  55. package/.docu/node/server.ts +23 -27
  56. package/.docu/node/types.ts +13 -0
  57. package/.docu/node/utils.ts +56 -149
  58. package/.docu/pages/404.tsx +2 -1
  59. package/.docu/pages/index.tsx +6 -13
  60. package/bin/cli.js +5 -0
  61. package/docu.schema.json +10 -1
  62. package/package.json +5 -5
  63. package/template/docu.json +1 -0
  64. package/.docu/lib/build.impl-C97QWe1I.js +0 -2
  65. package/.docu/lib/build.impl-CA7XPLz0.js.map +0 -1
  66. package/.docu/lib/deploy.shared-DjD3qs7t.js.map +0 -1
  67. package/.docu/lib/html.shared-CsEFmy_Y.js.map +0 -1
  68. package/.docu/lib/paths-Cbz-dj4s.js +0 -72
  69. package/.docu/lib/paths-Cbz-dj4s.js.map +0 -1
  70. package/.docu/lib/preview.impl-DiTu2Coo.js.map +0 -1
  71. package/.docu/lib/server.impl-DPBnKdIh.js.map +0 -1
  72. package/.docu/lib/utils-B_CoyKie.js.map +0 -1
@@ -25,12 +25,17 @@ import {
25
25
  import {
26
26
  DOCS_DIR,
27
27
  DIST_DIR,
28
+ DOCS_OUT_DIR,
28
29
  ASSETS_DIR,
29
30
  CACHE_FILE,
30
31
  DOCS_ASSETS_DIR,
31
32
  PROJECT_ROOT,
32
33
  PAGES_DIR,
33
34
  loadDocuConfig,
35
+ servedBasePath,
36
+ docsDepth,
37
+ deployPath,
38
+ assertValidBasePath,
34
39
  } from "./paths";
35
40
  import { htmlShell } from "./html.shared";
36
41
  import { generateSearchIndex } from "./search-indexer";
@@ -39,7 +44,8 @@ import { logger } from "./logger";
39
44
  import { initSentry, captureException } from "./sentry";
40
45
  import { loadPlugins } from "./plugin-loader";
41
46
  import { BuildPluginBuilder } from "./plugin-builder";
42
- import { scanMdxFiles, resolveDocsIndexSource, DEFAULT_FAVICON } from "./utils";
47
+ import { scanMdxFiles, resolveDocsIndexSource } from "./server-utils";
48
+ import { defaultFavicon } from "./paths";
43
49
  import type { AssetManifest, BuildCache, BuildCacheMeta, CliArgs } from "./types";
44
50
  import { isCacheEntry } from "./types";
45
51
  import {
@@ -47,6 +53,7 @@ import {
47
53
  atomicWriteFile,
48
54
  hashMdxSources,
49
55
  hookMemoryPressure,
56
+ renderToolchainStamp,
50
57
  runtimeStamp,
51
58
  } from "./cache-key";
52
59
  import { clearDerivedPageCaches } from "./mdx";
@@ -76,7 +83,15 @@ async function readCache(): Promise<BuildCache> {
76
83
  const data = await readFile(CACHE_FILE, "utf-8");
77
84
  const parsed = JSON.parse(data) as BuildCache;
78
85
  const meta = parsed.__meta__ as BuildCacheMeta | undefined;
79
- if (!meta || meta.version !== BUILD_CACHE_VERSION || meta.runtime !== runtimeStamp()) {
86
+ // `render` covers the framework's own rendering sources: a template or
87
+ // meta-tag edit changes output without touching any MDX file, so an
88
+ // older cache must not be trusted after one.
89
+ if (
90
+ !meta ||
91
+ meta.version !== BUILD_CACHE_VERSION ||
92
+ meta.runtime !== runtimeStamp() ||
93
+ meta.render !== renderToolchainStamp()
94
+ ) {
80
95
  return {};
81
96
  }
82
97
  return parsed;
@@ -94,6 +109,7 @@ function stampCache(cache: BuildCache): void {
94
109
  builtAt: Date.now(),
95
110
  version: BUILD_CACHE_VERSION,
96
111
  runtime: runtimeStamp(),
112
+ render: renderToolchainStamp(),
97
113
  };
98
114
  }
99
115
 
@@ -245,8 +261,8 @@ async function renderDocsPage(
245
261
  const headExtra = builder?.collectHead(ctx);
246
262
  const bodyExtra = builder?.collectBody(ctx);
247
263
 
248
- const depth = slug ? slug.split("/").length : 1;
249
- const favicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
264
+ const depth = docsDepth(slug, servedBasePath());
265
+ const favicon = docuConfig.meta?.favicon || defaultFavicon();
250
266
  const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
251
267
  // MDX content hydrates from the bundled ESM module (mdx-hydrate), not
252
268
  // new Function — no 'unsafe-eval' needed in the CSP.
@@ -263,6 +279,7 @@ async function renderDocsPage(
263
279
  nonce,
264
280
  themeCss: inlineThemeCss,
265
281
  depth,
282
+ basePath: servedBasePath(),
266
283
  headExtra,
267
284
  bodyExtra,
268
285
  });
@@ -295,6 +312,11 @@ export async function runBuild(): Promise<void> {
295
312
 
296
313
  hookMemoryPressure(clearDerivedPageCaches);
297
314
 
315
+ // Normalized prefixes (case, whitespace, unservable characters) and values
316
+ // that cannot be honored would otherwise only surface after the whole build —
317
+ // report them, or stop before anything is written.
318
+ for (const warning of assertValidBasePath()) logger.warn(warning.message);
319
+
298
320
  logger.buildStart();
299
321
 
300
322
  if (args.clean) {
@@ -309,7 +331,7 @@ export async function runBuild(): Promise<void> {
309
331
  await mkdir(DIST_DIR, { recursive: true });
310
332
  await mkdir(ASSETS_DIR, { recursive: true });
311
333
 
312
- await copyDirectoryRecursive(DOCS_ASSETS_DIR, join(DIST_DIR, "docs", "assets"));
334
+ await copyDirectoryRecursive(DOCS_ASSETS_DIR, join(DOCS_OUT_DIR, "assets"));
313
335
 
314
336
  const mdxFiles = await scanMdxFiles(DOCS_DIR);
315
337
  const cache = args.force ? {} : await readCache();
@@ -363,6 +385,13 @@ export async function runBuild(): Promise<void> {
363
385
  // key so the index page hydrates too. Its render has its own try/catch; skip
364
386
  // on error.
365
387
  const indexSource = resolveDocsIndexSource(DOCS_DIR);
388
+ const docsIndexPath = join(DOCS_OUT_DIR, "index.html");
389
+ // If the source was removed since the previous build, don't ship stale docs
390
+ // output. At the deployment root, this path belongs to the landing page.
391
+ if (!indexSource && DOCS_OUT_DIR !== DIST_DIR) {
392
+ const { rm } = await import("node:fs/promises");
393
+ await rm(docsIndexPath, { force: true });
394
+ }
366
395
  if (indexSource) {
367
396
  try {
368
397
  const indexRaw = await readFile(indexSource, "utf-8");
@@ -426,7 +455,7 @@ export async function runBuild(): Promise<void> {
426
455
  const rebuildDecision = shouldRebuild(file.path, file.mtime, cache);
427
456
 
428
457
  if (rebuildDecision === "no" && !builder) {
429
- const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
458
+ const outputPath = join(DOCS_OUT_DIR, `${file.path}.html`);
430
459
  if (existsSync(outputPath) && !assetsChanged) {
431
460
  skipped++;
432
461
  continue;
@@ -448,7 +477,7 @@ export async function runBuild(): Promise<void> {
448
477
  const cached = cache[file.path];
449
478
  if (isCacheEntry(cached) && cached.hash === contentHash) {
450
479
  if (!assetsChanged) {
451
- const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
480
+ const outputPath = join(DOCS_OUT_DIR, `${file.path}.html`);
452
481
  if (existsSync(outputPath)) {
453
482
  cache[file.path] = { ...cached, mtime: file.mtime, builtAt: Date.now() };
454
483
  skipped++;
@@ -474,7 +503,7 @@ export async function runBuild(): Promise<void> {
474
503
  builder,
475
504
  pageNonce
476
505
  );
477
- const outputPath = join(DIST_DIR, "docs", `${capturedFile.path}.html`);
506
+ const outputPath = join(DOCS_OUT_DIR, `${capturedFile.path}.html`);
478
507
  await mkdir(dirname(outputPath), { recursive: true });
479
508
  await writeFile(outputPath, html);
480
509
  cache[capturedFile.path] = {
@@ -508,21 +537,19 @@ export async function runBuild(): Promise<void> {
508
537
  builder,
509
538
  generateNonce()
510
539
  );
511
- await mkdir(join(DIST_DIR, "docs"), { recursive: true });
512
- await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
540
+ if (DOCS_OUT_DIR !== DIST_DIR) {
541
+ await mkdir(DOCS_OUT_DIR, { recursive: true });
542
+ await writeFile(docsIndexPath, indexHtml);
543
+ }
513
544
  } catch (err) {
514
545
  const msg = err instanceof Error ? err.message : String(err);
515
546
  errors.push(`index: ${msg}`);
516
547
  console.error(`\n❌ Failed to build index: ${msg}\n`);
517
548
  }
518
- } else {
519
- const msg = "docs root index: docs/index.mdx (or docs/index.md) not found";
520
- errors.push(`index: ${msg}`);
521
- console.error(`\n❌ Failed to build index: ${msg}\n`);
522
549
  }
523
550
 
524
551
  const landingPage = React.createElement(IndexPage);
525
- const landingFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
552
+ const landingFavicon = docuConfig.meta?.favicon || defaultFavicon();
526
553
  const landingSeo = buildSeoMeta(
527
554
  docuConfig,
528
555
  docuConfig.meta as unknown as Record<string, unknown>,
@@ -548,14 +575,18 @@ export async function runBuild(): Promise<void> {
548
575
  js: landingContext.assets.js,
549
576
  nonce: landingNonce,
550
577
  themeCss: inlineThemeCss,
578
+ basePath: servedBasePath(),
551
579
  headExtra: builder?.collectHead(landingContext),
552
580
  bodyExtra: builder?.collectBody(landingContext),
553
581
  });
554
582
  if (builder) landingHtml = await builder.runTransformHtmlChain(landingHtml, landingContext);
583
+ // The landing page always owns `/`. At a root deployment the docs index
584
+ // would collide with it, so the index write above is skipped there — the
585
+ // docs root page simply does not exist.
555
586
  await writeFile(join(DIST_DIR, "index.html"), landingHtml);
556
587
 
557
588
  const notFoundPage = React.createElement(NotFoundPage);
558
- const notFoundFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
589
+ const notFoundFavicon = docuConfig.meta?.favicon || defaultFavicon();
559
590
  const notFoundNonce = generateNonce();
560
591
  const notFoundContext: PageContext = {
561
592
  pageType: "notFound",
@@ -583,6 +614,8 @@ export async function runBuild(): Promise<void> {
583
614
  // Served as the static-host fallback at ANY requested path — relative
584
615
  // depth can never be right there, so use root-absolute asset URLs.
585
616
  absoluteAssets: true,
617
+ basePath: servedBasePath(),
618
+ deployPath: deployPath(),
586
619
  });
587
620
  if (builder) notFoundHtml = await builder.runTransformHtmlChain(notFoundHtml, notFoundContext);
588
621
  await writeFile(join(DIST_DIR, "404.html"), notFoundHtml);
@@ -596,7 +629,7 @@ export async function runBuild(): Promise<void> {
596
629
  slug: f.path,
597
630
  title: f.path.split("/").pop() || f.path,
598
631
  filePath: join(DOCS_DIR, f.path),
599
- outputPath: join(DIST_DIR, "docs", `${f.path}.html`),
632
+ outputPath: join(DOCS_OUT_DIR, `${f.path}.html`),
600
633
  }));
601
634
  await builder.runOnEnd(pages, { assetManifest, outDir: DIST_DIR });
602
635
  }
@@ -17,12 +17,17 @@ import {
17
17
  import {
18
18
  DOCS_DIR,
19
19
  DIST_DIR,
20
+ DOCS_OUT_DIR,
20
21
  ASSETS_DIR,
21
22
  CACHE_FILE,
22
23
  DOCS_ASSETS_DIR,
23
24
  PROJECT_ROOT,
24
25
  PAGES_DIR,
25
26
  loadDocuConfig,
27
+ servedBasePath,
28
+ docsDepth,
29
+ deployPath,
30
+ assertValidBasePath,
26
31
  } from "./paths";
27
32
  import { htmlShell } from "./html";
28
33
  import { generateSearchIndex } from "./search-indexer";
@@ -31,7 +36,8 @@ import { logger } from "./logger";
31
36
  import { initSentry, captureException } from "./sentry";
32
37
  import { loadPlugins } from "./plugin-loader";
33
38
  import { BuildPluginBuilder } from "./plugin-builder";
34
- import { scanMdxFiles, resolveDocsIndexSource, DEFAULT_FAVICON } from "./utils";
39
+ import { scanMdxFiles, resolveDocsIndexSource } from "./server-utils";
40
+ import { defaultFavicon } from "./paths";
35
41
  import type { AssetManifest, BuildCache, BuildCacheMeta, CliArgs } from "./types";
36
42
  import { isCacheEntry } from "./types";
37
43
  import {
@@ -39,6 +45,7 @@ import {
39
45
  atomicWriteFile,
40
46
  hashMdxSources,
41
47
  hookMemoryPressure,
48
+ renderToolchainStamp,
42
49
  runtimeStamp,
43
50
  } from "./cache-key";
44
51
  import { clearDerivedPageCaches } from "./mdx";
@@ -73,7 +80,15 @@ async function readCache(): Promise<BuildCache> {
73
80
  // output without changing page content — a stale cache would false-hit.
74
81
  // Discard when the version stamp or runtime fingerprint mismatches.
75
82
  const meta = parsed.__meta__ as BuildCacheMeta | undefined;
76
- if (!meta || meta.version !== BUILD_CACHE_VERSION || meta.runtime !== runtimeStamp()) {
83
+ // `render` covers the framework's own rendering sources: a template or
84
+ // meta-tag edit changes output without touching any MDX file, so an
85
+ // older cache must not be trusted after one.
86
+ if (
87
+ !meta ||
88
+ meta.version !== BUILD_CACHE_VERSION ||
89
+ meta.runtime !== runtimeStamp() ||
90
+ meta.render !== renderToolchainStamp()
91
+ ) {
77
92
  return {};
78
93
  }
79
94
  return parsed;
@@ -92,6 +107,7 @@ function stampCache(cache: BuildCache): void {
92
107
  builtAt: Date.now(),
93
108
  version: BUILD_CACHE_VERSION,
94
109
  runtime: runtimeStamp(),
110
+ render: renderToolchainStamp(),
95
111
  };
96
112
  }
97
113
 
@@ -243,8 +259,8 @@ async function renderDocsPage(
243
259
  const headExtra = builder?.collectHead(ctx);
244
260
  const bodyExtra = builder?.collectBody(ctx);
245
261
 
246
- const depth = slug ? slug.split("/").length : 1;
247
- const favicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
262
+ const depth = docsDepth(slug, servedBasePath());
263
+ const favicon = docuConfig.meta?.favicon || defaultFavicon();
248
264
  const seo = buildSeoMeta(docuConfig, frontmatter, slug || "");
249
265
  // Parity with build.impl.ts: static hosts without header control (GitHub
250
266
  // Pages) rely on the <meta> CSP for the per-page script policy.
@@ -261,6 +277,7 @@ async function renderDocsPage(
261
277
  nonce,
262
278
  themeCss: inlineThemeCss,
263
279
  depth,
280
+ basePath: servedBasePath(),
264
281
  headExtra,
265
282
  bodyExtra,
266
283
  });
@@ -294,6 +311,11 @@ async function build() {
294
311
  // OS runs low on memory (long CI builds). No-op on older runtimes.
295
312
  hookMemoryPressure(clearDerivedPageCaches);
296
313
 
314
+ // Normalized prefixes (case, whitespace, unservable characters) and values
315
+ // that cannot be honored would otherwise only surface after the whole build —
316
+ // report them, or stop before anything is written.
317
+ for (const warning of assertValidBasePath()) logger.warn(warning.message);
318
+
297
319
  logger.buildStart();
298
320
 
299
321
  if (args.clean) {
@@ -308,7 +330,7 @@ async function build() {
308
330
  await mkdir(DIST_DIR, { recursive: true });
309
331
  await mkdir(ASSETS_DIR, { recursive: true });
310
332
 
311
- await copyDirectoryRecursive(DOCS_ASSETS_DIR, join(DIST_DIR, "docs", "assets"));
333
+ await copyDirectoryRecursive(DOCS_ASSETS_DIR, join(DOCS_OUT_DIR, "assets"));
312
334
 
313
335
  const mdxFiles = await scanMdxFiles(DOCS_DIR);
314
336
  const cache = args.force ? {} : await readCache();
@@ -362,6 +384,13 @@ async function build() {
362
384
  // key so the index page hydrates too. Its render has its own try/catch; skip
363
385
  // on error.
364
386
  const indexSource = resolveDocsIndexSource(DOCS_DIR);
387
+ const docsIndexPath = join(DOCS_OUT_DIR, "index.html");
388
+ // If the source was removed since the previous build, don't ship stale docs
389
+ // output. At the deployment root, this path belongs to the landing page.
390
+ if (!indexSource && DOCS_OUT_DIR !== DIST_DIR) {
391
+ const { rm } = await import("node:fs/promises");
392
+ await rm(docsIndexPath, { force: true });
393
+ }
365
394
  if (indexSource) {
366
395
  try {
367
396
  const indexRaw = await readFile(indexSource, "utf-8");
@@ -425,7 +454,7 @@ async function build() {
425
454
  const rebuildDecision = shouldRebuild(file.path, file.mtime, cache);
426
455
 
427
456
  if (rebuildDecision === "no" && !builder) {
428
- const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
457
+ const outputPath = join(DOCS_OUT_DIR, `${file.path}.html`);
429
458
  if (existsSync(outputPath) && !assetsChanged) {
430
459
  skipped++;
431
460
  continue;
@@ -447,7 +476,7 @@ async function build() {
447
476
  const cached = cache[file.path];
448
477
  if (isCacheEntry(cached) && cached.hash === contentHash) {
449
478
  if (!assetsChanged) {
450
- const outputPath = join(DIST_DIR, "docs", `${file.path}.html`);
479
+ const outputPath = join(DOCS_OUT_DIR, `${file.path}.html`);
451
480
  if (existsSync(outputPath)) {
452
481
  cache[file.path] = { ...cached, mtime: file.mtime, builtAt: Date.now() };
453
482
  skipped++;
@@ -472,7 +501,7 @@ async function build() {
472
501
  builder,
473
502
  pageNonce
474
503
  );
475
- const outputPath = join(DIST_DIR, "docs", `${capturedFile.path}.html`);
504
+ const outputPath = join(DOCS_OUT_DIR, `${capturedFile.path}.html`);
476
505
  await mkdir(dirname(outputPath), { recursive: true });
477
506
  await writeFile(outputPath, html);
478
507
  cache[capturedFile.path] = {
@@ -505,21 +534,19 @@ async function build() {
505
534
  builder,
506
535
  generateNonce()
507
536
  );
508
- await mkdir(join(DIST_DIR, "docs"), { recursive: true });
509
- await writeFile(join(DIST_DIR, "docs", "index.html"), indexHtml);
537
+ if (DOCS_OUT_DIR !== DIST_DIR) {
538
+ await mkdir(DOCS_OUT_DIR, { recursive: true });
539
+ await writeFile(docsIndexPath, indexHtml);
540
+ }
510
541
  } catch (err) {
511
542
  const msg = err instanceof Error ? err.message : String(err);
512
543
  errors.push(`index: ${msg}`);
513
544
  console.error(`\n❌ Failed to build index: ${msg}\n`);
514
545
  }
515
- } else {
516
- const msg = "docs root index: docs/index.mdx (or docs/index.md) not found";
517
- errors.push(`index: ${msg}`);
518
- console.error(`\n❌ Failed to build index: ${msg}\n`);
519
546
  }
520
547
 
521
548
  const landingPage = React.createElement(IndexPage);
522
- const landingFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
549
+ const landingFavicon = docuConfig.meta?.favicon || defaultFavicon();
523
550
  const landingSeo = buildSeoMeta(
524
551
  docuConfig,
525
552
  docuConfig.meta as unknown as Record<string, unknown>,
@@ -545,14 +572,18 @@ async function build() {
545
572
  js: landingContext.assets.js,
546
573
  nonce: landingNonce,
547
574
  themeCss: inlineThemeCss,
575
+ basePath: servedBasePath(),
548
576
  headExtra: builder?.collectHead(landingContext),
549
577
  bodyExtra: builder?.collectBody(landingContext),
550
578
  });
551
579
  if (builder) landingHtml = await builder.runTransformHtmlChain(landingHtml, landingContext);
580
+ // The landing page always owns `/`. At a root deployment the docs index
581
+ // would collide with it, so the index write above is skipped there — the
582
+ // docs root page simply does not exist.
552
583
  await writeFile(join(DIST_DIR, "index.html"), landingHtml);
553
584
 
554
585
  const notFoundPage = React.createElement(NotFoundPage);
555
- const notFoundFavicon = docuConfig.meta?.favicon || DEFAULT_FAVICON;
586
+ const notFoundFavicon = docuConfig.meta?.favicon || defaultFavicon();
556
587
  const notFoundNonce = generateNonce();
557
588
  const notFoundContext: PageContext = {
558
589
  pageType: "notFound",
@@ -580,6 +611,8 @@ async function build() {
580
611
  // Served as the static-host fallback at ANY requested path — relative
581
612
  // depth can never be right there, so use root-absolute asset URLs.
582
613
  absoluteAssets: true,
614
+ basePath: servedBasePath(),
615
+ deployPath: deployPath(),
583
616
  });
584
617
  if (builder) notFoundHtml = await builder.runTransformHtmlChain(notFoundHtml, notFoundContext);
585
618
  await writeFile(join(DIST_DIR, "404.html"), notFoundHtml);
@@ -593,7 +626,7 @@ async function build() {
593
626
  slug: f.path,
594
627
  title: f.path.split("/").pop() || f.path,
595
628
  filePath: join(DOCS_DIR, f.path),
596
- outputPath: join(DIST_DIR, "docs", `${f.path}.html`),
629
+ outputPath: join(DOCS_OUT_DIR, `${f.path}.html`),
597
630
  }));
598
631
  await builder.runOnEnd(pages, { assetManifest, outDir: DIST_DIR });
599
632
  }
@@ -1,16 +1,47 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- import { FRAMEWORK_ROOT, STYLES_DIR, resolveProjectFile } from "./paths";
4
+ import { FRAMEWORK_ROOT, STYLES_DIR, resolveProjectFile, basePath, deployPath } from "./paths";
5
5
 
6
6
  /**
7
7
  * Build cache version — bump when the toolchain output contract changes
8
8
  * (e.g. Bun.build barrel optimization, Tailwind CLI upgrade). Old caches
9
9
  * with a mismatched version are discarded on read (see build.ts readCache).
10
+ *
11
+ * v6: pages became base-path aware (`meta.basePath`), and the resolved value
12
+ * is folded into `basePathStamp()` below. Bumping here also invalidates any
13
+ * cache written by an earlier config, so a base-path edit can never be
14
+ * silently skipped as "unchanged".
10
15
  */
11
- export const BUILD_CACHE_VERSION = 5;
16
+ export const BUILD_CACHE_VERSION = 6;
12
17
 
13
- /** Toolchain fingerprint: Bun version on Bun, Deno version on Deno, Node elsewhere. */
18
+ /**
19
+ * Resolved docs URL prefix and deployment path, part of every cache key.
20
+ *
21
+ * The page cache is keyed by relative MDX path and the bundle/asset slots are
22
+ * hashed from MDX source alone — neither changes when only `meta.basePath` or
23
+ * `meta.baseURL`'s path changes. Without this stamp, editing either would leave
24
+ * every key untouched and the build would skip every page, emitting HTML that
25
+ * still points at the old prefix.
26
+ */
27
+ export function basePathStamp(): string {
28
+ return prefixStamp(basePath(), deployPath());
29
+ }
30
+
31
+ /**
32
+ * Compose the cache stamp from both prefixes that shape generated URLs: the
33
+ * docs prefix and the host's deployment path (from `meta.baseURL`). Either one
34
+ * changing has to invalidate the cache, or a warm cache keeps serving HTML that
35
+ * points at the old location.
36
+ */
37
+ export function prefixStamp(resolvedBasePath: string, deploymentPath: string): string {
38
+ if (deploymentPath.length === 0) return resolvedBasePath;
39
+ return `${resolvedBasePath || "/"}@${deploymentPath}`;
40
+ }
41
+
42
+ /**
43
+ * Toolchain fingerprint: Bun version on Bun, Deno version on Deno, Node elsewhere.
44
+ */
14
45
  export function runtimeStamp(): string {
15
46
  const g = globalThis as Record<string, unknown>;
16
47
  const bun = g.Bun as { version?: string } | undefined;
@@ -23,6 +54,59 @@ export function runtimeStamp(): string {
23
54
  return "node-unknown";
24
55
  }
25
56
 
57
+ /**
58
+ * Framework source files that shape rendered HTML.
59
+ *
60
+ * Page cache entries are keyed by MDX source alone, so editing the renderer
61
+ * (a template tweak, a new meta tag, base-path plumbing) would otherwise leave
62
+ * every page a cache hit and keep serving HTML built by the old code. Hashing
63
+ * these sources makes a framework edit invalidate the pages it affects.
64
+ */
65
+ const RENDER_SOURCE_FILES = [
66
+ ".docu/node/html.ts",
67
+ ".docu/node/html.shared.ts",
68
+ ".docu/node/seo.ts",
69
+ ".docu/node/utils.ts",
70
+ ".docu/node/paths.ts",
71
+ ".docu/node/base-path.ts",
72
+ ".docu/node/mdx.ts",
73
+ ".docu/node/search-indexer.ts",
74
+ ".docu/node/build.ts",
75
+ ".docu/node/build.impl.ts",
76
+ ".docu/node/server-routes.ts",
77
+ ".docu/components/DocsLayout.tsx",
78
+ ".docu/components/Navbar.tsx",
79
+ ".docu/pages/index.tsx",
80
+ ".docu/pages/404.tsx",
81
+ ".docu/pages/docs/[[...slug]].tsx",
82
+ ] as const;
83
+
84
+ let renderStampCache: string | null = null;
85
+
86
+ /**
87
+ * Fingerprint of the framework's rendering sources.
88
+ *
89
+ * Memoized per process — the files cannot change mid-build, and hashing them
90
+ * on every page would dominate build time. A missing file contributes nothing
91
+ * so a partial install still yields a stable (if weaker) stamp.
92
+ */
93
+ export function renderToolchainStamp(): string {
94
+ if (renderStampCache !== null) return renderStampCache;
95
+ const h = createHash("sha256");
96
+ h.update(`v${BUILD_CACHE_VERSION}`);
97
+ for (const rel of RENDER_SOURCE_FILES) {
98
+ const file = join(FRAMEWORK_ROOT, rel);
99
+ try {
100
+ if (!existsSync(file)) continue;
101
+ h.update(rel).update("\0").update(readFileSync(file, "utf-8"));
102
+ } catch {
103
+ // unreadable source — skip it rather than break the build
104
+ }
105
+ }
106
+ renderStampCache = h.digest("hex").slice(0, 16);
107
+ return renderStampCache;
108
+ }
109
+
26
110
  /**
27
111
  * True when a CSS file can change Tailwind v4 output.
28
112
  * v4 is CSS-first: `@theme`, `@source`, `@plugin`, `@custom-variant`,
@@ -230,6 +314,8 @@ export function computeTailwindCacheKey(
230
314
  h.update("\0");
231
315
  h.update(runtimeStamp());
232
316
  h.update("\0");
317
+ h.update(basePathStamp());
318
+ h.update("\0");
233
319
  h.update(`v${BUILD_CACHE_VERSION}`);
234
320
  return h.digest("hex").slice(0, 16);
235
321
  }
@@ -304,7 +390,9 @@ export function hookMemoryPressure(clear: () => void): void {
304
390
  export function hashMdxSources(mdxSources: Record<string, string>): string {
305
391
  const h = createHash("sha256");
306
392
  const slugs = Object.keys(mdxSources).sort();
307
- h.update(`v${BUILD_CACHE_VERSION}:${runtimeStamp()}:`);
393
+ h.update(
394
+ `v${BUILD_CACHE_VERSION}:${runtimeStamp()}:${basePathStamp()}:${renderToolchainStamp()}:`
395
+ );
308
396
  for (const slug of slugs) {
309
397
  h.update(slug);
310
398
  h.update("\0");
@@ -1,7 +1,22 @@
1
1
  import type { DocuRoute, DocuConfig } from "./types";
2
- import { loadDocuConfig } from "./paths";
2
+ import { loadDocuConfig, resolveBasePath, resolveDeployPath } from "./paths";
3
3
  import { resolveRoutes } from "./fs-scanner";
4
4
 
5
5
  const docuConfig = loadDocuConfig();
6
6
  export const routes: DocuRoute[] = resolveRoutes(docuConfig.routes || []);
7
7
  export const config = docuConfig as unknown as DocuConfig;
8
+
9
+ /**
10
+ * URL prefix the site is served under (`""` at the domain root, else `/prefix`).
11
+ * Components use this instead of assuming `/docs`, so a subpath deployment does
12
+ * not request assets from the domain root.
13
+ */
14
+ export const basePath: string = resolveBasePath(docuConfig);
15
+
16
+ /**
17
+ * Path the host serves the dist root under (`""` at an origin root, `/repo` for
18
+ * a GitHub Pages project site — see `resolveDeployPath`). Root-absolute browser
19
+ * requests (the search index) build their URL from it, because the client
20
+ * bundle cannot read `docu.json` at runtime.
21
+ */
22
+ export const deployPath: string = resolveDeployPath(docuConfig);
@@ -11,7 +11,7 @@
11
11
  import { writeFile, mkdir } from "node:fs/promises";
12
12
  import { existsSync, readFileSync } from "node:fs";
13
13
  import { join, resolve } from "node:path";
14
- import { DIST_DIR, PROJECT_ROOT, FRAMEWORK_ROOT } from "./paths";
14
+ import { DIST_DIR, PROJECT_ROOT, FRAMEWORK_ROOT, servedBasePath } from "./paths";
15
15
 
16
16
  const FLAME_VERSION = JSON.parse(
17
17
  readFileSync(resolve(FRAMEWORK_ROOT, "package.json"), "utf-8")
@@ -20,7 +20,49 @@ const FLAME_VERSION = JSON.parse(
20
20
  const WORKFLOW_DIR = join(PROJECT_ROOT, ".github/workflows");
21
21
  const WORKFLOW_FILE = join(WORKFLOW_DIR, "deploy.yml");
22
22
 
23
- export const NGINX_CONF = `server {
23
+ /**
24
+ * Nginx content-assets block.
25
+ *
26
+ * Two distinct asset trees:
27
+ * - `/assets/` — hash-named client bundles, written to the dist root, immutable.
28
+ * - `<prefix>/assets/` — user content assets copied from `docs/assets`,
29
+ * revalidated after 7d.
30
+ *
31
+ * When the prefix is empty (root deployment) both trees land on `/assets/`, and
32
+ * nginx rejects duplicate location blocks — so the content tree is omitted and
33
+ * the immutable bundle rules win. Non-hashed content assets then inherit the
34
+ * 1y immutable header; that is a deliberate trade for a valid config, and root
35
+ * deployments are the uncommon case.
36
+ */
37
+ export function buildNginxConf(basePath: string): string {
38
+ // nginx `location` names must be plain URI prefixes; anything else (spaces,
39
+ // braces, semicolons) fails `nginx -t` and the container never starts.
40
+ // Unsafe prefixes simply lose the content-assets cache block — the default
41
+ // location still serves the files, the same trade root deployments make.
42
+ const safePrefix = /^\/[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/.test(basePath);
43
+ if (basePath.length > 0 && !safePrefix) {
44
+ console.warn(
45
+ `[flame] basePath "${basePath}" contains characters nginx cannot use in a location block; ` +
46
+ "omitting the content-assets caching block (assets still served)"
47
+ );
48
+ }
49
+ const contentAssetsBlock =
50
+ safePrefix && basePath.length > 0
51
+ ? `
52
+ location ${basePath}/assets/ {
53
+ expires 7d;
54
+ add_header Cache-Control "public";
55
+ add_header X-Frame-Options "DENY" always;
56
+ add_header X-Content-Type-Options "nosniff" always;
57
+ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
58
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
59
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
60
+ add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
61
+ }
62
+ `
63
+ : "";
64
+
65
+ return `server {
24
66
  listen 80;
25
67
  server_name _;
26
68
  root /usr/share/nginx/html;
@@ -58,18 +100,7 @@ export const NGINX_CONF = `server {
58
100
  add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
59
101
  add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
60
102
  }
61
-
62
- location /docs/assets/ {
63
- expires 7d;
64
- add_header Cache-Control "public";
65
- add_header X-Frame-Options "DENY" always;
66
- add_header X-Content-Type-Options "nosniff" always;
67
- add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
68
- add_header Referrer-Policy "strict-origin-when-cross-origin" always;
69
- add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
70
- add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; font-src 'self' data:; connect-src 'self' https:; frame-src https://www.youtube-nocookie.com; frame-ancestors 'none'" always;
71
- }
72
-
103
+ ${contentAssetsBlock}
73
104
  location = /404.html { }
74
105
 
75
106
  location / {
@@ -77,6 +108,10 @@ export const NGINX_CONF = `server {
77
108
  }
78
109
  }
79
110
  `;
111
+ }
112
+
113
+ /** Backward-compatible default config (root-level `/docs` prefix). */
114
+ export const NGINX_CONF = buildNginxConf("/docs");
80
115
 
81
116
  export const DOCKERIGNORE = `node_modules
82
117
  *.DS_Store
@@ -260,6 +295,7 @@ export const HEADERS_FILE = `/*
260
295
  `;
261
296
 
262
297
  const isDocker = !!process.env.FLAME_DEPLOY_DOCKER;
298
+
263
299
  const isSilent = !!process.env.FLAME_DEPLOY_SILENT;
264
300
  const isCi = !!process.env.FLAME_DEPLOY_CI;
265
301
 
@@ -300,7 +336,7 @@ async function writeDockerFiles() {
300
336
  }
301
337
 
302
338
  if (!existsSync(join(dockerDir, "nginx.conf"))) {
303
- await writeFile(join(dockerDir, "nginx.conf"), NGINX_CONF);
339
+ await writeFile(join(dockerDir, "nginx.conf"), buildNginxConf(servedBasePath()));
304
340
  log.created("📄 Created nginx.conf");
305
341
  }
306
342