@dogsbay/format-astro 0.2.0-beta.1 → 0.2.0-beta.100

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 (50) hide show
  1. package/dist/base-path.d.ts +115 -7
  2. package/dist/base-path.d.ts.map +1 -1
  3. package/dist/base-path.js +147 -8
  4. package/dist/base-path.js.map +1 -1
  5. package/dist/blog.d.ts +134 -0
  6. package/dist/blog.d.ts.map +1 -0
  7. package/dist/blog.js +319 -0
  8. package/dist/blog.js.map +1 -0
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +1 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/diff-decoration.d.ts +79 -0
  13. package/dist/diff-decoration.d.ts.map +1 -0
  14. package/dist/diff-decoration.js +541 -0
  15. package/dist/diff-decoration.js.map +1 -0
  16. package/dist/granularity.d.ts +83 -0
  17. package/dist/granularity.d.ts.map +1 -0
  18. package/dist/granularity.js +247 -0
  19. package/dist/granularity.js.map +1 -0
  20. package/dist/index.d.ts +22 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +26 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/lead.d.ts +19 -0
  25. package/dist/lead.d.ts.map +1 -1
  26. package/dist/lead.js +101 -6
  27. package/dist/lead.js.map +1 -1
  28. package/dist/llms-txt.d.ts +48 -2
  29. package/dist/llms-txt.d.ts.map +1 -1
  30. package/dist/llms-txt.js +131 -14
  31. package/dist/llms-txt.js.map +1 -1
  32. package/dist/plugins.js +1 -1
  33. package/dist/plugins.js.map +1 -1
  34. package/dist/project.d.ts +310 -13
  35. package/dist/project.d.ts.map +1 -1
  36. package/dist/project.js +2270 -193
  37. package/dist/project.js.map +1 -1
  38. package/dist/serialize.d.ts +23 -0
  39. package/dist/serialize.d.ts.map +1 -1
  40. package/dist/serialize.js +442 -136
  41. package/dist/serialize.js.map +1 -1
  42. package/dist/sitemap.d.ts +81 -0
  43. package/dist/sitemap.d.ts.map +1 -0
  44. package/dist/sitemap.js +200 -0
  45. package/dist/sitemap.js.map +1 -0
  46. package/dist/taxonomy.d.ts +61 -1
  47. package/dist/taxonomy.d.ts.map +1 -1
  48. package/dist/taxonomy.js +83 -21
  49. package/dist/taxonomy.js.map +1 -1
  50. package/package.json +8 -7
package/dist/project.js CHANGED
@@ -4,14 +4,43 @@
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, 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
- import { treeToAstro } from "./serialize.js";
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
- import { normalizeBasePath, basePathSegments, buildCurrentPath } from "./base-path.js";
14
- import { detectLeadingNodes } from "./lead.js";
14
+ import { buildSitemap, buildSitemapIndex } from "./sitemap.js";
15
+ import { assembleBook, estimateTreeBytes } from "./granularity.js";
16
+ /**
17
+ * The per-topic "Read as single page" affordance (granularity book view).
18
+ * Returns `[]` when the topic has no book (granularity off / not in a group).
19
+ * `href` is a build-time-known route (`<bookRoute>#<anchor>`).
20
+ */
21
+ function singlePageLinkLines(href, indent) {
22
+ if (!href)
23
+ return [];
24
+ const pad = " ".repeat(indent);
25
+ return [`${pad}<a class="dsb-read-single-page" href="${href}">Read as single page →</a>`];
26
+ }
27
+ import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath, parseSiteUrl, combinePrefix, } from "./base-path.js";
28
+ import { buildBlogData, blogAdjacency } from "./blog.js";
29
+ /**
30
+ * Combined URL prefix = urlBase (Astro `base` from site.url path) +
31
+ * basePath (filesystem layout prefix). Every URL emitter (nav,
32
+ * sitemap, llms.txt, .md mirror, _headers, taxonomy) uses this for
33
+ * href output. Filesystem-layout consumers (mkdir, page output
34
+ * paths) keep using basePath alone — Astro's `base` config adds the
35
+ * urlBase prefix at route time.
36
+ *
37
+ * See plans/astro-base-from-site-url.md.
38
+ */
39
+ function combinedPrefix(options) {
40
+ const { urlBase } = parseSiteUrl(options.siteUrl);
41
+ return combinePrefix(urlBase, normalizeBasePath(options.basePath));
42
+ }
43
+ import { detectLeadingNodes, deriveDescription } from "./lead.js";
15
44
  /**
16
45
  * Recursively prefix all hrefs in a nav tree.
17
46
  * Turns `<basePath>/foo` into `<basePath>/{section}/foo`. The
@@ -49,6 +78,39 @@ function prefixNavHrefs(items, section, basePath) {
49
78
  function escapeRegex(s) {
50
79
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
80
  }
81
+ /**
82
+ * Prefix the combined URL base onto root-absolute nav hrefs.
83
+ *
84
+ * Most importers emit nav hrefs already carrying the combined prefix —
85
+ * the dogsbay-md importer walks source paths through
86
+ * `fileToHref(file, hrefPrefix=combined)`, so OpenShift's `nav.yml`
87
+ * (`welcome/foo.md`) resolves straight to `/<base>/welcome/foo`. But an
88
+ * importer that produces a nav.json of *pre-resolved* hrefs at a
89
+ * different base — e.g. `dogsbay convert --from docusaurus --to
90
+ * dogsbay-md`, whose hrefs are root-absolute (`/about/foo`) — would
91
+ * otherwise emit unprefixed nav links that 404 under a site base, and
92
+ * break prev/next (pagination matches against the combined-prefixed
93
+ * `currentPath`).
94
+ *
95
+ * Running every nav href through `rewriteHref` here is the single
96
+ * chokepoint that makes nav base-correct for ALL importers. It's
97
+ * idempotent — hrefs already starting with the prefix (or external /
98
+ * anchor / protocol-relative) are left untouched — so importer-side
99
+ * prefixing keeps working unchanged, and an empty prefix is a no-op.
100
+ */
101
+ function prefixNavBaseHrefs(items, prefix) {
102
+ if (!prefix)
103
+ return items;
104
+ return items.map((item) => {
105
+ const result = { ...item };
106
+ if (result.href)
107
+ result.href = rewriteHref(result.href, prefix);
108
+ if (result.children) {
109
+ result.children = prefixNavBaseHrefs(result.children, prefix);
110
+ }
111
+ return result;
112
+ });
113
+ }
52
114
  /**
53
115
  * Rewrite internal hrefs in a page's tree nodes.
54
116
  * Prepends `prefix` to root-relative links (e.g. `/workers/...` → `/docs/workers/...`).
@@ -97,6 +159,210 @@ function rewriteHref(href, prefix) {
97
159
  return href;
98
160
  return prefix + href;
99
161
  }
162
+ /**
163
+ * Rewrite image srcs in inline nodes + raw HTML to include the
164
+ * combined URL prefix.
165
+ *
166
+ * Astro auto-prefixes `<a href>` and image imports going through
167
+ * `<AstroImage>`, but raw `<img src="...">` HTML in template
168
+ * output is left untouched. The serializer emits raw `<img>` for
169
+ * inline images and falls back to it for non-optimized block
170
+ * images, so we have to prefix manually before serialization to
171
+ * make `/_assets/...` paths resolve under subpath-mounted deploys
172
+ * (GH Pages project pages, multi-mount Cloudflare).
173
+ *
174
+ * Symmetric with rewriteTreeHrefs — same skip-rules (external,
175
+ * anchors, already-prefixed). Block images keep their prefix
176
+ * stripped back off for the `imageMap[...]` lookup key (see
177
+ * paragraphToAstro in serialize.ts) so Astro's image optimization
178
+ * still finds the source.
179
+ */
180
+ function rewriteTreeImageSrcs(nodes, prefix) {
181
+ for (const node of nodes) {
182
+ if (node.inline) {
183
+ rewriteInlineImageSrcs(node.inline, prefix);
184
+ }
185
+ if (node.html) {
186
+ node.html = rewriteHtmlImageSrcs(node.html, prefix);
187
+ }
188
+ if (node.children) {
189
+ rewriteTreeImageSrcs(node.children, prefix);
190
+ }
191
+ }
192
+ }
193
+ function rewriteInlineImageSrcs(nodes, prefix) {
194
+ for (const node of nodes) {
195
+ if (node.type === "image" && typeof node.src === "string") {
196
+ node.src = rewriteHref(node.src, prefix);
197
+ }
198
+ else if (node.type === "link") {
199
+ // Links wrap inline children (which may include images) — same
200
+ // recursion shape as rewriteInlineHrefs.
201
+ rewriteInlineImageSrcs(node.children, prefix);
202
+ }
203
+ else if (node.type === "highlight" && node.children) {
204
+ rewriteInlineImageSrcs(node.children, prefix);
205
+ }
206
+ }
207
+ }
208
+ function rewriteHtmlImageSrcs(html, prefix) {
209
+ return html.replace(/(<img\b[^>]*\ssrc=")(\/[^"]+)"/g, (_match, before, src) => `${before}${rewriteHref(src, prefix)}"`);
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
+ }
100
366
  /**
101
367
  * Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
102
368
  *
@@ -147,18 +413,144 @@ function buildWranglerConfig(siteName, options) {
147
413
  lines.push(`}`);
148
414
  return lines.join("\n") + "\n";
149
415
  }
416
+ /**
417
+ * Build the GitHub Actions workflow YAML for `actions/deploy-pages`.
418
+ *
419
+ * The workflow:
420
+ * 1. Checks out the repo on every push to the default branch.
421
+ * 2. Installs node + pnpm at the Astro project directory, runs
422
+ * `dogsbay site build` (via `pnpm dlx` since Dogsbay is a
423
+ * global CLI, not a project dep), then `pnpm run build`
424
+ * (which runs `astro build && pagefind`).
425
+ * 3. Uploads `<astroDirRel>/dist` as a Pages artifact via
426
+ * `actions/upload-pages-artifact`.
427
+ * 4. Deploys via `actions/deploy-pages`.
428
+ *
429
+ * `astroDirRel` is the path of the Astro output relative to the
430
+ * repo root (typically "astro" — the default config has
431
+ * `output: ./astro`). Empty string is allowed when the project is
432
+ * flat (outputDir === projectDir); the workflow degrades naturally
433
+ * by omitting the `defaults: working-directory` block.
434
+ *
435
+ * Author edits — extra build steps, secrets, deploy gating — survive
436
+ * subsequent `dogsbay site build` runs because the file is written
437
+ * write-if-missing (see emitDeployArtifacts). To start over, delete
438
+ * the workflow file and rebuild.
439
+ *
440
+ * Note on basePath: GitHub Pages serves project sites at
441
+ * `https://<user>.github.io/<repo>/`. Authors who want their docs at
442
+ * the repo root should set `site.basePath: /<repo-name>` (or empty
443
+ * for user/org pages). The platform's basePath plumbing handles all
444
+ * URL rewriting; this workflow doesn't need to know about it.
445
+ */
446
+ function buildGitHubPagesWorkflow(astroDirRel) {
447
+ // When the Astro output IS the project root, drop the working-
448
+ // directory block and reference cache + artifact paths without a
449
+ // prefix. This is the flat-layout case (rare for site-init flows;
450
+ // common for `dogsbay convert` outputs that get manually wired up).
451
+ const isFlat = astroDirRel === "" || astroDirRel === ".";
452
+ const workingDirBlock = isFlat
453
+ ? ""
454
+ : `
455
+ defaults:
456
+ run:
457
+ working-directory: ${astroDirRel}`;
458
+ const cacheDep = isFlat
459
+ ? "pnpm-lock.yaml"
460
+ : `${astroDirRel}/pnpm-lock.yaml`;
461
+ const artifactPath = isFlat ? "dist" : `${astroDirRel}/dist`;
462
+ return `# Deploy to GitHub Pages.
463
+ # Generated by \`dogsbay site init --deploy=github-pages\` (or by
464
+ # adding \`deploy: { target: github-pages }\` to dogsbay.config.yml
465
+ # and running \`dogsbay site build\`). Author edits survive every
466
+ # subsequent build — the file is never overwritten. To regenerate
467
+ # from template, delete the file and rebuild.
468
+ #
469
+ # Repo settings: Settings → Pages → Source = "GitHub Actions".
470
+ name: Deploy to GitHub Pages
471
+
472
+ on:
473
+ push:
474
+ branches: [main]
475
+ workflow_dispatch:
476
+
477
+ permissions:
478
+ contents: read
479
+ pages: write
480
+ id-token: write
481
+
482
+ # Allow only one concurrent deployment, skipping queued runs.
483
+ concurrency:
484
+ group: pages
485
+ cancel-in-progress: false
486
+
487
+ jobs:
488
+ build:
489
+ runs-on: ubuntu-latest${workingDirBlock}
490
+ steps:
491
+ - uses: actions/checkout@v4
492
+
493
+ - uses: pnpm/action-setup@v4
494
+ with:
495
+ version: 10
496
+
497
+ - uses: actions/setup-node@v4
498
+ with:
499
+ # Astro 6 requires Node ^20.19.5 || >=22.12.0; pin 22 for
500
+ # forward-compat (Node 20 LTS is fine for Astro 5 sites
501
+ # but the Dogsbay scaffold targets Astro 6).
502
+ node-version: 22
503
+ cache: pnpm
504
+ cache-dependency-path: ${cacheDep}
505
+
506
+ - name: Install dependencies
507
+ run: pnpm install --frozen-lockfile
508
+
509
+ # \`dogsbay\` is a global CLI, not a project dep — pnpm dlx
510
+ # fetches it on demand. To pin a version, replace with e.g.
511
+ # \`pnpm dlx dogsbay@0.2.0-beta.18 site build\`.
512
+ - name: Build with Dogsbay
513
+ run: pnpm dlx dogsbay@beta site build
514
+
515
+ - name: Build Astro site
516
+ run: pnpm run build
517
+
518
+ - name: Upload Pages artifact
519
+ uses: actions/upload-pages-artifact@v3
520
+ with:
521
+ path: ${artifactPath}
522
+
523
+ deploy:
524
+ needs: build
525
+ runs-on: ubuntu-latest
526
+ environment:
527
+ name: github-pages
528
+ url: \${{ steps.deployment.outputs.page_url }}
529
+ steps:
530
+ - name: Deploy to GitHub Pages
531
+ id: deployment
532
+ uses: actions/deploy-pages@v4
533
+ `;
534
+ }
150
535
  /**
151
536
  * Construct the SiteConfig object that gets serialized to
152
537
  * `src/data/site.json`. Backward-compatible: existing fields keep their
153
538
  * empty-string defaults; new optional fields are omitted when undefined.
154
539
  */
155
- function buildSiteConfig(siteName, options) {
540
+ function buildSiteConfig(siteName, options, outputDir) {
156
541
  const cfg = {
157
542
  siteName,
158
543
  repoUrl: options.repoUrl || "",
159
- editUri: options.editUri || "blob/main/docs/",
160
- copyright: options.copyright || "",
161
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
+ }
162
554
  if (options.siteUrl)
163
555
  cfg.siteUrl = options.siteUrl;
164
556
  if (options.description)
@@ -169,6 +561,15 @@ function buildSiteConfig(siteName, options) {
169
561
  cfg.twitterHandle = options.twitterHandle;
170
562
  if (options.themeColor)
171
563
  cfg.themeColor = options.themeColor;
564
+ // editUri + copyright follow the same omit-on-empty pattern as the
565
+ // optional fields above; previously they were always written
566
+ // (editUri defaulted to "blob/main/docs/", copyright to ""), which
567
+ // left zombie config in src/data/site.json. Downstream guards already
568
+ // treat empty / undefined as "don't render" so this is purely a tidy.
569
+ if (options.editUri)
570
+ cfg.editUri = options.editUri;
571
+ if (options.copyright)
572
+ cfg.copyright = options.copyright;
172
573
  if (options.brandKeywords && options.brandKeywords.length > 0) {
173
574
  cfg.brandKeywords = options.brandKeywords;
174
575
  }
@@ -185,13 +586,65 @@ function buildSiteConfig(siteName, options) {
185
586
  if (options.tagLabels && Object.keys(options.tagLabels).length > 0) {
186
587
  cfg.tagLabels = options.tagLabels;
187
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
+ }
188
594
  if (options.taxonomyIndexPaths &&
189
595
  Object.keys(options.taxonomyIndexPaths).length > 0) {
190
- cfg.taxonomyIndexPaths = options.taxonomyIndexPaths;
596
+ // Bake basePath into every emitted indexPath so consumers
597
+ // (TypeBadge / StatusBadge / future components) compose hrefs
598
+ // like `${indexPath}/<value>/` and resolve under the configured
599
+ // site base. Without the prefix, `/by-type/tutorial/` 404s on
600
+ // any site with `site.basePath` set. Caller passes raw config
601
+ // values (`/by-type`, `/tags`, etc.) — basePath threading is
602
+ // this emitter's responsibility, matching how `page.url` is
603
+ // already prefixed in the taxonomy data file.
604
+ // Taxonomy index paths are baked into site.json so components
605
+ // (TagList, TaxonomyIndex, TypeBadge) emit correct hrefs at
606
+ // runtime. Use combined so these resolve under the host's
607
+ // served subpath.
608
+ const taxoPrefix = combinedPrefix(options);
609
+ cfg.taxonomyIndexPaths = Object.fromEntries(Object.entries(options.taxonomyIndexPaths).map(([name, raw]) => [
610
+ name,
611
+ withBasePath(taxoPrefix, raw),
612
+ ]));
191
613
  }
192
614
  if (options.taxonomyDisplay &&
193
615
  Object.keys(options.taxonomyDisplay).length > 0) {
194
- cfg.taxonomyDisplay = options.taxonomyDisplay;
616
+ // Flatten prefix labels into top-level entries so the
617
+ // search-facets resolver finds them after DocsLayout splits
618
+ // slash-nested tags into per-prefix Pagefind filter divs.
619
+ //
620
+ // Input:
621
+ // tags.prefixes = { difficulty: { label, color }, ... }
622
+ // tags.labels = { "difficulty/1": "Beginner", "difficulty/2": ... }
623
+ // Output additions (kept alongside the original `tags` entry):
624
+ // difficulty.labels = { "1": "Beginner", "2": ... }
625
+ //
626
+ // Resolver does `display[facetName].labels[value]` — facet name
627
+ // is now `difficulty`, value is `1`, → "Beginner". See
628
+ // plans/per-prefix-search-facets.md.
629
+ const flat = { ...options.taxonomyDisplay };
630
+ const tagsDisplay = options.taxonomyDisplay.tags;
631
+ if (tagsDisplay?.prefixes) {
632
+ for (const prefix of Object.keys(tagsDisplay.prefixes)) {
633
+ if (flat[prefix])
634
+ continue; // top-level entry wins
635
+ const leafLabels = {};
636
+ if (tagsDisplay.labels) {
637
+ const needle = `${prefix}/`;
638
+ for (const [slug, label] of Object.entries(tagsDisplay.labels)) {
639
+ if (slug.startsWith(needle)) {
640
+ leafLabels[slug.slice(needle.length)] = label;
641
+ }
642
+ }
643
+ }
644
+ flat[prefix] = { labels: leafLabels };
645
+ }
646
+ }
647
+ cfg.taxonomyDisplay = flat;
195
648
  }
196
649
  return cfg;
197
650
  }
@@ -220,6 +673,21 @@ export async function exportAstroProject(pages, nav, outputDir, options = {}) {
220
673
  const scaffoldSkipped = emitSiteScaffold(outputDir, siteName, options, writeScaffold);
221
674
  const { generated, outputNav } = await emitAstroPages(pages, nav, outputDir, options);
222
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
+ });
223
691
  emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options);
224
692
  console.log(`Generated ${generated} static .astro pages`);
225
693
  if (alreadyScaffolded && !options.force && scaffoldSkipped > 0) {
@@ -256,7 +724,300 @@ function ensureDirectoryStructure(outputDir, basePath) {
256
724
  */
257
725
  export function emitSiteConfig(outputDir, siteName, options) {
258
726
  mkdirSync(join(outputDir, "src", "data"), { recursive: true });
259
- 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));
728
+ // Auto-generated companion to astro.config.mjs. Carries the
729
+ // site/base values derived from dogsbay.config.yml's site.url so
730
+ // changes propagate without --force-rescaffolding the main
731
+ // astro.config.mjs (which is scaffold-once and may have author
732
+ // edits — custom integrations, build hooks, etc.). The main
733
+ // config imports `dogsbaySite` + `dogsbayBase` from here.
734
+ // See plans/astro-base-from-site-url.md.
735
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
736
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
737
+ const dogsbaySiteJson = hasSiteUrl
738
+ ? JSON.stringify(origin ?? options.siteUrl)
739
+ : "undefined";
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";
746
+ // build.inlineStylesheets — defaults to "auto" (Astro's own
747
+ // default; matches our docs-first bias since theme.css is ~120KB
748
+ // and externalizing it lets the file cache cross-page). Authors
749
+ // wanting "always" / "never" set it via dogsbay.config.yml's
750
+ // build.inlineStylesheets. See docs/perf-tuning.md.
751
+ const dogsbayInline = options.inlineStylesheets ?? "auto";
752
+ writeFileSync(join(outputDir, "astro.config.dogsbay.mjs"), [
753
+ "// Auto-generated by `dogsbay site build` — DO NOT EDIT.",
754
+ "// Tracks site.url + derived Astro base + build behaviour from",
755
+ "// dogsbay.config.yml. Edit dogsbay.config.yml and rebuild;",
756
+ "// edits to this file will be overwritten on the next build.",
757
+ `export const dogsbaySite = ${dogsbaySiteJson};`,
758
+ `export const dogsbayBase = ${dogsbayBaseJson};`,
759
+ `export const dogsbayInlineStylesheets = ${JSON.stringify(dogsbayInline)};`,
760
+ `export const dogsbayOutDir = ${dogsbayOutDirJson};`,
761
+ "",
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);
856
+ // Migration check: pre-beta.20 sites have an astro.config.mjs that
857
+ // doesn't import the companion. Without the import, the values
858
+ // emitted above are unused and Astro's `base` stays unset — the
859
+ // exact bug this work was meant to close. Warn loudly, with the
860
+ // patch the user needs to apply, until astro.config.mjs is
861
+ // updated. We don't auto-patch because the file may have author
862
+ // edits (custom integrations, build hooks).
863
+ const astroConfigPath = join(outputDir, "astro.config.mjs");
864
+ if (existsSync(astroConfigPath)) {
865
+ const astroConfigSrc = readFileSync(astroConfigPath, "utf-8");
866
+ if (!astroConfigSrc.includes("astro.config.dogsbay.mjs")) {
867
+ console.warn([
868
+ "",
869
+ " ⚠ astro.config.mjs is missing the dogsbay companion import.",
870
+ " Without it, Astro's `base` config stays unset and assets",
871
+ " served from a host subpath (GH Pages project pages,",
872
+ " multi-mount Cloudflare) will 404.",
873
+ "",
874
+ " Add these two lines to astro.config.mjs:",
875
+ "",
876
+ ' import {',
877
+ ' dogsbaySite,',
878
+ ' dogsbayBase,',
879
+ ' dogsbayInlineStylesheets,',
880
+ ' dogsbayOutDir,',
881
+ ' } from "./astro.config.dogsbay.mjs";',
882
+ "",
883
+ " export default defineConfig({",
884
+ " ...(dogsbaySite ? { site: dogsbaySite } : {}),",
885
+ " ...(dogsbayBase ? { base: dogsbayBase } : {}),",
886
+ " ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
887
+ " build: { inlineStylesheets: dogsbayInlineStylesheets },",
888
+ " // ...your existing config...",
889
+ " });",
890
+ "",
891
+ " OR regenerate from template (overwrites your edits):",
892
+ " dogsbay site init . --scaffold-only --force",
893
+ "",
894
+ ].join("\n"));
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
+ }
920
+ }
921
+ }
922
+ /**
923
+ * Re-pin every `@dogsbay/*` dependency in an existing package.json to
924
+ * `version` (the building CLI's lockstep version), leaving non-Dogsbay deps
925
+ * and any `workspace:` / `file:` dev links untouched. Returns the changes made.
926
+ *
927
+ * package.json is scaffold-once, so without this an upgraded CLI keeps building
928
+ * against the lib versions written at first scaffold — the drift that strands a
929
+ * site on a stale DocsLayout that can't render the new emitter's output.
930
+ */
931
+ function syncDogsbayDepVersions(pkgPath, version) {
932
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
933
+ const changed = [];
934
+ for (const field of ["dependencies", "devDependencies"]) {
935
+ const deps = pkg[field];
936
+ if (!deps)
937
+ continue;
938
+ for (const [name, spec] of Object.entries(deps)) {
939
+ if (!name.startsWith("@dogsbay/"))
940
+ continue;
941
+ if (spec.startsWith("workspace:") || spec.startsWith("file:"))
942
+ continue;
943
+ if (spec !== version) {
944
+ deps[name] = version;
945
+ changed.push(`${name}: ${spec} -> ${version}`);
946
+ }
947
+ }
948
+ }
949
+ if (changed.length > 0) {
950
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
951
+ }
952
+ return changed;
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"));
260
1021
  }
261
1022
  export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
262
1023
  let scaffoldFilesSkipped = 0;
@@ -271,21 +1032,79 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
271
1032
  // - --local outside workspace: use file: paths pointing at monorepo packages
272
1033
  // - Otherwise: versioned specs (expects registry-published packages)
273
1034
  const insideWs = isInsideWorkspace(outputDir);
1035
+ // Read format-astro's own version at runtime — every @dogsbay/*
1036
+ // package ships in lockstep at the same version (publish-beta.sh
1037
+ // bumps them together), so format-astro's version is the right
1038
+ // peer spec for `@dogsbay/docs-layout`, `@dogsbay/ui`, etc. in
1039
+ // the scaffolded astro/package.json. Fixes the "hardcoded
1040
+ // ^0.1.0" trap that caused npm install to fail post-publish.
1041
+ const dogsbayPeerVersion = (() => {
1042
+ try {
1043
+ const here = dirname(fileURLToPath(import.meta.url));
1044
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
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.
1071
+ return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
1072
+ }
1073
+ catch {
1074
+ return "^0.1.0"; // last-resort fallback
1075
+ }
1076
+ })();
274
1077
  const dogsbayDep = (name) => {
275
1078
  if (insideWs)
276
1079
  return "workspace:*";
277
1080
  if (options.local)
278
1081
  return `file:${resolveMonorepoPkg(name)}`;
279
- return "^0.1.0";
1082
+ return dogsbayPeerVersion;
280
1083
  };
281
1084
  // Per-deploy-target additions to package.json
282
1085
  const isCloudflare = options.deploy === "cloudflare-workers";
1086
+ const isGitHubPages = options.deploy === "github-pages";
283
1087
  const deployScripts = isCloudflare
284
1088
  ? { deploy: "pnpm build && wrangler deploy" }
285
1089
  : {};
286
1090
  const deployDevDeps = isCloudflare
287
1091
  ? { wrangler: "^4.0.0" }
288
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);
289
1108
  // package.json — scaffold-once. Maintainers add their own deps; the
290
1109
  // detection of "already scaffolded" actually keys off this file's
291
1110
  // existence, so it's also our sentinel.
@@ -299,20 +1118,45 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
299
1118
  dev: "astro dev",
300
1119
  // Pagefind runs after astro build; indexes the static dist/ output and
301
1120
  // writes `dist/pagefind/` for the search UI to load lazily on Cmd+K.
302
- 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",
303
1137
  preview: "astro preview",
304
1138
  ...deployScripts,
305
1139
  },
306
1140
  dependencies: {
307
- astro: "^6.0.0",
308
- "@astrojs/sitemap": "^3.0.0",
1141
+ astro: "^7.0.4",
1142
+ // Sitemap is emitted directly by Dogsbay into
1143
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount
1144
+ // deploys don't collide at the host root. We deliberately
1145
+ // do NOT depend on @astrojs/sitemap (it hardcodes output to
1146
+ // dist/ root, which is what we're moving away from).
309
1147
  // Pagefind is invoked from the build script (see scripts.build above).
310
1148
  // Lives in dependencies (not devDependencies) so production builds
311
1149
  // include it; the produced search index is shipped statically and
312
1150
  // doesn't load this dep at runtime.
313
1151
  pagefind: "^1.4.0",
314
- tailwindcss: "^4.0.0",
315
- "@tailwindcss/vite": "^4.0.0",
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",
316
1160
  "tailwind-variants": "^0.3.0",
317
1161
  shiki: "^4.0.0",
318
1162
  "@shikijs/transformers": "^4.0.0",
@@ -328,7 +1172,32 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
328
1172
  "@dogsbay/primitives": dogsbayDep("primitives"),
329
1173
  "@dogsbay/icons": dogsbayDep("icons"),
330
1174
  "@dogsbay/elements": dogsbayDep("elements"),
1175
+ // Transitive of `@dogsbay/primitives` (via
1176
+ // `@floating-ui/dom`). Listed at the top level because
1177
+ // npm doesn't hoist the second-level transitive when
1178
+ // `@dogsbay/primitives` is linked via `file:` (the
1179
+ // `--local` monorepo mode + the canary publish flow on
1180
+ // GH Pages CI both hit this). Surfaced during the
1181
+ // FastAPI import: Rollup failed with "Cannot resolve
1182
+ // @floating-ui/core" at astro build time.
1183
+ "@floating-ui/core": "^1.7.0",
331
1184
  },
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.
332
1201
  ...(Object.keys(deployDevDeps).length > 0
333
1202
  ? { devDependencies: deployDevDeps }
334
1203
  : {}),
@@ -336,6 +1205,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
336
1205
  }
337
1206
  else {
338
1207
  scaffoldFilesSkipped++;
1208
+ // package.json is scaffold-once, but the @dogsbay/* libraries ship in
1209
+ // lockstep with the CLI. Re-sync their pins to the building CLI's version
1210
+ // on every build so upgrading the CLI (e.g. `npm i -g dogsbay@beta`) can't
1211
+ // leave the astro project on stale libraries whose older components don't
1212
+ // render the new emitter's markup. Versioned (published) style only —
1213
+ // `workspace:*` / `file:` dev links are left untouched.
1214
+ if (!insideWs && !options.local) {
1215
+ const synced = syncDogsbayDepVersions(join(outputDir, "package.json"), dogsbayPeerVersion);
1216
+ if (synced.length > 0) {
1217
+ console.log(`Synced ${synced.length} @dogsbay/* dependency pin(s) to ${dogsbayPeerVersion}`);
1218
+ }
1219
+ }
339
1220
  }
340
1221
  // wrangler.jsonc — scaffold-once. Bindings, secrets, custom routes
341
1222
  // belong here post-generation.
@@ -347,6 +1228,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
347
1228
  scaffoldFilesSkipped++;
348
1229
  }
349
1230
  }
1231
+ // GitHub Pages deploy artifacts — workflow + .nojekyll. The actual
1232
+ // emission lives in `emitDeployArtifacts` so site-build can also
1233
+ // call it on existing sites without going through scaffold (a user
1234
+ // adds `deploy: github-pages` to dogsbay.config.yml and reruns
1235
+ // `site build` to get the workflow). At scaffold-time we pass
1236
+ // forceOverwrite=writeScaffold so `--force` regenerates from
1237
+ // template; on regular builds it stays write-if-missing.
1238
+ if (isGitHubPages) {
1239
+ emitDeployArtifacts(outputDir, options, {
1240
+ forceOverwrite: writeScaffold,
1241
+ });
1242
+ }
350
1243
  // Generate astro.config.mjs
351
1244
  // `preserveSymlinks: true` is used with --local to pin local file: deps to
352
1245
  // their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
@@ -358,52 +1251,52 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
358
1251
  preserveSymlinks: true,
359
1252
  },`
360
1253
  : "";
361
- // Sitemap integration is conditional: requires an absolute site URL so
362
- // <loc> entries can be properly absolute. Without siteUrl, the sitemap
363
- // step is skipped (the import + integration call are simply omitted from
364
- // the generated config). Sitemap also filters out frontmatter-noindex pages.
365
- const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
366
- const sitemapImport = hasSiteUrl ? `import sitemap from "@astrojs/sitemap";\n` : "";
367
- // Strip any path component from site.url before emitting. The
368
- // config validator already rejects `site.url` containing a path
369
- // when `basePath` is non-empty (canonical URLs would double-count
370
- // the prefix); this is a defensive normalisation for the case
371
- // where the validator is bypassed or basePath is empty.
1254
+ // siteUrl gates absolute-URL emission (sitemap <loc> entries,
1255
+ // canonical tags). Without one, both are skipped — relative URLs
1256
+ // are still correct, the sitemap is just not generated.
372
1257
  //
373
- // Note: we deliberately do NOT emit Astro's `base:` field. With
374
- // the current file emission (pages live under
375
- // `src/pages/<basePath>/...`), adding `base` would cause Astro
376
- // to doubly-prefix every route. Switching to `base`-driven
377
- // routing is a separate refactor — see plans/configurable-base-path.md.
378
- let siteField = "";
379
- if (hasSiteUrl) {
380
- let originOnly;
381
- try {
382
- const u = new URL(options.siteUrl);
383
- originOnly = `${u.protocol}//${u.host}`;
384
- }
385
- catch {
386
- originOnly = options.siteUrl;
387
- }
388
- siteField = `\n site: ${JSON.stringify(originOnly)},`;
389
- }
390
- const integrationsField = hasSiteUrl ? `\n integrations: [sitemap()],` : "";
391
- // astro.config.mjs — scaffold-once. Maintainer adds custom integrations.
392
- // The plugin-aliases import is for the Dogsbay plugin API: each
393
- // build emits `astro.config.plugins.mjs` exporting `pluginAliases`,
394
- // a Vite alias map for `virtual:dogsbay-plugin-config/<id>` modules.
395
- // When no plugins use defineClientConfig the map is empty and the
396
- // spread is a no-op. See plans/plugin-api.md.
1258
+ // Sitemap is emitted directly by Dogsbay (see emitSitemapFiles)
1259
+ // into public/<basePath>/sitemap-*.xml. We deliberately do NOT
1260
+ // wire @astrojs/sitemap here; that integration hardcodes output
1261
+ // to dist/ root, breaking multi-mount deploys.
1262
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
1263
+ // site.url's path component (if any) becomes Astro's `base`. The
1264
+ // origin alone goes into `site`. This split lets dogsbay model
1265
+ // both axes independently:
1266
+ // - Astro's `base` (= urlBase) controls the URL prefix Astro
1267
+ // bakes into HTML asset references (`<basePath>/_astro/...`)
1268
+ // and the routes Astro generates from src/pages.
1269
+ // - dogsbay's basePath controls the filesystem layout
1270
+ // (`src/pages/<basePath>/...`).
1271
+ // The two compose at emit time — combining for nav hrefs,
1272
+ // sitemap, llms.txt, etc. See plans/astro-base-from-site-url.md.
1273
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
1274
+ // astro.config.mjs — scaffold-once, but the site/base values flow
1275
+ // through a separate auto-generated file (`astro.config.dogsbay.mjs`,
1276
+ // emitted unconditionally below) so dogsbay-derived values stay in
1277
+ // sync with `dogsbay.config.yml` even on existing sites where the
1278
+ // main config is preserved. Same pattern as
1279
+ // `astro.config.plugins.mjs` — the import line is the load-bearing
1280
+ // bit; the auto-file is what changes.
397
1281
  if (writeScaffold) {
398
1282
  writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
399
1283
  import tailwindcss from "@tailwindcss/vite";
400
- ${sitemapImport}import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
1284
+ import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
1285
+ import {
1286
+ dogsbaySite,
1287
+ dogsbayBase,
1288
+ dogsbayInlineStylesheets,
1289
+ dogsbayOutDir,
1290
+ } from "./astro.config.dogsbay.mjs";
401
1291
 
402
- export default defineConfig({${siteField}
1292
+ export default defineConfig({
1293
+ ...(dogsbaySite ? { site: dogsbaySite } : {}),
1294
+ ...(dogsbayBase ? { base: dogsbayBase } : {}),
1295
+ ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),
403
1296
  output: "static",
404
1297
  build: {
405
- inlineStylesheets: "always",
406
- },${integrationsField}
1298
+ inlineStylesheets: dogsbayInlineStylesheets,
1299
+ },
407
1300
  vite: {
408
1301
  plugins: [tailwindcss()],
409
1302
  resolve: {
@@ -425,6 +1318,9 @@ export default defineConfig({${siteField}
425
1318
  else {
426
1319
  scaffoldFilesSkipped++;
427
1320
  }
1321
+ // astro.config.dogsbay.mjs is emitted by emitSiteConfig (called
1322
+ // above and on every site build) so site/base values stay in
1323
+ // sync without a re-scaffold. See its definition for rationale.
428
1324
  // Always seed an empty astro.config.plugins.mjs so the import in
429
1325
  // astro.config.mjs resolves before the first plugin-emitting
430
1326
  // build. Subsequent builds replace it via emitPluginRuntime.
@@ -493,9 +1389,85 @@ export default defineConfig({${siteField}
493
1389
  * redirect. Returns the count of pages emitted and the merged nav for
494
1390
  * downstream consumers (llms.txt builder).
495
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
+ }
496
1463
  export async function emitAstroPages(pages, nav, outputDir, options) {
497
1464
  const siteName = options.siteName || "Documentation";
1465
+ // basePath = filesystem layout prefix (where pages live under
1466
+ // src/pages/...). combined = the URL prefix HTML hrefs need
1467
+ // (urlBase + basePath). The two diverge whenever site.url has a
1468
+ // path component (GH Pages project pages, multi-mount Cloudflare).
498
1469
  const basePath = normalizeBasePath(options.basePath);
1470
+ const combined = combinedPrefix(options);
499
1471
  const baseSegments = basePathSegments(basePath);
500
1472
  // Ensure dirs exist (callers may invoke us without going through the
501
1473
  // full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
@@ -516,35 +1488,217 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
516
1488
  // Remove existing entry for this section (full replace)
517
1489
  existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
518
1490
  && item.label?.toLowerCase() !== section.toLowerCase());
519
- const prefixedNav = prefixNavHrefs(nav, section, basePath);
1491
+ // Nav hrefs already carry the `combined` prefix (the importer
1492
+ // emits them via fileToHref(file, hrefPrefix=combined)).
1493
+ // prefixNavHrefs takes the existing prefix and weaves a section
1494
+ // segment into it.
1495
+ const prefixedNav = prefixNavHrefs(nav, section, combined);
520
1496
  const sectionLabel = siteName
521
1497
  || section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
522
1498
  existingNav.push({ label: sectionLabel, children: prefixedNav });
523
1499
  outputNav = existingNav;
524
1500
  }
1501
+ // Safety net: ensure every nav href carries the combined URL base,
1502
+ // regardless of which importer produced the nav. Idempotent — a nav
1503
+ // already prefixed at import time (dogsbay-md walking source paths) is
1504
+ // left untouched; a nav of pre-baked root-absolute hrefs (docusaurus
1505
+ // convert) gets the base it was missing. Keeps nav hrefs aligned with
1506
+ // the combined-prefixed `currentPath` so prev/next still matches.
1507
+ outputNav = prefixNavBaseHrefs(outputNav, combined);
525
1508
  writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(outputNav, null, 2));
1509
+ // Also publish nav.json under public/_dogsbay/ so the
1510
+ // client-mode <DocsNavClient /> can fetch it at runtime
1511
+ // (Astro copies public/ to dist/ as-is at build). The src/data
1512
+ // copy stays for build-time imports (pagination, prev/next
1513
+ // calculation in each page) — the public copy is the on-the-wire
1514
+ // copy that the browser ever sees. Kept identical (same bytes,
1515
+ // same shape); the duplicate is cheap (one file) and keeps the
1516
+ // build-time and runtime worlds cleanly separated. See
1517
+ // plans/client-rendered-nav.md.
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");
1525
+ mkdirSync(publicNavDir, { recursive: true });
1526
+ writeFileSync(join(publicNavDir, "nav.json"), JSON.stringify(outputNav));
526
1527
  // Static assets (images etc.) — content-tier; always copy from the
527
1528
  // user's source dir. If they removed an asset, we want it gone here
528
1529
  // too. Skipped when sourceDir isn't supplied (programmatic callers
529
1530
  // that want pure page emission).
530
- 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) {
531
1541
  copyAssets(options.sourceDir, outputDir, options.imageOptimization);
532
1542
  }
1543
+ // External asset mounts (e.g. a Docusaurus `static/` dir) → public/_assets/,
1544
+ // preserving internal structure so the importer's `/_assets/...` image refs
1545
+ // resolve. Used by the convert --to astro path; the dogsbay-md → site build
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.
1550
+ if (options.assetMounts) {
1551
+ for (const mount of options.assetMounts) {
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 });
1561
+ }
1562
+ }
533
1563
  let generated = 0;
1564
+ // Tracks whether a real root home page (`content/index.md` → slug
1565
+ // "index") was emitted. Drives the root-served-site redirect fallback
1566
+ // below: a corpus whose nav root is e.g. `welcome/index` (OpenShift)
1567
+ // has no `/` page, so without a redirect `/` 404s. See
1568
+ // plans/dir-index-slug-nav-drop.md.
1569
+ let rootIndexEmitted = false;
1570
+ const generatedPaths = new Set();
1571
+ let pageFailures = 0;
534
1572
  const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
535
1573
  const useImageOpt = options.imageOptimization ?? false;
536
- // hrefPrefix is the same string as basePath. rewriteHref handles the
537
- // empty-basePath case correctly: any link starting with "/" matches
538
- // the early-return guard, so root-relative links pass through
539
- // unrewritten when the site is served at host root.
540
- const hrefPrefix = basePath;
541
- for (const page of pages) {
1574
+ // hrefPrefix is the COMBINED prefix (urlBase + basePath) — what
1575
+ // rendered HTML hrefs need so internal links resolve under the
1576
+ // host's served subpath AND under the dogsbay basePath. For
1577
+ // simple host-apex deploys with basePath, urlBase is empty so
1578
+ // combined === basePath (back-compat). For GH Pages project pages
1579
+ // and multi-mount Cloudflare, combined adds the urlBase layer.
1580
+ const hrefPrefix = combined;
1581
+ // Route-exclusion gate. Two signals, either is enough:
1582
+ // - frontmatter `_fragment: true` (loader-stamped — the importer
1583
+ // knew this .md was include-only, not a navigable page)
1584
+ // - excludeFromRoutes match against the slug (project-declared,
1585
+ // in dogsbay.config.yml — for content under conventional
1586
+ // fragment dirs like `modules/`, `_attributes/`, `snippets/`)
1587
+ // Excluded pages stay on disk under content/ (so includes resolve)
1588
+ // but don't produce .astro / .md.ts routes. See plans/build-at-scale.md.
1589
+ //
1590
+ // Match semantics:
1591
+ // - A single-segment pattern (no `/`) matches ANY occurrence of
1592
+ // that segment in the slug path. So `modules` excludes both
1593
+ // `modules/foo` AND `welcome/modules/foo` AND
1594
+ // `drupal-build/openshift-enterprise/ai/includes/x`. This is
1595
+ // what AsciiBinder corpora want — fragment dirs symlink into
1596
+ // section dirs so the same `includes/` etc. appears at many
1597
+ // depths.
1598
+ // - A multi-segment pattern (contains `/`) matches as a leading
1599
+ // prefix (exact path-prefix). `welcome/_internal` excludes
1600
+ // only that specific path tree, not arbitrary `_internal/`
1601
+ // elsewhere.
1602
+ const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
1603
+ // Granularity (additive book view): roll each top-level nav group into one
1604
+ // `_book` page emitted alongside the topics. Kept out of nav; quarantined via
1605
+ // noindex + excludeFromSearch frontmatter so robots/sitemap/pagefind exclude
1606
+ // it, and — being local to this emitter — it never reaches the llms.txt builder
1607
+ // either. The `_book` page renders through the same path as any other page.
1608
+ // See plans/granularity-views.md.
1609
+ const bookPages = [];
1610
+ // topic slug → "<bookRoute>#<anchor>" — the per-topic "Read as single page"
1611
+ // button target. Empty unless granularity.book is on.
1612
+ const singlePageBySlug = new Map();
1613
+ // Emit a single-page (book) per nav node whose EFFECTIVE granularity resolves
1614
+ // to "yes". The annotation lives on each nav node (`singlePage`):
1615
+ // - "yes" → roll up this node's subtree into one page.
1616
+ // - "children" → make each direct child default to "yes" (chapters).
1617
+ // - "no" → no page (the default).
1618
+ // `granularity.book: true` is the legacy all-on switch: it seeds the top level
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
1622
+ // a warning (no silent multi-minute page). See plans/granularity-views.md.
1623
+ const maxBookBytes = Math.round((options.granularity?.maxSinglePageMB ?? 3) * 1024 * 1024);
1624
+ const rootInheritYes = options.granularity?.book === true;
1625
+ const emitBook = (nav, label) => {
1626
+ const book = assembleBook(nav, pages, { hrefPrefix: combined, title: label });
1627
+ if (!book)
1628
+ return;
1629
+ const bytes = estimateTreeBytes(book.page.tree);
1630
+ if (bytes > maxBookBytes) {
1631
+ console.warn(`[dogsbay] granularity: skipping single-page "${label}" — ~${(bytes / 1048576).toFixed(1)} MB ` +
1632
+ `exceeds maxSinglePageMB (${options.granularity?.maxSinglePageMB ?? 3}). ` +
1633
+ `Mark it "single-page: children" in the nav to split it into chapters.`);
1634
+ return;
1635
+ }
1636
+ book.page.frontmatter = { ...book.page.frontmatter, excludeFromSearch: true };
1637
+ bookPages.push(book.page);
1638
+ const bookRoute = combined ? `${combined}/${book.page.slug}` : `/${book.page.slug}`;
1639
+ for (const [slug, anchor] of book.topicAnchors) {
1640
+ singlePageBySlug.set(slug, `${bookRoute}#${anchor}`);
1641
+ }
1642
+ };
1643
+ const walkBooks = (items, inheritYes) => {
1644
+ for (const item of items) {
1645
+ const effective = item.singlePage ?? (inheritYes ? "yes" : "no");
1646
+ if (effective === "yes" && item.children?.length) {
1647
+ emitBook(item.children, item.label);
1648
+ }
1649
+ if (item.children?.length)
1650
+ walkBooks(item.children, effective === "children");
1651
+ }
1652
+ };
1653
+ const hasAnnotation = (items) => items.some((i) => i.singlePage !== undefined || (i.children && hasAnnotation(i.children)));
1654
+ if (rootInheritYes || hasAnnotation(outputNav)) {
1655
+ walkBooks(outputNav, rootInheritYes);
1656
+ }
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]) : []);
1676
+ for (const page of emitPages) {
542
1677
  try {
1678
+ // Skip excluded pages before any expensive work (tree rewrite,
1679
+ // serialize, IO).
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);
1688
+ if (isFragment || isExcludedSlug(page.slug)) {
1689
+ continue;
1690
+ }
543
1691
  // Rewrite internal hrefs to match the output URL structure
544
1692
  rewriteTreeHrefs(page.tree, hrefPrefix);
1693
+ // Same for raw image srcs — Astro doesn't auto-prefix
1694
+ // `<img src="/_assets/...">` so we do it here. Block images
1695
+ // strip the prefix back off for the `imageMap[...]` lookup
1696
+ // (see paragraphToAstro in serialize.ts).
1697
+ rewriteTreeImageSrcs(page.tree, hrefPrefix);
545
1698
  const result = treeToAstro(page.tree, {
546
1699
  imageOptimization: useImageOpt,
547
1700
  codeBlockTitle: options.codeBlockTitle ?? true,
1701
+ combinedPrefix: hrefPrefix,
548
1702
  });
549
1703
  const imageSetup = useImageOpt ? [
550
1704
  '',
@@ -558,11 +1712,32 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
558
1712
  ' imageMap[publicPath] = mod.default;',
559
1713
  '}',
560
1714
  ] : [];
561
- // Per-page meta from frontmatter
1715
+ // Per-page meta from frontmatter. When no description is authored,
1716
+ // derive one from the first prose paragraph so every page emits a
1717
+ // <meta name="description"> (Lighthouse flags pages without one).
1718
+ // See plans/auto-meta-description.md.
562
1719
  const fm = (page.frontmatter ?? {});
563
- const pageDescription = fm.description ?? "";
1720
+ // A writer-authored frontmatter description is meta-only (not in the body),
1721
+ // so it's safe to also render as a visible lede. A DERIVED description is
1722
+ // pulled FROM the body, so rendering it as a lede always duplicates content
1723
+ // (e.g. a page whose first paragraph sits under "## Big picture"). Keep the
1724
+ // two separate: derive for the <meta> tag, but only lede the authored one.
1725
+ const fmDescription = typeof fm.description === "string" && fm.description.trim().length > 0
1726
+ ? fm.description
1727
+ : undefined;
1728
+ const pageDescription = fmDescription || deriveDescription(page.tree) || "";
564
1729
  const pageOgImage = fm.ogImage ?? "";
565
- const pageNoindex = fm.noindex === true || fm.draft === true;
1730
+ // Noindex / nofollow are independent meta directives. Site-level
1731
+ // forces both bits site-wide (staging / compliance lockdown);
1732
+ // page frontmatter can ESCALATE either bit independently but
1733
+ // cannot opt out of a site-level lockdown. `draft: true` keeps
1734
+ // its existing role as a noindex shorthand. See
1735
+ // plans/site-level-robots-meta.md.
1736
+ const pageNoindex = options.noindex === true ||
1737
+ fm.noindex === true ||
1738
+ fm.draft === true;
1739
+ const pageNofollow = options.nofollow === true ||
1740
+ fm.nofollow === true;
566
1741
  // Independent of noindex: pages can be excluded from in-site
567
1742
  // Pagefind search even when external SEs should index them
568
1743
  // (or vice versa). See DocsLayout's prop docs for the
@@ -581,7 +1756,42 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
581
1756
  const pageCategory = Array.isArray(pageMeta?.category)
582
1757
  ? pageMeta.category
583
1758
  : undefined;
584
- const tagsIndexPath = options.tagsIndexPath ?? "/tags";
1759
+ // "Last updated" date → DocsFooter. `updated` is the canonical PageMeta
1760
+ // field (parseMeta); also accept `lastUpdated` / `lastmod` for sitemap
1761
+ // parity. Plain string, rendered as-is.
1762
+ const rawLastUpdated = (typeof pageMeta?.updated === "string" && pageMeta.updated) ||
1763
+ (typeof fm.updated === "string" && fm.updated) ||
1764
+ (typeof fm.lastUpdated === "string" && fm.lastUpdated) ||
1765
+ (typeof fm.lastmod === "string" && fm.lastmod) ||
1766
+ null;
1767
+ // Canonical meta normalises to a full ISO datetime ("…T00:00:00.000Z").
1768
+ // Drop the time for display — these are dates, not timestamps. The footer
1769
+ // prettifies further; non-ISO strings pass through untouched.
1770
+ const pageLastUpdated = typeof rawLastUpdated === "string" && /^\d{4}-\d{2}-\d{2}T/.test(rawLastUpdated)
1771
+ ? rawLastUpdated.slice(0, 10)
1772
+ : rawLastUpdated;
1773
+ // Custom-taxonomy values lifted from frontmatter into
1774
+ // `meta.taxonomies` by the importer (see `parseMeta` in
1775
+ // `@dogsbay/types`). Surfaced to DocsLayout so it can emit one
1776
+ // `<div data-pagefind-filter="<name>:<value>">` per entry — this
1777
+ // is what makes user-declared taxonomies (`difficulty`, `team`,
1778
+ // anything not in the five built-ins) appear as visible facet
1779
+ // checkboxes in the search dialog. Without this passthrough
1780
+ // they're silently dropped after the importer.
1781
+ const pageTaxonomies = pageMeta?.taxonomies && Object.keys(pageMeta.taxonomies).length > 0
1782
+ ? pageMeta.taxonomies
1783
+ : undefined;
1784
+ // `tagsIndexPath` flows to `<TagList>` for chip hrefs
1785
+ // (`${indexPath}/${tag}/`). Caller passes the raw config value
1786
+ // (e.g. `/tags`); we bake the COMBINED prefix (urlBase from
1787
+ // site.url's path + basePath) here so chips resolve under both
1788
+ // the host's served subpath AND the dogsbay basePath. With
1789
+ // basePath alone, chips 404 on GH Pages project deploys
1790
+ // (basePath="" + non-empty urlBase) — same shape as the
1791
+ // typeBadgeHref / statusBadgeHref composition in DocsLayout,
1792
+ // which already reads combined-prefixed values out of
1793
+ // siteConfig.taxonomyIndexPaths (baked in buildSiteConfig).
1794
+ const tagsIndexPath = withBasePath(combined, options.tagsIndexPath ?? "/tags");
585
1795
  // Auto-lede detection. If the markdown body doesn't already
586
1796
  // start with an H1 / leading paragraph, we ask DocsLayout to
587
1797
  // render the frontmatter title / description at the top of
@@ -595,7 +1805,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
595
1805
  page.title.length > 0;
596
1806
  const autoLede = !isRedirect &&
597
1807
  !bodyHasLede &&
598
- pageDescription.length > 0;
1808
+ fmDescription !== undefined;
599
1809
  // Per-page LLM action UI. Site-wide config in
600
1810
  // `options.llmActions`; per-page opt-out via frontmatter
601
1811
  // `llmActions: false`. Skipped on redirect pages (no real
@@ -613,12 +1823,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
613
1823
  // available via other means. The "Open in" deep links work
614
1824
  // regardless of mirror availability — agents that can't fetch
615
1825
  // the page just see the URL in their chat.
1826
+ // pageHrefBase uses combined (urlBase + basePath) so the URL
1827
+ // resolves correctly when the host serves dist/ at a subpath
1828
+ // (GH Pages project page, multi-mount Cloudflare).
616
1829
  const pageHrefBase = section
617
- ? (basePath ? `${basePath}/${section}/${page.slug}` : `/${section}/${page.slug}`)
618
- : (basePath ? `${basePath}/${page.slug}` : `/${page.slug}`);
1830
+ ? (combined ? `${combined}/${section}/${page.slug}` : `/${section}/${page.slug}`)
1831
+ : (combined ? `${combined}/${page.slug}` : `/${page.slug}`);
619
1832
  const pageMdHref = `${pageHrefBase}.md`;
620
- const pageMdAbsoluteUrl = options.siteUrl
621
- ? options.siteUrl.replace(/\/$/, "") + pageMdHref
1833
+ // For absolute URLs (the "Copy as MD" deep link), use the
1834
+ // origin (no path) + the full combined path; siteUrl alone
1835
+ // would double-include the urlBase since pageHrefBase already
1836
+ // contains it.
1837
+ const { origin } = parseSiteUrl(options.siteUrl);
1838
+ const pageMdAbsoluteUrl = origin
1839
+ ? origin + pageMdHref
622
1840
  : pageMdHref;
623
1841
  // Markdown body for the Copy button. Reuse the same serializer
624
1842
  // that produces the .md mirror so what the user copies matches
@@ -635,6 +1853,12 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
635
1853
  // full inset width; the prose-readable cap and the right TOC
636
1854
  // would just steal pixels. See plans/openapi-builtin.md.
637
1855
  const wideLayout = (page.tree ?? []).some((n) => n && n.type === "endpoint");
1856
+ // TOC placement (DocsLayout `toc` prop). Default "top" — the
1857
+ // expandable "On this page" disclosure that frees the right rail
1858
+ // for plugin content (e.g. Ask AI). "rail" keeps the classic
1859
+ // right-hand TOC; in that mode the rail belongs to the TOC, so the
1860
+ // RightRail plugin region (slot + stack) is not emitted.
1861
+ const tocMode = options.toc ?? "top";
638
1862
  const pageLines = [
639
1863
  "---",
640
1864
  "// AUTO-GENERATED by `dogsbay convert` — do not edit.",
@@ -654,13 +1878,22 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
654
1878
  // Always exists; passthrough <slot /> when no plugin
655
1879
  // contributes a wrapper for the MarkdownContent slot.
656
1880
  'import MarkdownContentStack from "@/components/wrappers/MarkdownContentStack.astro";',
1881
+ // RightRailStack — hosts plugin content (e.g. an Ask AI panel) in
1882
+ // the freed right rail via DocsLayout's `right-rail` named slot.
1883
+ // Only imported/used when the rail isn't the classic TOC's.
1884
+ ...(tocMode !== "rail"
1885
+ ? ['import RightRailStack from "@/components/wrappers/RightRailStack.astro";']
1886
+ : []),
657
1887
  'const siteConfig = siteConfigData as SiteConfig;',
658
1888
  ...result.imports,
659
1889
  ...imageSetup,
660
1890
  "",
661
1891
  `const headings = ${JSON.stringify(page.headings || [])};`,
662
1892
  `const nav = navData;`,
663
- `const currentPath = "${buildCurrentPath(basePath, section, page.slug)}";`,
1893
+ // currentPath uses combined so it matches nav.json hrefs
1894
+ // (which are also combined-prefixed). getPagination compares
1895
+ // them as strings; mismatched prefixes break prev/next.
1896
+ `const currentPath = "${buildCurrentPath(combined, section, page.slug)}";`,
664
1897
  // Filter nav to the current (locale, version) bucket
665
1898
  // before computing prev/next — without this, pagination
666
1899
  // walks the global nav and a "Next" link can leak from
@@ -669,22 +1902,40 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
669
1902
  // this keeps prev/next aligned with what the reader
670
1903
  // sees.
671
1904
  `const _navForPagination = filterNavByAxis(nav as any[], {`,
672
- ` 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,`,
673
1911
  ` version: ${JSON.stringify(page.multiSource?.version ?? null)} ?? undefined,`,
674
1912
  ` locale: ${JSON.stringify(page.multiSource?.locale ?? null)} ?? undefined,`,
675
1913
  `});`,
676
- `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);`]),
677
1925
  `const title = ${JSON.stringify(page.title)};`,
678
1926
  `const description = ${JSON.stringify(pageDescription)} || undefined;`,
679
1927
  `const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
680
1928
  `const noindex = ${JSON.stringify(pageNoindex)};`,
1929
+ `const nofollow = ${JSON.stringify(pageNofollow)};`,
681
1930
  `const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
682
1931
  `const pageTags = ${JSON.stringify(pageTags ?? null)};`,
683
1932
  `const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
684
1933
  `const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
685
1934
  `const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
686
1935
  `const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
1936
+ `const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
687
1937
  `const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
1938
+ `const lastUpdated = ${JSON.stringify(pageLastUpdated)} || undefined;`,
688
1939
  `const llmActionsProps = ${JSON.stringify(llmActionsEnabled
689
1940
  ? {
690
1941
  enabled: true,
@@ -714,6 +1965,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
714
1965
  ` twitterHandle={siteConfig.twitterHandle || undefined}`,
715
1966
  ` themeColor={siteConfig.themeColor || undefined}`,
716
1967
  ` noindex={noindex}`,
1968
+ ` nofollow={nofollow}`,
717
1969
  ` excludeFromSearch={excludeFromSearch}`,
718
1970
  ` plausibleDomain={siteConfig.plausible?.domain}`,
719
1971
  ` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
@@ -729,24 +1981,86 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
729
1981
  ` pageType={pageType ?? undefined}`,
730
1982
  ` audience={pageAudience ?? undefined}`,
731
1983
  ` category={pageCategory ?? undefined}`,
1984
+ ` lastUpdated={lastUpdated}`,
1985
+ ` taxonomies={pageTaxonomies ?? undefined}`,
732
1986
  ` autoH1={${autoH1}}`,
733
1987
  ` autoLede={${autoLede}}`,
734
1988
  ` llmActions={llmActionsProps}`,
1989
+ ` linkIcons={siteConfig.linkIcons}`,
735
1990
  ` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
736
1991
  ` switcherMap={switcherMapData}`,
737
- ` basePath={${JSON.stringify(basePath || "/docs")}}`,
1992
+ // basePath here is the COMBINED URL prefix (urlBase from
1993
+ // site.url's path + dogsbay basePath). DocsLayout uses it
1994
+ // for switcher links, the footer llms.txt link, and the
1995
+ // <head> alternate link — all three need the full URL
1996
+ // prefix the host actually serves under. Empty string is
1997
+ // valid (root-served sites with no urlBase or basePath);
1998
+ // don't fall back to "/docs" — that would 404 for those.
1999
+ ` basePath={${JSON.stringify(combined)}}`,
2000
+ // navMode — controls whether DocsLayout server-renders the
2001
+ // full sidebar nav tree per page (`ssr-full`) or emits a
2002
+ // client-hydrated placeholder (`client`, default). The
2003
+ // client mode shrinks per-page HTML dramatically at scale.
2004
+ // See plans/client-rendered-nav.md.
2005
+ ` navMode={${JSON.stringify(options.navMode ?? "client")}}`,
2006
+ // Pagefind index URL — must include the combined prefix or
2007
+ // the loader 404s on subpath-mounted deploys. The pagefind
2008
+ // CLI writes to <astroOutput>/dist/pagefind/ which Astro
2009
+ // serves under its `base` (= urlBase); dogsbay's basePath
2010
+ // adds the second prefix layer. Empty combined → `/pagefind/`.
2011
+ ` pagefindUrl={${JSON.stringify(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
2012
+ // Favicon — composed with combined prefix so the
2013
+ // <link rel="icon"> resolves on subpath-mounted deploys.
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))}}`,
738
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
+ : []),
2045
+ ` toc={${JSON.stringify(tocMode)}}`,
739
2046
  `>`,
2047
+ // RightRail region — assigned to DocsLayout's `right-rail` named
2048
+ // slot. Inert (empty passthrough) until a plugin contributes a
2049
+ // RightRail wrapper. Omitted in "rail" mode (TOC owns the rail).
2050
+ ...(tocMode !== "rail"
2051
+ ? [` <RightRailStack slot="right-rail" />`]
2052
+ : []),
740
2053
  ` <MarkdownContentStack>`,
741
- wideLayout
742
- ? result.body.split("\n").map((l) => ` ${l}`).join("\n")
743
- : ` <article class="docs-prose">`,
744
- ...(wideLayout
745
- ? []
746
- : [
747
- result.body.split("\n").map((l) => ` ${l}`).join("\n"),
748
- ` </article>`,
749
- ]),
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>`,
750
2064
  ` </MarkdownContentStack>`,
751
2065
  `</DocsLayout>`,
752
2066
  ];
@@ -771,6 +2085,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
771
2085
  mkdirSync(dirname(pagePath), { recursive: true });
772
2086
  writeFileSync(pagePath, pageLines.join("\n") + "\n");
773
2087
  generated++;
2088
+ if (!section && page.slug === "index")
2089
+ rootIndexEmitted = true;
2090
+ generatedPaths.add(relative(outputDir, pagePath));
774
2091
  // Companion .md endpoint for content negotiation. Prerendered, so
775
2092
  // it's served as a static asset at runtime — no Worker overhead.
776
2093
  //
@@ -786,6 +2103,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
786
2103
  mkdirSync(dirname(mdEndpointPath), { recursive: true });
787
2104
  const endpointBody = buildMdEndpoint(page, sourceRel);
788
2105
  writeFileSync(mdEndpointPath, endpointBody);
2106
+ generatedPaths.add(relative(outputDir, mdEndpointPath));
789
2107
  // Sibling-level mirror for the index page under a non-empty
790
2108
  // basePath. baseSegments is empty when basePath is empty
791
2109
  // (root-served sites); in that case the index slug is just
@@ -798,22 +2116,128 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
798
2116
  const siblingPath = join(outputDir, "src", "pages", ...parentSegments, `${lastSeg}.md.ts`);
799
2117
  mkdirSync(dirname(siblingPath), { recursive: true });
800
2118
  writeFileSync(siblingPath, endpointBody);
2119
+ generatedPaths.add(relative(outputDir, siblingPath));
801
2120
  }
802
2121
  }
803
2122
  }
804
2123
  catch (err) {
805
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++;
806
2129
  }
807
2130
  }
808
2131
  // Generate index redirect at src/pages/index.astro — sends `/` to the
809
- // first nav href. Skipped when basePath is empty: with root-served
810
- // sites, src/pages/index.astro is the actual home page (not a
811
- // redirect target), and writing a redirect would clobber it.
812
- if (basePath !== "") {
813
- const firstHref = findFirstNavHref(nav, basePath);
814
- writeFileSync(join(outputDir, "src", "pages", "index.astro"), `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
2132
+ // first nav href. Two cases need it:
2133
+ // - basePath set: `/` (host apex) isn't a content page, so redirect
2134
+ // into the basePath subtree.
2135
+ // - basePath empty BUT no root home page emitted (no
2136
+ // `content/index.md`): the corpus's entry point is a nested page
2137
+ // (e.g. OpenShift's `welcome/index`), so `/` would 404 without a
2138
+ // redirect. Only emit here for non-section builds and never when a
2139
+ // real root index page exists (writing a redirect would clobber it).
2140
+ // Use the base-prefixed nav (the same hrefs written to nav.json), not the
2141
+ // raw input `nav` — otherwise importers whose nav starts root-relative (e.g.
2142
+ // the docusaurus convert path) emit a redirect that drops the URL base and
2143
+ // sends `/` to the host root instead of into the site's subpath.
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);
2182
+ const needsRootRedirect = basePath !== "" ||
2183
+ (!section && !rootIndexEmitted && firstHref !== "");
2184
+ if (needsRootRedirect) {
2185
+ const indexPath = join(outputDir, "src", "pages", "index.astro");
2186
+ writeFileSync(indexPath, `---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${firstHref}");\n---\n`);
2187
+ generatedPaths.add(relative(outputDir, indexPath));
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.
815
2193
  }
816
- return { generated, outputNav };
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);
2208
+ return { generated, outputNav, generatedPaths };
2209
+ }
2210
+ /**
2211
+ * Copy each passthrough `.astro` source to its computed output path.
2212
+ * Aborts with a clear error if the destination is already in
2213
+ * `generatedPaths` (a generated page from `emitAstroPages` would
2214
+ * silently overwrite the hand-authored file otherwise).
2215
+ */
2216
+ export function emitPassthroughAstroPages(copies, outputDir, generatedPaths) {
2217
+ if (copies.length === 0)
2218
+ return { copied: 0 };
2219
+ // Collision detection — a generated page and a passthrough page
2220
+ // would write to the same file. Refuse to overwrite; tell the
2221
+ // author exactly which two files conflict.
2222
+ const collisions = [];
2223
+ for (const copy of copies) {
2224
+ if (generatedPaths.has(copy.outputRelPath)) {
2225
+ collisions.push(copy.outputRelPath);
2226
+ }
2227
+ }
2228
+ if (collisions.length > 0) {
2229
+ throw new Error(`Passthrough Astro page collides with a generated page:\n` +
2230
+ collisions.map((c) => ` - ${c}`).join("\n") + "\n" +
2231
+ `Rename the .astro source or remove the colliding entry from nav.yml.`);
2232
+ }
2233
+ let copied = 0;
2234
+ for (const copy of copies) {
2235
+ const dest = join(outputDir, copy.outputRelPath);
2236
+ mkdirSync(dirname(dest), { recursive: true });
2237
+ copyFileSync(copy.sourceAbs, dest);
2238
+ copied++;
2239
+ }
2240
+ return { copied };
817
2241
  }
818
2242
  // ─── Tier 1: config-derived ─────────────────────────────────────────────
819
2243
  // Files driven entirely by config + flags. Always regenerated; site
@@ -829,6 +2253,54 @@ export function emitConfigDerivedFiles(outputDir, options) {
829
2253
  const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
830
2254
  writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
831
2255
  }
2256
+ /**
2257
+ * Per-deploy-target artifact emission.
2258
+ *
2259
+ * Called from `emitSiteScaffold` (with `forceOverwrite=writeScaffold`
2260
+ * so `--force` regenerates from template) and from `dogsbay site
2261
+ * build` (with `forceOverwrite=false` so an existing site can adopt
2262
+ * a deploy target by editing config and rebuilding — the missing
2263
+ * artifact gets created on the next build).
2264
+ *
2265
+ * Emit policy is the union: write when forced OR when the file is
2266
+ * missing. Author edits to e.g. the workflow YAML survive every
2267
+ * regular build.
2268
+ *
2269
+ * Currently handles `github-pages` (workflow + .nojekyll). The
2270
+ * existing `cloudflare-workers` artifacts (wrangler.jsonc + package
2271
+ * scripts) stay in the scaffold-only path because they overlap with
2272
+ * scaffold-only files (package.json scripts, devDependencies). A
2273
+ * future refactor could fold them in here too.
2274
+ */
2275
+ export function emitDeployArtifacts(outputDir, options, opts = { forceOverwrite: false }) {
2276
+ if (options.deploy === "github-pages") {
2277
+ // GitHub reads workflows from <repo-root>/.github/workflows/, NOT
2278
+ // from inside subdirectories. Use projectDir (the repo root) for
2279
+ // the workflow file; fall back to outputDir when unset (flat
2280
+ // `dogsbay convert` flows where the Astro project IS the repo).
2281
+ const projectDir = options.projectDir ?? outputDir;
2282
+ // Path of the Astro output relative to the project root. Used by
2283
+ // the workflow's working-directory + cache-dependency-path so
2284
+ // pnpm install / pnpm run build target the right place. Empty
2285
+ // string when outputDir === projectDir (flat layout).
2286
+ const astroDirRel = relative(projectDir, outputDir).replace(/\\/g, "/");
2287
+ const workflowPath = join(projectDir, ".github", "workflows", "deploy.yml");
2288
+ if (opts.forceOverwrite || !existsSync(workflowPath)) {
2289
+ mkdirSync(dirname(workflowPath), { recursive: true });
2290
+ writeFileSync(workflowPath, buildGitHubPagesWorkflow(astroDirRel));
2291
+ }
2292
+ // .nojekyll — must exist in the deployed artifact root so GH
2293
+ // Pages skips Jekyll's `_underscored-paths` filter (Astro's
2294
+ // `_astro/` chunk dir gets eaten otherwise). Lives inside the
2295
+ // Astro project's `public/` so it's copied into `dist/` at
2296
+ // build time.
2297
+ const nojekyllPath = join(outputDir, "public", ".nojekyll");
2298
+ mkdirSync(dirname(nojekyllPath), { recursive: true });
2299
+ if (opts.forceOverwrite || !existsSync(nojekyllPath)) {
2300
+ writeFileSync(nojekyllPath, "");
2301
+ }
2302
+ }
2303
+ }
832
2304
  /**
833
2305
  * Emit `src/data/switcherMap.json` describing per-page
834
2306
  * version + locale equivalents. Always writes the file —
@@ -845,7 +2317,10 @@ export function emitConfigDerivedFiles(outputDir, options) {
845
2317
  * baseline page in a multi-version site).
846
2318
  */
847
2319
  export function emitSwitcherMap(pages, outputDir, options) {
848
- const basePath = normalizeBasePath(options.basePath);
2320
+ // Switcher URLs use combined so the link the dropdown emits
2321
+ // resolves under the host's served subpath (GH Pages project
2322
+ // pages, multi-mount Cloudflare).
2323
+ const combined = combinedPrefix(options);
849
2324
  const dataDir = join(outputDir, "src", "data");
850
2325
  const outPath = join(dataDir, "switcherMap.json");
851
2326
  // Detect axis activation by inspecting the data the loader
@@ -884,7 +2359,7 @@ export function emitSwitcherMap(pages, outputDir, options) {
884
2359
  const variant = {
885
2360
  ...(ms.locale !== undefined ? { locale: ms.locale } : {}),
886
2361
  ...(ms.version !== undefined ? { version: ms.version } : {}),
887
- url: `${basePath}/${page.slug}`,
2362
+ url: `${combined}/${page.slug}`,
888
2363
  };
889
2364
  if (!byLogicalKey[key])
890
2365
  byLogicalKey[key] = [];
@@ -905,11 +2380,22 @@ function composeAxisHeader(declared, seen, defaultId, allowEol) {
905
2380
  const out = [];
906
2381
  const seenInDeclared = new Set();
907
2382
  for (const d of declared) {
908
- 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) {
909
2392
  out.push({
910
2393
  id: d.id,
911
2394
  ...(d.label !== undefined ? { label: d.label } : {}),
2395
+ // eol + prerelease + hidden are version-only marks (allowEol gates them).
912
2396
  ...(allowEol && d.eol === true ? { eol: true } : {}),
2397
+ ...(allowEol && d.prerelease === true ? { prerelease: true } : {}),
2398
+ ...(allowEol && d.hidden === true ? { hidden: true } : {}),
913
2399
  ...(defaultId === d.id ? { default: true } : {}),
914
2400
  });
915
2401
  seenInDeclared.add(d.id);
@@ -962,6 +2448,10 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
962
2448
  return;
963
2449
  const basePath = normalizeBasePath(options.basePath);
964
2450
  const baseSegments = basePathSegments(basePath);
2451
+ // combined drives the redirect URL (the user-facing path they
2452
+ // get bounced to); basePath stays the filesystem path under
2453
+ // src/pages/ where the stub lives.
2454
+ const combined = combinedPrefix(options);
965
2455
  // Index existing pages by (slug after locale segment) so we
966
2456
  // can detect missing translations cheaply. Key shape:
967
2457
  // `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
@@ -995,7 +2485,7 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
995
2485
  const targetUrl = `${basePath}/${targetSlug}`;
996
2486
  if (existingByUrl.has(targetUrl))
997
2487
  continue; // already translated
998
- const defaultUrl = `${basePath}/${defaultPage.slug}`;
2488
+ const defaultUrl = `${combined}/${defaultPage.slug}`;
999
2489
  const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
1000
2490
  // Ensure parent dir exists; write a redirect-stub Astro
1001
2491
  // file. Adding `.astro` to the leaf turns it into a
@@ -1037,10 +2527,20 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1037
2527
  if (options.llmsTxt !== false) {
1038
2528
  emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
1039
2529
  // public/_headers — Cloudflare Workers / Pages convention. Adds an
1040
- // RFC 8288 Link header pointing agents at /llms.txt without parsing
1041
- // HTML. Emitted alongside llms.txt so the two files travel together.
2530
+ // RFC 8288 Link header pointing agents at this mount's llms.txt
2531
+ // (basePath-prefixed) without parsing HTML. Emitted alongside
2532
+ // llms.txt so the two files travel together.
1042
2533
  mkdirSync(join(outputDir, "public"), { recursive: true });
1043
- writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile());
2534
+ // _headers Link header points at the per-mount llms.txt at
2535
+ // <combined>/llms.txt — the URL agents would actually fetch.
2536
+ writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(combinedPrefix(options)));
2537
+ }
2538
+ // Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
2539
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
2540
+ // don't collide at host root. Gated on a valid http(s) siteUrl
2541
+ // because <loc> entries must be absolute.
2542
+ if (options.siteUrl && /^https?:\/\//.test(options.siteUrl)) {
2543
+ emitSitemapFiles(outputDir, options, pages);
1044
2544
  }
1045
2545
  // src/middleware.ts — Tier 1 (always update). Drives both the
1046
2546
  // `Accept: text/markdown` content-negotiation rewrite (via
@@ -1052,19 +2552,71 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1052
2552
  // OR the version axis has a defaultVersion set with ≥2
1053
2553
  // declared versions. When neither applies, no middleware is
1054
2554
  // emitted (keeps single-feature sites' src/ tidy).
1055
- 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;
1056
2575
  const knownVersions = pageVersions(pages);
1057
2576
  const knownLocales = pageLocales(pages);
1058
2577
  const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2;
1059
2578
  const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
1060
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
+ }
1061
2592
  if (mdMirrorOn || axisRedirectOn) {
2593
+ // Taxonomy index paths share a single global namespace across
2594
+ // locales / versions (one `/tags/` for the whole site, not one
2595
+ // per locale). The redirect helper has to know to skip them or
2596
+ // it will 302 chip hrefs to non-existent locale-prefixed routes.
2597
+ // Strip leading `/` and pull just the first segment so a config
2598
+ // like `/tags` becomes the global-prefix entry `tags`.
2599
+ const globalPrefixes = [];
2600
+ if (options.taxonomyIndexPaths) {
2601
+ for (const raw of Object.values(options.taxonomyIndexPaths)) {
2602
+ const first = raw.replace(/^\/+/, "").split("/")[0];
2603
+ if (first)
2604
+ globalPrefixes.push(first);
2605
+ }
2606
+ }
1062
2607
  mkdirSync(join(outputDir, "src"), { recursive: true });
1063
2608
  writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
1064
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),
1065
2614
  axisRedirect: axisRedirectOn
1066
2615
  ? {
1067
- basePath: normalizeBasePath(options.basePath),
2616
+ // Middleware compares paths against the request URL,
2617
+ // which carries the host's served subpath — so use the
2618
+ // combined prefix here.
2619
+ basePath: combinedPrefix(options),
1068
2620
  ...(versionRedirectOn
1069
2621
  ? {
1070
2622
  defaultVersion: options.defaultVersion,
@@ -1077,6 +2629,7 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1077
2629
  knownLocales,
1078
2630
  }
1079
2631
  : {}),
2632
+ ...(globalPrefixes.length > 0 ? { globalPrefixes } : {}),
1080
2633
  }
1081
2634
  : undefined,
1082
2635
  }));
@@ -1137,21 +2690,147 @@ function buildRobotsTxt(options, hasSiteUrl) {
1137
2690
  const aiInput = options.aiInput ?? "yes";
1138
2691
  const aiTrain = options.aiTrain ?? "no";
1139
2692
  const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
1140
- const sitemap = hasSiteUrl
1141
- ? `Sitemap: ${options.siteUrl.replace(/\/$/, "")}/sitemap-index.xml\n`
2693
+ // Per-mount sitemap path: each Dogsbay site emits its sitemap
2694
+ // index under <basePath>/, so robots.txt must point there too.
2695
+ // (Multi-mount deploys end up with one robots.txt per site at
2696
+ // their respective hosts / paths; each correctly references its
2697
+ // own mount's sitemap-index.)
2698
+ // Sitemap URL = origin + combined + /sitemap-index.xml. Use the
2699
+ // origin (no path) from site.url and the combined prefix (urlBase
2700
+ // + basePath); siteUrl could itself include a path component when
2701
+ // hosting on a subpath (GH Pages project page), so we strip it
2702
+ // here to avoid double-counting.
2703
+ const { origin } = parseSiteUrl(options.siteUrl);
2704
+ const combined = combinedPrefix(options);
2705
+ const sitemap = hasSiteUrl && origin
2706
+ ? `Sitemap: ${origin}${withBasePath(combined, "/sitemap-index.xml")}\n`
2707
+ : "";
2708
+ // Llms-Txt: line — non-standard but follows the same shape as
2709
+ // `Sitemap:`. Crawlers and agents that scan robots.txt before
2710
+ // fetching pages get a direct pointer at the per-mount llms.txt.
2711
+ // RFC 9309 explicitly permits unknown directives ("intentionally
2712
+ // permissive of such future extensions") so this is harmless to
2713
+ // standards-compliant parsers. Emitted alongside Sitemap when
2714
+ // siteUrl is set; absolute URLs only (relative paths would be
2715
+ // ambiguous without a base).
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
2729
+ ? `Llms-Txt: ${origin}${withBasePath(combined, "/llms.txt")}\n`
1142
2730
  : "";
1143
- return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}`;
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
+ `;
1144
2787
  }
1145
2788
  /**
1146
2789
  * Build the contents of `public/_headers` (Cloudflare Pages / Workers
1147
2790
  * Static Assets convention). Emits a global RFC 8288 Link header
1148
- * pointing at the site's llms.txt index, so agents don't need to
2791
+ * pointing at this mount's llms.txt index, so agents don't need to
1149
2792
  * parse HTML to discover the LLM-friendly content listing.
2793
+ *
2794
+ * The Link target is basePath-prefixed (`</docs/llms.txt>` for a
2795
+ * `/docs` mount) — matches where the platform actually emits
2796
+ * llms.txt under the per-mount layout.
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.
1150
2806
  */
1151
- function buildHeadersFile() {
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
+ }
2829
+ function buildHeadersFile(basePath) {
2830
+ const llmsHref = withBasePath(basePath, "/llms.txt");
1152
2831
  return [
1153
2832
  "/*",
1154
- ' Link: </llms.txt>; rel="describedby"; type="text/plain"',
2833
+ ` Link: <${llmsHref}>; rel="describedby"; type="text/plain"`,
1155
2834
  "",
1156
2835
  ].join("\n");
1157
2836
  }
@@ -1160,20 +2839,28 @@ function buildMiddlewareSource(config) {
1160
2839
  "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
1161
2840
  "// Composes the docs-layout middleware helpers.",
1162
2841
  "//",
1163
- "// Markdown content negotiation:",
1164
- "// This middleware fires on every request, but in Astro's static",
1165
- "// prerender mode (output: \"static\") request headers are NOT",
1166
- "// forwarded — Astro warns about \"Astro.request.headers was used",
1167
- "// when rendering...\" and serves a prerendered HTML response.",
1168
- "// That means `Accept: text/markdown` negotiation only kicks in",
1169
- "// under SSR (output: \"server\") or via an edge function on the",
1170
- "// deployment layer (Cloudflare Worker, Netlify Edge, etc.).",
1171
- "// For pure-static deploys, agents should follow the page's",
1172
- "// <link rel=\"alternate\" type=\"text/markdown\"> href to fetch",
1173
- "// the .md mirror directly (e.g. /docs.md).",
2842
+ "// Static-prerender guard:",
2843
+ "// In Astro's static output mode, this middleware is invoked",
2844
+ "// for every prerendered route at build time. Reading",
2845
+ "// `context.request.headers` there triggers an Astro warning",
2846
+ "// per page (\"Astro.request.headers was used during static",
2847
+ "// render\"), which floods `dogsbay site build` / `site preview`",
2848
+ "// output. Worse, the negotiation can't actually happen at",
2849
+ "// build time — there's no runtime client whose Accept header",
2850
+ "// we'd be honoring.",
1174
2851
  "//",
1175
- "// The Cloudflare-Worker-driven full fix is tracked in",
1176
- "// plans/cloudflare-deploy-content-negotiation.md.",
2852
+ "// We guard with `context.isPrerendered` so prerendered routes",
2853
+ "// short-circuit to `next()` immediately. At runtime in static",
2854
+ "// deploys, middleware doesn't fire at all (no server); at",
2855
+ "// runtime in SSR / hybrid deploys, only dynamic routes fire,",
2856
+ "// which is exactly when negotiation makes sense.",
2857
+ "//",
2858
+ "// Markdown content negotiation:",
2859
+ "// For pure-static deploys, `Accept: text/markdown` is honored",
2860
+ "// by the platform (Cloudflare _headers + Worker, Netlify Edge",
2861
+ "// functions). Agents that can't send Accept headers should",
2862
+ "// follow the page's <link rel=\"alternate\" type=\"text/markdown\">",
2863
+ "// to fetch the .md mirror directly (e.g. /docs.md).",
1177
2864
  'import { defineMiddleware } from "astro:middleware";',
1178
2865
  ];
1179
2866
  if (config.mdMirror) {
@@ -1187,9 +2874,14 @@ function buildMiddlewareSource(config) {
1187
2874
  lines.push(`const AXIS_REDIRECT_CONFIG = ${JSON.stringify(config.axisRedirect, null, 2)};`, "");
1188
2875
  }
1189
2876
  lines.push("export const onRequest = defineMiddleware((context, next) => {");
2877
+ // Skip prerendered routes — see file-top comment for the rationale.
2878
+ // Avoids per-page Astro.request.headers warnings during build, and
2879
+ // matches runtime semantics (middleware doesn't fire on prerendered
2880
+ // routes when deployed).
2881
+ lines.push(" if (context.isPrerendered) return next();");
1190
2882
  lines.push(" const url = new URL(context.request.url);");
1191
2883
  if (config.mdMirror) {
1192
- 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);");
1193
2885
  }
1194
2886
  if (config.axisRedirect) {
1195
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);", " }");
@@ -1218,8 +2910,18 @@ function buildMdEndpoint(page, sourceRel) {
1218
2910
  ].join("\n");
1219
2911
  }
1220
2912
  /**
1221
- * Emit `public/llms.txt`, `public/llms-full.txt`, and per-section
1222
- * `public/<dir>/llms.txt` files for the site.
2913
+ * Emit per-mount llms.txt + llms-full.txt + per-section indexes.
2914
+ *
2915
+ * Files live under `public/<basePath>/...` so multiple Dogsbay sites
2916
+ * can mount on the same host (`/docs/llms.txt` + `/api/llms.txt` +
2917
+ * `/handbook/llms.txt`) without colliding at the root. When basePath
2918
+ * is empty, this collapses to `public/llms.txt` — the single-site
2919
+ * llmstxt.org-spec layout.
2920
+ *
2921
+ * The host root `/llms.txt` is intentionally NOT emitted by the
2922
+ * platform: it's the user's umbrella file, analogous to
2923
+ * `sitemap-index.xml`. Multi-mount deploys hand-write a top-level
2924
+ * `/llms.txt` that links to each per-mount index.
1223
2925
  *
1224
2926
  * Per-section files are written for every top-level nav group that
1225
2927
  * resolves to a site directory (either via `group.href` or via the
@@ -1231,26 +2933,102 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
1231
2933
  description: options.description,
1232
2934
  siteUrl: options.siteUrl,
1233
2935
  };
1234
- const publicDir = join(outputDir, "public");
1235
- mkdirSync(publicDir, { recursive: true });
1236
- const hrefPrefix = normalizeBasePath(options.basePath);
1237
- writeFileSync(join(publicDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
1238
- writeFileSync(join(publicDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
2936
+ // hrefPrefix is the COMBINED prefix — used for the URL paths that
2937
+ // appear inside the llms.txt body (so agents fetch the correct
2938
+ // host-relative URLs). Filesystem layout uses basePath alone:
2939
+ // `public/<basePath>/llms.txt` matches the existing per-mount
2940
+ // delivery shape.
2941
+ const hrefPrefix = combinedPrefix(options);
2942
+ const basePath = normalizeBasePath(options.basePath);
2943
+ const baseSegments = basePathSegments(basePath);
2944
+ const mountDir = join(outputDir, "public", ...baseSegments);
2945
+ mkdirSync(mountDir, { recursive: true });
2946
+ writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, {
2947
+ hrefPrefix,
2948
+ aggregates: options.aggregates,
2949
+ whenToUse: options.llmsWhenToUse,
2950
+ }));
2951
+ writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
1239
2952
  summary: "body",
1240
2953
  serializePage: serializePageMd,
1241
2954
  hrefPrefix,
1242
2955
  }));
2956
+ // Per-section files. `deriveSectionDir` returns a host-absolute
2957
+ // path derived from nav hrefs, which since the combined-prefix
2958
+ // refactor (commit 132891e) include urlBase + basePath — NOT just
2959
+ // basePath. So joining its return onto public/ directly would
2960
+ // double-prefix into `public/<urlBase>/<basePath>/<section>/llms.txt`,
2961
+ // which then serves at `<urlBase>/<urlBase>/<basePath>/<section>/...`
2962
+ // once Astro's base prefix is applied at request time.
2963
+ //
2964
+ // Strip the combined prefix off the section dir to get just the
2965
+ // section tail, then re-prepend basePath via mountDir. Result:
2966
+ // `public/<basePath>/<section>/llms.txt`, served under the deploy's
2967
+ // base mount as `<urlBase>/<basePath>/<section>/llms.txt`.
2968
+ const combinedSegs = hrefPrefix.replace(/^\//, "");
1243
2969
  for (const group of nav) {
1244
2970
  if (!group.children || group.children.length === 0)
1245
2971
  continue;
1246
2972
  const dir = deriveSectionDir(group);
1247
2973
  if (!dir)
1248
2974
  continue;
1249
- const sectionPath = join(publicDir, dir, "llms.txt");
2975
+ let relDir;
2976
+ if (combinedSegs && dir === combinedSegs) {
2977
+ relDir = "";
2978
+ }
2979
+ else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
2980
+ relDir = dir.slice(combinedSegs.length + 1);
2981
+ }
2982
+ else {
2983
+ // Defensive: if for some reason the dir doesn't carry the
2984
+ // combined prefix (older importer, manual nav.yml, etc.), fall
2985
+ // back to the raw value rather than rooting at /.
2986
+ relDir = dir;
2987
+ }
2988
+ const sectionPath = relDir
2989
+ ? join(mountDir, relDir, "llms.txt")
2990
+ : join(mountDir, "llms.txt");
1250
2991
  mkdirSync(dirname(sectionPath), { recursive: true });
1251
2992
  writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
1252
2993
  }
1253
2994
  }
2995
+ /**
2996
+ * Emit per-mount sitemap files.
2997
+ *
2998
+ * Writes `public/<basePath>/sitemap-index.xml` + `sitemap-0.xml`.
2999
+ * The index lists the single sub-sitemap today; future splits add
3000
+ * more sub-sitemap entries as the page count grows past
3001
+ * sitemaps.org's 50K-URL recommendation.
3002
+ *
3003
+ * Caller has already guarded on a valid http(s) `siteUrl` — without
3004
+ * one, `<loc>` entries can't be absolute and crawlers reject the
3005
+ * file. Skip emission rather than write a broken sitemap.
3006
+ */
3007
+ function emitSitemapFiles(outputDir, options, pages) {
3008
+ // Filesystem path uses basePath (sitemap files live in
3009
+ // public/<basePath>/sitemap-*.xml). The URL prefix encoded into
3010
+ // each <loc> uses combined so the absolute URLs resolve under the
3011
+ // host's served subpath. buildSitemap strips path off siteUrl
3012
+ // internally, so passing siteUrl + combined as basePath gives
3013
+ // origin + combined as the final URL.
3014
+ const basePath = normalizeBasePath(options.basePath);
3015
+ const combined = combinedPrefix(options);
3016
+ const baseSegments = basePathSegments(basePath);
3017
+ const mountDir = join(outputDir, "public", ...baseSegments);
3018
+ mkdirSync(mountDir, { recursive: true });
3019
+ writeFileSync(join(mountDir, "sitemap-0.xml"), buildSitemap(pages, {
3020
+ siteUrl: options.siteUrl,
3021
+ basePath: combined,
3022
+ siteNoindex: options.noindex === true,
3023
+ }));
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
+ }));
3031
+ }
1254
3032
  /**
1255
3033
  * Pick a directory under `public/` for a top-level nav group. Prefers
1256
3034
  * the group's own href (already a `/docs/x/y` path); otherwise falls
@@ -1303,6 +3081,55 @@ function findFirstNavHref(items, fallback) {
1303
3081
  }
1304
3082
  return fallback;
1305
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
+ }
1306
3133
  function copyComponents(outputDir) {
1307
3134
  const componentsSource = resolveComponentsSource();
1308
3135
  if (!componentsSource)
@@ -1328,6 +3155,11 @@ function copyComponents(outputDir) {
1328
3155
  "response-tabs", "schema-viewer", "code-samples", "copy-button",
1329
3156
  "markdown-example",
1330
3157
  "accordion", "link-card", "avatar", "math",
3158
+ // Icon resolves @ui/icon/Icon.astro → built-time SVG inlining
3159
+ // via @dogsbay/icons. Used by `:::cards` `{icon=...}` and the
3160
+ // inline `:icon[name]` directive. Without this entry every page
3161
+ // emitting the icon import 500s with "module not found".
3162
+ "icon",
1331
3163
  ];
1332
3164
  for (const name of needed) {
1333
3165
  const src = join(componentsSource, name);
@@ -1337,9 +3169,38 @@ function copyComponents(outputDir) {
1337
3169
  }
1338
3170
  }
1339
3171
  }
1340
- 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) {
1341
3193
  // sourceDir is already the docs dir (e.g. .../fastapi/docs/en/docs)
1342
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;
1343
3204
  // Raster images benefit from Astro optimization (WebP, dimensions)
1344
3205
  const optimizableExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
1345
3206
  // SVGs, icons, PDFs always go to public/ (no optimization needed)
@@ -1353,7 +3214,7 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
1353
3214
  else {
1354
3215
  const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
1355
3216
  if (optimizableExts.has(ext)) {
1356
- const rel = relative(searchDir, full);
3217
+ const rel = withPrefix(relative(searchDir, full));
1357
3218
  // Always copy to public/ so inline <img src="/..."> works
1358
3219
  const pubDest = join(outputDir, "public", rel);
1359
3220
  mkdirSync(dirname(pubDest), { recursive: true });
@@ -1368,7 +3229,7 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
1368
3229
  }
1369
3230
  else if (passthroughExts.has(ext)) {
1370
3231
  // SVGs, icons, PDFs always go to public/
1371
- const rel = relative(searchDir, full);
3232
+ const rel = withPrefix(relative(searchDir, full));
1372
3233
  const dest = join(outputDir, "public", rel);
1373
3234
  mkdirSync(dirname(dest), { recursive: true });
1374
3235
  cpSync(full, dest);
@@ -1382,60 +3243,205 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
1382
3243
  catch { /* source may not exist */ }
1383
3244
  }
1384
3245
  // ── CSS generation (ported from import-mkdocs.ts) ───────
3246
+ /**
3247
+ * Build the `@source inline("...")` directive that pins the
3248
+ * grid-tone palette into the generated stylesheet.
3249
+ *
3250
+ * Why we need it: tone classes like `bg-primary/10` only appear in
3251
+ * `.astro` pages emitted by Dogsbay's grid-item serializer. When
3252
+ * Tailwind's content scanner doesn't pick them up — because the
3253
+ * page lives outside the default scan globs, or because a class
3254
+ * is composed at the boundary of an interpolation — they get
3255
+ * purged. Result observed in dogsbay-docs-markdown audit: half
3256
+ * the grid demo cells render with no background. Pinning forces
3257
+ * generation regardless of scanner reach.
3258
+ *
3259
+ * Single source of truth: derived from TONE_CLASSES so any new
3260
+ * tone added to the palette is automatically safelisted.
3261
+ */
3262
+ function buildToneSafelist() {
3263
+ const seen = new Set();
3264
+ for (const classes of Object.values(TONE_CLASSES)) {
3265
+ for (const cls of classes.split(/\s+/)) {
3266
+ if (cls)
3267
+ seen.add(cls);
3268
+ }
3269
+ }
3270
+ return [...seen].sort().join(" ");
3271
+ }
1385
3272
  function generateGlobalCss() {
1386
3273
  return `@import "tailwindcss";
1387
3274
  @import "./theme.css";
1388
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
+
1389
3292
  /* Scan @dogsbay packages for Tailwind classes */
1390
3293
  @source "../../node_modules/@dogsbay/ui/src";
1391
3294
  @source "../../node_modules/@dogsbay/docs-layout/src";
1392
3295
 
1393
- /* Prose typography for rendered content */
3296
+ /* Pin the grid-tone palette. These classes are emitted into
3297
+ markdown-generated .astro pages by the grid-item serializer
3298
+ (TONE_CLASSES in @dogsbay/format-astro). Without inlining,
3299
+ opacity-modified utilities like bg-primary/10 get purged when
3300
+ Tailwind doesn't see them in the scanned globs, leaving grid
3301
+ demo cells with no visible background. */
3302
+ @source inline("${buildToneSafelist()}");
3303
+
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. */
1394
3310
  .docs-prose {
1395
3311
  line-height: 1.7;
1396
3312
 
1397
- & 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; }
1398
- & 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; }
1399
- & h3 { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
1400
- & 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; }
1401
3317
 
1402
- & p { margin-top: 0.75rem; margin-bottom: 0.75rem; }
3318
+ & p:not(.dba-api *) { margin-top: 0.75rem; margin-bottom: 0.75rem; }
1403
3319
 
1404
- & a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
1405
- & 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; }
1406
3331
 
1407
- & strong { font-weight: 600; }
1408
- & 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); }
1409
- & 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; }
1410
3335
 
1411
3336
  /* Spacing between consecutive block elements (code blocks, alerts, etc.) */
1412
3337
  & > * + * { margin-top: 0.75rem; }
1413
- & li > * + * { margin-top: 0.75rem; }
3338
+ & li > * + *:not(.dba-api *) { margin-top: 0.75rem; }
1414
3339
 
1415
- & ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
1416
- & ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
1417
- & li { margin: 0.25rem 0; }
1418
- & 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; }
1419
3344
 
1420
- & 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; }
1421
3346
 
1422
- & 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; }
1423
3348
 
1424
- & table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
1425
- & th { text-align: left; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
1426
- & td { padding: 0.5rem; 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); }
1427
3352
 
1428
- & img { max-width: 100%; border-radius: 0.5rem; }
3353
+ & img:not(.dba-api *) { max-width: 100%; border-radius: 0.5rem; }
1429
3354
 
1430
3355
  & .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
1431
3356
  & h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
1432
3357
 
1433
- & details { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
1434
- & 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); }
3371
+
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); }
3403
+
3404
+ /* Granularity book-view affordances (plans/granularity-views.md):
3405
+ "Read as single page →" on topics, "Open as page ↗" on book sections.
3406
+ Deliberately subtle/muted — the book has one per section. */
3407
+ & .dsb-read-single-page {
3408
+ display: inline-block;
3409
+ margin: 0 0 1.25rem;
3410
+ font-size: 0.8125rem;
3411
+ color: var(--muted-foreground);
3412
+ text-decoration: none;
3413
+ }
3414
+ & .dsb-read-single-page:hover { color: var(--foreground); text-decoration: underline; }
3415
+ & .dsb-open-as-page { margin: -0.25rem 0 0.5rem; font-size: 0.75rem; }
3416
+ & .dsb-open-as-page a { color: var(--muted-foreground); text-decoration: none; }
3417
+ & .dsb-open-as-page a:hover { color: var(--foreground); text-decoration: underline; }
3418
+
3419
+ /* AsciiDoc block roles ([role="x"] / [.x]) preserved as classes (#384).
3420
+ _abstract marks a module's lead/intro paragraph; .lead/.small are
3421
+ Asciidoctor built-ins; _additional-resources marks the trailing
3422
+ "Additional resources" section heading. */
3423
+ & ._abstract, & .abstract, & .lead {
3424
+ font-size: 1.05rem;
3425
+ line-height: 1.6;
3426
+ color: var(--muted-foreground);
3427
+ }
3428
+ & .small { font-size: 0.875rem; }
3429
+ & h2._additional-resources, & h3._additional-resources {
3430
+ font-size: 1.15rem;
3431
+ margin-top: 2rem;
3432
+ }
1435
3433
 
1436
- & dl { margin: 1rem 0; }
1437
- & dt { font-weight: 600; margin-top: 0.75rem; }
1438
- & dd { margin-left: 1.5rem; color: var(--muted-foreground); }
3434
+ /* Related resources (#384 R3): [role="_additional-resources"] + .Title +
3435
+ list folds to a <section class="related-resources"> (→ DITA related-links). */
3436
+ & .related-resources {
3437
+ margin-top: 2rem;
3438
+ padding-top: 1rem;
3439
+ border-top: 1px solid var(--border);
3440
+ }
3441
+ & .related-resources-title {
3442
+ font-weight: 600;
3443
+ margin: 0 0 0.5rem;
3444
+ }
1439
3445
  }
1440
3446
 
1441
3447
  @utility scrollbar-none {
@@ -1499,23 +3505,24 @@ const THEME_DEFAULT = {
1499
3505
  --api-panel-fg: oklch(0.926 0.013 253.833);
1500
3506
 
1501
3507
  /* API semantic colors — HTTP methods, types, status codes */
1502
- --api-get: oklch(0.723 0.15 162);
1503
- --api-post: oklch(0.637 0.174 259);
1504
- --api-put: oklch(0.74 0.175 63);
1505
- --api-patch: oklch(0.7 0.18 45);
1506
- --api-delete: oklch(0.614 0.194 22);
1507
- --api-head: oklch(0.534 0.222 286);
1508
- --api-required: oklch(0.614 0.194 22);
1509
- --api-deprecated: oklch(0.74 0.175 63);
1510
- --api-type-string: oklch(0.723 0.15 162);
1511
- --api-type-number: oklch(0.637 0.174 259);
1512
- --api-type-boolean: oklch(0.74 0.175 63);
1513
- --api-type-object: oklch(0.534 0.222 286);
1514
- --api-type-array: oklch(0.7 0.18 45);
1515
- --api-status-2xx: oklch(0.723 0.15 162);
1516
- --api-status-3xx: oklch(0.637 0.174 259);
1517
- --api-status-4xx: oklch(0.74 0.175 63);
1518
- --api-status-5xx: oklch(0.614 0.194 22);
3508
+ /* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
3509
+ --api-get: oklch(0.435 0.095 162);
3510
+ --api-post: oklch(0.45 0.174 259);
3511
+ --api-put: oklch(0.455 0.105 63);
3512
+ --api-patch: oklch(0.455 0.13 45);
3513
+ --api-delete: oklch(0.45 0.179 22);
3514
+ --api-head: oklch(0.47 0.222 286);
3515
+ --api-required: oklch(0.45 0.179 22);
3516
+ --api-deprecated: oklch(0.455 0.105 63);
3517
+ --api-type-string: oklch(0.435 0.095 162);
3518
+ --api-type-number: oklch(0.45 0.174 259);
3519
+ --api-type-boolean: oklch(0.455 0.105 63);
3520
+ --api-type-object: oklch(0.47 0.222 286);
3521
+ --api-type-array: oklch(0.455 0.13 45);
3522
+ --api-status-2xx: oklch(0.435 0.095 162);
3523
+ --api-status-3xx: oklch(0.45 0.174 259);
3524
+ --api-status-4xx: oklch(0.455 0.105 63);
3525
+ --api-status-5xx: oklch(0.45 0.179 22);
1519
3526
 
1520
3527
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1521
3528
  --font-heading: var(--font-sans);
@@ -1552,6 +3559,25 @@ const THEME_DEFAULT = {
1552
3559
  --sidebar-ring: oklch(0.439 0.01 286.375);
1553
3560
  --code-background: oklch(0.18 0.014 253.833);
1554
3561
  --code-foreground: oklch(0.926 0.013 253.833);
3562
+
3563
+ /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
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);
1555
3581
  }`,
1556
3582
  };
1557
3583
  /**
@@ -1618,23 +3644,24 @@ const THEME_MINTLIFY = {
1618
3644
  --api-panel-fg: oklch(0.926 0.013 253.833);
1619
3645
 
1620
3646
  /* API semantic colors — HTTP methods, types, status codes */
1621
- --api-get: oklch(0.723 0.15 162);
1622
- --api-post: oklch(0.637 0.174 259);
1623
- --api-put: oklch(0.74 0.175 63);
1624
- --api-patch: oklch(0.7 0.18 45);
1625
- --api-delete: oklch(0.614 0.194 22);
1626
- --api-head: oklch(0.534 0.222 286);
1627
- --api-required: oklch(0.614 0.194 22);
1628
- --api-deprecated: oklch(0.74 0.175 63);
1629
- --api-type-string: oklch(0.723 0.15 162);
1630
- --api-type-number: oklch(0.637 0.174 259);
1631
- --api-type-boolean: oklch(0.74 0.175 63);
1632
- --api-type-object: oklch(0.534 0.222 286);
1633
- --api-type-array: oklch(0.7 0.18 45);
1634
- --api-status-2xx: oklch(0.723 0.15 162);
1635
- --api-status-3xx: oklch(0.637 0.174 259);
1636
- --api-status-4xx: oklch(0.74 0.175 63);
1637
- --api-status-5xx: oklch(0.614 0.194 22);
3647
+ /* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
3648
+ --api-get: oklch(0.435 0.095 162);
3649
+ --api-post: oklch(0.45 0.174 259);
3650
+ --api-put: oklch(0.455 0.105 63);
3651
+ --api-patch: oklch(0.455 0.13 45);
3652
+ --api-delete: oklch(0.45 0.179 22);
3653
+ --api-head: oklch(0.47 0.222 286);
3654
+ --api-required: oklch(0.45 0.179 22);
3655
+ --api-deprecated: oklch(0.455 0.105 63);
3656
+ --api-type-string: oklch(0.435 0.095 162);
3657
+ --api-type-number: oklch(0.45 0.174 259);
3658
+ --api-type-boolean: oklch(0.455 0.105 63);
3659
+ --api-type-object: oklch(0.47 0.222 286);
3660
+ --api-type-array: oklch(0.455 0.13 45);
3661
+ --api-status-2xx: oklch(0.435 0.095 162);
3662
+ --api-status-3xx: oklch(0.45 0.174 259);
3663
+ --api-status-4xx: oklch(0.455 0.105 63);
3664
+ --api-status-5xx: oklch(0.45 0.179 22);
1638
3665
 
1639
3666
  /* Fonts — system sans for body, JetBrains Mono for code */
1640
3667
  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@@ -1672,6 +3699,25 @@ const THEME_MINTLIFY = {
1672
3699
  --sidebar-ring: oklch(0.45 0.01 260);
1673
3700
  --code-background: oklch(0.2 0.01 260);
1674
3701
  --code-foreground: oklch(0.9 0.01 260);
3702
+
3703
+ /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
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);
1675
3721
  }`,
1676
3722
  };
1677
3723
  function writeThemeFile(path, themeName) {
@@ -1822,6 +3868,37 @@ function isInsideWorkspace(outputDir) {
1822
3868
  }
1823
3869
  return false;
1824
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
+ }
1825
3902
  function resolveMonorepoPkg(name) {
1826
3903
  const thisDir = dirname(fileURLToPath(import.meta.url));
1827
3904
  // From packages/format-astro/src/ → ../../{name}