@dogsbay/format-astro 0.2.0-beta.93 → 0.2.0-beta.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/project.js CHANGED
@@ -4,11 +4,12 @@
4
4
  * Takes ExportPage[] + NavItem[] and generates a complete Astro project
5
5
  * with static .astro pages using real Dogsbay components.
6
6
  */
7
- import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, cpSync, readdirSync, statSync, } from "node:fs";
7
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, cpSync, readdirSync, statSync, rmSync, rmdirSync, openSync, readSync, closeSync, } from "node:fs";
8
8
  import { join, dirname, relative, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
11
11
  import { treeToAstro, TONE_CLASSES } from "./serialize.js";
12
+ import { emitPluginRuntime } from "./plugins.js";
12
13
  import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
13
14
  import { buildSitemap, buildSitemapIndex } from "./sitemap.js";
14
15
  import { assembleBook, estimateTreeBytes } from "./granularity.js";
@@ -24,6 +25,7 @@ function singlePageLinkLines(href, indent) {
24
25
  return [`${pad}<a class="dsb-read-single-page" href="${href}">Read as single page →</a>`];
25
26
  }
26
27
  import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath, parseSiteUrl, combinePrefix, } from "./base-path.js";
28
+ import { buildBlogData, blogAdjacency } from "./blog.js";
27
29
  /**
28
30
  * Combined URL prefix = urlBase (Astro `base` from site.url path) +
29
31
  * basePath (filesystem layout prefix). Every URL emitter (nav,
@@ -206,6 +208,161 @@ function rewriteInlineImageSrcs(nodes, prefix) {
206
208
  function rewriteHtmlImageSrcs(html, prefix) {
207
209
  return html.replace(/(<img\b[^>]*\ssrc=")(\/[^"]+)"/g, (_match, before, src) => `${before}${rewriteHref(src, prefix)}"`);
208
210
  }
211
+ /**
212
+ * Astro `outDir` for a Cloudflare Workers deploy mounted on a host subpath.
213
+ *
214
+ * Workers Static Assets resolves the FULL request pathname against the
215
+ * asset manifest and strips nothing. A site mounted at `dogsbay.ai/blog/*`
216
+ * is therefore asked for `/blog/index.html`, and unless the build output
217
+ * actually contains `blog/`, every request 404s. Astro's `base` does not
218
+ * do this: it prefixes generated URLs (including `/blog/_astro/...`) but
219
+ * leaves the emitted files at `dist/` root. Setting `outDir` to
220
+ * `./dist<urlBase>` is what makes the two agree.
221
+ *
222
+ * GitHub Pages needs the OPPOSITE and must not get this. There the
223
+ * uploaded artifact IS the site root, served at `https://<user>.github.io/
224
+ * <repo>/`, so the host supplies the prefix: URLs must carry it, files
225
+ * must not. Emitting `outDir` for a Pages build would produce
226
+ * `/<repo>/<repo>/`. Hence the deploy-target gate — the same `site.url`
227
+ * deliberately yields different layouts per target.
228
+ *
229
+ * Returns `undefined` when the target is not Workers or the site sits at
230
+ * the host root (no path in `site.url`), which is the common case.
231
+ */
232
+ export function workersSubpathOutDir(deploy, urlBase) {
233
+ if (deploy !== "cloudflare-workers")
234
+ return undefined;
235
+ if (!urlBase)
236
+ return undefined;
237
+ return `./dist${urlBase}`;
238
+ }
239
+ /**
240
+ * Delete generated content pages whose source markdown is gone.
241
+ *
242
+ * `emitAstroPages` writes a page per source file but never removed one,
243
+ * so deleting a post left its `.astro` and `.md.ts` behind: the page kept
244
+ * building, kept deploying, and kept being linked from tag archives.
245
+ * Measured on the blog — three deleted posts were still live.
246
+ *
247
+ * Deleting files needs a tighter contract than writing them, so this is
248
+ * bounded four ways:
249
+ *
250
+ * ROOT Only the subtree this invocation owns —
251
+ * `src/pages/<basePath>/<section>` — never all of `src/pages`.
252
+ * `--section` exists so several converts land in one project;
253
+ * walking the whole tree meant each run deleted the previous
254
+ * run's section.
255
+ * FLOOR Never prune when this run generated nothing. A mis-set
256
+ * `--section`, a moved source dir or a config typo yields zero
257
+ * pages, and an unguarded prune would delete the entire site
258
+ * and exit 0.
259
+ * WHOLE Never prune when a page failed to generate. The page loop
260
+ * warns and continues, so a failed page is absent from
261
+ * `generatedPaths` and would look exactly like a deleted one —
262
+ * turning a transient serializer throw into a deletion.
263
+ * BANNER Only files carrying the `dogsbay convert` banner. That marks
264
+ * content pages and their `.md` mirrors; taxonomy and blog
265
+ * routes carry a `dogsbay site build` banner and a writer's own
266
+ * page carries none. This runs BEFORE those emitters, when last
267
+ * build's routes are present and unclaimed, so the distinction
268
+ * is what keeps them alive.
269
+ *
270
+ * KNOWN GAP: missing-translation stubs are not covered. They carry
271
+ * `// AUTO-GENERATED missing-translation stub.`, not the convert banner,
272
+ * so deleting `en/foo.md` leaves `/fr/foo` as a live route redirecting to
273
+ * a now-404 `/en/foo` — the same symptom, one hop away. Fixing it needs
274
+ * the stubs tracked in `generatedPaths` first; giving them the convert
275
+ * banner alone would make every build prune the stub it just wrote.
276
+ */
277
+ function pruneOrphanedPages(outputDir, root, generatedPaths) {
278
+ if (!existsSync(root))
279
+ return [];
280
+ const removed = [];
281
+ const walk = (dir) => {
282
+ let emptied = false;
283
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
284
+ const full = join(dir, entry.name);
285
+ if (entry.isDirectory()) {
286
+ walk(full);
287
+ continue;
288
+ }
289
+ // Only our two output kinds can be orphans; skip everything else
290
+ // rather than reading it.
291
+ if (!entry.name.endsWith(".astro") && !entry.name.endsWith(".md.ts"))
292
+ continue;
293
+ if (generatedPaths.has(relative(outputDir, full)))
294
+ continue;
295
+ // The banner is always the first line, so read a prefix rather than
296
+ // the whole file — a `.md.ts` mirror embeds an entire page's
297
+ // markdown, and a large corpus is thousands of them per build.
298
+ let head;
299
+ try {
300
+ const fd = openSync(full, "r");
301
+ try {
302
+ const buf = Buffer.alloc(256);
303
+ const n = readSync(fd, buf, 0, 256, 0);
304
+ head = buf.subarray(0, n).toString("utf-8");
305
+ }
306
+ finally {
307
+ closeSync(fd);
308
+ }
309
+ }
310
+ catch {
311
+ continue;
312
+ }
313
+ if (!head.includes("AUTO-GENERATED by `dogsbay convert`"))
314
+ continue;
315
+ rmSync(full, { force: true });
316
+ removed.push(relative(outputDir, full));
317
+ emptied = true;
318
+ }
319
+ // Only directories THIS pass emptied, and only via non-recursive
320
+ // rmdir: it fails atomically if anything landed in the meantime,
321
+ // which matters under `site dev` where the build re-runs while an
322
+ // editor and the dev server are live.
323
+ if (emptied && dir !== root) {
324
+ try {
325
+ rmdirSync(dir);
326
+ }
327
+ catch {
328
+ /* not empty, or raced — correct to leave it */
329
+ }
330
+ }
331
+ };
332
+ walk(root);
333
+ return removed;
334
+ }
335
+ /**
336
+ * Build the `excludeFromRoutes` predicate.
337
+ *
338
+ * Exported because more than one emitter has to agree on which pages
339
+ * become routes. The blog listed excluded pages as posts, producing index
340
+ * cards and Newer/Older links to pages that were never emitted — a 404
341
+ * from the site's own front page.
342
+ *
343
+ * A single-segment pattern matches ANY segment (fragment dirs symlink to
344
+ * many depths in AsciiBinder corpora); a multi-segment pattern matches as
345
+ * a leading path prefix, so `welcome/_internal` excludes only that tree.
346
+ */
347
+ export function makeSlugExcluder(excludeFromRoutes) {
348
+ const patterns = (excludeFromRoutes ?? []).map((p) => p.replace(/^\/+/, "").replace(/\/+$/, ""));
349
+ return (slug) => {
350
+ if (patterns.length === 0)
351
+ return false;
352
+ const segments = slug.split("/");
353
+ for (const pattern of patterns) {
354
+ if (pattern.includes("/")) {
355
+ if (slug === pattern || slug.startsWith(pattern + "/"))
356
+ return true;
357
+ }
358
+ else {
359
+ if (segments.includes(pattern))
360
+ return true;
361
+ }
362
+ }
363
+ return false;
364
+ };
365
+ }
209
366
  /**
210
367
  * Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
211
368
  *
@@ -380,11 +537,20 @@ jobs:
380
537
  * `src/data/site.json`. Backward-compatible: existing fields keep their
381
538
  * empty-string defaults; new optional fields are omitted when undefined.
382
539
  */
383
- function buildSiteConfig(siteName, options) {
540
+ function buildSiteConfig(siteName, options, outputDir) {
384
541
  const cfg = {
385
542
  siteName,
386
543
  repoUrl: options.repoUrl || "",
387
544
  };
545
+ // Favicon href, so GENERATED routes can emit <link rel="icon"> too.
546
+ // Content pages get it computed per page; the blog index and the
547
+ // taxonomy routes are emitted by other emitters that never received it,
548
+ // so the site's own front page had no icon while its posts did.
549
+ if (outputDir) {
550
+ const href = faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase);
551
+ if (href)
552
+ cfg.favicon = href;
553
+ }
388
554
  if (options.siteUrl)
389
555
  cfg.siteUrl = options.siteUrl;
390
556
  if (options.description)
@@ -420,6 +586,11 @@ function buildSiteConfig(siteName, options) {
420
586
  if (options.tagLabels && Object.keys(options.tagLabels).length > 0) {
421
587
  cfg.tagLabels = options.tagLabels;
422
588
  }
589
+ // Link-icon glyphs — only emitted when configured, so sites without
590
+ // the feature keep byte-identical site.json.
591
+ if (options.linkIcons && (options.linkIcons.external || options.linkIcons.internal)) {
592
+ cfg.linkIcons = options.linkIcons;
593
+ }
423
594
  if (options.taxonomyIndexPaths &&
424
595
  Object.keys(options.taxonomyIndexPaths).length > 0) {
425
596
  // Bake basePath into every emitted indexPath so consumers
@@ -502,6 +673,21 @@ export async function exportAstroProject(pages, nav, outputDir, options = {}) {
502
673
  const scaffoldSkipped = emitSiteScaffold(outputDir, siteName, options, writeScaffold);
503
674
  const { generated, outputNav } = await emitAstroPages(pages, nav, outputDir, options);
504
675
  emitConfigDerivedFiles(outputDir, options);
676
+ // The generated layout unconditionally imports
677
+ // `@/data/switcherMap.json` and the plugin wrapper stacks
678
+ // (`@/components/wrappers/*Stack.astro`); the exporter must
679
+ // guarantee both exist (empty axes / passthrough slots when there
680
+ // is no version data and no plugins) — previously only
681
+ // `dogsbay site build` emitted them, so every direct
682
+ // exportAstroProject consumer produced an unbuildable site
683
+ // (root-caused 2026-07-13 on the first comparison-site build).
684
+ emitSwitcherMap(pages, outputDir, options);
685
+ emitPluginRuntime({
686
+ outputDir,
687
+ clientModules: [],
688
+ styles: [],
689
+ clientConfigs: [],
690
+ });
505
691
  emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options);
506
692
  console.log(`Generated ${generated} static .astro pages`);
507
693
  if (alreadyScaffolded && !options.force && scaffoldSkipped > 0) {
@@ -538,7 +724,7 @@ function ensureDirectoryStructure(outputDir, basePath) {
538
724
  */
539
725
  export function emitSiteConfig(outputDir, siteName, options) {
540
726
  mkdirSync(join(outputDir, "src", "data"), { recursive: true });
541
- writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
727
+ writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options, outputDir), null, 2));
542
728
  // Auto-generated companion to astro.config.mjs. Carries the
543
729
  // site/base values derived from dogsbay.config.yml's site.url so
544
730
  // changes propagate without --force-rescaffolding the main
@@ -552,6 +738,11 @@ export function emitSiteConfig(outputDir, siteName, options) {
552
738
  ? JSON.stringify(origin ?? options.siteUrl)
553
739
  : "undefined";
554
740
  const dogsbayBaseJson = astroBase ? JSON.stringify(astroBase) : "undefined";
741
+ // Workers subpath mounts need the build output to live under the mount
742
+ // path too — see workersSubpathOutDir. Undefined for every other target
743
+ // and for host-root sites, so nothing changes for existing deploys.
744
+ const dogsbayOutDir = workersSubpathOutDir(options.deploy, astroBase);
745
+ const dogsbayOutDirJson = dogsbayOutDir ? JSON.stringify(dogsbayOutDir) : "undefined";
555
746
  // build.inlineStylesheets — defaults to "auto" (Astro's own
556
747
  // default; matches our docs-first bias since theme.css is ~120KB
557
748
  // and externalizing it lets the file cache cross-page). Authors
@@ -566,8 +757,102 @@ export function emitSiteConfig(outputDir, siteName, options) {
566
757
  `export const dogsbaySite = ${dogsbaySiteJson};`,
567
758
  `export const dogsbayBase = ${dogsbayBaseJson};`,
568
759
  `export const dogsbayInlineStylesheets = ${JSON.stringify(dogsbayInline)};`,
760
+ `export const dogsbayOutDir = ${dogsbayOutDirJson};`,
569
761
  "",
570
762
  ].join("\n"));
763
+ // dogsbay-mount.mjs — auto-generated, emitted only for subpath mounts.
764
+ //
765
+ // Two jobs that Astro cannot do itself once outDir moves to
766
+ // dist/<urlBase>/:
767
+ //
768
+ // clean `astro build` clears outDir, which is now the MOUNT dir,
769
+ // not dist/. Stale output therefore survives forever — a
770
+ // site migrating from the pre-mount layout keeps its old
771
+ // root-level index.html and _astro/, and changing site.url
772
+ // from /blog to /news strands the whole old tree. Both keep
773
+ // being uploaded, because `wrangler deploy` ships all of
774
+ // ./dist. dist/ is entirely derived, so clearing it is safe.
775
+ //
776
+ // NOTE robots.txt is deliberately NOT lifted. _headers is a CONFIG
777
+ // file that Workers reads from the assets root; robots.txt is a
778
+ // SERVED URL at /robots.txt, and on a subpath mount that path
779
+ // belongs to whichever worker owns the host root, not to this
780
+ // one. Lifting it would put a file at a path this worker's route
781
+ // never matches. The host-root worker's robots.txt is the
782
+ // authoritative one and must name this mount's sitemap — see
783
+ // plans/dogsbay-ai-site.md. The per-mount copy at
784
+ // <urlBase>/robots.txt is harmless but is NOT what crawlers read.
785
+ //
786
+ // finalize Workers Static Assets reads _headers only from the ROOT of
787
+ // assets.directory (./dist). Astro copies public/ into
788
+ // outDir, so _headers landed at dist/<urlBase>/_headers,
789
+ // where it does nothing — the RFC 8288 Link header pointing
790
+ // agents at llms.txt silently stopped applying, and the raw
791
+ // file was served as an asset. Move it up.
792
+ const mountHelperPath = join(outputDir, "dogsbay-mount.mjs");
793
+ if (!dogsbayOutDir && existsSync(mountHelperPath)) {
794
+ // No longer mounted (deploy target or site.url changed).
795
+ //
796
+ // Deleting it outright looks tidy and is wrong: package.json is
797
+ // scaffold-once, so the build script still runs
798
+ // `node ./dogsbay-mount.mjs clean && …` and the next `pnpm build` dies
799
+ // with ERR_MODULE_NOT_FOUND before Astro even starts. Downgrade it to
800
+ // a no-op instead, so the build keeps working while
801
+ // warnStaleMountBuildScript tells the author to update the script; only
802
+ // remove the file once nothing references it.
803
+ const pkgPath = join(outputDir, "package.json");
804
+ const stillReferenced = existsSync(pkgPath) && readFileSync(pkgPath, "utf-8").includes("dogsbay-mount.mjs");
805
+ if (stillReferenced) {
806
+ writeFileSync(mountHelperPath, [
807
+ "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
808
+ "// This site is no longer mounted on a subpath, but package.json",
809
+ "// still calls this script, so it stays as a no-op rather than",
810
+ "// breaking the build. Update the build script and it goes away.",
811
+ "process.exit(0);",
812
+ "",
813
+ ].join("\n"));
814
+ }
815
+ else {
816
+ rmSync(mountHelperPath, { force: true });
817
+ }
818
+ }
819
+ if (dogsbayOutDir) {
820
+ const mountDir = dogsbayOutDir.replace(/^\.\//, "");
821
+ writeFileSync(mountHelperPath, [
822
+ "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
823
+ "// Subpath-mount build steps. See project.ts (dogsbay-mount.mjs).",
824
+ 'import { rmSync, existsSync, mkdirSync, renameSync } from "node:fs";',
825
+ 'import { dirname, join, resolve } from "node:path";',
826
+ 'import { fileURLToPath } from "node:url";',
827
+ "",
828
+ `const MOUNT_DIR = ${JSON.stringify(mountDir)};`,
829
+ "// Resolve against THIS FILE, never process.cwd(). `pnpm build` runs",
830
+ "// with cwd set to the project, but this is an ordinary script sitting",
831
+ "// in the project root, and the natural CI/debug invocation",
832
+ "// `node path/to/site/dogsbay-mount.mjs clean` would otherwise",
833
+ "// recursively delete the CALLER's dist/.",
834
+ 'const ROOT = dirname(fileURLToPath(import.meta.url));',
835
+ 'const DIST = join(ROOT, "dist");',
836
+ 'const mode = process.argv[2];',
837
+ "",
838
+ 'if (mode === "clean") {',
839
+ ' // Derived output only — astro build + pagefind regenerate all of it.',
840
+ ' rmSync(DIST, { recursive: true, force: true });',
841
+ '} else if (mode === "finalize") {',
842
+ " const from = resolve(ROOT, `${MOUNT_DIR}/_headers`);",
843
+ ' if (existsSync(from)) {',
844
+ ' const to = join(DIST, "_headers");',
845
+ ' mkdirSync(dirname(to), { recursive: true });',
846
+ ' renameSync(from, to);',
847
+ " }",
848
+ '} else {',
849
+ ' console.error("dogsbay-mount.mjs: expected `clean` or `finalize`");',
850
+ " process.exit(1);",
851
+ "}",
852
+ "",
853
+ ].join("\n"));
854
+ }
855
+ warnStaleMountBuildScript(outputDir, dogsbayOutDir, astroBase);
571
856
  // Migration check: pre-beta.20 sites have an astro.config.mjs that
572
857
  // doesn't import the companion. Without the import, the values
573
858
  // emitted above are unused and Astro's `base` stays unset — the
@@ -592,11 +877,13 @@ export function emitSiteConfig(outputDir, siteName, options) {
592
877
  ' dogsbaySite,',
593
878
  ' dogsbayBase,',
594
879
  ' dogsbayInlineStylesheets,',
880
+ ' dogsbayOutDir,',
595
881
  ' } from "./astro.config.dogsbay.mjs";',
596
882
  "",
597
883
  " export default defineConfig({",
598
884
  " ...(dogsbaySite ? { site: dogsbaySite } : {}),",
599
885
  " ...(dogsbayBase ? { base: dogsbayBase } : {}),",
886
+ " ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
600
887
  " build: { inlineStylesheets: dogsbayInlineStylesheets },",
601
888
  " // ...your existing config...",
602
889
  " });",
@@ -606,6 +893,30 @@ export function emitSiteConfig(outputDir, siteName, options) {
606
893
  "",
607
894
  ].join("\n"));
608
895
  }
896
+ else if (dogsbayOutDir && !astroConfigSrc.includes("dogsbayOutDir")) {
897
+ // The companion is imported, but this scaffold predates outDir
898
+ // support. astro.config.mjs is scaffold-once, so an existing site
899
+ // that moves to a Workers subpath mount keeps a config that emits
900
+ // to dist/ root while its wrangler route asks for dist<urlBase>/.
901
+ // Every request 404s and the HTML looks fine locally, so warn.
902
+ console.warn([
903
+ "",
904
+ " ⚠ astro.config.mjs does not apply `dogsbayOutDir`.",
905
+ ` This site deploys to Cloudflare Workers under "${dogsbayOutDir.replace("./dist", "")}",`,
906
+ " so the build output must live under that path or every",
907
+ " request 404s — Workers matches the full request pathname",
908
+ " and strips no prefix.",
909
+ "",
910
+ " Add to the companion import in astro.config.mjs:",
911
+ "",
912
+ " dogsbayOutDir,",
913
+ "",
914
+ " and to defineConfig:",
915
+ "",
916
+ " ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
917
+ "",
918
+ ].join("\n"));
919
+ }
609
920
  }
610
921
  }
611
922
  /**
@@ -640,6 +951,74 @@ function syncDogsbayDepVersions(pkgPath, version) {
640
951
  }
641
952
  return changed;
642
953
  }
954
+ /**
955
+ * Warn when the scaffold-once build script disagrees with the site's
956
+ * current mount.
957
+ *
958
+ * Lives here, not in `emitSiteScaffold`, because that function only runs
959
+ * on `site init` — a migration warning there would never fire for the
960
+ * sites that need it. Called from `emitSiteConfig`, which runs on every
961
+ * build.
962
+ *
963
+ * Both directions fail the same way: silently. Search stops working and
964
+ * nothing else about the build looks wrong.
965
+ */
966
+ function warnStaleMountBuildScript(outputDir, outDir, urlBase) {
967
+ const pkgJsonPath = join(outputDir, "package.json");
968
+ if (!existsSync(pkgJsonPath))
969
+ return;
970
+ const pkgSrc = readFileSync(pkgJsonPath, "utf-8");
971
+ if (!outDir) {
972
+ // Moved OFF a mount: the script still clears dist/ and writes the
973
+ // Pagefind bundle to the old mount dir, while the page asks elsewhere.
974
+ if (!pkgSrc.includes("dogsbay-mount.mjs"))
975
+ return;
976
+ console.warn([
977
+ "",
978
+ " ⚠ package.json's build script still runs the subpath-mount steps,",
979
+ " but this site is no longer mounted on a subpath. dogsbay-mount.mjs",
980
+ " has been reduced to a no-op so the build keeps working, and the",
981
+ " Pagefind bundle is still being written to the old mount directory,",
982
+ " so Cmd+K will 404 until the script is updated.",
983
+ "",
984
+ " Change the build script back to:",
985
+ "",
986
+ " astro build && pagefind --site dist",
987
+ "",
988
+ ].join("\n"));
989
+ return;
990
+ }
991
+ // Moved ONTO a mount, or BETWEEN mounts.
992
+ //
993
+ // Gating on the mere presence of "dogsbay-mount.mjs" missed the second
994
+ // case: a site moving from /docs to /news regenerates MOUNT_DIR and
995
+ // astro build writes dist/news, but the scaffold-once script still runs
996
+ // `--output-path dist/docs/pagefind`. The bundle lands in a directory no
997
+ // page references and Cmd+K silently dies — the exact failure this
998
+ // warning exists for. Compare against the CURRENT mount instead.
999
+ if (!pkgSrc.includes("pagefind --site dist"))
1000
+ return;
1001
+ const mountDir = outDir.replace(/^\.\//, "");
1002
+ if (pkgSrc.includes(`--output-path ${mountDir}/pagefind`))
1003
+ return;
1004
+ const wanted = `node ./dogsbay-mount.mjs clean && astro build && ` +
1005
+ `pagefind --site dist --output-path ${mountDir}/pagefind && ` +
1006
+ `node ./dogsbay-mount.mjs finalize`;
1007
+ console.warn([
1008
+ "",
1009
+ " ⚠ package.json's build script predates subpath-mount support.",
1010
+ ` This site is mounted at "${urlBase}", which needs three things`,
1011
+ " the old script does not do: clear dist/ (astro build only clears",
1012
+ " the mount dir, so stale output ships forever), write the Pagefind",
1013
+ " bundle under the mount (or Cmd+K 404s), and lift _headers to the",
1014
+ " assets root (or the llms.txt Link header stops applying).",
1015
+ "",
1016
+ " Change the build script to:",
1017
+ "",
1018
+ ` ${wanted}`,
1019
+ "",
1020
+ ].join("\n"));
1021
+ }
643
1022
  export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
644
1023
  let scaffoldFilesSkipped = 0;
645
1024
  // Ensure dirs exist when called standalone (not via the orchestrator).
@@ -663,10 +1042,32 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
663
1042
  try {
664
1043
  const here = dirname(fileURLToPath(import.meta.url));
665
1044
  const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
666
- // Caret on a stable version, exact pin on a prerelease (npm
667
- // treats `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3` — the
668
- // prerelease semantics force exact-or-explicit-range. Pinning
669
- // prereleases avoids surprise resolves to incompatible betas.)
1045
+ // Caret on a stable version, exact pin on a prerelease.
1046
+ //
1047
+ // The reason given here used to be that "npm treats
1048
+ // `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3`". That is FALSE.
1049
+ // Checked against semver 7:
1050
+ //
1051
+ // ^0.2.0-beta.96 matches 0.2.0-beta.97 -> true
1052
+ // ^0.2.0-beta.96 matches 0.2.0 -> true
1053
+ // ^0.2.0-beta.96 matches 0.2.1-beta.1 -> false
1054
+ //
1055
+ // A caret DOES pick up later prereleases of the same
1056
+ // major.minor.patch tuple. What it will not do is cross to a
1057
+ // prerelease of a DIFFERENT tuple — probably what the original
1058
+ // note was reaching for.
1059
+ //
1060
+ // The pin stays, for a reason that is actually true: a scaffolded
1061
+ // site commits its generated `astro/` output, so an exact version
1062
+ // guarantees the packages that produced the committed source are
1063
+ // the ones CI installs. A floating range lets a lockfile refresh
1064
+ // change a deployed site with nothing in the diff to show it.
1065
+ //
1066
+ // Our own site repos (dogsbay-ai-blog, dogsbay-ai-site) opt OUT
1067
+ // of that and track the `beta` dist-tag instead, because we are
1068
+ // the only consumers before release and the per-publish version
1069
+ // bump is not worth the commit. Their .gitignore explains the
1070
+ // trade and what to reverse at release.
670
1071
  return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
671
1072
  }
672
1073
  catch {
@@ -689,6 +1090,21 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
689
1090
  const deployDevDeps = isCloudflare
690
1091
  ? { wrangler: "^4.0.0" }
691
1092
  : {};
1093
+ // `--local` outside a workspace also needs a hoisted node_modules.
1094
+ //
1095
+ // With pnpm's default isolated layout, `file:`-linked packages resolve their
1096
+ // own dependencies from their own store path, and Astro's transitives then
1097
+ // fail to resolve from the SITE (measured: "Rolldown failed to resolve import
1098
+ // @astrojs/internal-helpers/path"). Hoisting is what the linked-package case
1099
+ // wants and costs nothing here — the site is disposable derived output, not a
1100
+ // library whose dependency isolation anyone relies on.
1101
+ if (options.local && !insideWs && writeScaffold) {
1102
+ writeFileSync(join(outputDir, ".npmrc"), "node-linker=hoisted\n");
1103
+ }
1104
+ // Subpath mounts move the Astro output to dist/<urlBase>/, so Pagefind
1105
+ // must write its bundle there too — see the build script below.
1106
+ const scaffoldUrlBase = parseSiteUrl(options.siteUrl).urlBase;
1107
+ const scaffoldOutDir = workersSubpathOutDir(options.deploy, scaffoldUrlBase);
692
1108
  // package.json — scaffold-once. Maintainers add their own deps; the
693
1109
  // detection of "already scaffolded" actually keys off this file's
694
1110
  // existence, so it's also our sentinel.
@@ -702,7 +1118,22 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
702
1118
  dev: "astro dev",
703
1119
  // Pagefind runs after astro build; indexes the static dist/ output and
704
1120
  // writes `dist/pagefind/` for the search UI to load lazily on Cmd+K.
705
- build: "astro build && pagefind --site dist",
1121
+ //
1122
+ // Under a subpath mount the Astro output moves to dist/<urlBase>/,
1123
+ // but `--site` stays `dist` on purpose: Pagefind derives result
1124
+ // URLs from the indexed root, so indexing dist/ keeps them as
1125
+ // `/<urlBase>/page/`. Only the BUNDLE has to move, or the page
1126
+ // requests `/<urlBase>/pagefind/pagefind.js` (the pagefindUrl prop
1127
+ // is built from the combined prefix) and gets a 404 — a path that
1128
+ // is not even matched by the emitted `<host>/<urlBase>/*` route,
1129
+ // so Cmd+K would be unreachable by any URL.
1130
+ // Subpath mounts bracket the build with dogsbay-mount.mjs:
1131
+ // `clean` clears dist/ (astro build only clears the MOUNT dir,
1132
+ // so stale layouts would ship forever), and `finalize` lifts
1133
+ // _headers to the assets root, where Workers actually reads it.
1134
+ build: scaffoldOutDir
1135
+ ? `node ./dogsbay-mount.mjs clean && astro build && pagefind --site dist --output-path ${scaffoldOutDir.replace(/^\.\//, "")}/pagefind && node ./dogsbay-mount.mjs finalize`
1136
+ : "astro build && pagefind --site dist",
706
1137
  preview: "astro preview",
707
1138
  ...deployScripts,
708
1139
  },
@@ -718,15 +1149,14 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
718
1149
  // include it; the produced search index is shipped statically and
719
1150
  // doesn't load this dep at runtime.
720
1151
  pagefind: "^1.4.0",
721
- tailwindcss: "^4.0.0",
722
- // Pinned to 4.2.x — `@tailwindcss/vite` 4.3.x ships an
723
- // oxcResolvePlugin shape that breaks Astro 6's
724
- // rolldown-vite ("Missing field tsconfigPaths in
725
- // oxcResolvePlugin"). Surfaced during the FastAPI import
726
- // (~150-page MkDocs site) on a fresh `dogsbay site init`.
727
- // Drop the ~ when Astro 6 picks up a compatible rolldown
728
- // build OR @tailwindcss/vite restores the prior shape.
729
- "@tailwindcss/vite": "~4.2.2",
1152
+ tailwindcss: "^4.3.2",
1153
+ // 4.3.x required on Astro 7: 4.2.x fails against vite 7.3
1154
+ // ("rollupOptions.input should not be an html file when
1155
+ // building for SSR" — surfaced on a fresh comparison-site
1156
+ // build 2026-07-13). The OLD ~4.2.2 pin guarded an Astro 6
1157
+ // rolldown incompatibility that no longer applies; the
1158
+ // monorepo apps build on 4.3.2.
1159
+ "@tailwindcss/vite": "^4.3.2",
730
1160
  "tailwind-variants": "^0.3.0",
731
1161
  shiki: "^4.0.0",
732
1162
  "@shikijs/transformers": "^4.0.0",
@@ -752,13 +1182,22 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
752
1182
  // @floating-ui/core" at astro build time.
753
1183
  "@floating-ui/core": "^1.7.0",
754
1184
  },
755
- // Pin transitive Vite to 7. Vite 8 just released; Astro 6
756
- // peer-deps Vite 7 and prints a warning when 8 is hoisted.
757
- // Without this override npm picks up Vite 8 by default.
758
- // Drop this when Astro 7 ships and bumps its peer.
759
- overrides: {
760
- vite: "^7",
761
- },
1185
+ // `--local` outside a workspace needs these.
1186
+ //
1187
+ // A `file:` dependency is installed from the package's OWN manifest,
1188
+ // and every @dogsbay/* manifest declares its siblings as `workspace:*`
1189
+ // — a protocol that only resolves inside the workspace. So `file:`
1190
+ // alone fails at install with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND, naming
1191
+ // a transitive dependency the caller never wrote down. Overrides map
1192
+ // every sibling to the same checkout, which is what makes `--local`
1193
+ // installable rather than merely emitted.
1194
+ ...(options.local && !insideWs ? { pnpm: { overrides: monorepoOverrides() } } : {}),
1195
+ // NOTE deliberately NO vite override: Astro 7 pairs with
1196
+ // vite 8. The old `overrides: { vite: "^7" }` (an Astro 6
1197
+ // era guard) FORCED vite 7 under astro 7.0.x and broke every
1198
+ // fresh scaffold build ("rollupOptions.input should not be
1199
+ // an html file when building for SSR") — root-caused
1200
+ // 2026-07-13 on the first standalone comparison-site build.
762
1201
  ...(Object.keys(deployDevDeps).length > 0
763
1202
  ? { devDependencies: deployDevDeps }
764
1203
  : {}),
@@ -847,11 +1286,13 @@ import {
847
1286
  dogsbaySite,
848
1287
  dogsbayBase,
849
1288
  dogsbayInlineStylesheets,
1289
+ dogsbayOutDir,
850
1290
  } from "./astro.config.dogsbay.mjs";
851
1291
 
852
1292
  export default defineConfig({
853
1293
  ...(dogsbaySite ? { site: dogsbaySite } : {}),
854
1294
  ...(dogsbayBase ? { base: dogsbayBase } : {}),
1295
+ ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),
855
1296
  output: "static",
856
1297
  build: {
857
1298
  inlineStylesheets: dogsbayInlineStylesheets,
@@ -948,6 +1389,77 @@ export default defineConfig({
948
1389
  * redirect. Returns the count of pages emitted and the merged nav for
949
1390
  * downstream consumers (llms.txt builder).
950
1391
  */
1392
+ /**
1393
+ * Emit `src/pages/404.astro`.
1394
+ *
1395
+ * The scaffold already sets `not_found_handling: "404-page"` in
1396
+ * wrangler.jsonc, but never emitted a page for it — so Workers had
1397
+ * nothing to serve and returned a bare 404 with a ZERO-BYTE body.
1398
+ * Correct status, no way to recover: an agent that mistypes a URL, or
1399
+ * follows a stale link, learns only that the page is gone.
1400
+ *
1401
+ * So this lists the routes worth trying next — the site index, the
1402
+ * sitemap, and llms.txt — which is what turns a dead end into a
1403
+ * redirectable one. The links are plain and visible rather than
1404
+ * decorative, because the reader here is as likely to be a crawler as
1405
+ * a person.
1406
+ *
1407
+ * Regenerated every build, so the links track the site. Skipped when
1408
+ * the author has removed the marker, matching every other emitted
1409
+ * page.
1410
+ */
1411
+ function emitNotFoundPage(outputDir, options) {
1412
+ const path = join(outputDir, "src", "pages", "404.astro");
1413
+ if (existsSync(path)) {
1414
+ const existing = readFileSync(path, "utf-8");
1415
+ if (!existing.includes("AUTO-GENERATED by dogsbay site build"))
1416
+ return;
1417
+ }
1418
+ const combined = combinedPrefix(options);
1419
+ const home = combined || "/";
1420
+ const sitemap = withBasePath(combined, "/sitemap-index.xml");
1421
+ const llms = withBasePath(combined, "/llms.txt");
1422
+ const showLlms = options.llmsTxt !== false;
1423
+ writeFileSync(path, `---
1424
+ // AUTO-GENERATED by dogsbay site build — safe to delete; will regenerate next build.
1425
+ // To customize this page, remove the marker line above (or modify the file
1426
+ // in any way that drops it) and dogsbay will leave your edits alone.
1427
+ import "@/styles/global.css";
1428
+ import DocsLayout from "@dogsbay/docs-layout/DocsLayout.astro";
1429
+ import type { SiteConfig } from "@dogsbay/types";
1430
+ import navData from "@/data/nav.json";
1431
+ import siteConfigData from "@/data/site.json";
1432
+ const siteConfig = siteConfigData as SiteConfig;
1433
+ const favicon = (siteConfigData as { favicon?: string }).favicon;
1434
+ ---
1435
+
1436
+ <DocsLayout
1437
+ siteName={siteConfig.siteName}
1438
+ title="Page not found"
1439
+ description="That page does not exist. Here is where to look instead."
1440
+ nav={navData as never}
1441
+ siteUrl={siteConfig.siteUrl}
1442
+ favicon={favicon}
1443
+ noindex={true}
1444
+ excludeFromSearch={true}
1445
+ ogType="website"
1446
+ >
1447
+ <article class="docs-prose">
1448
+ <h1>Page not found</h1>
1449
+ <p>There is no page at this address. It may have moved, or the link
1450
+ that brought you here may be out of date.</p>
1451
+ <p>Where to look instead:</p>
1452
+ <ul>
1453
+ <li><a href="${home}">Site index</a> — start here</li>
1454
+ <li><a href="${sitemap}">sitemap-index.xml</a> — every page on this site</li>${showLlms
1455
+ ? `
1456
+ <li><a href="${llms}">llms.txt</a> — the same index, for agents</li>`
1457
+ : ""}
1458
+ </ul>
1459
+ </article>
1460
+ </DocsLayout>
1461
+ `);
1462
+ }
951
1463
  export async function emitAstroPages(pages, nav, outputDir, options) {
952
1464
  const siteName = options.siteName || "Documentation";
953
1465
  // basePath = filesystem layout prefix (where pages live under
@@ -1003,25 +1515,49 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1003
1515
  // same shape); the duplicate is cheap (one file) and keeps the
1004
1516
  // build-time and runtime worlds cleanly separated. See
1005
1517
  // plans/client-rendered-nav.md.
1006
- const publicNavDir = join(outputDir, "public", "_dogsbay");
1518
+ // …UNDER the basePath, because that is where the client fetches it
1519
+ // from (`${basePath}/_dogsbay/nav.json`) and because two sites mounted
1520
+ // on one origin — e.g. two release-comparison pairs — would otherwise
1521
+ // both claim `/_dogsbay/nav.json` and collide. Found by the comparison
1522
+ // acceptance suite: the nav silently never hydrated (404), so no page
1523
+ // marks rendered at all.
1524
+ const publicNavDir = join(outputDir, "public", ...basePathSegments(normalizeBasePath(options.basePath)), "_dogsbay");
1007
1525
  mkdirSync(publicNavDir, { recursive: true });
1008
1526
  writeFileSync(join(publicNavDir, "nav.json"), JSON.stringify(outputNav));
1009
1527
  // Static assets (images etc.) — content-tier; always copy from the
1010
1528
  // user's source dir. If they removed an asset, we want it gone here
1011
1529
  // too. Skipped when sourceDir isn't supplied (programmatic callers
1012
1530
  // that want pure page emission).
1013
- if (options.sourceDir) {
1531
+ if (options.assetSourceDirs && options.assetSourceDirs.length > 0) {
1532
+ // Multi-source: copy EACH source's assets under its version/locale
1533
+ // URL prefix (public/<prefix>/...), matching the per-source ref
1534
+ // rewrite. Replaces the single-sourceDir copy, which only handled
1535
+ // the first source and never version-namespaced.
1536
+ for (const { dir, urlPrefix } of options.assetSourceDirs) {
1537
+ copyAssets(dir, outputDir, options.imageOptimization, urlPrefix);
1538
+ }
1539
+ }
1540
+ else if (options.sourceDir) {
1014
1541
  copyAssets(options.sourceDir, outputDir, options.imageOptimization);
1015
1542
  }
1016
1543
  // External asset mounts (e.g. a Docusaurus `static/` dir) → public/_assets/,
1017
1544
  // preserving internal structure so the importer's `/_assets/...` image refs
1018
1545
  // resolve. Used by the convert --to astro path; the dogsbay-md → site build
1019
1546
  // path instead lands these under content/_assets and copyAssets picks them up.
1547
+ // A mount may target an `_assets` subdir (`into`) and restrict itself to
1548
+ // media files (`mediaOnly`) — used for docs-co-located images where the
1549
+ // mounted dir is the docs tree itself.
1020
1550
  if (options.assetMounts) {
1021
1551
  for (const mount of options.assetMounts) {
1022
- if (existsSync(mount.dir)) {
1023
- cpSync(mount.dir, join(outputDir, "public", "_assets"), { recursive: true });
1024
- }
1552
+ if (!existsSync(mount.dir))
1553
+ continue;
1554
+ const dest = mount.into
1555
+ ? join(outputDir, "public", "_assets", mount.into)
1556
+ : join(outputDir, "public", "_assets");
1557
+ if (mount.mediaOnly)
1558
+ copyMediaTree(mount.dir, dest);
1559
+ else
1560
+ cpSync(mount.dir, dest, { recursive: true });
1025
1561
  }
1026
1562
  }
1027
1563
  let generated = 0;
@@ -1032,6 +1568,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1032
1568
  // plans/dir-index-slug-nav-drop.md.
1033
1569
  let rootIndexEmitted = false;
1034
1570
  const generatedPaths = new Set();
1571
+ let pageFailures = 0;
1035
1572
  const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
1036
1573
  const useImageOpt = options.imageOptimization ?? false;
1037
1574
  // hrefPrefix is the COMBINED prefix (urlBase + basePath) — what
@@ -1062,25 +1599,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1062
1599
  // prefix (exact path-prefix). `welcome/_internal` excludes
1063
1600
  // only that specific path tree, not arbitrary `_internal/`
1064
1601
  // elsewhere.
1065
- const excludePatterns = (options.excludeFromRoutes ?? []).map((p) => p.replace(/^\/+/, "").replace(/\/+$/, ""));
1066
- function isExcludedSlug(slug) {
1067
- if (excludePatterns.length === 0)
1068
- return false;
1069
- const segments = slug.split("/");
1070
- for (const pattern of excludePatterns) {
1071
- if (pattern.includes("/")) {
1072
- // Multi-segment → leading-prefix match.
1073
- if (slug === pattern || slug.startsWith(pattern + "/"))
1074
- return true;
1075
- }
1076
- else {
1077
- // Single-segment → any-segment match.
1078
- if (segments.includes(pattern))
1079
- return true;
1080
- }
1081
- }
1082
- return false;
1083
- }
1602
+ const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
1084
1603
  // Granularity (additive book view): roll each top-level nav group into one
1085
1604
  // `_book` page emitted alongside the topics. Kept out of nav; quarantined via
1086
1605
  // noindex + excludeFromSearch frontmatter so robots/sitemap/pagefind exclude
@@ -1097,8 +1616,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1097
1616
  // - "children" → make each direct child default to "yes" (chapters).
1098
1617
  // - "no" → no page (the default).
1099
1618
  // `granularity.book: true` is the legacy all-on switch: it seeds the top level
1100
- // as "children" so every top-level group books — a node's own annotation still
1101
- // wins. A rollup whose estimated source exceeds maxSinglePageMB is SKIPPED with
1619
+ // as "yes", so every top-level group rolls up — a node's own annotation still
1620
+ // wins. (This comment previously said "children", which is the same OUTCOME
1621
+ // but the wrong value, and disagreed with the config schema's wording.) A rollup whose estimated source exceeds maxSinglePageMB is SKIPPED with
1102
1622
  // a warning (no silent multi-minute page). See plans/granularity-views.md.
1103
1623
  const maxBookBytes = Math.round((options.granularity?.maxSinglePageMB ?? 3) * 1024 * 1024);
1104
1624
  const rootInheritYes = options.granularity?.book === true;
@@ -1135,11 +1655,36 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1135
1655
  walkBooks(outputNav, rootInheritYes);
1136
1656
  }
1137
1657
  const emitPages = bookPages.length ? [...pages, ...bookPages] : pages;
1658
+ // Blog mode: compute the post list ONCE, then read adjacency and
1659
+ // reading time per page out of it. Both derive from the same sorted
1660
+ // list, so a post's "next" and its neighbour's "prev" cannot disagree.
1661
+ // Book rollups are excluded — they are quarantined aggregates, not posts.
1662
+ const blogData = options.blog
1663
+ ? buildBlogData(pages, {
1664
+ basePath,
1665
+ section: options.section,
1666
+ excludeSlug: isExcludedSlug,
1667
+ // Post links must carry the COMBINED prefix — the path the host
1668
+ // serves under. With basePath alone, a site mounted at /blog
1669
+ // emitted /second-post/, which is outside its own route.
1670
+ urlPrefix: combinedPrefix(options),
1671
+ config: options.blog,
1672
+ })
1673
+ : undefined;
1674
+ const blogAdj = blogData ? blogAdjacency(blogData) : undefined;
1675
+ const blogMinutes = new Map(blogData ? blogData.posts.map((post) => [post.slug, post.readingMinutes]) : []);
1138
1676
  for (const page of emitPages) {
1139
1677
  try {
1140
1678
  // Skip excluded pages before any expensive work (tree rewrite,
1141
1679
  // serialize, IO).
1142
- const isFragment = page.frontmatter && page.frontmatter._fragment === true;
1680
+ // BOTH signals. `ExportPage.fragment` is the typed field
1681
+ // (`@dogsbay/types` format.ts) that format-docusaurus sets in structural
1682
+ // mode; `frontmatter._fragment` is the loader-stamped one. Checking only
1683
+ // the second emitted every Docusaurus partial as its own routable,
1684
+ // empty-titled, orphan page — duplicating content already inlined into
1685
+ // the pages that included it, and landing in sitemap and search.
1686
+ const isFragment = page.fragment === true ||
1687
+ (page.frontmatter && page.frontmatter._fragment === true);
1143
1688
  if (isFragment || isExcludedSlug(page.slug)) {
1144
1689
  continue;
1145
1690
  }
@@ -1357,11 +1902,26 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1357
1902
  // this keeps prev/next aligned with what the reader
1358
1903
  // sees.
1359
1904
  `const _navForPagination = filterNavByAxis(nav as any[], {`,
1360
- ` basePath: ${JSON.stringify(basePath || "/docs")},`,
1905
+ // Pass the REAL basePath ("" for a root-served site) — coercing ""
1906
+ // → "/docs" makes the filter look for `/docs/…` bucket hrefs that
1907
+ // don't exist, emptying the pagination nav so prev/next vanish on
1908
+ // every page of a root-served multi-source site.
1909
+ ` basePath: ${JSON.stringify(basePath ?? "")},`,
1910
+ ` namespace: ${JSON.stringify(page.multiSource?.namespace ?? null)} ?? undefined,`,
1361
1911
  ` version: ${JSON.stringify(page.multiSource?.version ?? null)} ?? undefined,`,
1362
1912
  ` locale: ${JSON.stringify(page.multiSource?.locale ?? null)} ?? undefined,`,
1363
1913
  `});`,
1364
- `const { prev, next } = getPagination(currentPath, _navForPagination);`,
1914
+ // Blog adjacency is CHRONOLOGICAL, not navigational. getPagination
1915
+ // walks NavItem[] — sidebar order — and a blog has no sidebar; its
1916
+ // neighbours are "the post before/after this one in time". Emitting
1917
+ // the pair as literals also means the page does no adjacency work at
1918
+ // runtime.
1919
+ ...(blogAdj
1920
+ ? [
1921
+ `const prev = ${JSON.stringify(blogAdj.get(page.slug)?.prev ?? null)} ?? undefined;`,
1922
+ `const next = ${JSON.stringify(blogAdj.get(page.slug)?.next ?? null)} ?? undefined;`,
1923
+ ]
1924
+ : [`const { prev, next } = getPagination(currentPath, _navForPagination);`]),
1365
1925
  `const title = ${JSON.stringify(page.title)};`,
1366
1926
  `const description = ${JSON.stringify(pageDescription)} || undefined;`,
1367
1927
  `const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
@@ -1426,6 +1986,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1426
1986
  ` autoH1={${autoH1}}`,
1427
1987
  ` autoLede={${autoLede}}`,
1428
1988
  ` llmActions={llmActionsProps}`,
1989
+ ` linkIcons={siteConfig.linkIcons}`,
1429
1990
  ` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
1430
1991
  ` switcherMap={switcherMapData}`,
1431
1992
  // basePath here is the COMBINED URL prefix (urlBase from
@@ -1450,12 +2011,37 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1450
2011
  ` pagefindUrl={${JSON.stringify(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
1451
2012
  // Favicon — composed with combined prefix so the
1452
2013
  // <link rel="icon"> resolves on subpath-mounted deploys.
1453
- // Authors who want a different favicon override via the
1454
- // `favicon` slot on DocsLayout, or drop the file at
1455
- // `public/favicon.ico` in their Astro project (which is
1456
- // what the default points at).
1457
- ` favicon={${JSON.stringify(combined ? `${combined}/favicon.ico` : "/favicon.ico")}}`,
2014
+ //
2015
+ // Emitted ONLY when the file actually exists in public/. The
2016
+ // scaffold ships no favicon, so an unconditional link meant every
2017
+ // generated site advertised an icon that 404s. Authors drop
2018
+ // `public/favicon.ico` (or `.svg`) in their Astro project and the
2019
+ // link appears on the next build; DocsLayout takes `false` to mean
2020
+ // "emit no <link rel=icon>".
2021
+ ` favicon={${JSON.stringify(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
1458
2022
  ` wideLayout={${wideLayout}}`,
2023
+ // Blog chrome — sidebar dropped, byline on. Every other prop above
2024
+ // is unchanged, which is what keeps the blog visually continuous
2025
+ // with the docs rather than a lookalike.
2026
+ ...(options.blog
2027
+ ? [
2028
+ ` chrome="blog"`,
2029
+ ` author={${JSON.stringify(page.meta?.author ?? null)} ?? undefined}`,
2030
+ ` publishedDate={${JSON.stringify(page.meta?.[options.blog.dateField] ?? null)} ?? undefined}`,
2031
+ ` updatedDate={${JSON.stringify(page.meta?.updated ?? null)} ?? undefined}`,
2032
+ ` readingMinutes={${blogMinutes.get(page.slug) ?? 1}}`,
2033
+ // Series position, derived in buildBlogData from publication
2034
+ // order — never hand-typed in prose, which goes stale the
2035
+ // moment another part lands.
2036
+ // String-guarded like buildBlogData's firstString: a
2037
+ // `heroImage: {src, alt}` object would otherwise reach
2038
+ // DocsLayout's `heroImage?: string` prop and render as
2039
+ // src="[object Object]".
2040
+ ` heroImage={${JSON.stringify(typeof (page.frontmatter ?? {}).heroImage === "string"
2041
+ ? (page.frontmatter ?? {}).heroImage
2042
+ : null)} ?? undefined}`,
2043
+ ]
2044
+ : []),
1459
2045
  ` toc={${JSON.stringify(tocMode)}}`,
1460
2046
  `>`,
1461
2047
  // RightRail region — assigned to DocsLayout's `right-rail` named
@@ -1465,17 +2051,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1465
2051
  ? [` <RightRailStack slot="right-rail" />`]
1466
2052
  : []),
1467
2053
  ` <MarkdownContentStack>`,
1468
- ...(wideLayout
1469
- ? [
1470
- ...singlePageLinkLines(singlePageBySlug.get(page.slug), 4),
1471
- result.body.split("\n").map((l) => ` ${l}`).join("\n"),
1472
- ]
1473
- : [
1474
- ` <article class="docs-prose">`,
1475
- ...singlePageLinkLines(singlePageBySlug.get(page.slug), 6),
1476
- result.body.split("\n").map((l) => ` ${l}`).join("\n"),
1477
- ` </article>`,
1478
- ]),
2054
+ // Every page body gets the docs-prose typography wrapper — wide
2055
+ // (endpoint) pages included. Mixed prose+endpoint pages (MDX
2056
+ // imports) previously lost ALL typography because the wrapper
2057
+ // was skipped; API-region internals opt out via the `dba-api`
2058
+ // marker on ApiLayout + the `:not(.dba-api *)` guards in the
2059
+ // generated .docs-prose rules.
2060
+ ` <article class="docs-prose">`,
2061
+ ...singlePageLinkLines(singlePageBySlug.get(page.slug), 6),
2062
+ result.body.split("\n").map((l) => ` ${l}`).join("\n"),
2063
+ ` </article>`,
1479
2064
  ` </MarkdownContentStack>`,
1480
2065
  `</DocsLayout>`,
1481
2066
  ];
@@ -1518,6 +2103,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1518
2103
  mkdirSync(dirname(mdEndpointPath), { recursive: true });
1519
2104
  const endpointBody = buildMdEndpoint(page, sourceRel);
1520
2105
  writeFileSync(mdEndpointPath, endpointBody);
2106
+ generatedPaths.add(relative(outputDir, mdEndpointPath));
1521
2107
  // Sibling-level mirror for the index page under a non-empty
1522
2108
  // basePath. baseSegments is empty when basePath is empty
1523
2109
  // (root-served sites); in that case the index slug is just
@@ -1530,11 +2116,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1530
2116
  const siblingPath = join(outputDir, "src", "pages", ...parentSegments, `${lastSeg}.md.ts`);
1531
2117
  mkdirSync(dirname(siblingPath), { recursive: true });
1532
2118
  writeFileSync(siblingPath, endpointBody);
2119
+ generatedPaths.add(relative(outputDir, siblingPath));
1533
2120
  }
1534
2121
  }
1535
2122
  }
1536
2123
  catch (err) {
1537
2124
  console.warn(`Warning: failed to generate ${page.slug}: ${err.message}`);
2125
+ // A failed page is absent from generatedPaths, which is
2126
+ // indistinguishable from a deleted one. Pruning is suppressed for
2127
+ // the whole run rather than deleting last build's still-good copy.
2128
+ pageFailures++;
1538
2129
  }
1539
2130
  }
1540
2131
  // Generate index redirect at src/pages/index.astro — sends `/` to the
@@ -1550,14 +2141,70 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1550
2141
  // raw input `nav` — otherwise importers whose nav starts root-relative (e.g.
1551
2142
  // the docusaurus convert path) emit a redirect that drops the URL base and
1552
2143
  // sends `/` to the host root instead of into the site's subpath.
1553
- const firstHref = findFirstNavHref(outputNav, basePath);
2144
+ // ─── Per-version subtree index redirects ─────────────────────────
2145
+ // A version subtree (`/3.29/…`, `/latest/…`) owns no page of its
2146
+ // own, so `/latest/` 404s while `/latest/about` resolves — and
2147
+ // `/latest/` is exactly what a reader types, what a switcher link
2148
+ // truncates to, and what a "latest" alias is FOR. Give each bucket
2149
+ // the same contract the site root gets: redirect to its first nav
2150
+ // entry.
2151
+ const subtreeRoots = axisSubtreeRoots(pages);
2152
+ for (const slugPrefix of subtreeRoots.keys()) {
2153
+ const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`]);
2154
+ if (!target)
2155
+ continue;
2156
+ const subtreeIndexPath = join(pagesDir, section ? section : "", slugPrefix, "index.astro");
2157
+ const subtreeIndexRel = relative(outputDir, subtreeIndexPath);
2158
+ // A real page at this route wins — never clobber content.
2159
+ if (generatedPaths.has(subtreeIndexRel))
2160
+ continue;
2161
+ mkdirSync(dirname(subtreeIndexPath), { recursive: true });
2162
+ writeFileSync(subtreeIndexPath,
2163
+ // Marked as ours so a later emitter (the blog index claims this
2164
+ // exact route) can tell a generated stub from a page a writer
2165
+ // wrote. Without the marker the stub looked hand-authored and
2166
+ // silently blocked the blog index from ever being emitted.
2167
+ `---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${target}");\n---\n`);
2168
+ generatedPaths.add(subtreeIndexRel);
2169
+ }
2170
+ // The site root targets the DEFAULT version's first entry, not the
2171
+ // first entry overall. Nav is assembled oldest-version-first, so
2172
+ // `findFirstNavHref` alone sends every reader arriving at `/` into
2173
+ // the OLDEST docs — the opposite of what `defaultVersion` declares.
2174
+ const defaultVersionPrefixes = options.defaultVersion
2175
+ ? [...subtreeRoots]
2176
+ .filter(([, version]) => version === options.defaultVersion)
2177
+ .map(([slugPrefix]) => `${combined}/${slugPrefix}`)
2178
+ : [];
2179
+ const firstHref = (defaultVersionPrefixes.length > 0
2180
+ ? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes)
2181
+ : undefined) ?? findFirstNavHref(outputNav, basePath);
1554
2182
  const needsRootRedirect = basePath !== "" ||
1555
2183
  (!section && !rootIndexEmitted && firstHref !== "");
1556
2184
  if (needsRootRedirect) {
1557
2185
  const indexPath = join(outputDir, "src", "pages", "index.astro");
1558
- writeFileSync(indexPath, `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
2186
+ writeFileSync(indexPath, `---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${firstHref}");\n---\n`);
1559
2187
  generatedPaths.add(relative(outputDir, indexPath));
1560
2188
  }
2189
+ // See pruneOrphanedPages for why each of these guards exists. They are
2190
+ // cheap; the failure they prevent is deleting a site.
2191
+ if (generatedPaths.size === 0) {
2192
+ // Zero pages is a broken input, not an empty site.
2193
+ }
2194
+ else if (pageFailures > 0) {
2195
+ console.warn(` Skipped pruning: ${pageFailures} page(s) failed to generate, so an ` +
2196
+ `orphan cannot be told from a failure.`);
2197
+ }
2198
+ else {
2199
+ const pruned = pruneOrphanedPages(outputDir, join(pagesDir, section ?? ""), generatedPaths);
2200
+ if (pruned.length > 0) {
2201
+ console.log(` Pruned ${pruned.length} page(s) whose source was deleted: ` +
2202
+ `${pruned.slice(0, 3).join(", ")}${pruned.length > 3 ? ", …" : ""}`);
2203
+ }
2204
+ }
2205
+ // After the prune, so it is never mistaken for an orphan, and after
2206
+ // outputNav exists so its links match the site that was just built.
2207
+ emitNotFoundPage(outputDir, options);
1561
2208
  return { generated, outputNav, generatedPaths };
1562
2209
  }
1563
2210
  /**
@@ -1733,11 +2380,22 @@ function composeAxisHeader(declared, seen, defaultId, allowEol) {
1733
2380
  const out = [];
1734
2381
  const seenInDeclared = new Set();
1735
2382
  for (const d of declared) {
1736
- if (seen.has(d.id)) {
2383
+ // A `hidden` version is the NUMBER an alias stands in for — the
2384
+ // Docusaurus `lastVersion` + `path: "latest"` pair. Its pages are
2385
+ // served under the alias, so no page ever carries its id and `seen`
2386
+ // will never contain it. Admit it anyway: it is label-only metadata
2387
+ // (the switcher filters hidden rows out of the dropdown), and it is
2388
+ // the sole source of the "3.32 (latest)" label. Gating it on `seen`
2389
+ // silently degrades that row to a bare "latest".
2390
+ const aliasedNumber = allowEol && d.hidden === true;
2391
+ if (seen.has(d.id) || aliasedNumber) {
1737
2392
  out.push({
1738
2393
  id: d.id,
1739
2394
  ...(d.label !== undefined ? { label: d.label } : {}),
2395
+ // eol + prerelease + hidden are version-only marks (allowEol gates them).
1740
2396
  ...(allowEol && d.eol === true ? { eol: true } : {}),
2397
+ ...(allowEol && d.prerelease === true ? { prerelease: true } : {}),
2398
+ ...(allowEol && d.hidden === true ? { hidden: true } : {}),
1741
2399
  ...(defaultId === d.id ? { default: true } : {}),
1742
2400
  });
1743
2401
  seenInDeclared.add(d.id);
@@ -1894,12 +2552,43 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1894
2552
  // OR the version axis has a defaultVersion set with ≥2
1895
2553
  // declared versions. When neither applies, no middleware is
1896
2554
  // emitted (keeps single-feature sites' src/ tidy).
1897
- const mdMirrorOn = options.mdMirror !== false;
2555
+ // Middleware only does something on an SSR deploy. In Astro's static
2556
+ // output — which is what `deploy: cloudflare-workers` produces, and
2557
+ // what every Dogsbay site ships today — there is no runtime to invoke
2558
+ // it: every route is prerendered, and the guard inside the generated
2559
+ // middleware short-circuits at BUILD time because there is no client
2560
+ // whose Accept header could be honoured.
2561
+ //
2562
+ // It used to be emitted anyway. That is worse than not emitting it:
2563
+ // an external agent audit reported markdown content negotiation as
2564
+ // failing, and the file sitting in src/ implied the feature existed
2565
+ // and was broken, rather than being inapplicable to the target. Dead
2566
+ // code that claims a capability costs more than a missing feature.
2567
+ //
2568
+ // The `.md` mirrors are unaffected — they are real files, and they
2569
+ // are how agents actually fetch markdown here. What is lost on a
2570
+ // static deploy is only `Accept: text/markdown` negotiation on the
2571
+ // HTML URL, which needs a Worker running before asset serving.
2572
+ // See docs-dev/markdown-negotiation.md.
2573
+ const staticDeploy = options.deploy === "cloudflare-workers";
2574
+ const mdMirrorOn = options.mdMirror !== false && !staticDeploy;
1898
2575
  const knownVersions = pageVersions(pages);
1899
2576
  const knownLocales = pageLocales(pages);
1900
2577
  const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2;
1901
2578
  const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
1902
2579
  const axisRedirectOn = versionRedirectOn || localeRedirectOn;
2580
+ const middlewarePath = join(outputDir, "src", "middleware.ts");
2581
+ if (!mdMirrorOn && !axisRedirectOn && existsSync(middlewarePath)) {
2582
+ // Remove a middleware emitted by an EARLIER build, from before this
2583
+ // stopped emitting for static targets. Left in place it keeps
2584
+ // implying a capability the deploy cannot provide, and a site that
2585
+ // commits its generated output would carry it indefinitely. Only
2586
+ // ours — an author-written middleware is left alone.
2587
+ const existing = readFileSync(middlewarePath, "utf-8");
2588
+ if (existing.includes("AUTO-GENERATED by `dogsbay site build`")) {
2589
+ rmSync(middlewarePath);
2590
+ }
2591
+ }
1903
2592
  if (mdMirrorOn || axisRedirectOn) {
1904
2593
  // Taxonomy index paths share a single global namespace across
1905
2594
  // locales / versions (one `/tags/` for the whole site, not one
@@ -1918,6 +2607,10 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1918
2607
  mkdirSync(join(outputDir, "src"), { recursive: true });
1919
2608
  writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
1920
2609
  mdMirror: mdMirrorOn,
2610
+ // Same combined prefix the axis redirect uses — the middleware
2611
+ // compares against the request URL, which carries the served
2612
+ // subpath.
2613
+ mdMirrorBasePath: combinedPrefix(options),
1921
2614
  axisRedirect: axisRedirectOn
1922
2615
  ? {
1923
2616
  // Middleware compares paths against the request URL,
@@ -2020,10 +2713,77 @@ function buildRobotsTxt(options, hasSiteUrl) {
2020
2713
  // standards-compliant parsers. Emitted alongside Sitemap when
2021
2714
  // siteUrl is set; absolute URLs only (relative paths would be
2022
2715
  // ambiguous without a base).
2023
- const llmsTxt = options.llmsTxt !== false && hasSiteUrl && origin
2716
+ // OPT-IN (`agent.llmsTxtDirective`), default off. RFC 9309 permits
2717
+ // unknown directives, but Lighthouse's validator reports this one as
2718
+ // `Unknown directive` and fails the "robots.txt is valid" audit —
2719
+ // measured: SEO 100 -> 92 on every page. Nothing consumes the line
2720
+ // either; `Llms-Txt:` is not part of the llms.txt proposal, and
2721
+ // agents find the file at its well-known path. A measured penalty
2722
+ // for a speculative benefit is not a sensible default.
2723
+ //
2724
+ // `llms.txt` itself is still emitted; this controls only the pointer.
2725
+ const llmsTxt = options.llmsTxtDirective === true &&
2726
+ options.llmsTxt !== false &&
2727
+ hasSiteUrl &&
2728
+ origin
2024
2729
  ? `Llms-Txt: ${origin}${withBasePath(combined, "/llms.txt")}\n`
2025
2730
  : "";
2026
- return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}${llmsTxt}`;
2731
+ return `${contentSignalPreamble(options)}User-agent: *\nAllow: /\n${contentSignal}${sitemap}${llmsTxt}`;
2732
+ }
2733
+ /**
2734
+ * The Article 4 reservation-of-rights preamble, as robots.txt comments.
2735
+ *
2736
+ * Opt-in via `agent.contentSignal.preamble`. Default off: it is 25
2737
+ * lines of legal text on every site, and it asserts a legal position
2738
+ * that is the operator's to take, not ours to take for them.
2739
+ *
2740
+ * Why it is worth having at all — `Content-Signal: ai-train=no` on its
2741
+ * own is a preference. Article 4(3) of EU Directive 2019/790 makes the
2742
+ * text-and-data-mining exception conditional on rights not having been
2743
+ * "expressly reserved in an appropriate manner", including
2744
+ * machine-readable means. The preamble is what turns the signal into
2745
+ * that express reservation, which is the difference between a request
2746
+ * and a reservation with legal effect in the EU.
2747
+ *
2748
+ * The wording is Cloudflare's Content Signals Policy text, which is
2749
+ * published under CC0 precisely so it can be reproduced. It is
2750
+ * reproduced rather than linked because a reservation has to be present
2751
+ * where the machine reads it.
2752
+ *
2753
+ * Kept BYTE-IDENTICAL to the published policy on purpose. It is a legal
2754
+ * instrument, not prose we own: reword it and a site is asserting
2755
+ * something subtly different from what its operator believes it is
2756
+ * asserting. `tests/robots-preamble.test.ts` pins the text.
2757
+ */
2758
+ function contentSignalPreamble(options) {
2759
+ if (options.contentSignalPreamble !== true)
2760
+ return "";
2761
+ return `# As a condition of accessing this website, you agree to abide by the following
2762
+ # content signals:
2763
+
2764
+ # (a) If a Content-Signal = yes, you may collect content for the corresponding
2765
+ # use.
2766
+ # (b) If a Content-Signal = no, you may not collect content for the
2767
+ # corresponding use.
2768
+ # (c) If the website operator does not include a Content-Signal for a
2769
+ # corresponding use, the website operator neither grants nor restricts
2770
+ # permission via Content-Signal with respect to the corresponding use.
2771
+
2772
+ # The content signals and their meanings are:
2773
+
2774
+ # search: building a search index and providing search results (e.g., returning
2775
+ # hyperlinks and short excerpts from your website's contents). Search does not
2776
+ # include providing AI-generated search summaries.
2777
+ # ai-input: inputting content into one or more AI models (e.g., retrieval
2778
+ # augmented generation, grounding, or other real-time taking of content for
2779
+ # generative AI search answers).
2780
+ # ai-train: training or fine-tuning AI models.
2781
+
2782
+ # ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF
2783
+ # RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT
2784
+ # AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET.
2785
+
2786
+ `;
2027
2787
  }
2028
2788
  /**
2029
2789
  * Build the contents of `public/_headers` (Cloudflare Pages / Workers
@@ -2035,6 +2795,37 @@ function buildRobotsTxt(options, hasSiteUrl) {
2035
2795
  * `/docs` mount) — matches where the platform actually emits
2036
2796
  * llms.txt under the per-mount layout.
2037
2797
  */
2798
+ /**
2799
+ * URL for `<link rel="icon">`, or `false` when the project ships no
2800
+ * favicon.
2801
+ *
2802
+ * The scaffold deliberately ships none — authors drop their own into
2803
+ * `public/`. Emitting the link unconditionally therefore advertised a
2804
+ * 404 on every generated site. Checks `.ico` then `.svg`, matching the
2805
+ * two the scaffold documents.
2806
+ */
2807
+ export function faviconHref(outputDir, urlBase) {
2808
+ // urlBase, NOT the combined prefix. `public/` is copied to the ROOT of
2809
+ // outDir, so a favicon serves from <urlBase>/favicon.ico on all three
2810
+ // layouts — mounted (outDir = dist/<urlBase>), GitHub Pages (host
2811
+ // supplies <urlBase>), and host-root. Composing with basePath as well
2812
+ // produced /blog/docs/favicon.ico for a file that serves from
2813
+ // /blog/favicon.ico: a 404 with a passing existence check in front of
2814
+ // it. Per-mount files (sitemap, llms.txt, nav.json) DO live under
2815
+ // public/<basePath>/ and legitimately use the combined prefix; the
2816
+ // root-level ones (favicon, robots.txt, _headers) do not.
2817
+ //
2818
+ // .png is in the list because it is the most common form — both
2819
+ // packages/cli/templates and apps/material-docs hardcode a .png — and
2820
+ // omitting it would silently emit no icon for authors who did the right
2821
+ // thing.
2822
+ for (const name of ["favicon.ico", "favicon.svg", "favicon.png"]) {
2823
+ if (existsSync(join(outputDir, "public", name))) {
2824
+ return urlBase ? `${urlBase}/${name}` : `/${name}`;
2825
+ }
2826
+ }
2827
+ return false;
2828
+ }
2038
2829
  function buildHeadersFile(basePath) {
2039
2830
  const llmsHref = withBasePath(basePath, "/llms.txt");
2040
2831
  return [
@@ -2090,7 +2881,7 @@ function buildMiddlewareSource(config) {
2090
2881
  lines.push(" if (context.isPrerendered) return next();");
2091
2882
  lines.push(" const url = new URL(context.request.url);");
2092
2883
  if (config.mdMirror) {
2093
- lines.push(' const accept = context.request.headers.get("accept");', " const mdTarget = shouldRewriteToMarkdown(accept, url.pathname);", " if (mdTarget) return context.rewrite(mdTarget);");
2884
+ lines.push(' const accept = context.request.headers.get("accept");', ` const mdTarget = shouldRewriteToMarkdown(accept, url.pathname, ${JSON.stringify(config.mdMirrorBasePath ?? "")});`, " if (mdTarget) return context.rewrite(mdTarget);");
2094
2885
  }
2095
2886
  if (config.axisRedirect) {
2096
2887
  lines.push(" const axisTarget = shouldRedirectToDefaultVersion(", " url.pathname,", " AXIS_REDIRECT_CONFIG,", " );", " if (axisTarget) {", " // 302 (not 301) — the version + locale switchers let readers", " // navigate away from defaults, so we don't want browsers", " // permanently caching the unprefixed URL as default content.", " return Response.redirect(new URL(axisTarget, url.origin), 302);", " }");
@@ -2152,7 +2943,11 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
2152
2943
  const baseSegments = basePathSegments(basePath);
2153
2944
  const mountDir = join(outputDir, "public", ...baseSegments);
2154
2945
  mkdirSync(mountDir, { recursive: true });
2155
- writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
2946
+ writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, {
2947
+ hrefPrefix,
2948
+ aggregates: options.aggregates,
2949
+ whenToUse: options.llmsWhenToUse,
2950
+ }));
2156
2951
  writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
2157
2952
  summary: "body",
2158
2953
  serializePage: serializePageMd,
@@ -2226,7 +3021,13 @@ function emitSitemapFiles(outputDir, options, pages) {
2226
3021
  basePath: combined,
2227
3022
  siteNoindex: options.noindex === true,
2228
3023
  }));
2229
- writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({ siteUrl: options.siteUrl, basePath: combined }));
3024
+ writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({
3025
+ siteUrl: options.siteUrl,
3026
+ basePath: combined,
3027
+ extraSitemaps: (options.aggregates ?? [])
3028
+ .map((a) => a.sitemap)
3029
+ .filter((u) => !!u),
3030
+ }));
2230
3031
  }
2231
3032
  /**
2232
3033
  * Pick a directory under `public/` for a top-level nav group. Prefers
@@ -2280,6 +3081,55 @@ function findFirstNavHref(items, fallback) {
2280
3081
  }
2281
3082
  return fallback;
2282
3083
  }
3084
+ /**
3085
+ * URL subtree root of every multi-source axis bucket, mapped to the
3086
+ * version that bucket carries. Keys are slug prefixes (no leading or
3087
+ * trailing slash), e.g. `3.29` or `calico/en/3.29`.
3088
+ *
3089
+ * `multiSource.originalSlug` is the slug as the importer produced it,
3090
+ * BEFORE the loader applied the axis prefix — so stripping it off the
3091
+ * emitted slug yields that bucket's prefix exactly. Deriving it that
3092
+ * way rather than by segment index matters because how many segments
3093
+ * precede the version depends on which axes are active
3094
+ * (`/<namespace>/<locale>/<version>/<slug>`), and a longest-common-
3095
+ * prefix guess collapses to nothing when two products share a version
3096
+ * id.
3097
+ */
3098
+ function axisSubtreeRoots(pages) {
3099
+ const roots = new Map();
3100
+ for (const page of pages) {
3101
+ const meta = page.multiSource;
3102
+ if (!meta?.version || meta.originalSlug === undefined)
3103
+ continue;
3104
+ if (!page.slug.endsWith(meta.originalSlug))
3105
+ continue;
3106
+ const prefix = page.slug
3107
+ .slice(0, page.slug.length - meta.originalSlug.length)
3108
+ .replace(/^\/+|\/+$/g, "");
3109
+ if (prefix)
3110
+ roots.set(prefix, meta.version);
3111
+ }
3112
+ return roots;
3113
+ }
3114
+ /**
3115
+ * First nav href (in nav order) living under any of `prefixes`, which
3116
+ * are URL paths without a trailing slash. Used to pick a redirect
3117
+ * target for a subtree root that has no page of its own.
3118
+ */
3119
+ function findFirstNavHrefUnder(items, prefixes) {
3120
+ for (const item of items) {
3121
+ const href = item.href;
3122
+ if (href && prefixes.some((prefix) => href.startsWith(`${prefix}/`))) {
3123
+ return href;
3124
+ }
3125
+ if (item.children) {
3126
+ const found = findFirstNavHrefUnder(item.children, prefixes);
3127
+ if (found)
3128
+ return found;
3129
+ }
3130
+ }
3131
+ return undefined;
3132
+ }
2283
3133
  function copyComponents(outputDir) {
2284
3134
  const componentsSource = resolveComponentsSource();
2285
3135
  if (!componentsSource)
@@ -2319,9 +3169,38 @@ function copyComponents(outputDir) {
2319
3169
  }
2320
3170
  }
2321
3171
  }
2322
- function copyAssets(sourceDir, outputDir, imageOptimization) {
3172
+ /** Media files worth carrying from a `mediaOnly` asset mount. */
3173
+ const MOUNT_MEDIA_EXTS = new Set([
3174
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
3175
+ ".svg", ".ico", ".pdf", ".mp4", ".webm",
3176
+ ]);
3177
+ /** Recursively copy only media files, preserving relative structure. */
3178
+ function copyMediaTree(srcDir, destDir) {
3179
+ for (const entry of readdirSync(srcDir)) {
3180
+ const full = join(srcDir, entry);
3181
+ if (statSync(full).isDirectory()) {
3182
+ copyMediaTree(full, join(destDir, entry));
3183
+ continue;
3184
+ }
3185
+ const dot = entry.lastIndexOf(".");
3186
+ if (dot === -1 || !MOUNT_MEDIA_EXTS.has(entry.slice(dot).toLowerCase()))
3187
+ continue;
3188
+ mkdirSync(destDir, { recursive: true });
3189
+ cpSync(full, join(destDir, entry));
3190
+ }
3191
+ }
3192
+ function copyAssets(sourceDir, outputDir, imageOptimization, urlPrefix) {
2323
3193
  // sourceDir is already the docs dir (e.g. .../fastapi/docs/en/docs)
2324
3194
  const searchDir = sourceDir;
3195
+ // A version/locale/namespace URL segment (e.g. "8.8") the content's
3196
+ // asset refs were rewritten with — so each source's assets land under
3197
+ // public/<prefix>/... and same-path images across versions never
3198
+ // collide. Empty for single-source builds (today's flat behaviour).
3199
+ const prefixParts = (urlPrefix ?? "")
3200
+ .split("/")
3201
+ .map((p) => p.trim())
3202
+ .filter(Boolean);
3203
+ const withPrefix = (rel) => prefixParts.length > 0 ? join(...prefixParts, rel) : rel;
2325
3204
  // Raster images benefit from Astro optimization (WebP, dimensions)
2326
3205
  const optimizableExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2327
3206
  // SVGs, icons, PDFs always go to public/ (no optimization needed)
@@ -2335,7 +3214,7 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
2335
3214
  else {
2336
3215
  const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
2337
3216
  if (optimizableExts.has(ext)) {
2338
- const rel = relative(searchDir, full);
3217
+ const rel = withPrefix(relative(searchDir, full));
2339
3218
  // Always copy to public/ so inline <img src="/..."> works
2340
3219
  const pubDest = join(outputDir, "public", rel);
2341
3220
  mkdirSync(dirname(pubDest), { recursive: true });
@@ -2350,7 +3229,7 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
2350
3229
  }
2351
3230
  else if (passthroughExts.has(ext)) {
2352
3231
  // SVGs, icons, PDFs always go to public/
2353
- const rel = relative(searchDir, full);
3232
+ const rel = withPrefix(relative(searchDir, full));
2354
3233
  const dest = join(outputDir, "public", rel);
2355
3234
  mkdirSync(dirname(dest), { recursive: true });
2356
3235
  cpSync(full, dest);
@@ -2394,6 +3273,22 @@ function generateGlobalCss() {
2394
3273
  return `@import "tailwindcss";
2395
3274
  @import "./theme.css";
2396
3275
 
3276
+ /* Custom elements have no user-agent style, so an unstyled one is
3277
+ display:inline — on which w-full does nothing and a border paints
3278
+ as detached fragments rather than around the content. @dogsbay/elements
3279
+ injects these same defaults at runtime, which is what reaches EXISTING
3280
+ sites (global.css is scaffolded once and never rewritten). Having them
3281
+ here too means a new site is correct at CSS time, with no flash before
3282
+ the module script runs.
3283
+
3284
+ :where() keeps specificity at zero, so any utility class wins. */
3285
+ :where(db-tabs, db-accordion, db-collapsible, db-card, db-steps, db-code-block) {
3286
+ display: block;
3287
+ }
3288
+ :where(db-link-button) {
3289
+ display: inline-block;
3290
+ }
3291
+
2397
3292
  /* Scan @dogsbay packages for Tailwind classes */
2398
3293
  @source "../../node_modules/@dogsbay/ui/src";
2399
3294
  @source "../../node_modules/@dogsbay/docs-layout/src";
@@ -2406,52 +3301,105 @@ function generateGlobalCss() {
2406
3301
  demo cells with no visible background. */
2407
3302
  @source inline("${buildToneSafelist()}");
2408
3303
 
2409
- /* Prose typography for rendered content */
3304
+ /* Prose typography for rendered content.
3305
+ Every element rule is guarded with :not(.dba-api *): API-reference
3306
+ regions (ApiLayout root carries the dba-api marker) compose their own
3307
+ typography from utility classes, and unguarded .docs-prose rules would
3308
+ out-specify them. The guard lets mixed prose+endpoint pages (MDX
3309
+ imports) keep full typography outside the cards. */
2410
3310
  .docs-prose {
2411
3311
  line-height: 1.7;
2412
3312
 
2413
- & h1 { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; line-height: 1.2; letter-spacing: -0.025em; }
2414
- & h2 { font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 0.75rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; letter-spacing: -0.015em; }
2415
- & h3 { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
2416
- & h4 { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
3313
+ & h1:not(.dba-api *) { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; line-height: 1.2; letter-spacing: -0.025em; }
3314
+ & h2:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 0.75rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; letter-spacing: -0.015em; }
3315
+ & h3:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
3316
+ & h4:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
2417
3317
 
2418
- & p { margin-top: 0.75rem; margin-bottom: 0.75rem; }
3318
+ & p:not(.dba-api *) { margin-top: 0.75rem; margin-bottom: 0.75rem; }
2419
3319
 
2420
- & a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
2421
- & a:hover { opacity: 0.8; }
3320
+ /* The :not([data-variant]) guard exempts anchors that ARE components. A
3321
+ link-button renders as an <a> carrying bg-primary + text-primary-foreground
3322
+ and a data-variant attribute, and this rule repainted its text with
3323
+ var(--primary) — primary on primary: an unreadable solid block in light
3324
+ mode, a blank one in dark. The secondary variant survived only because its
3325
+ background is light enough to read primary-coloured text, which is why it
3326
+ looked like a colour bug in one button rather than a cascade bug in both.
3327
+ (No backticks in this comment: the whole stylesheet is a template
3328
+ literal.) */
3329
+ & a:not(.dba-api *):not([data-variant]) { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
3330
+ & a:hover:not(.dba-api *):not([data-variant]) { opacity: 0.8; }
2422
3331
 
2423
- & strong { font-weight: 600; }
2424
- & code { font-size: 0.875em; padding: 0.15em 0.35em; border-radius: 0.25rem; background: var(--muted); font-family: var(--font-code, ui-monospace, monospace); }
2425
- & pre code { padding: 0; background: none; font-size: 1em; }
3332
+ & strong:not(.dba-api *) { font-weight: 600; }
3333
+ & code:not(.dba-api *) { font-size: 0.875em; padding: 0.15em 0.35em; border-radius: 0.25rem; background: var(--muted); font-family: var(--font-code, ui-monospace, monospace); }
3334
+ & pre code:not(.dba-api *) { padding: 0; background: none; font-size: 1em; }
2426
3335
 
2427
3336
  /* Spacing between consecutive block elements (code blocks, alerts, etc.) */
2428
3337
  & > * + * { margin-top: 0.75rem; }
2429
- & li > * + * { margin-top: 0.75rem; }
3338
+ & li > * + *:not(.dba-api *) { margin-top: 0.75rem; }
2430
3339
 
2431
- & ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
2432
- & ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
2433
- & li { margin: 0.25rem 0; }
2434
- & li > ul, & li > ol { margin: 0.25rem 0; }
3340
+ & ul:not(.dba-api *) { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
3341
+ & ol:not(.dba-api *) { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
3342
+ & li:not(.dba-api *) { margin: 0.25rem 0; }
3343
+ & li > ul:not(.dba-api *), & li > ol:not(.dba-api *) { margin: 0.25rem 0; }
2435
3344
 
2436
- & blockquote { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
3345
+ & blockquote:not(.dba-api *) { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
2437
3346
 
2438
- & hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
3347
+ & hr:not(.dba-api *) { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
2439
3348
 
2440
- & table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
2441
- & th { text-align: left; vertical-align: bottom; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
2442
- & td { padding: 0.5rem; vertical-align: top; border-bottom: 1px solid var(--border); }
3349
+ & table:not(.dba-api *) { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
3350
+ & th:not(.dba-api *) { text-align: left; vertical-align: bottom; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
3351
+ & td:not(.dba-api *) { padding: 0.5rem; vertical-align: top; border-bottom: 1px solid var(--border); }
2443
3352
 
2444
- & img { max-width: 100%; border-radius: 0.5rem; }
3353
+ & img:not(.dba-api *) { max-width: 100%; border-radius: 0.5rem; }
2445
3354
 
2446
3355
  & .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
2447
3356
  & h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
2448
3357
 
2449
- & details { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
2450
- & summary { cursor: pointer; font-weight: 600; }
3358
+ & details:not(.dba-api *) { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
3359
+ & summary:not(.dba-api *) { cursor: pointer; font-weight: 600; }
3360
+
3361
+ /* mark is emitted by the highlight directive AND can arrive as raw HTML
3362
+ from an importer. Without a rule here it renders as the browser default
3363
+ yellow, which clashes in light mode and is unreadable on a dark page
3364
+ (nothing declares color-scheme). Token-based so it follows the theme. */
3365
+ & mark:not(.dba-api *) { background: var(--dsb-mark-bg, rgb(254 240 138 / 0.6)); color: inherit; border-radius: 0.125rem; padding: 0 0.125rem; }
3366
+ &:where(.dark *) mark:not(.dba-api *), .dark & mark:not(.dba-api *) { background: var(--dsb-mark-bg-dark, rgb(234 179 8 / 0.3)); }
3367
+
3368
+ & dl:not(.dba-api *) { margin: 1rem 0; }
3369
+ & dt:not(.dba-api *) { font-weight: 600; margin-top: 0.75rem; }
3370
+ & dd:not(.dba-api *) { margin-left: 1.5rem; color: var(--muted-foreground); }
2451
3371
 
2452
- & dl { margin: 1rem 0; }
2453
- & dt { font-weight: 600; margin-top: 0.75rem; }
2454
- & dd { margin-left: 1.5rem; color: var(--muted-foreground); }
3372
+ /* In-cell admonitions. Inside a raw HTML table cell nothing
3373
+ markdown-native works, so the AsciiDoc engine emits a neutral
3374
+ dl.db-admonition shape (DD-010) and this rule renders it as a compact
3375
+ callout box — same contract the Obsidian plugin styles. Per-type
3376
+ accent from theme tokens; overrides the generic dl/dt/dd rules above. */
3377
+ & dl.db-admonition {
3378
+ margin: 0.75rem 0;
3379
+ padding: 0.5rem 0.75rem;
3380
+ border-left: 3px solid var(--info);
3381
+ border-radius: 0.25rem;
3382
+ background: color-mix(in oklab, var(--info) 8%, transparent);
3383
+ }
3384
+ & dl.db-admonition > dt {
3385
+ margin-top: 0;
3386
+ font-weight: 600;
3387
+ font-size: 0.8125rem;
3388
+ text-transform: uppercase;
3389
+ letter-spacing: 0.03em;
3390
+ color: var(--info);
3391
+ }
3392
+ & dl.db-admonition > dd { margin-left: 0; margin-top: 0.25rem; color: var(--foreground); }
3393
+ & dl.db-admonition-warning, & dl.db-admonition-caution {
3394
+ border-left-color: var(--warning);
3395
+ background: color-mix(in oklab, var(--warning) 8%, transparent);
3396
+ }
3397
+ & dl.db-admonition-warning > dt, & dl.db-admonition-caution > dt { color: var(--warning); }
3398
+ & dl.db-admonition-important {
3399
+ border-left-color: var(--destructive);
3400
+ background: color-mix(in oklab, var(--destructive) 8%, transparent);
3401
+ }
3402
+ & dl.db-admonition-important > dt { color: var(--destructive); }
2455
3403
 
2456
3404
  /* Granularity book-view affordances (plans/granularity-views.md):
2457
3405
  "Read as single page →" on topics, "Open as page ↗" on book sections.
@@ -2613,23 +3561,23 @@ const THEME_DEFAULT = {
2613
3561
  --code-foreground: oklch(0.926 0.013 253.833);
2614
3562
 
2615
3563
  /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
2616
- --api-get: oklch(0.635 0.135 162);
2617
- --api-post: oklch(0.655 0.174 259);
2618
- --api-put: oklch(0.66 0.15 63);
2619
- --api-patch: oklch(0.665 0.18 45);
2620
- --api-delete: oklch(0.67 0.194 22);
2621
- --api-head: oklch(0.67 0.182 286);
2622
- --api-required: oklch(0.67 0.194 22);
2623
- --api-deprecated: oklch(0.66 0.15 63);
2624
- --api-type-string: oklch(0.635 0.135 162);
2625
- --api-type-number: oklch(0.655 0.174 259);
2626
- --api-type-boolean: oklch(0.66 0.15 63);
2627
- --api-type-object: oklch(0.67 0.182 286);
2628
- --api-type-array: oklch(0.665 0.18 45);
2629
- --api-status-2xx: oklch(0.635 0.135 162);
2630
- --api-status-3xx: oklch(0.655 0.174 259);
2631
- --api-status-4xx: oklch(0.66 0.15 63);
2632
- --api-status-5xx: oklch(0.67 0.194 22);
3564
+ --api-get: oklch(0.720 0.135 162);
3565
+ --api-post: oklch(0.755 0.174 259);
3566
+ --api-put: oklch(0.745 0.15 63);
3567
+ --api-patch: oklch(0.725 0.18 45);
3568
+ --api-delete: oklch(0.805 0.194 22);
3569
+ --api-head: oklch(0.740 0.182 286);
3570
+ --api-required: oklch(0.805 0.194 22);
3571
+ --api-deprecated: oklch(0.745 0.15 63);
3572
+ --api-type-string: oklch(0.690 0.135 162);
3573
+ --api-type-number: oklch(0.725 0.174 259);
3574
+ --api-type-boolean: oklch(0.720 0.15 63);
3575
+ --api-type-object: oklch(0.740 0.182 286);
3576
+ --api-type-array: oklch(0.725 0.18 45);
3577
+ --api-status-2xx: oklch(0.720 0.135 162);
3578
+ --api-status-3xx: oklch(0.755 0.174 259);
3579
+ --api-status-4xx: oklch(0.770 0.15 63);
3580
+ --api-status-5xx: oklch(0.805 0.194 22);
2633
3581
  }`,
2634
3582
  };
2635
3583
  /**
@@ -2753,23 +3701,23 @@ const THEME_MINTLIFY = {
2753
3701
  --code-foreground: oklch(0.9 0.01 260);
2754
3702
 
2755
3703
  /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
2756
- --api-get: oklch(0.635 0.135 162);
2757
- --api-post: oklch(0.655 0.174 259);
2758
- --api-put: oklch(0.66 0.15 63);
2759
- --api-patch: oklch(0.665 0.18 45);
2760
- --api-delete: oklch(0.67 0.194 22);
2761
- --api-head: oklch(0.67 0.182 286);
2762
- --api-required: oklch(0.67 0.194 22);
2763
- --api-deprecated: oklch(0.66 0.15 63);
2764
- --api-type-string: oklch(0.635 0.135 162);
2765
- --api-type-number: oklch(0.655 0.174 259);
2766
- --api-type-boolean: oklch(0.66 0.15 63);
2767
- --api-type-object: oklch(0.67 0.182 286);
2768
- --api-type-array: oklch(0.665 0.18 45);
2769
- --api-status-2xx: oklch(0.635 0.135 162);
2770
- --api-status-3xx: oklch(0.655 0.174 259);
2771
- --api-status-4xx: oklch(0.66 0.15 63);
2772
- --api-status-5xx: oklch(0.67 0.194 22);
3704
+ --api-get: oklch(0.720 0.135 162);
3705
+ --api-post: oklch(0.755 0.174 259);
3706
+ --api-put: oklch(0.745 0.15 63);
3707
+ --api-patch: oklch(0.725 0.18 45);
3708
+ --api-delete: oklch(0.805 0.194 22);
3709
+ --api-head: oklch(0.740 0.182 286);
3710
+ --api-required: oklch(0.805 0.194 22);
3711
+ --api-deprecated: oklch(0.745 0.15 63);
3712
+ --api-type-string: oklch(0.690 0.135 162);
3713
+ --api-type-number: oklch(0.725 0.174 259);
3714
+ --api-type-boolean: oklch(0.720 0.15 63);
3715
+ --api-type-object: oklch(0.740 0.182 286);
3716
+ --api-type-array: oklch(0.725 0.18 45);
3717
+ --api-status-2xx: oklch(0.720 0.135 162);
3718
+ --api-status-3xx: oklch(0.755 0.174 259);
3719
+ --api-status-4xx: oklch(0.770 0.15 63);
3720
+ --api-status-5xx: oklch(0.805 0.194 22);
2773
3721
  }`,
2774
3722
  };
2775
3723
  function writeThemeFile(path, themeName) {
@@ -2920,6 +3868,37 @@ function isInsideWorkspace(outputDir) {
2920
3868
  }
2921
3869
  return false;
2922
3870
  }
3871
+ /**
3872
+ * Every @dogsbay/* package in this checkout, mapped to its `file:` path.
3873
+ *
3874
+ * Enumerated from disk rather than hard-coded: the set of packages changes, and
3875
+ * a stale list fails as a confusing install error naming a package nobody
3876
+ * mentioned.
3877
+ */
3878
+ function monorepoOverrides() {
3879
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
3880
+ const overrides = {};
3881
+ if (!existsSync(root))
3882
+ return overrides;
3883
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
3884
+ if (!entry.isDirectory())
3885
+ continue;
3886
+ const manifest = join(root, entry.name, "package.json");
3887
+ if (!existsSync(manifest))
3888
+ continue;
3889
+ try {
3890
+ const { name } = JSON.parse(readFileSync(manifest, "utf-8"));
3891
+ if (name?.startsWith("@dogsbay/"))
3892
+ overrides[name] = `file:${join(root, entry.name)}`;
3893
+ }
3894
+ catch {
3895
+ // A malformed manifest in the checkout is not this function's problem;
3896
+ // skipping it degrades to the previous behaviour for that one package
3897
+ // rather than failing the whole export.
3898
+ }
3899
+ }
3900
+ return overrides;
3901
+ }
2923
3902
  function resolveMonorepoPkg(name) {
2924
3903
  const thisDir = dirname(fileURLToPath(import.meta.url));
2925
3904
  // From packages/format-astro/src/ → ../../{name}