@dogsbay/format-astro 0.2.0-beta.12 → 0.2.0-beta.120

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 (83) hide show
  1. package/dist/api-markdown.d.ts +8 -0
  2. package/dist/api-markdown.d.ts.map +1 -0
  3. package/dist/api-markdown.js +50 -0
  4. package/dist/api-markdown.js.map +1 -0
  5. package/dist/base-path.d.ts +93 -7
  6. package/dist/base-path.d.ts.map +1 -1
  7. package/dist/base-path.js +122 -8
  8. package/dist/base-path.js.map +1 -1
  9. package/dist/blog.d.ts +134 -0
  10. package/dist/blog.d.ts.map +1 -0
  11. package/dist/blog.js +324 -0
  12. package/dist/blog.js.map +1 -0
  13. package/dist/browser.d.ts +14 -0
  14. package/dist/browser.d.ts.map +1 -0
  15. package/dist/browser.js +14 -0
  16. package/dist/browser.js.map +1 -0
  17. package/dist/canonical.d.ts +34 -0
  18. package/dist/canonical.d.ts.map +1 -0
  19. package/dist/canonical.js +107 -0
  20. package/dist/canonical.js.map +1 -0
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +1 -0
  23. package/dist/cli.js.map +1 -1
  24. package/dist/diff-decoration.d.ts +79 -0
  25. package/dist/diff-decoration.d.ts.map +1 -0
  26. package/dist/diff-decoration.js +541 -0
  27. package/dist/diff-decoration.js.map +1 -0
  28. package/dist/granularity.d.ts +83 -0
  29. package/dist/granularity.d.ts.map +1 -0
  30. package/dist/granularity.js +247 -0
  31. package/dist/granularity.js.map +1 -0
  32. package/dist/head-metadata.d.ts +26 -0
  33. package/dist/head-metadata.d.ts.map +1 -0
  34. package/dist/head-metadata.js +56 -0
  35. package/dist/head-metadata.js.map +1 -0
  36. package/dist/index.d.ts +24 -4
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +30 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/lead.d.ts +1 -38
  41. package/dist/lead.d.ts.map +1 -1
  42. package/dist/lead.js +3 -37
  43. package/dist/lead.js.map +1 -1
  44. package/dist/llms-txt.d.ts +53 -11
  45. package/dist/llms-txt.d.ts.map +1 -1
  46. package/dist/llms-txt.js +85 -222
  47. package/dist/llms-txt.js.map +1 -1
  48. package/dist/plugins.d.ts.map +1 -1
  49. package/dist/plugins.js +4 -2
  50. package/dist/plugins.js.map +1 -1
  51. package/dist/project.d.ts +377 -12
  52. package/dist/project.d.ts.map +1 -1
  53. package/dist/project.js +2263 -661
  54. package/dist/project.js.map +1 -1
  55. package/dist/redirects.d.ts +116 -0
  56. package/dist/redirects.d.ts.map +1 -0
  57. package/dist/redirects.js +224 -0
  58. package/dist/redirects.js.map +1 -0
  59. package/dist/serialize.d.ts +14 -0
  60. package/dist/serialize.d.ts.map +1 -1
  61. package/dist/serialize.js +643 -216
  62. package/dist/serialize.js.map +1 -1
  63. package/dist/sitemap.d.ts +108 -0
  64. package/dist/sitemap.d.ts.map +1 -0
  65. package/dist/sitemap.js +127 -0
  66. package/dist/sitemap.js.map +1 -0
  67. package/dist/taxonomy.d.ts +48 -1
  68. package/dist/taxonomy.d.ts.map +1 -1
  69. package/dist/taxonomy.js +46 -11
  70. package/dist/taxonomy.js.map +1 -1
  71. package/dist/theme-css.d.ts +26 -0
  72. package/dist/theme-css.d.ts.map +1 -0
  73. package/dist/theme-css.js +642 -0
  74. package/dist/theme-css.js.map +1 -0
  75. package/dist/tone-classes.d.ts +14 -0
  76. package/dist/tone-classes.d.ts.map +1 -0
  77. package/dist/tone-classes.js +30 -0
  78. package/dist/tone-classes.js.map +1 -0
  79. package/dist/write-if-changed.d.ts +21 -0
  80. package/dist/write-if-changed.d.ts.map +1 -0
  81. package/dist/write-if-changed.js +58 -0
  82. package/dist/write-if-changed.js.map +1 -0
  83. package/package.json +16 -7
package/dist/project.js CHANGED
@@ -4,14 +4,62 @@
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, readFileSync, cpSync, readdirSync, statSync, rmSync, rmdirSync, openSync, readSync, closeSync, } from "node:fs";
8
+ // Unchanged files are not rewritten, so `site dev` reloads only what changed
9
+ import { writeFileSync, copyFileSync } from "./write-if-changed.js";
8
10
  import { join, dirname, relative, resolve } from "node:path";
9
11
  import { fileURLToPath } from "node:url";
10
12
  import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
11
13
  import { treeToAstro } from "./serialize.js";
14
+ import { apiPageLayout } from "@dogsbay/serialize-core";
15
+ import { missingTranslations } from "@dogsbay/site-files";
16
+ import { sanitizeTree } from "@dogsbay/render-html/sanitize";
17
+ import { generateGlobalCss, themeCss } from "./theme-css.js";
18
+ /**
19
+ * A value as a JavaScript literal in a generated page. `<` becomes \u003c: Astro reads
20
+ * a page's frontmatter and template looking for markup, so a `<script>` inside a string
21
+ * (a page's own Markdown, a code block showing one) would be taken for a real script tag
22
+ * and its text compiled as JavaScript, which fails Vite's dependency scan.
23
+ */
24
+ function pageLiteral(value, indent) {
25
+ return (JSON.stringify(value, null, indent) ?? "undefined").replace(/</g, "\\u003c");
26
+ }
27
+ import { emitPluginRuntime } from "./plugins.js";
12
28
  import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
13
- import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath } from "./base-path.js";
14
- import { detectLeadingNodes } from "./lead.js";
29
+ import { buildSitemapFiles, buildSitemapIndex } from "./sitemap.js";
30
+ import { canonicalPlan } from "./canonical.js";
31
+ import { headMetadata } from "./head-metadata.js";
32
+ import { buildHeadersFile, buildRobotsTxt as buildRobotsTxtFile } from "@dogsbay/site-files";
33
+ import { assembleBook, estimateTreeBytes } from "./granularity.js";
34
+ /**
35
+ * The per-topic "Read as single page" affordance (granularity book view).
36
+ * Returns `[]` when the topic has no book (granularity off / not in a group).
37
+ * `href` is a build-time-known route (`<bookRoute>#<anchor>`).
38
+ */
39
+ function singlePageLinkLines(href, indent) {
40
+ if (!href)
41
+ return [];
42
+ const pad = " ".repeat(indent);
43
+ return [`${pad}<a class="dsb-read-single-page" href="${href}">Read as single page →</a>`];
44
+ }
45
+ import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath, parseSiteUrl, combinePrefix, } from "./base-path.js";
46
+ import { buildBlogData, blogAdjacency } from "./blog.js";
47
+ /**
48
+ * Combined URL prefix = urlBase (Astro `base` from site.url path) +
49
+ * basePath (filesystem layout prefix). Every URL emitter (nav,
50
+ * sitemap, llms.txt, .md mirror, _headers, taxonomy) uses this for
51
+ * href output. Filesystem-layout consumers (mkdir, page output
52
+ * paths) keep using basePath alone — Astro's `base` config adds the
53
+ * urlBase prefix at route time.
54
+ *
55
+ * See plans/astro-base-from-site-url.md.
56
+ */
57
+ function combinedPrefix(options) {
58
+ const { urlBase } = parseSiteUrl(options.siteUrl);
59
+ return combinePrefix(urlBase, normalizeBasePath(options.basePath));
60
+ }
61
+ import { detectLeadingNodes, deriveDescription } from "./lead.js";
62
+ import { collectRedirects, renderMetaRefreshPage, renderRedirectsFile, stubPathForRule, } from "./redirects.js";
15
63
  /**
16
64
  * Recursively prefix all hrefs in a nav tree.
17
65
  * Turns `<basePath>/foo` into `<basePath>/{section}/foo`. The
@@ -49,6 +97,39 @@ function prefixNavHrefs(items, section, basePath) {
49
97
  function escapeRegex(s) {
50
98
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
99
  }
100
+ /**
101
+ * Prefix the combined URL base onto root-absolute nav hrefs.
102
+ *
103
+ * Most importers emit nav hrefs already carrying the combined prefix —
104
+ * the dogsbay-md importer walks source paths through
105
+ * `fileToHref(file, hrefPrefix=combined)`, so OpenShift's `nav.yml`
106
+ * (`welcome/foo.md`) resolves straight to `/<base>/welcome/foo`. But an
107
+ * importer that produces a nav.json of *pre-resolved* hrefs at a
108
+ * different base — e.g. `dogsbay convert --from docusaurus --to
109
+ * dogsbay-md`, whose hrefs are root-absolute (`/about/foo`) — would
110
+ * otherwise emit unprefixed nav links that 404 under a site base, and
111
+ * break prev/next (pagination matches against the combined-prefixed
112
+ * `currentPath`).
113
+ *
114
+ * Running every nav href through `rewriteHref` here is the single
115
+ * chokepoint that makes nav base-correct for ALL importers. It's
116
+ * idempotent — hrefs already starting with the prefix (or external /
117
+ * anchor / protocol-relative) are left untouched — so importer-side
118
+ * prefixing keeps working unchanged, and an empty prefix is a no-op.
119
+ */
120
+ function prefixNavBaseHrefs(items, prefix) {
121
+ if (!prefix)
122
+ return items;
123
+ return items.map((item) => {
124
+ const result = { ...item };
125
+ if (result.href)
126
+ result.href = rewriteHref(result.href, prefix);
127
+ if (result.children) {
128
+ result.children = prefixNavBaseHrefs(result.children, prefix);
129
+ }
130
+ return result;
131
+ });
132
+ }
52
133
  /**
53
134
  * Rewrite internal hrefs in a page's tree nodes.
54
135
  * Prepends `prefix` to root-relative links (e.g. `/workers/...` → `/docs/workers/...`).
@@ -97,6 +178,210 @@ function rewriteHref(href, prefix) {
97
178
  return href;
98
179
  return prefix + href;
99
180
  }
181
+ /**
182
+ * Rewrite image srcs in inline nodes + raw HTML to include the
183
+ * combined URL prefix.
184
+ *
185
+ * Astro auto-prefixes `<a href>` and image imports going through
186
+ * `<AstroImage>`, but raw `<img src="...">` HTML in template
187
+ * output is left untouched. The serializer emits raw `<img>` for
188
+ * inline images and falls back to it for non-optimized block
189
+ * images, so we have to prefix manually before serialization to
190
+ * make `/_assets/...` paths resolve under subpath-mounted deploys
191
+ * (GH Pages project pages, multi-mount Cloudflare).
192
+ *
193
+ * Symmetric with rewriteTreeHrefs — same skip-rules (external,
194
+ * anchors, already-prefixed). Block images keep their prefix
195
+ * stripped back off for the `imageMap[...]` lookup key (see
196
+ * paragraphToAstro in serialize.ts) so Astro's image optimization
197
+ * still finds the source.
198
+ */
199
+ function rewriteTreeImageSrcs(nodes, prefix) {
200
+ for (const node of nodes) {
201
+ if (node.inline) {
202
+ rewriteInlineImageSrcs(node.inline, prefix);
203
+ }
204
+ if (node.html) {
205
+ node.html = rewriteHtmlImageSrcs(node.html, prefix);
206
+ }
207
+ if (node.children) {
208
+ rewriteTreeImageSrcs(node.children, prefix);
209
+ }
210
+ }
211
+ }
212
+ function rewriteInlineImageSrcs(nodes, prefix) {
213
+ for (const node of nodes) {
214
+ if (node.type === "image" && typeof node.src === "string") {
215
+ node.src = rewriteHref(node.src, prefix);
216
+ }
217
+ else if (node.type === "link") {
218
+ // Links wrap inline children (which may include images) — same
219
+ // recursion shape as rewriteInlineHrefs.
220
+ rewriteInlineImageSrcs(node.children, prefix);
221
+ }
222
+ else if (node.type === "highlight" && node.children) {
223
+ rewriteInlineImageSrcs(node.children, prefix);
224
+ }
225
+ }
226
+ }
227
+ function rewriteHtmlImageSrcs(html, prefix) {
228
+ return html.replace(/(<img\b[^>]*\ssrc=")(\/[^"]+)"/g, (_match, before, src) => `${before}${rewriteHref(src, prefix)}"`);
229
+ }
230
+ /**
231
+ * Astro `outDir` for a Cloudflare Workers deploy mounted on a host subpath.
232
+ *
233
+ * Workers Static Assets resolves the FULL request pathname against the
234
+ * asset manifest and strips nothing. A site mounted at `dogsbay.ai/blog/*`
235
+ * is therefore asked for `/blog/index.html`, and unless the build output
236
+ * actually contains `blog/`, every request 404s. Astro's `base` does not
237
+ * do this: it prefixes generated URLs (including `/blog/_astro/...`) but
238
+ * leaves the emitted files at `dist/` root. Setting `outDir` to
239
+ * `./dist<urlBase>` is what makes the two agree.
240
+ *
241
+ * GitHub Pages needs the OPPOSITE and must not get this. There the
242
+ * uploaded artifact IS the site root, served at `https://<user>.github.io/
243
+ * <repo>/`, so the host supplies the prefix: URLs must carry it, files
244
+ * must not. Emitting `outDir` for a Pages build would produce
245
+ * `/<repo>/<repo>/`. Hence the deploy-target gate — the same `site.url`
246
+ * deliberately yields different layouts per target.
247
+ *
248
+ * Returns `undefined` when the target is not Workers or the site sits at
249
+ * the host root (no path in `site.url`), which is the common case.
250
+ */
251
+ export function workersSubpathOutDir(deploy, urlBase) {
252
+ if (deploy !== "cloudflare-workers")
253
+ return undefined;
254
+ if (!urlBase)
255
+ return undefined;
256
+ return `./dist${urlBase}`;
257
+ }
258
+ /**
259
+ * Delete generated content pages whose source markdown is gone.
260
+ *
261
+ * `emitAstroPages` writes a page per source file but never removed one,
262
+ * so deleting a post left its `.astro` and `.md.ts` behind: the page kept
263
+ * building, kept deploying, and kept being linked from tag archives.
264
+ * Measured on the blog — three deleted posts were still live.
265
+ *
266
+ * Deleting files needs a tighter contract than writing them, so this is
267
+ * bounded four ways:
268
+ *
269
+ * ROOT Only the subtree this invocation owns —
270
+ * `src/pages/<basePath>/<section>` — never all of `src/pages`.
271
+ * `--section` exists so several converts land in one project;
272
+ * walking the whole tree meant each run deleted the previous
273
+ * run's section.
274
+ * FLOOR Never prune when this run generated nothing. A mis-set
275
+ * `--section`, a moved source dir or a config typo yields zero
276
+ * pages, and an unguarded prune would delete the entire site
277
+ * and exit 0.
278
+ * WHOLE Never prune when a page failed to generate. The page loop
279
+ * warns and continues, so a failed page is absent from
280
+ * `generatedPaths` and would look exactly like a deleted one —
281
+ * turning a transient serializer throw into a deletion.
282
+ * BANNER Only files carrying the `dogsbay convert` banner. That marks
283
+ * content pages and their `.md` mirrors; taxonomy and blog
284
+ * routes carry a `dogsbay site build` banner and a writer's own
285
+ * page carries none. This runs BEFORE those emitters, when last
286
+ * build's routes are present and unclaimed, so the distinction
287
+ * is what keeps them alive.
288
+ *
289
+ * KNOWN GAP: missing-translation stubs are not covered. They carry
290
+ * `// AUTO-GENERATED missing-translation stub.`, not the convert banner,
291
+ * so deleting `en/foo.md` leaves `/fr/foo` as a live route redirecting to
292
+ * a now-404 `/en/foo` — the same symptom, one hop away. Fixing it needs
293
+ * the stubs tracked in `generatedPaths` first; giving them the convert
294
+ * banner alone would make every build prune the stub it just wrote.
295
+ */
296
+ function pruneOrphanedPages(outputDir, root, generatedPaths) {
297
+ if (!existsSync(root))
298
+ return [];
299
+ const removed = [];
300
+ const walk = (dir) => {
301
+ let emptied = false;
302
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
303
+ const full = join(dir, entry.name);
304
+ if (entry.isDirectory()) {
305
+ walk(full);
306
+ continue;
307
+ }
308
+ // Only our two output kinds can be orphans; skip everything else
309
+ // rather than reading it.
310
+ if (!entry.name.endsWith(".astro") && !entry.name.endsWith(".md.ts"))
311
+ continue;
312
+ if (generatedPaths.has(relative(outputDir, full)))
313
+ continue;
314
+ // The banner is always the first line, so read a prefix rather than
315
+ // the whole file — a `.md.ts` mirror embeds an entire page's
316
+ // markdown, and a large corpus is thousands of them per build.
317
+ let head;
318
+ try {
319
+ const fd = openSync(full, "r");
320
+ try {
321
+ const buf = Buffer.alloc(256);
322
+ const n = readSync(fd, buf, 0, 256, 0);
323
+ head = buf.subarray(0, n).toString("utf-8");
324
+ }
325
+ finally {
326
+ closeSync(fd);
327
+ }
328
+ }
329
+ catch {
330
+ continue;
331
+ }
332
+ if (!head.includes("AUTO-GENERATED by `dogsbay convert`"))
333
+ continue;
334
+ rmSync(full, { force: true });
335
+ removed.push(relative(outputDir, full));
336
+ emptied = true;
337
+ }
338
+ // Only directories THIS pass emptied, and only via non-recursive
339
+ // rmdir: it fails atomically if anything landed in the meantime,
340
+ // which matters under `site dev` where the build re-runs while an
341
+ // editor and the dev server are live.
342
+ if (emptied && dir !== root) {
343
+ try {
344
+ rmdirSync(dir);
345
+ }
346
+ catch {
347
+ /* not empty, or raced — correct to leave it */
348
+ }
349
+ }
350
+ };
351
+ walk(root);
352
+ return removed;
353
+ }
354
+ /**
355
+ * Build the `excludeFromRoutes` predicate.
356
+ *
357
+ * Exported because more than one emitter has to agree on which pages
358
+ * become routes. The blog listed excluded pages as posts, producing index
359
+ * cards and Newer/Older links to pages that were never emitted — a 404
360
+ * from the site's own front page.
361
+ *
362
+ * A single-segment pattern matches ANY segment (fragment dirs symlink to
363
+ * many depths in AsciiBinder corpora); a multi-segment pattern matches as
364
+ * a leading path prefix, so `welcome/_internal` excludes only that tree.
365
+ */
366
+ export function makeSlugExcluder(excludeFromRoutes) {
367
+ const patterns = (excludeFromRoutes ?? []).map((p) => p.replace(/^\/+/, "").replace(/\/+$/, ""));
368
+ return (slug) => {
369
+ if (patterns.length === 0)
370
+ return false;
371
+ const segments = slug.split("/");
372
+ for (const pattern of patterns) {
373
+ if (pattern.includes("/")) {
374
+ if (slug === pattern || slug.startsWith(pattern + "/"))
375
+ return true;
376
+ }
377
+ else {
378
+ if (segments.includes(pattern))
379
+ return true;
380
+ }
381
+ }
382
+ return false;
383
+ };
384
+ }
100
385
  /**
101
386
  * Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
102
387
  *
@@ -147,18 +432,144 @@ function buildWranglerConfig(siteName, options) {
147
432
  lines.push(`}`);
148
433
  return lines.join("\n") + "\n";
149
434
  }
435
+ /**
436
+ * Build the GitHub Actions workflow YAML for `actions/deploy-pages`.
437
+ *
438
+ * The workflow:
439
+ * 1. Checks out the repo on every push to the default branch.
440
+ * 2. Installs node + pnpm at the Astro project directory, runs
441
+ * `dogsbay site build` (via `pnpm dlx` since Dogsbay is a
442
+ * global CLI, not a project dep), then `pnpm run build`
443
+ * (which runs `astro build && pagefind`).
444
+ * 3. Uploads `<astroDirRel>/dist` as a Pages artifact via
445
+ * `actions/upload-pages-artifact`.
446
+ * 4. Deploys via `actions/deploy-pages`.
447
+ *
448
+ * `astroDirRel` is the path of the Astro output relative to the
449
+ * repo root (typically "astro" — the default config has
450
+ * `output: ./astro`). Empty string is allowed when the project is
451
+ * flat (outputDir === projectDir); the workflow degrades naturally
452
+ * by omitting the `defaults: working-directory` block.
453
+ *
454
+ * Author edits — extra build steps, secrets, deploy gating — survive
455
+ * subsequent `dogsbay site build` runs because the file is written
456
+ * write-if-missing (see emitDeployArtifacts). To start over, delete
457
+ * the workflow file and rebuild.
458
+ *
459
+ * Note on basePath: GitHub Pages serves project sites at
460
+ * `https://<user>.github.io/<repo>/`. Authors who want their docs at
461
+ * the repo root should set `site.basePath: /<repo-name>` (or empty
462
+ * for user/org pages). The platform's basePath plumbing handles all
463
+ * URL rewriting; this workflow doesn't need to know about it.
464
+ */
465
+ function buildGitHubPagesWorkflow(astroDirRel) {
466
+ // When the Astro output IS the project root, drop the working-
467
+ // directory block and reference cache + artifact paths without a
468
+ // prefix. This is the flat-layout case (rare for site-init flows;
469
+ // common for `dogsbay convert` outputs that get manually wired up).
470
+ const isFlat = astroDirRel === "" || astroDirRel === ".";
471
+ const workingDirBlock = isFlat
472
+ ? ""
473
+ : `
474
+ defaults:
475
+ run:
476
+ working-directory: ${astroDirRel}`;
477
+ const cacheDep = isFlat
478
+ ? "pnpm-lock.yaml"
479
+ : `${astroDirRel}/pnpm-lock.yaml`;
480
+ const artifactPath = isFlat ? "dist" : `${astroDirRel}/dist`;
481
+ return `# Deploy to GitHub Pages.
482
+ # Generated by \`dogsbay site init --deploy=github-pages\` (or by
483
+ # adding \`deploy: { target: github-pages }\` to dogsbay.config.yml
484
+ # and running \`dogsbay site build\`). Author edits survive every
485
+ # subsequent build — the file is never overwritten. To regenerate
486
+ # from template, delete the file and rebuild.
487
+ #
488
+ # Repo settings: Settings → Pages → Source = "GitHub Actions".
489
+ name: Deploy to GitHub Pages
490
+
491
+ on:
492
+ push:
493
+ branches: [main]
494
+ workflow_dispatch:
495
+
496
+ permissions:
497
+ contents: read
498
+ pages: write
499
+ id-token: write
500
+
501
+ # Allow only one concurrent deployment, skipping queued runs.
502
+ concurrency:
503
+ group: pages
504
+ cancel-in-progress: false
505
+
506
+ jobs:
507
+ build:
508
+ runs-on: ubuntu-latest${workingDirBlock}
509
+ steps:
510
+ - uses: actions/checkout@v4
511
+
512
+ - uses: pnpm/action-setup@v4
513
+ with:
514
+ version: 10
515
+
516
+ - uses: actions/setup-node@v4
517
+ with:
518
+ # Astro 6 requires Node ^20.19.5 || >=22.12.0; pin 22 for
519
+ # forward-compat (Node 20 LTS is fine for Astro 5 sites
520
+ # but the Dogsbay scaffold targets Astro 6).
521
+ node-version: 22
522
+ cache: pnpm
523
+ cache-dependency-path: ${cacheDep}
524
+
525
+ - name: Install dependencies
526
+ run: pnpm install --frozen-lockfile
527
+
528
+ # \`dogsbay\` is a global CLI, not a project dep — pnpm dlx
529
+ # fetches it on demand. To pin a version, replace with e.g.
530
+ # \`pnpm dlx dogsbay@0.2.0-beta.18 site build\`.
531
+ - name: Build with Dogsbay
532
+ run: pnpm dlx dogsbay@beta site build
533
+
534
+ - name: Build Astro site
535
+ run: pnpm run build
536
+
537
+ - name: Upload Pages artifact
538
+ uses: actions/upload-pages-artifact@v3
539
+ with:
540
+ path: ${artifactPath}
541
+
542
+ deploy:
543
+ needs: build
544
+ runs-on: ubuntu-latest
545
+ environment:
546
+ name: github-pages
547
+ url: \${{ steps.deployment.outputs.page_url }}
548
+ steps:
549
+ - name: Deploy to GitHub Pages
550
+ id: deployment
551
+ uses: actions/deploy-pages@v4
552
+ `;
553
+ }
150
554
  /**
151
555
  * Construct the SiteConfig object that gets serialized to
152
556
  * `src/data/site.json`. Backward-compatible: existing fields keep their
153
557
  * empty-string defaults; new optional fields are omitted when undefined.
154
558
  */
155
- function buildSiteConfig(siteName, options) {
559
+ function buildSiteConfig(siteName, options, outputDir) {
156
560
  const cfg = {
157
561
  siteName,
158
562
  repoUrl: options.repoUrl || "",
159
- editUri: options.editUri || "blob/main/docs/",
160
- copyright: options.copyright || "",
161
563
  };
564
+ // Favicon href, so GENERATED routes can emit <link rel="icon"> too.
565
+ // Content pages get it computed per page; the blog index and the
566
+ // taxonomy routes are emitted by other emitters that never received it,
567
+ // so the site's own front page had no icon while its posts did.
568
+ if (outputDir) {
569
+ const href = faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase);
570
+ if (href)
571
+ cfg.favicon = href;
572
+ }
162
573
  if (options.siteUrl)
163
574
  cfg.siteUrl = options.siteUrl;
164
575
  if (options.description)
@@ -169,6 +580,21 @@ function buildSiteConfig(siteName, options) {
169
580
  cfg.twitterHandle = options.twitterHandle;
170
581
  if (options.themeColor)
171
582
  cfg.themeColor = options.themeColor;
583
+ // editUri + copyright follow the same omit-on-empty pattern as the
584
+ // optional fields above; previously they were always written
585
+ // (editUri defaulted to "blob/main/docs/", copyright to ""), which
586
+ // left zombie config in src/data/site.json. Downstream guards already
587
+ // treat empty / undefined as "don't render" so this is purely a tidy.
588
+ if (options.editUri)
589
+ cfg.editUri = options.editUri;
590
+ if (options.copyright)
591
+ cfg.copyright = options.copyright;
592
+ if (options.tagline)
593
+ cfg.tagline = options.tagline;
594
+ // Written explicitly, both ways: the default is ON, so an ABSENT key in
595
+ // site.json would be indistinguishable from "not yet regenerated" and the
596
+ // page template would have to guess. `false` is what the opt-out looks like.
597
+ cfg.poweredBy = options.poweredBy !== false;
172
598
  if (options.brandKeywords && options.brandKeywords.length > 0) {
173
599
  cfg.brandKeywords = options.brandKeywords;
174
600
  }
@@ -185,6 +611,14 @@ function buildSiteConfig(siteName, options) {
185
611
  if (options.tagLabels && Object.keys(options.tagLabels).length > 0) {
186
612
  cfg.tagLabels = options.tagLabels;
187
613
  }
614
+ if (options.tagAutoColor) {
615
+ cfg.tagAutoColor = true;
616
+ }
617
+ // Link-icon glyphs — only emitted when configured, so sites without
618
+ // the feature keep byte-identical site.json.
619
+ if (options.linkIcons && (options.linkIcons.external || options.linkIcons.internal)) {
620
+ cfg.linkIcons = options.linkIcons;
621
+ }
188
622
  if (options.taxonomyIndexPaths &&
189
623
  Object.keys(options.taxonomyIndexPaths).length > 0) {
190
624
  // Bake basePath into every emitted indexPath so consumers
@@ -195,15 +629,50 @@ function buildSiteConfig(siteName, options) {
195
629
  // values (`/by-type`, `/tags`, etc.) — basePath threading is
196
630
  // this emitter's responsibility, matching how `page.url` is
197
631
  // already prefixed in the taxonomy data file.
198
- const basePath = normalizeBasePath(options.basePath);
632
+ // Taxonomy index paths are baked into site.json so components
633
+ // (TagList, TaxonomyIndex, TypeBadge) emit correct hrefs at
634
+ // runtime. Use combined so these resolve under the host's
635
+ // served subpath.
636
+ const taxoPrefix = combinedPrefix(options);
199
637
  cfg.taxonomyIndexPaths = Object.fromEntries(Object.entries(options.taxonomyIndexPaths).map(([name, raw]) => [
200
638
  name,
201
- withBasePath(basePath, raw),
639
+ withBasePath(taxoPrefix, raw),
202
640
  ]));
203
641
  }
204
642
  if (options.taxonomyDisplay &&
205
643
  Object.keys(options.taxonomyDisplay).length > 0) {
206
- cfg.taxonomyDisplay = options.taxonomyDisplay;
644
+ // Flatten prefix labels into top-level entries so the
645
+ // search-facets resolver finds them after DocsLayout splits
646
+ // slash-nested tags into per-prefix Pagefind filter divs.
647
+ //
648
+ // Input:
649
+ // tags.prefixes = { difficulty: { label, color }, ... }
650
+ // tags.labels = { "difficulty/1": "Beginner", "difficulty/2": ... }
651
+ // Output additions (kept alongside the original `tags` entry):
652
+ // difficulty.labels = { "1": "Beginner", "2": ... }
653
+ //
654
+ // Resolver does `display[facetName].labels[value]` — facet name
655
+ // is now `difficulty`, value is `1`, → "Beginner". See
656
+ // plans/per-prefix-search-facets.md.
657
+ const flat = { ...options.taxonomyDisplay };
658
+ const tagsDisplay = options.taxonomyDisplay.tags;
659
+ if (tagsDisplay?.prefixes) {
660
+ for (const prefix of Object.keys(tagsDisplay.prefixes)) {
661
+ if (flat[prefix])
662
+ continue; // top-level entry wins
663
+ const leafLabels = {};
664
+ if (tagsDisplay.labels) {
665
+ const needle = `${prefix}/`;
666
+ for (const [slug, label] of Object.entries(tagsDisplay.labels)) {
667
+ if (slug.startsWith(needle)) {
668
+ leafLabels[slug.slice(needle.length)] = label;
669
+ }
670
+ }
671
+ }
672
+ flat[prefix] = { labels: leafLabels };
673
+ }
674
+ }
675
+ cfg.taxonomyDisplay = flat;
207
676
  }
208
677
  return cfg;
209
678
  }
@@ -232,6 +701,21 @@ export async function exportAstroProject(pages, nav, outputDir, options = {}) {
232
701
  const scaffoldSkipped = emitSiteScaffold(outputDir, siteName, options, writeScaffold);
233
702
  const { generated, outputNav } = await emitAstroPages(pages, nav, outputDir, options);
234
703
  emitConfigDerivedFiles(outputDir, options);
704
+ // The generated layout unconditionally imports
705
+ // `@/data/switcherMap.json` and the plugin wrapper stacks
706
+ // (`@/components/wrappers/*Stack.astro`); the exporter must
707
+ // guarantee both exist (empty axes / passthrough slots when there
708
+ // is no version data and no plugins) — previously only
709
+ // `dogsbay site build` emitted them, so every direct
710
+ // exportAstroProject consumer produced an unbuildable site
711
+ // (root-caused 2026-07-13 on the first comparison-site build).
712
+ emitSwitcherMap(pages, outputDir, options);
713
+ emitPluginRuntime({
714
+ outputDir,
715
+ clientModules: [],
716
+ styles: [],
717
+ clientConfigs: [],
718
+ });
235
719
  emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options);
236
720
  console.log(`Generated ${generated} static .astro pages`);
237
721
  if (alreadyScaffolded && !options.force && scaffoldSkipped > 0) {
@@ -268,7 +752,370 @@ function ensureDirectoryStructure(outputDir, basePath) {
268
752
  */
269
753
  export function emitSiteConfig(outputDir, siteName, options) {
270
754
  mkdirSync(join(outputDir, "src", "data"), { recursive: true });
271
- writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
755
+ writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options, outputDir), null, 2));
756
+ // Auto-generated companion to astro.config.mjs. Carries the
757
+ // site/base values derived from dogsbay.config.yml's site.url so
758
+ // changes propagate without --force-rescaffolding the main
759
+ // astro.config.mjs (which is scaffold-once and may have author
760
+ // edits — custom integrations, build hooks, etc.). The main
761
+ // config imports `dogsbaySite` + `dogsbayBase` from here.
762
+ // See plans/astro-base-from-site-url.md.
763
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
764
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
765
+ const dogsbaySiteJson = hasSiteUrl
766
+ ? JSON.stringify(origin ?? options.siteUrl)
767
+ : "undefined";
768
+ const dogsbayBaseJson = astroBase ? JSON.stringify(astroBase) : "undefined";
769
+ // Workers subpath mounts need the build output to live under the mount
770
+ // path too — see workersSubpathOutDir. Undefined for every other target
771
+ // and for host-root sites, so nothing changes for existing deploys.
772
+ const dogsbayOutDir = workersSubpathOutDir(options.deploy, astroBase);
773
+ const dogsbayOutDirJson = dogsbayOutDir ? JSON.stringify(dogsbayOutDir) : "undefined";
774
+ // build.inlineStylesheets — defaults to "auto" (Astro's own
775
+ // default; matches our docs-first bias since theme.css is ~120KB
776
+ // and externalizing it lets the file cache cross-page). Authors
777
+ // wanting "always" / "never" set it via dogsbay.config.yml's
778
+ // build.inlineStylesheets. See docs/perf-tuning.md.
779
+ const dogsbayInline = options.inlineStylesheets ?? "auto";
780
+ writeFileSync(join(outputDir, "astro.config.dogsbay.mjs"), [
781
+ "// Auto-generated by `dogsbay site build` — DO NOT EDIT.",
782
+ "// Tracks site.url + derived Astro base + build behaviour from",
783
+ "// dogsbay.config.yml. Edit dogsbay.config.yml and rebuild;",
784
+ "// edits to this file will be overwritten on the next build.",
785
+ `export const dogsbaySite = ${dogsbaySiteJson};`,
786
+ `export const dogsbayBase = ${dogsbayBaseJson};`,
787
+ `export const dogsbayInlineStylesheets = ${JSON.stringify(dogsbayInline)};`,
788
+ `export const dogsbayOutDir = ${dogsbayOutDirJson};`,
789
+ "",
790
+ ].join("\n"));
791
+ // dogsbay-mount.mjs — auto-generated, emitted only for subpath mounts.
792
+ //
793
+ // Two jobs that Astro cannot do itself once outDir moves to
794
+ // dist/<urlBase>/:
795
+ //
796
+ // clean `astro build` clears outDir, which is now the MOUNT dir,
797
+ // not dist/. Stale output therefore survives forever — a
798
+ // site migrating from the pre-mount layout keeps its old
799
+ // root-level index.html and _astro/, and changing site.url
800
+ // from /blog to /news strands the whole old tree. Both keep
801
+ // being uploaded, because `wrangler deploy` ships all of
802
+ // ./dist. dist/ is entirely derived, so clearing it is safe.
803
+ //
804
+ // NOTE robots.txt is deliberately NOT lifted. _headers is a CONFIG
805
+ // file that Workers reads from the assets root; robots.txt is a
806
+ // SERVED URL at /robots.txt, and on a subpath mount that path
807
+ // belongs to whichever worker owns the host root, not to this
808
+ // one. Lifting it would put a file at a path this worker's route
809
+ // never matches. The host-root worker's robots.txt is the
810
+ // authoritative one and must name this mount's sitemap — see
811
+ // plans/dogsbay-ai-site.md. The per-mount copy at
812
+ // <urlBase>/robots.txt is harmless but is NOT what crawlers read.
813
+ //
814
+ // finalize Workers Static Assets reads _headers AND _redirects only
815
+ // from the ROOT of assets.directory (./dist). Astro copies
816
+ // public/ into outDir, so both landed at dist/<urlBase>/,
817
+ // where they do nothing — the RFC 8288 Link header pointing
818
+ // agents at llms.txt silently stopped applying, and the raw
819
+ // files were served as assets. Move them up.
820
+ const mountHelperPath = join(outputDir, "dogsbay-mount.mjs");
821
+ if (!dogsbayOutDir && existsSync(mountHelperPath)) {
822
+ // No longer mounted (deploy target or site.url changed).
823
+ //
824
+ // Deleting it outright looks tidy and is wrong: package.json is
825
+ // scaffold-once, so the build script still runs
826
+ // `node ./dogsbay-mount.mjs clean && …` and the next `pnpm build` dies
827
+ // with ERR_MODULE_NOT_FOUND before Astro even starts. Downgrade it to
828
+ // a no-op instead, so the build keeps working while
829
+ // warnStaleMountBuildScript tells the author to update the script; only
830
+ // remove the file once nothing references it.
831
+ const pkgPath = join(outputDir, "package.json");
832
+ const stillReferenced = existsSync(pkgPath) && readFileSync(pkgPath, "utf-8").includes("dogsbay-mount.mjs");
833
+ if (stillReferenced) {
834
+ writeFileSync(mountHelperPath, [
835
+ "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
836
+ "// This site is no longer mounted on a subpath, but package.json",
837
+ "// still calls this script, so it stays as a no-op rather than",
838
+ "// breaking the build. Update the build script and it goes away.",
839
+ "process.exit(0);",
840
+ "",
841
+ ].join("\n"));
842
+ }
843
+ else {
844
+ rmSync(mountHelperPath, { force: true });
845
+ }
846
+ }
847
+ if (dogsbayOutDir) {
848
+ const mountDir = dogsbayOutDir.replace(/^\.\//, "");
849
+ writeFileSync(mountHelperPath, [
850
+ "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
851
+ "// Subpath-mount build steps. See project.ts (dogsbay-mount.mjs).",
852
+ 'import { rmSync, existsSync, mkdirSync, renameSync } from "node:fs";',
853
+ 'import { dirname, join, resolve } from "node:path";',
854
+ 'import { fileURLToPath } from "node:url";',
855
+ "",
856
+ `const MOUNT_DIR = ${JSON.stringify(mountDir)};`,
857
+ "// Resolve against THIS FILE, never process.cwd(). `pnpm build` runs",
858
+ "// with cwd set to the project, but this is an ordinary script sitting",
859
+ "// in the project root, and the natural CI/debug invocation",
860
+ "// `node path/to/site/dogsbay-mount.mjs clean` would otherwise",
861
+ "// recursively delete the CALLER's dist/.",
862
+ 'const ROOT = dirname(fileURLToPath(import.meta.url));',
863
+ 'const DIST = join(ROOT, "dist");',
864
+ 'const mode = process.argv[2];',
865
+ "",
866
+ 'if (mode === "clean") {',
867
+ ' // Derived output only — astro build + pagefind regenerate all of it.',
868
+ ' rmSync(DIST, { recursive: true, force: true });',
869
+ '} else if (mode === "finalize") {',
870
+ " // Cloudflare reads BOTH of these only from the root of the assets",
871
+ " // directory, and Astro copies public/ into the mount dir — so on a",
872
+ " // subpath mount they land at dist/<mount>/ and apply to nothing.",
873
+ " for (const name of [\"_headers\", \"_redirects\"]) {",
874
+ " const from = resolve(ROOT, `${MOUNT_DIR}/${name}`);",
875
+ " if (!existsSync(from)) continue;",
876
+ " const to = join(DIST, name);",
877
+ " mkdirSync(dirname(to), { recursive: true });",
878
+ " renameSync(from, to);",
879
+ " }",
880
+ '} else {',
881
+ ' console.error("dogsbay-mount.mjs: expected `clean` or `finalize`");',
882
+ " process.exit(1);",
883
+ "}",
884
+ "",
885
+ ].join("\n"));
886
+ }
887
+ warnStaleMountBuildScript(outputDir, dogsbayOutDir, astroBase);
888
+ // Migration check: pre-beta.20 sites have an astro.config.mjs that
889
+ // doesn't import the companion. Without the import, the values
890
+ // emitted above are unused and Astro's `base` stays unset — the
891
+ // exact bug this work was meant to close. Warn loudly, with the
892
+ // patch the user needs to apply, until astro.config.mjs is
893
+ // updated. We don't auto-patch because the file may have author
894
+ // edits (custom integrations, build hooks).
895
+ const astroConfigPath = join(outputDir, "astro.config.mjs");
896
+ if (existsSync(astroConfigPath)) {
897
+ const astroConfigSrc = readFileSync(astroConfigPath, "utf-8");
898
+ if (!astroConfigSrc.includes("astro.config.dogsbay.mjs")) {
899
+ console.warn([
900
+ "",
901
+ " ⚠ astro.config.mjs is missing the dogsbay companion import.",
902
+ " Without it, Astro's `base` config stays unset and assets",
903
+ " served from a host subpath (GH Pages project pages,",
904
+ " multi-mount Cloudflare) will 404.",
905
+ "",
906
+ " Add these two lines to astro.config.mjs:",
907
+ "",
908
+ ' import {',
909
+ ' dogsbaySite,',
910
+ ' dogsbayBase,',
911
+ ' dogsbayInlineStylesheets,',
912
+ ' dogsbayOutDir,',
913
+ ' } from "./astro.config.dogsbay.mjs";',
914
+ "",
915
+ " export default defineConfig({",
916
+ " ...(dogsbaySite ? { site: dogsbaySite } : {}),",
917
+ " ...(dogsbayBase ? { base: dogsbayBase } : {}),",
918
+ " ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
919
+ " build: { inlineStylesheets: dogsbayInlineStylesheets },",
920
+ " // ...your existing config...",
921
+ " });",
922
+ "",
923
+ " OR regenerate from template (overwrites your edits):",
924
+ " dogsbay site init . --scaffold-only --force",
925
+ "",
926
+ ].join("\n"));
927
+ }
928
+ else if (dogsbayOutDir && !astroConfigSrc.includes("dogsbayOutDir")) {
929
+ // The companion is imported, but this scaffold predates outDir
930
+ // support. astro.config.mjs is scaffold-once, so an existing site
931
+ // that moves to a Workers subpath mount keeps a config that emits
932
+ // to dist/ root while its wrangler route asks for dist<urlBase>/.
933
+ // Every request 404s and the HTML looks fine locally, so warn.
934
+ console.warn([
935
+ "",
936
+ " ⚠ astro.config.mjs does not apply `dogsbayOutDir`.",
937
+ ` This site deploys to Cloudflare Workers under "${dogsbayOutDir.replace("./dist", "")}",`,
938
+ " so the build output must live under that path or every",
939
+ " request 404s — Workers matches the full request pathname",
940
+ " and strips no prefix.",
941
+ "",
942
+ " Add to the companion import in astro.config.mjs:",
943
+ "",
944
+ " dogsbayOutDir,",
945
+ "",
946
+ " and to defineConfig:",
947
+ "",
948
+ " ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
949
+ "",
950
+ ].join("\n"));
951
+ }
952
+ }
953
+ }
954
+ /**
955
+ * The version spec a scaffolded site should pin `@dogsbay/*` at: this
956
+ * package's own version, since every `@dogsbay/*` package ships in lockstep
957
+ * (publish-beta.sh bumps them together).
958
+ */
959
+ function readDogsbayPeerVersion() {
960
+ try {
961
+ const here = dirname(fileURLToPath(import.meta.url));
962
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
963
+ // Caret on a stable version, exact pin on a prerelease.
964
+ //
965
+ // The reason given here used to be that "npm treats
966
+ // `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3`". That is FALSE.
967
+ // Checked against semver 7:
968
+ //
969
+ // ^0.2.0-beta.96 matches 0.2.0-beta.97 -> true
970
+ // ^0.2.0-beta.96 matches 0.2.0 -> true
971
+ // ^0.2.0-beta.96 matches 0.2.1-beta.1 -> false
972
+ //
973
+ // A caret DOES pick up later prereleases of the same
974
+ // major.minor.patch tuple. What it will not do is cross to a
975
+ // prerelease of a DIFFERENT tuple — probably what the original
976
+ // note was reaching for.
977
+ //
978
+ // The pin stays, for a reason that is actually true: a scaffolded
979
+ // site commits its generated `astro/` output, so an exact version
980
+ // guarantees the packages that produced the committed source are
981
+ // the ones CI installs. A floating range lets a lockfile refresh
982
+ // change a deployed site with nothing in the diff to show it.
983
+ //
984
+ // Our own site repos (dogsbay-ai-blog, dogsbay-ai-site) opt OUT
985
+ // of that and track the `beta` dist-tag instead, because we are
986
+ // the only consumers before release and the per-publish version
987
+ // bump is not worth the commit. Their .gitignore explains the
988
+ // trade and what to reverse at release.
989
+ return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
990
+ }
991
+ catch {
992
+ return "^0.1.0"; // last-resort fallback
993
+ }
994
+ }
995
+ /**
996
+ * Re-pin an existing site's `@dogsbay/*` dependencies to the building CLI's
997
+ * version. Safe to call on every build.
998
+ *
999
+ * `package.json` is scaffold-once, and for a long time this ran only from
1000
+ * `emitSiteScaffold`, which `site build` never calls — so the guard against
1001
+ * version drift was unreachable from the one command where drift happens.
1002
+ * Measured on openshift-docs-markdown: a site rebuilt weekly for months kept
1003
+ * installing the `@dogsbay/docs-layout` written at its first scaffold, so no
1004
+ * change to a runtime component ever reached it. Its sibling escaped only
1005
+ * because that workflow passes `--force`, which re-scaffolds.
1006
+ *
1007
+ * Returns the changes made, so the caller can report them: a build that
1008
+ * silently edits a tracked file is worse than the drift it fixes.
1009
+ */
1010
+ export function syncSiteDependencies(outputDir, options = {}) {
1011
+ const pkgPath = join(outputDir, "package.json");
1012
+ if (!existsSync(pkgPath))
1013
+ return [];
1014
+ // Versioned (published) style only — `workspace:*` and `file:` dev links
1015
+ // point at a checkout and must not be rewritten to a registry version.
1016
+ if (isInsideWorkspace(outputDir) || options.local)
1017
+ return [];
1018
+ return syncDogsbayDepVersions(pkgPath, readDogsbayPeerVersion());
1019
+ }
1020
+ /**
1021
+ * Re-pin every `@dogsbay/*` dependency in an existing package.json to
1022
+ * `version` (the building CLI's lockstep version), leaving non-Dogsbay deps
1023
+ * and any `workspace:` / `file:` dev links untouched. Returns the changes made.
1024
+ *
1025
+ * package.json is scaffold-once, so without this an upgraded CLI keeps building
1026
+ * against the lib versions written at first scaffold — the drift that strands a
1027
+ * site on a stale DocsLayout that can't render the new emitter's output.
1028
+ */
1029
+ function syncDogsbayDepVersions(pkgPath, version) {
1030
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1031
+ const changed = [];
1032
+ for (const field of ["dependencies", "devDependencies"]) {
1033
+ const deps = pkg[field];
1034
+ if (!deps)
1035
+ continue;
1036
+ for (const [name, spec] of Object.entries(deps)) {
1037
+ if (!name.startsWith("@dogsbay/"))
1038
+ continue;
1039
+ if (spec.startsWith("workspace:") || spec.startsWith("file:"))
1040
+ continue;
1041
+ if (spec !== version) {
1042
+ deps[name] = version;
1043
+ changed.push(`${name}: ${spec} -> ${version}`);
1044
+ }
1045
+ }
1046
+ }
1047
+ if (changed.length > 0) {
1048
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
1049
+ }
1050
+ return changed;
1051
+ }
1052
+ /**
1053
+ * Warn when the scaffold-once build script disagrees with the site's
1054
+ * current mount.
1055
+ *
1056
+ * Lives here, not in `emitSiteScaffold`, because that function only runs
1057
+ * on `site init` — a migration warning there would never fire for the
1058
+ * sites that need it. Called from `emitSiteConfig`, which runs on every
1059
+ * build.
1060
+ *
1061
+ * Both directions fail the same way: silently. Search stops working and
1062
+ * nothing else about the build looks wrong.
1063
+ */
1064
+ function warnStaleMountBuildScript(outputDir, outDir, urlBase) {
1065
+ const pkgJsonPath = join(outputDir, "package.json");
1066
+ if (!existsSync(pkgJsonPath))
1067
+ return;
1068
+ const pkgSrc = readFileSync(pkgJsonPath, "utf-8");
1069
+ if (!outDir) {
1070
+ // Moved OFF a mount: the script still clears dist/ and writes the
1071
+ // Pagefind bundle to the old mount dir, while the page asks elsewhere.
1072
+ if (!pkgSrc.includes("dogsbay-mount.mjs"))
1073
+ return;
1074
+ console.warn([
1075
+ "",
1076
+ " ⚠ package.json's build script still runs the subpath-mount steps,",
1077
+ " but this site is no longer mounted on a subpath. dogsbay-mount.mjs",
1078
+ " has been reduced to a no-op so the build keeps working, and the",
1079
+ " Pagefind bundle is still being written to the old mount directory,",
1080
+ " so Cmd+K will 404 until the script is updated.",
1081
+ "",
1082
+ " Change the build script back to:",
1083
+ "",
1084
+ " astro build && pagefind --site dist",
1085
+ "",
1086
+ ].join("\n"));
1087
+ return;
1088
+ }
1089
+ // Moved ONTO a mount, or BETWEEN mounts.
1090
+ //
1091
+ // Gating on the mere presence of "dogsbay-mount.mjs" missed the second
1092
+ // case: a site moving from /docs to /news regenerates MOUNT_DIR and
1093
+ // astro build writes dist/news, but the scaffold-once script still runs
1094
+ // `--output-path dist/docs/pagefind`. The bundle lands in a directory no
1095
+ // page references and Cmd+K silently dies — the exact failure this
1096
+ // warning exists for. Compare against the CURRENT mount instead.
1097
+ if (!pkgSrc.includes("pagefind --site dist"))
1098
+ return;
1099
+ const mountDir = outDir.replace(/^\.\//, "");
1100
+ if (pkgSrc.includes(`--output-path ${mountDir}/pagefind`))
1101
+ return;
1102
+ const wanted = `node ./dogsbay-mount.mjs clean && astro build && ` +
1103
+ `pagefind --site dist --output-path ${mountDir}/pagefind && ` +
1104
+ `node ./dogsbay-mount.mjs finalize`;
1105
+ console.warn([
1106
+ "",
1107
+ " ⚠ package.json's build script predates subpath-mount support.",
1108
+ ` This site is mounted at "${urlBase}", which needs three things`,
1109
+ " the old script does not do: clear dist/ (astro build only clears",
1110
+ " the mount dir, so stale output ships forever), write the Pagefind",
1111
+ " bundle under the mount (or Cmd+K 404s), and lift _headers to the",
1112
+ " assets root (or the llms.txt Link header stops applying).",
1113
+ "",
1114
+ " Change the build script to:",
1115
+ "",
1116
+ ` ${wanted}`,
1117
+ "",
1118
+ ].join("\n"));
272
1119
  }
273
1120
  export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
274
1121
  let scaffoldFilesSkipped = 0;
@@ -289,20 +1136,7 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
289
1136
  // peer spec for `@dogsbay/docs-layout`, `@dogsbay/ui`, etc. in
290
1137
  // the scaffolded astro/package.json. Fixes the "hardcoded
291
1138
  // ^0.1.0" trap that caused npm install to fail post-publish.
292
- const dogsbayPeerVersion = (() => {
293
- try {
294
- const here = dirname(fileURLToPath(import.meta.url));
295
- const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
296
- // Caret on a stable version, exact pin on a prerelease (npm
297
- // treats `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3` — the
298
- // prerelease semantics force exact-or-explicit-range. Pinning
299
- // prereleases avoids surprise resolves to incompatible betas.)
300
- return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
301
- }
302
- catch {
303
- return "^0.1.0"; // last-resort fallback
304
- }
305
- })();
1139
+ const dogsbayPeerVersion = readDogsbayPeerVersion();
306
1140
  const dogsbayDep = (name) => {
307
1141
  if (insideWs)
308
1142
  return "workspace:*";
@@ -312,12 +1146,28 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
312
1146
  };
313
1147
  // Per-deploy-target additions to package.json
314
1148
  const isCloudflare = options.deploy === "cloudflare-workers";
1149
+ const isGitHubPages = options.deploy === "github-pages";
315
1150
  const deployScripts = isCloudflare
316
1151
  ? { deploy: "pnpm build && wrangler deploy" }
317
1152
  : {};
318
1153
  const deployDevDeps = isCloudflare
319
1154
  ? { wrangler: "^4.0.0" }
320
1155
  : {};
1156
+ // `--local` outside a workspace also needs a hoisted node_modules.
1157
+ //
1158
+ // With pnpm's default isolated layout, `file:`-linked packages resolve their
1159
+ // own dependencies from their own store path, and Astro's transitives then
1160
+ // fail to resolve from the SITE (measured: "Rolldown failed to resolve import
1161
+ // @astrojs/internal-helpers/path"). Hoisting is what the linked-package case
1162
+ // wants and costs nothing here — the site is disposable derived output, not a
1163
+ // library whose dependency isolation anyone relies on.
1164
+ if (options.local && !insideWs && writeScaffold) {
1165
+ writeFileSync(join(outputDir, ".npmrc"), "node-linker=hoisted\n");
1166
+ }
1167
+ // Subpath mounts move the Astro output to dist/<urlBase>/, so Pagefind
1168
+ // must write its bundle there too — see the build script below.
1169
+ const scaffoldUrlBase = parseSiteUrl(options.siteUrl).urlBase;
1170
+ const scaffoldOutDir = workersSubpathOutDir(options.deploy, scaffoldUrlBase);
321
1171
  // package.json — scaffold-once. Maintainers add their own deps; the
322
1172
  // detection of "already scaffolded" actually keys off this file's
323
1173
  // existence, so it's also our sentinel.
@@ -331,20 +1181,45 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
331
1181
  dev: "astro dev",
332
1182
  // Pagefind runs after astro build; indexes the static dist/ output and
333
1183
  // writes `dist/pagefind/` for the search UI to load lazily on Cmd+K.
334
- build: "astro build && pagefind --site dist",
1184
+ //
1185
+ // Under a subpath mount the Astro output moves to dist/<urlBase>/,
1186
+ // but `--site` stays `dist` on purpose: Pagefind derives result
1187
+ // URLs from the indexed root, so indexing dist/ keeps them as
1188
+ // `/<urlBase>/page/`. Only the BUNDLE has to move, or the page
1189
+ // requests `/<urlBase>/pagefind/pagefind.js` (the pagefindUrl prop
1190
+ // is built from the combined prefix) and gets a 404 — a path that
1191
+ // is not even matched by the emitted `<host>/<urlBase>/*` route,
1192
+ // so Cmd+K would be unreachable by any URL.
1193
+ // Subpath mounts bracket the build with dogsbay-mount.mjs:
1194
+ // `clean` clears dist/ (astro build only clears the MOUNT dir,
1195
+ // so stale layouts would ship forever), and `finalize` lifts
1196
+ // _headers to the assets root, where Workers actually reads it.
1197
+ build: scaffoldOutDir
1198
+ ? `node ./dogsbay-mount.mjs clean && astro build && pagefind --site dist --output-path ${scaffoldOutDir.replace(/^\.\//, "")}/pagefind && node ./dogsbay-mount.mjs finalize`
1199
+ : "astro build && pagefind --site dist",
335
1200
  preview: "astro preview",
336
1201
  ...deployScripts,
337
1202
  },
338
1203
  dependencies: {
339
- astro: "^6.0.0",
340
- "@astrojs/sitemap": "^3.0.0",
1204
+ astro: "^7.0.4",
1205
+ // Sitemap is emitted directly by Dogsbay into
1206
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount
1207
+ // deploys don't collide at the host root. We deliberately
1208
+ // do NOT depend on @astrojs/sitemap (it hardcodes output to
1209
+ // dist/ root, which is what we're moving away from).
341
1210
  // Pagefind is invoked from the build script (see scripts.build above).
342
1211
  // Lives in dependencies (not devDependencies) so production builds
343
1212
  // include it; the produced search index is shipped statically and
344
1213
  // doesn't load this dep at runtime.
345
1214
  pagefind: "^1.4.0",
346
- tailwindcss: "^4.0.0",
347
- "@tailwindcss/vite": "^4.0.0",
1215
+ tailwindcss: "^4.3.2",
1216
+ // 4.3.x required on Astro 7: 4.2.x fails against vite 7.3
1217
+ // ("rollupOptions.input should not be an html file when
1218
+ // building for SSR" — surfaced on a fresh comparison-site
1219
+ // build 2026-07-13). The OLD ~4.2.2 pin guarded an Astro 6
1220
+ // rolldown incompatibility that no longer applies; the
1221
+ // monorepo apps build on 4.3.2.
1222
+ "@tailwindcss/vite": "^4.3.2",
348
1223
  "tailwind-variants": "^0.3.0",
349
1224
  shiki: "^4.0.0",
350
1225
  "@shikijs/transformers": "^4.0.0",
@@ -360,14 +1235,32 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
360
1235
  "@dogsbay/primitives": dogsbayDep("primitives"),
361
1236
  "@dogsbay/icons": dogsbayDep("icons"),
362
1237
  "@dogsbay/elements": dogsbayDep("elements"),
1238
+ // Transitive of `@dogsbay/primitives` (via
1239
+ // `@floating-ui/dom`). Listed at the top level because
1240
+ // npm doesn't hoist the second-level transitive when
1241
+ // `@dogsbay/primitives` is linked via `file:` (the
1242
+ // `--local` monorepo mode + the canary publish flow on
1243
+ // GH Pages CI both hit this). Surfaced during the
1244
+ // FastAPI import: Rollup failed with "Cannot resolve
1245
+ // @floating-ui/core" at astro build time.
1246
+ "@floating-ui/core": "^1.7.0",
363
1247
  },
364
- // Pin transitive Vite to 7. Vite 8 just released; Astro 6
365
- // peer-deps Vite 7 and prints a warning when 8 is hoisted.
366
- // Without this override npm picks up Vite 8 by default.
367
- // Drop this when Astro 7 ships and bumps its peer.
368
- overrides: {
369
- vite: "^7",
370
- },
1248
+ // `--local` outside a workspace needs these.
1249
+ //
1250
+ // A `file:` dependency is installed from the package's OWN manifest,
1251
+ // and every @dogsbay/* manifest declares its siblings as `workspace:*`
1252
+ // — a protocol that only resolves inside the workspace. So `file:`
1253
+ // alone fails at install with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND, naming
1254
+ // a transitive dependency the caller never wrote down. Overrides map
1255
+ // every sibling to the same checkout, which is what makes `--local`
1256
+ // installable rather than merely emitted.
1257
+ ...(options.local && !insideWs ? { pnpm: { overrides: monorepoOverrides() } } : {}),
1258
+ // NOTE deliberately NO vite override: Astro 7 pairs with
1259
+ // vite 8. The old `overrides: { vite: "^7" }` (an Astro 6
1260
+ // era guard) FORCED vite 7 under astro 7.0.x and broke every
1261
+ // fresh scaffold build ("rollupOptions.input should not be
1262
+ // an html file when building for SSR") — root-caused
1263
+ // 2026-07-13 on the first standalone comparison-site build.
371
1264
  ...(Object.keys(deployDevDeps).length > 0
372
1265
  ? { devDependencies: deployDevDeps }
373
1266
  : {}),
@@ -375,6 +1268,16 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
375
1268
  }
376
1269
  else {
377
1270
  scaffoldFilesSkipped++;
1271
+ // package.json is scaffold-once, but the @dogsbay/* libraries ship in
1272
+ // lockstep with the CLI. Re-sync their pins to the building CLI's version
1273
+ // on every build so upgrading the CLI (e.g. `npm i -g dogsbay@beta`) can't
1274
+ // leave the astro project on stale libraries whose older components don't
1275
+ // render the new emitter's markup. Versioned (published) style only —
1276
+ // `workspace:*` / `file:` dev links are left untouched.
1277
+ const synced = syncSiteDependencies(outputDir, options);
1278
+ if (synced.length > 0) {
1279
+ console.log(`Synced ${synced.length} @dogsbay/* dependency pin(s) to ${dogsbayPeerVersion}`);
1280
+ }
378
1281
  }
379
1282
  // wrangler.jsonc — scaffold-once. Bindings, secrets, custom routes
380
1283
  // belong here post-generation.
@@ -386,6 +1289,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
386
1289
  scaffoldFilesSkipped++;
387
1290
  }
388
1291
  }
1292
+ // GitHub Pages deploy artifacts — workflow + .nojekyll. The actual
1293
+ // emission lives in `emitDeployArtifacts` so site-build can also
1294
+ // call it on existing sites without going through scaffold (a user
1295
+ // adds `deploy: github-pages` to dogsbay.config.yml and reruns
1296
+ // `site build` to get the workflow). At scaffold-time we pass
1297
+ // forceOverwrite=writeScaffold so `--force` regenerates from
1298
+ // template; on regular builds it stays write-if-missing.
1299
+ if (isGitHubPages) {
1300
+ emitDeployArtifacts(outputDir, options, {
1301
+ forceOverwrite: writeScaffold,
1302
+ });
1303
+ }
389
1304
  // Generate astro.config.mjs
390
1305
  // `preserveSymlinks: true` is used with --local to pin local file: deps to
391
1306
  // their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
@@ -397,52 +1312,52 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
397
1312
  preserveSymlinks: true,
398
1313
  },`
399
1314
  : "";
400
- // Sitemap integration is conditional: requires an absolute site URL so
401
- // <loc> entries can be properly absolute. Without siteUrl, the sitemap
402
- // step is skipped (the import + integration call are simply omitted from
403
- // the generated config). Sitemap also filters out frontmatter-noindex pages.
404
- const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
405
- const sitemapImport = hasSiteUrl ? `import sitemap from "@astrojs/sitemap";\n` : "";
406
- // Strip any path component from site.url before emitting. The
407
- // config validator already rejects `site.url` containing a path
408
- // when `basePath` is non-empty (canonical URLs would double-count
409
- // the prefix); this is a defensive normalisation for the case
410
- // where the validator is bypassed or basePath is empty.
1315
+ // siteUrl gates absolute-URL emission (sitemap <loc> entries,
1316
+ // canonical tags). Without one, both are skipped — relative URLs
1317
+ // are still correct, the sitemap is just not generated.
411
1318
  //
412
- // Note: we deliberately do NOT emit Astro's `base:` field. With
413
- // the current file emission (pages live under
414
- // `src/pages/<basePath>/...`), adding `base` would cause Astro
415
- // to doubly-prefix every route. Switching to `base`-driven
416
- // routing is a separate refactor — see plans/configurable-base-path.md.
417
- let siteField = "";
418
- if (hasSiteUrl) {
419
- let originOnly;
420
- try {
421
- const u = new URL(options.siteUrl);
422
- originOnly = `${u.protocol}//${u.host}`;
423
- }
424
- catch {
425
- originOnly = options.siteUrl;
426
- }
427
- siteField = `\n site: ${JSON.stringify(originOnly)},`;
428
- }
429
- const integrationsField = hasSiteUrl ? `\n integrations: [sitemap()],` : "";
430
- // astro.config.mjs — scaffold-once. Maintainer adds custom integrations.
431
- // The plugin-aliases import is for the Dogsbay plugin API: each
432
- // build emits `astro.config.plugins.mjs` exporting `pluginAliases`,
433
- // a Vite alias map for `virtual:dogsbay-plugin-config/<id>` modules.
434
- // When no plugins use defineClientConfig the map is empty and the
435
- // spread is a no-op. See plans/plugin-api.md.
1319
+ // Sitemap is emitted directly by Dogsbay (see emitSitemapFiles)
1320
+ // into public/<basePath>/sitemap-*.xml. We deliberately do NOT
1321
+ // wire @astrojs/sitemap here; that integration hardcodes output
1322
+ // to dist/ root, breaking multi-mount deploys.
1323
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
1324
+ // site.url's path component (if any) becomes Astro's `base`. The
1325
+ // origin alone goes into `site`. This split lets dogsbay model
1326
+ // both axes independently:
1327
+ // - Astro's `base` (= urlBase) controls the URL prefix Astro
1328
+ // bakes into HTML asset references (`<basePath>/_astro/...`)
1329
+ // and the routes Astro generates from src/pages.
1330
+ // - dogsbay's basePath controls the filesystem layout
1331
+ // (`src/pages/<basePath>/...`).
1332
+ // The two compose at emit time — combining for nav hrefs,
1333
+ // sitemap, llms.txt, etc. See plans/astro-base-from-site-url.md.
1334
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
1335
+ // astro.config.mjs — scaffold-once, but the site/base values flow
1336
+ // through a separate auto-generated file (`astro.config.dogsbay.mjs`,
1337
+ // emitted unconditionally below) so dogsbay-derived values stay in
1338
+ // sync with `dogsbay.config.yml` even on existing sites where the
1339
+ // main config is preserved. Same pattern as
1340
+ // `astro.config.plugins.mjs` — the import line is the load-bearing
1341
+ // bit; the auto-file is what changes.
436
1342
  if (writeScaffold) {
437
1343
  writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
438
1344
  import tailwindcss from "@tailwindcss/vite";
439
- ${sitemapImport}import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
1345
+ import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
1346
+ import {
1347
+ dogsbaySite,
1348
+ dogsbayBase,
1349
+ dogsbayInlineStylesheets,
1350
+ dogsbayOutDir,
1351
+ } from "./astro.config.dogsbay.mjs";
440
1352
 
441
- export default defineConfig({${siteField}
1353
+ export default defineConfig({
1354
+ ...(dogsbaySite ? { site: dogsbaySite } : {}),
1355
+ ...(dogsbayBase ? { base: dogsbayBase } : {}),
1356
+ ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),
442
1357
  output: "static",
443
1358
  build: {
444
- inlineStylesheets: "always",
445
- },${integrationsField}
1359
+ inlineStylesheets: dogsbayInlineStylesheets,
1360
+ },
446
1361
  vite: {
447
1362
  plugins: [tailwindcss()],
448
1363
  resolve: {
@@ -464,6 +1379,9 @@ export default defineConfig({${siteField}
464
1379
  else {
465
1380
  scaffoldFilesSkipped++;
466
1381
  }
1382
+ // astro.config.dogsbay.mjs is emitted by emitSiteConfig (called
1383
+ // above and on every site build) so site/base values stay in
1384
+ // sync without a re-scaffold. See its definition for rationale.
467
1385
  // Always seed an empty astro.config.plugins.mjs so the import in
468
1386
  // astro.config.mjs resolves before the first plugin-emitting
469
1387
  // build. Subsequent builds replace it via emitPluginRuntime.
@@ -511,15 +1429,12 @@ export default defineConfig({${siteField}
511
1429
  else {
512
1430
  scaffoldFilesSkipped += 2; // global.css + theme.css
513
1431
  }
514
- // UI components — scaffold-once. Per shadcn pattern, copies are owned
515
- // by the project and meant to be edited. Re-running convert leaves the
516
- // existing tree alone.
517
- if (writeScaffold) {
518
- copyComponents(outputDir);
519
- }
520
- else {
521
- scaffoldFilesSkipped++;
522
- }
1432
+ // UI components. Per shadcn pattern, copies are owned by the project and
1433
+ // meant to be edited, so an existing component folder is never touched.
1434
+ // Missing ones are added on every build: the serializer starts emitting a
1435
+ // new component (Kbd, FileTree) in a release, and a site scaffolded before
1436
+ // it would otherwise fail with "failed to resolve @ui/kbd/Kbd.astro".
1437
+ copyComponents(outputDir);
523
1438
  return scaffoldFilesSkipped;
524
1439
  }
525
1440
  // ─── Tier 1: content ────────────────────────────────────────────────────
@@ -532,10 +1447,117 @@ export default defineConfig({${siteField}
532
1447
  * redirect. Returns the count of pages emitted and the merged nav for
533
1448
  * downstream consumers (llms.txt builder).
534
1449
  */
1450
+ /**
1451
+ * Emit `src/pages/404.astro`.
1452
+ *
1453
+ * The scaffold already sets `not_found_handling: "404-page"` in
1454
+ * wrangler.jsonc, but never emitted a page for it — so Workers had
1455
+ * nothing to serve and returned a bare 404 with a ZERO-BYTE body.
1456
+ * Correct status, no way to recover: an agent that mistypes a URL, or
1457
+ * follows a stale link, learns only that the page is gone.
1458
+ *
1459
+ * So this lists the routes worth trying next — the site index, the
1460
+ * sitemap, and llms.txt — which is what turns a dead end into a
1461
+ * redirectable one. The links are plain and visible rather than
1462
+ * decorative, because the reader here is as likely to be a crawler as
1463
+ * a person.
1464
+ *
1465
+ * Regenerated every build, so the links track the site. Skipped when
1466
+ * the author has removed the marker, matching every other emitted
1467
+ * page.
1468
+ */
1469
+ function emitNotFoundPage(outputDir, options) {
1470
+ const path = join(outputDir, "src", "pages", "404.astro");
1471
+ if (existsSync(path)) {
1472
+ const existing = readFileSync(path, "utf-8");
1473
+ if (!existing.includes("AUTO-GENERATED by dogsbay site build"))
1474
+ return;
1475
+ }
1476
+ const combined = combinedPrefix(options);
1477
+ const home = combined || "/";
1478
+ const sitemap = withBasePath(combined, "/sitemap-index.xml");
1479
+ const llms = withBasePath(combined, "/llms.txt");
1480
+ const showLlms = options.llmsTxt !== false;
1481
+ writeFileSync(path, `---
1482
+ // AUTO-GENERATED by dogsbay site build — safe to delete; will regenerate next build.
1483
+ // To customize this page, remove the marker line above (or modify the file
1484
+ // in any way that drops it) and dogsbay will leave your edits alone.
1485
+ import "@/styles/global.css";
1486
+ import DocsLayout from "@dogsbay/docs-layout/DocsLayout.astro";
1487
+ import type { SiteConfig } from "@dogsbay/types";
1488
+ import navData from "@/data/nav.json";
1489
+ import siteConfigData from "@/data/site.json";
1490
+ const siteConfig = siteConfigData as SiteConfig;
1491
+ const favicon = (siteConfigData as { favicon?: string }).favicon;
1492
+ ---
1493
+
1494
+ <DocsLayout
1495
+ siteName={siteConfig.siteName}
1496
+ siteTagline={siteConfig.tagline}
1497
+ title="Page not found"
1498
+ description="That page does not exist. Here is where to look instead."
1499
+ nav={navData as never}
1500
+ siteUrl={siteConfig.siteUrl}
1501
+ favicon={favicon}
1502
+ noindex={true}
1503
+ excludeFromSearch={true}
1504
+ ogType="website"
1505
+ >
1506
+ <article class="docs-prose">
1507
+ <h1>Page not found</h1>
1508
+ <p>There is no page at this address. It may have moved, or the link
1509
+ that brought you here may be out of date.</p>
1510
+ <p>Where to look instead:</p>
1511
+ <ul>
1512
+ <li><a href="${home}">Site index</a> — start here</li>
1513
+ <li><a href="${sitemap}">sitemap-index.xml</a> — every page on this site</li>${showLlms
1514
+ ? `
1515
+ <li><a href="${llms}">llms.txt</a> — the same index, for agents</li>`
1516
+ : ""}
1517
+ </ul>
1518
+ </article>
1519
+ </DocsLayout>
1520
+ `);
1521
+ }
1522
+ /**
1523
+ * Nav entries come from a project's own TOC files (toctrees, `_toc.yml`,
1524
+ * `myst.yml`, nav.yml), i.e. from content. An href with a scheme other than
1525
+ * http(s)/mailto/tel — `javascript:` above all — is dropped (the entry keeps
1526
+ * its label and children), so it cannot reach the sidebar, nav.json or a
1527
+ * page's previous/next link.
1528
+ */
1529
+ export function dropUnsafeNavHrefs(items) {
1530
+ return items.map((item) => {
1531
+ const out = { ...item };
1532
+ if (typeof out.href === "string") {
1533
+ // Browsers strip tabs, newlines and other C0 controls from a URL
1534
+ // (`java\tscript:` IS javascript:), so the scheme is tested without them.
1535
+ const probe = out.href.replace(/[\u0000-\u0020]/g, "");
1536
+ if (/^[a-z][a-z0-9+.-]*:/i.test(probe) && !/^(?:https?|mailto|tel):/i.test(probe))
1537
+ delete out.href;
1538
+ }
1539
+ if (out.children)
1540
+ out.children = dropUnsafeNavHrefs(out.children);
1541
+ return out;
1542
+ });
1543
+ }
535
1544
  export async function emitAstroPages(pages, nav, outputDir, options) {
1545
+ nav = dropUnsafeNavHrefs(nav);
536
1546
  const siteName = options.siteName || "Documentation";
1547
+ // basePath = filesystem layout prefix (where pages live under
1548
+ // src/pages/...). combined = the URL prefix HTML hrefs need
1549
+ // (urlBase + basePath). The two diverge whenever site.url has a
1550
+ // path component (GH Pages project pages, multi-mount Cloudflare).
537
1551
  const basePath = normalizeBasePath(options.basePath);
1552
+ const combined = combinedPrefix(options);
538
1553
  const baseSegments = basePathSegments(basePath);
1554
+ // Base Pagefind prefixes RESULT urls with: the served path dist/'s root
1555
+ // maps to. Same rule as docs-layout's pagefindBaseUrl(); see the comment
1556
+ // on the pagefindBaseUrl prop below. Computed once, not per page.
1557
+ const pageUrlBase = parseSiteUrl(options.siteUrl).urlBase;
1558
+ const searchBaseUrl = workersSubpathOutDir(options.deploy, pageUrlBase)
1559
+ ? "/"
1560
+ : `${pageUrlBase}/`;
539
1561
  // Ensure dirs exist (callers may invoke us without going through the
540
1562
  // full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
541
1563
  mkdirSync(join(outputDir, "src", "pages", ...baseSegments), { recursive: true });
@@ -555,35 +1577,261 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
555
1577
  // Remove existing entry for this section (full replace)
556
1578
  existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
557
1579
  && item.label?.toLowerCase() !== section.toLowerCase());
558
- const prefixedNav = prefixNavHrefs(nav, section, basePath);
1580
+ // Nav hrefs already carry the `combined` prefix (the importer
1581
+ // emits them via fileToHref(file, hrefPrefix=combined)).
1582
+ // prefixNavHrefs takes the existing prefix and weaves a section
1583
+ // segment into it.
1584
+ const prefixedNav = prefixNavHrefs(nav, section, combined);
559
1585
  const sectionLabel = siteName
560
1586
  || section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
561
1587
  existingNav.push({ label: sectionLabel, children: prefixedNav });
562
1588
  outputNav = existingNav;
563
1589
  }
1590
+ // Safety net: ensure every nav href carries the combined URL base,
1591
+ // regardless of which importer produced the nav. Idempotent — a nav
1592
+ // already prefixed at import time (dogsbay-md walking source paths) is
1593
+ // left untouched; a nav of pre-baked root-absolute hrefs (docusaurus
1594
+ // convert) gets the base it was missing. Keeps nav hrefs aligned with
1595
+ // the combined-prefixed `currentPath` so prev/next still matches.
1596
+ outputNav = prefixNavBaseHrefs(outputNav, combined);
564
1597
  writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(outputNav, null, 2));
1598
+ // Also publish nav.json under public/_dogsbay/ so the
1599
+ // client-mode <DocsNavClient /> can fetch it at runtime
1600
+ // (Astro copies public/ to dist/ as-is at build). The src/data
1601
+ // copy stays for build-time imports (pagination, prev/next
1602
+ // calculation in each page) — the public copy is the on-the-wire
1603
+ // copy that the browser ever sees. Kept identical (same bytes,
1604
+ // same shape); the duplicate is cheap (one file) and keeps the
1605
+ // build-time and runtime worlds cleanly separated. See
1606
+ // plans/client-rendered-nav.md.
1607
+ // …UNDER the basePath, because that is where the client fetches it
1608
+ // from (`${basePath}/_dogsbay/nav.json`) and because two sites mounted
1609
+ // on one origin — e.g. two release-comparison pairs — would otherwise
1610
+ // both claim `/_dogsbay/nav.json` and collide. Found by the comparison
1611
+ // acceptance suite: the nav silently never hydrated (404), so no page
1612
+ // marks rendered at all.
1613
+ const publicNavDir = join(outputDir, "public", ...basePathSegments(normalizeBasePath(options.basePath)), "_dogsbay");
1614
+ mkdirSync(publicNavDir, { recursive: true });
1615
+ writeFileSync(join(publicNavDir, "nav.json"), JSON.stringify(outputNav));
565
1616
  // Static assets (images etc.) — content-tier; always copy from the
566
1617
  // user's source dir. If they removed an asset, we want it gone here
567
1618
  // too. Skipped when sourceDir isn't supplied (programmatic callers
568
1619
  // that want pure page emission).
569
- if (options.sourceDir) {
1620
+ if (options.assetSourceDirs && options.assetSourceDirs.length > 0) {
1621
+ // Multi-source: copy EACH source's assets under its version/locale
1622
+ // URL prefix (public/<prefix>/...), matching the per-source ref
1623
+ // rewrite. Replaces the single-sourceDir copy, which only handled
1624
+ // the first source and never version-namespaced.
1625
+ for (const { dir, urlPrefix } of options.assetSourceDirs) {
1626
+ copyAssets(dir, outputDir, options.imageOptimization, urlPrefix);
1627
+ }
1628
+ }
1629
+ else if (options.sourceDir) {
570
1630
  copyAssets(options.sourceDir, outputDir, options.imageOptimization);
571
1631
  }
1632
+ // External asset mounts (e.g. a Docusaurus `static/` dir) → public/_assets/,
1633
+ // preserving internal structure so the importer's `/_assets/...` image refs
1634
+ // resolve. Used by the convert --to astro path; the dogsbay-md → site build
1635
+ // path instead lands these under content/_assets and copyAssets picks them up.
1636
+ // A mount may target an `_assets` subdir (`into`) and restrict itself to
1637
+ // media files (`mediaOnly`) — used for docs-co-located images where the
1638
+ // mounted dir is the docs tree itself.
1639
+ if (options.assetMounts) {
1640
+ for (const mount of options.assetMounts) {
1641
+ if (!existsSync(mount.dir))
1642
+ continue;
1643
+ const dest = mount.into
1644
+ ? join(outputDir, "public", mount.root ?? "_assets", mount.into)
1645
+ : join(outputDir, "public", mount.root ?? "_assets");
1646
+ if (mount.mediaOnly)
1647
+ copyMediaTree(mount.dir, dest);
1648
+ else
1649
+ cpSync(mount.dir, dest, { recursive: true });
1650
+ }
1651
+ }
572
1652
  let generated = 0;
1653
+ // Tracks whether a real root home page (`content/index.md` → slug
1654
+ // "index") was emitted. Drives the root-served-site redirect fallback
1655
+ // below: a corpus whose nav root is e.g. `welcome/index` (OpenShift)
1656
+ // has no `/` page, so without a redirect `/` 404s. See
1657
+ // plans/dir-index-slug-nav-drop.md.
1658
+ let rootIndexEmitted = false;
1659
+ const generatedPaths = new Set();
1660
+ let pageFailures = 0;
573
1661
  const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
574
1662
  const useImageOpt = options.imageOptimization ?? false;
575
- // hrefPrefix is the same string as basePath. rewriteHref handles the
576
- // empty-basePath case correctly: any link starting with "/" matches
577
- // the early-return guard, so root-relative links pass through
578
- // unrewritten when the site is served at host root.
579
- const hrefPrefix = basePath;
580
- for (const page of pages) {
1663
+ // hrefPrefix is the COMBINED prefix (urlBase + basePath) — what
1664
+ // rendered HTML hrefs need so internal links resolve under the
1665
+ // host's served subpath AND under the dogsbay basePath. For
1666
+ // simple host-apex deploys with basePath, urlBase is empty so
1667
+ // combined === basePath (back-compat). For GH Pages project pages
1668
+ // and multi-mount Cloudflare, combined adds the urlBase layer.
1669
+ const hrefPrefix = combined;
1670
+ // Route-exclusion gate. Two signals, either is enough:
1671
+ // - frontmatter `_fragment: true` (loader-stamped — the importer
1672
+ // knew this .md was include-only, not a navigable page)
1673
+ // - excludeFromRoutes match against the slug (project-declared,
1674
+ // in dogsbay.config.yml — for content under conventional
1675
+ // fragment dirs like `modules/`, `_attributes/`, `snippets/`)
1676
+ // Excluded pages stay on disk under content/ (so includes resolve)
1677
+ // but don't produce .astro / .md.ts routes. See plans/build-at-scale.md.
1678
+ //
1679
+ // Match semantics:
1680
+ // - A single-segment pattern (no `/`) matches ANY occurrence of
1681
+ // that segment in the slug path. So `modules` excludes both
1682
+ // `modules/foo` AND `welcome/modules/foo` AND
1683
+ // `drupal-build/openshift-enterprise/ai/includes/x`. This is
1684
+ // what AsciiBinder corpora want — fragment dirs symlink into
1685
+ // section dirs so the same `includes/` etc. appears at many
1686
+ // depths.
1687
+ // - A multi-segment pattern (contains `/`) matches as a leading
1688
+ // prefix (exact path-prefix). `welcome/_internal` excludes
1689
+ // only that specific path tree, not arbitrary `_internal/`
1690
+ // elsewhere.
1691
+ const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
1692
+ // Granularity (additive book view): roll each top-level nav group into one
1693
+ // `_book` page emitted alongside the topics. Kept out of nav; quarantined via
1694
+ // noindex + excludeFromSearch frontmatter so robots/sitemap/pagefind exclude
1695
+ // it, and — being local to this emitter — it never reaches the llms.txt builder
1696
+ // either. The `_book` page renders through the same path as any other page.
1697
+ // See plans/granularity-views.md.
1698
+ const bookPages = [];
1699
+ // topic slug → "<bookRoute>#<anchor>" — the per-topic "Read as single page"
1700
+ // button target. Empty unless granularity.book is on.
1701
+ const singlePageBySlug = new Map();
1702
+ // Emit a single-page (book) per nav node whose EFFECTIVE granularity resolves
1703
+ // to "yes". The annotation lives on each nav node (`singlePage`):
1704
+ // - "yes" → roll up this node's subtree into one page.
1705
+ // - "children" → make each direct child default to "yes" (chapters).
1706
+ // - "no" → no page (the default).
1707
+ // `granularity.book: true` is the legacy all-on switch: it seeds the top level
1708
+ // as "yes", so every top-level group rolls up — a node's own annotation still
1709
+ // wins. (This comment previously said "children", which is the same OUTCOME
1710
+ // but the wrong value, and disagreed with the config schema's wording.) A rollup whose estimated source exceeds maxSinglePageMB is SKIPPED with
1711
+ // a warning (no silent multi-minute page). See plans/granularity-views.md.
1712
+ const maxBookBytes = Math.round((options.granularity?.maxSinglePageMB ?? 3) * 1024 * 1024);
1713
+ const rootInheritYes = options.granularity?.book === true;
1714
+ const emitBook = (nav, label) => {
1715
+ const book = assembleBook(nav, pages, { hrefPrefix: combined, title: label });
1716
+ if (!book)
1717
+ return;
1718
+ const bytes = estimateTreeBytes(book.page.tree);
1719
+ if (bytes > maxBookBytes) {
1720
+ console.warn(`[dogsbay] granularity: skipping single-page "${label}" — ~${(bytes / 1048576).toFixed(1)} MB ` +
1721
+ `exceeds maxSinglePageMB (${options.granularity?.maxSinglePageMB ?? 3}). ` +
1722
+ `Mark it "single-page: children" in the nav to split it into chapters.`);
1723
+ return;
1724
+ }
1725
+ book.page.frontmatter = { ...book.page.frontmatter, excludeFromSearch: true };
1726
+ bookPages.push(book.page);
1727
+ const bookRoute = combined ? `${combined}/${book.page.slug}` : `/${book.page.slug}`;
1728
+ for (const [slug, anchor] of book.topicAnchors) {
1729
+ singlePageBySlug.set(slug, `${bookRoute}#${anchor}`);
1730
+ }
1731
+ };
1732
+ const walkBooks = (items, inheritYes) => {
1733
+ for (const item of items) {
1734
+ const effective = item.singlePage ?? (inheritYes ? "yes" : "no");
1735
+ if (effective === "yes" && item.children?.length) {
1736
+ emitBook(item.children, item.label);
1737
+ }
1738
+ if (item.children?.length)
1739
+ walkBooks(item.children, effective === "children");
1740
+ }
1741
+ };
1742
+ const hasAnnotation = (items) => items.some((i) => i.singlePage !== undefined || (i.children && hasAnnotation(i.children)));
1743
+ if (rootInheritYes || hasAnnotation(outputNav)) {
1744
+ walkBooks(outputNav, rootInheritYes);
1745
+ }
1746
+ const emitPages = bookPages.length ? [...pages, ...bookPages] : pages;
1747
+ // Blog mode: compute the post list ONCE, then read adjacency and
1748
+ // reading time per page out of it. Both derive from the same sorted
1749
+ // list, so a post's "next" and its neighbour's "prev" cannot disagree.
1750
+ // Book rollups are excluded — they are quarantined aggregates, not posts.
1751
+ const blogData = options.blog
1752
+ ? buildBlogData(pages, {
1753
+ basePath,
1754
+ section: options.section,
1755
+ excludeSlug: isExcludedSlug,
1756
+ // Post links must carry the COMBINED prefix — the path the host
1757
+ // serves under. With basePath alone, a site mounted at /blog
1758
+ // emitted /second-post/, which is outside its own route.
1759
+ urlPrefix: combinedPrefix(options),
1760
+ config: options.blog,
1761
+ })
1762
+ : undefined;
1763
+ const blogAdj = blogData ? blogAdjacency(blogData) : undefined;
1764
+ const blogMinutes = new Map(blogData ? blogData.posts.map((post) => [post.slug, post.readingMinutes]) : []);
1765
+ // Older versions' pages name the default version's page as canonical (src/canonical.ts).
1766
+ const isFragmentPage = (p) => p.fragment === true || p.frontmatter?._fragment === true;
1767
+ // The version served at the root (`path: ""`) and the other versions'
1768
+ // segments: the nav filter keeps the root version's bucket by excluding them.
1769
+ const rootVersion = rootVersionOf(options);
1770
+ const otherVersionSegments = rootVersion
1771
+ ? pageVersions(pages).filter((v) => v !== rootVersion)
1772
+ : [];
1773
+ const canonical = canonicalPlan(pages, {
1774
+ isEmitted: (p) => !isFragmentPage(p) && !isExcludedSlug(p.slug),
1775
+ siteUrl: options.siteUrl,
1776
+ basePath: combined,
1777
+ versions: options.versions,
1778
+ defaultVersion: options.defaultVersion,
1779
+ redirects: options.redirects,
1780
+ });
1781
+ // hreflang alternates, WebSite on home pages, BreadcrumbList from the nav (src/head-metadata.ts).
1782
+ const head = headMetadata(pages, outputNav, {
1783
+ siteUrl: options.siteUrl,
1784
+ basePath: combined,
1785
+ defaultLocale: options.defaultLocale,
1786
+ isEmitted: (p) => !isFragmentPage(p) && !isExcludedSlug(p.slug),
1787
+ indexable: (p) => {
1788
+ const c = canonical.get(p.slug);
1789
+ return !options.noindex && !c?.url && !c?.noindex && p.frontmatter?.noindex !== true;
1790
+ },
1791
+ });
1792
+ for (const page of emitPages) {
581
1793
  try {
1794
+ // Skip excluded pages before any expensive work (tree rewrite,
1795
+ // serialize, IO).
1796
+ // BOTH signals. `ExportPage.fragment` is the typed field
1797
+ // (`@dogsbay/types` format.ts) that format-docusaurus sets in structural
1798
+ // mode; `frontmatter._fragment` is the loader-stamped one. Checking only
1799
+ // the second emitted every Docusaurus partial as its own routable,
1800
+ // empty-titled, orphan page — duplicating content already inlined into
1801
+ // the pages that included it, and landing in sitemap and search.
1802
+ const isFragment = page.fragment === true ||
1803
+ (page.frontmatter && page.frontmatter._fragment === true);
1804
+ if (isFragment || isExcludedSlug(page.slug)) {
1805
+ continue;
1806
+ }
1807
+ // A page that declares `redirect:` is not a page — it is the old URL of
1808
+ // one. Emitting it produced a live, empty, titled page at the slug the
1809
+ // author was trying to retire, which is the bug this whole feature
1810
+ // exists for (plans/redirects-and-aliases.md). The rule is emitted by
1811
+ // emitConfigDerivedFiles; here we simply do not write the page.
1812
+ if (typeof page.redirect === "string" && page.redirect.trim()) {
1813
+ continue;
1814
+ }
1815
+ // content.html: sanitize filters raw HTML and directive attributes, as dogsbay-ssg
1816
+ // does (script links are refused by the serializer either way: isSafeHref).
1817
+ if (options.html === "sanitize") {
1818
+ const removed = new Set();
1819
+ page.tree = sanitizeTree(page.tree, (_code, message) => removed.add(message));
1820
+ for (const m of removed)
1821
+ console.warn(`[html] ${page.slug || "/"}: ${m}`);
1822
+ }
582
1823
  // Rewrite internal hrefs to match the output URL structure
583
1824
  rewriteTreeHrefs(page.tree, hrefPrefix);
1825
+ // Same for raw image srcs — Astro doesn't auto-prefix
1826
+ // `<img src="/_assets/...">` so we do it here. Block images
1827
+ // strip the prefix back off for the `imageMap[...]` lookup
1828
+ // (see paragraphToAstro in serialize.ts).
1829
+ rewriteTreeImageSrcs(page.tree, hrefPrefix);
584
1830
  const result = treeToAstro(page.tree, {
585
1831
  imageOptimization: useImageOpt,
586
1832
  codeBlockTitle: options.codeBlockTitle ?? true,
1833
+ imageCaptions: options.imageCaptions === "title",
1834
+ combinedPrefix: hrefPrefix,
587
1835
  });
588
1836
  const imageSetup = useImageOpt ? [
589
1837
  '',
@@ -597,11 +1845,35 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
597
1845
  ' imageMap[publicPath] = mod.default;',
598
1846
  '}',
599
1847
  ] : [];
600
- // Per-page meta from frontmatter
1848
+ // Per-page meta from frontmatter. When no description is authored,
1849
+ // derive one from the first prose paragraph so every page emits a
1850
+ // <meta name="description"> (Lighthouse flags pages without one).
1851
+ // See plans/auto-meta-description.md.
601
1852
  const fm = (page.frontmatter ?? {});
602
- const pageDescription = fm.description ?? "";
1853
+ // A writer-authored frontmatter description is meta-only (not in the body),
1854
+ // so it's safe to also render as a visible lede. A DERIVED description is
1855
+ // pulled FROM the body, so rendering it as a lede always duplicates content
1856
+ // (e.g. a page whose first paragraph sits under "## Big picture"). Keep the
1857
+ // two separate: derive for the <meta> tag, but only lede the authored one.
1858
+ const fmDescription = typeof fm.description === "string" && fm.description.trim().length > 0
1859
+ ? fm.description
1860
+ : undefined;
1861
+ const pageDescription = fmDescription || deriveDescription(page.tree) || "";
603
1862
  const pageOgImage = fm.ogImage ?? "";
604
- const pageNoindex = fm.noindex === true || fm.draft === true;
1863
+ // Noindex / nofollow are independent meta directives. Site-level
1864
+ // forces both bits site-wide (staging / compliance lockdown);
1865
+ // page frontmatter can ESCALATE either bit independently but
1866
+ // cannot opt out of a site-level lockdown. `draft: true` keeps
1867
+ // its existing role as a noindex shorthand. See
1868
+ // plans/site-level-robots-meta.md.
1869
+ const pageCanonical = canonical.get(page.slug);
1870
+ const pageHead = head.get(page.slug);
1871
+ const pageNoindex = options.noindex === true ||
1872
+ fm.noindex === true ||
1873
+ fm.draft === true ||
1874
+ pageCanonical?.noindex === true;
1875
+ const pageNofollow = options.nofollow === true ||
1876
+ fm.nofollow === true;
605
1877
  // Independent of noindex: pages can be excluded from in-site
606
1878
  // Pagefind search even when external SEs should index them
607
1879
  // (or vice versa). See DocsLayout's prop docs for the
@@ -620,6 +1892,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
620
1892
  const pageCategory = Array.isArray(pageMeta?.category)
621
1893
  ? pageMeta.category
622
1894
  : undefined;
1895
+ // "Last updated" date → DocsFooter. `updated` is the canonical PageMeta
1896
+ // field (parseMeta); also accept `lastUpdated` / `lastmod` for sitemap
1897
+ // parity. Plain string, rendered as-is.
1898
+ const rawLastUpdated = (typeof pageMeta?.updated === "string" && pageMeta.updated) ||
1899
+ (typeof fm.updated === "string" && fm.updated) ||
1900
+ (typeof fm.lastUpdated === "string" && fm.lastUpdated) ||
1901
+ (typeof fm.lastmod === "string" && fm.lastmod) ||
1902
+ null;
1903
+ // Canonical meta normalises to a full ISO datetime ("…T00:00:00.000Z").
1904
+ // Drop the time for display — these are dates, not timestamps. The footer
1905
+ // prettifies further; non-ISO strings pass through untouched.
1906
+ const pageLastUpdated = typeof rawLastUpdated === "string" && /^\d{4}-\d{2}-\d{2}T/.test(rawLastUpdated)
1907
+ ? rawLastUpdated.slice(0, 10)
1908
+ : rawLastUpdated;
623
1909
  // Custom-taxonomy values lifted from frontmatter into
624
1910
  // `meta.taxonomies` by the importer (see `parseMeta` in
625
1911
  // `@dogsbay/types`). Surfaced to DocsLayout so it can emit one
@@ -633,10 +1919,15 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
633
1919
  : undefined;
634
1920
  // `tagsIndexPath` flows to `<TagList>` for chip hrefs
635
1921
  // (`${indexPath}/${tag}/`). Caller passes the raw config value
636
- // (e.g. `/tags`); we bake basePath here so chips resolve under
637
- // the configured site base. Without the prefix, every tag chip
638
- // 404s on any site with `site.basePath` set.
639
- const tagsIndexPath = withBasePath(basePath, options.tagsIndexPath ?? "/tags");
1922
+ // (e.g. `/tags`); we bake the COMBINED prefix (urlBase from
1923
+ // site.url's path + basePath) here so chips resolve under both
1924
+ // the host's served subpath AND the dogsbay basePath. With
1925
+ // basePath alone, chips 404 on GH Pages project deploys
1926
+ // (basePath="" + non-empty urlBase) — same shape as the
1927
+ // typeBadgeHref / statusBadgeHref composition in DocsLayout,
1928
+ // which already reads combined-prefixed values out of
1929
+ // siteConfig.taxonomyIndexPaths (baked in buildSiteConfig).
1930
+ const tagsIndexPath = withBasePath(combined, options.tagsIndexPath ?? "/tags");
640
1931
  // Auto-lede detection. If the markdown body doesn't already
641
1932
  // start with an H1 / leading paragraph, we ask DocsLayout to
642
1933
  // render the frontmatter title / description at the top of
@@ -650,7 +1941,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
650
1941
  page.title.length > 0;
651
1942
  const autoLede = !isRedirect &&
652
1943
  !bodyHasLede &&
653
- pageDescription.length > 0;
1944
+ fmDescription !== undefined;
654
1945
  // Per-page LLM action UI. Site-wide config in
655
1946
  // `options.llmActions`; per-page opt-out via frontmatter
656
1947
  // `llmActions: false`. Skipped on redirect pages (no real
@@ -668,12 +1959,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
668
1959
  // available via other means. The "Open in" deep links work
669
1960
  // regardless of mirror availability — agents that can't fetch
670
1961
  // the page just see the URL in their chat.
1962
+ // pageHrefBase uses combined (urlBase + basePath) so the URL
1963
+ // resolves correctly when the host serves dist/ at a subpath
1964
+ // (GH Pages project page, multi-mount Cloudflare).
671
1965
  const pageHrefBase = section
672
- ? (basePath ? `${basePath}/${section}/${page.slug}` : `/${section}/${page.slug}`)
673
- : (basePath ? `${basePath}/${page.slug}` : `/${page.slug}`);
1966
+ ? (combined ? `${combined}/${section}/${page.slug}` : `/${section}/${page.slug}`)
1967
+ : (combined ? `${combined}/${page.slug}` : `/${page.slug}`);
674
1968
  const pageMdHref = `${pageHrefBase}.md`;
675
- const pageMdAbsoluteUrl = options.siteUrl
676
- ? options.siteUrl.replace(/\/$/, "") + pageMdHref
1969
+ // For absolute URLs (the "Copy as MD" deep link), use the
1970
+ // origin (no path) + the full combined path; siteUrl alone
1971
+ // would double-include the urlBase since pageHrefBase already
1972
+ // contains it.
1973
+ const { origin } = parseSiteUrl(options.siteUrl);
1974
+ const pageMdAbsoluteUrl = origin
1975
+ ? origin + pageMdHref
677
1976
  : pageMdHref;
678
1977
  // Markdown body for the Copy button. Reuse the same serializer
679
1978
  // that produces the .md mirror so what the user copies matches
@@ -689,7 +1988,13 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
689
1988
  // These pages compose their own internal columns and need the
690
1989
  // full inset width; the prose-readable cap and the right TOC
691
1990
  // would just steal pixels. See plans/openapi-builtin.md.
692
- const wideLayout = (page.tree ?? []).some((n) => n && n.type === "endpoint");
1991
+ const wideLayout = apiPageLayout(page.tree).wide;
1992
+ // TOC placement (DocsLayout `toc` prop). Default "top" — the
1993
+ // expandable "On this page" disclosure that frees the right rail
1994
+ // for plugin content (e.g. Ask AI). "rail" keeps the classic
1995
+ // right-hand TOC; in that mode the rail belongs to the TOC, so the
1996
+ // RightRail plugin region (slot + stack) is not emitted.
1997
+ const tocMode = options.toc ?? "top";
693
1998
  const pageLines = [
694
1999
  "---",
695
2000
  "// AUTO-GENERATED by `dogsbay convert` — do not edit.",
@@ -709,13 +2014,22 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
709
2014
  // Always exists; passthrough <slot /> when no plugin
710
2015
  // contributes a wrapper for the MarkdownContent slot.
711
2016
  'import MarkdownContentStack from "@/components/wrappers/MarkdownContentStack.astro";',
2017
+ // RightRailStack — hosts plugin content (e.g. an Ask AI panel) in
2018
+ // the freed right rail via DocsLayout's `right-rail` named slot.
2019
+ // Only imported/used when the rail isn't the classic TOC's.
2020
+ ...(tocMode !== "rail"
2021
+ ? ['import RightRailStack from "@/components/wrappers/RightRailStack.astro";']
2022
+ : []),
712
2023
  'const siteConfig = siteConfigData as SiteConfig;',
713
2024
  ...result.imports,
714
2025
  ...imageSetup,
715
2026
  "",
716
- `const headings = ${JSON.stringify(page.headings || [])};`,
2027
+ `const headings = ${pageLiteral(page.headings || [])};`,
717
2028
  `const nav = navData;`,
718
- `const currentPath = "${buildCurrentPath(basePath, section, page.slug)}";`,
2029
+ // currentPath uses combined so it matches nav.json hrefs
2030
+ // (which are also combined-prefixed). getPagination compares
2031
+ // them as strings; mismatched prefixes break prev/next.
2032
+ `const currentPath = "${buildCurrentPath(combined, section, page.slug)}";`,
719
2033
  // Filter nav to the current (locale, version) bucket
720
2034
  // before computing prev/next — without this, pagination
721
2035
  // walks the global nav and a "Next" link can leak from
@@ -724,24 +2038,50 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
724
2038
  // this keeps prev/next aligned with what the reader
725
2039
  // sees.
726
2040
  `const _navForPagination = filterNavByAxis(nav as any[], {`,
727
- ` basePath: ${JSON.stringify(basePath || "/docs")},`,
728
- ` version: ${JSON.stringify(page.multiSource?.version ?? null)} ?? undefined,`,
729
- ` locale: ${JSON.stringify(page.multiSource?.locale ?? null)} ?? undefined,`,
2041
+ // Pass the REAL basePath ("" for a root-served site) — coercing ""
2042
+ // → "/docs" makes the filter look for `/docs/…` bucket hrefs that
2043
+ // don't exist, emptying the pagination nav so prev/next vanish on
2044
+ // every page of a root-served multi-source site.
2045
+ ` basePath: ${pageLiteral(basePath ?? "")},`,
2046
+ ` namespace: ${pageLiteral(page.multiSource?.namespace ?? null)} ?? undefined,`,
2047
+ ` version: ${pageLiteral(page.multiSource?.version ?? null)} ?? undefined,`,
2048
+ ` locale: ${pageLiteral(page.multiSource?.locale ?? null)} ?? undefined,`,
2049
+ ...(rootVersion
2050
+ ? [
2051
+ ` rootVersion: ${pageLiteral(rootVersion)},`,
2052
+ ` versionSegments: ${pageLiteral(otherVersionSegments)},`,
2053
+ ]
2054
+ : []),
730
2055
  `});`,
731
- `const { prev, next } = getPagination(currentPath, _navForPagination);`,
732
- `const title = ${JSON.stringify(page.title)};`,
733
- `const description = ${JSON.stringify(pageDescription)} || undefined;`,
734
- `const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
735
- `const noindex = ${JSON.stringify(pageNoindex)};`,
736
- `const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
737
- `const pageTags = ${JSON.stringify(pageTags ?? null)};`,
738
- `const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
739
- `const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
740
- `const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
741
- `const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
742
- `const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
743
- `const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
744
- `const llmActionsProps = ${JSON.stringify(llmActionsEnabled
2056
+ // Blog adjacency is CHRONOLOGICAL, not navigational. getPagination
2057
+ // walks NavItem[] — sidebar order — and a blog has no sidebar; its
2058
+ // neighbours are "the post before/after this one in time". Emitting
2059
+ // the pair as literals also means the page does no adjacency work at
2060
+ // runtime.
2061
+ ...(blogAdj
2062
+ ? [
2063
+ `const prev = ${pageLiteral(blogAdj.get(page.slug)?.prev ?? null)} ?? undefined;`,
2064
+ `const next = ${pageLiteral(blogAdj.get(page.slug)?.next ?? null)} ?? undefined;`,
2065
+ ]
2066
+ : [`const { prev, next } = getPagination(currentPath, _navForPagination);`]),
2067
+ `const title = ${pageLiteral(page.title)};`,
2068
+ `const description = ${pageLiteral(pageDescription)} || undefined;`,
2069
+ `const ogImage = ${pageLiteral(pageOgImage)} || undefined;`,
2070
+ `const noindex = ${pageLiteral(pageNoindex)};`,
2071
+ `const canonicalUrl = ${pageLiteral(pageCanonical?.url ?? null)} ?? undefined;`,
2072
+ `const alternates = ${pageLiteral(pageHead?.alternates ?? [])};`,
2073
+ `const breadcrumbJsonLd = ${pageLiteral(pageHead?.breadcrumbJsonLd ?? null)} ?? undefined;`,
2074
+ `const nofollow = ${pageLiteral(pageNofollow)};`,
2075
+ `const excludeFromSearch = ${pageLiteral(pageExcludeFromSearch)};`,
2076
+ `const pageTags = ${pageLiteral(pageTags ?? null)};`,
2077
+ `const pageStatus = ${pageLiteral(pageStatus ?? null)};`,
2078
+ `const pageType = ${pageLiteral(pageTypeStr ?? null)};`,
2079
+ `const pageAudience = ${pageLiteral(pageAudience ?? null)};`,
2080
+ `const pageCategory = ${pageLiteral(pageCategory ?? null)};`,
2081
+ `const pageTaxonomies = ${pageLiteral(pageTaxonomies ?? null)};`,
2082
+ `const tagsIndexPath = ${pageLiteral(tagsIndexPath)};`,
2083
+ `const lastUpdated = ${pageLiteral(pageLastUpdated)} || undefined;`,
2084
+ `const llmActionsProps = ${pageLiteral(llmActionsEnabled
745
2085
  ? {
746
2086
  enabled: true,
747
2087
  providers: options.llmActions?.providers,
@@ -757,6 +2097,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
757
2097
  "",
758
2098
  `<DocsLayout`,
759
2099
  ` siteName={siteConfig.siteName}`,
2100
+ ` siteTagline={siteConfig.tagline}`,
760
2101
  ` title={title}`,
761
2102
  ` nav={nav as any[]}`,
762
2103
  ` headings={headings}`,
@@ -770,6 +2111,11 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
770
2111
  ` twitterHandle={siteConfig.twitterHandle || undefined}`,
771
2112
  ` themeColor={siteConfig.themeColor || undefined}`,
772
2113
  ` noindex={noindex}`,
2114
+ ` nofollow={nofollow}`,
2115
+ ` canonicalUrl={canonicalUrl}`,
2116
+ ` alternates={alternates}`,
2117
+ ` breadcrumbJsonLd={breadcrumbJsonLd}`,
2118
+ ...(pageHead?.home ? [` ogType="website"`] : []),
773
2119
  ` excludeFromSearch={excludeFromSearch}`,
774
2120
  ` plausibleDomain={siteConfig.plausible?.domain}`,
775
2121
  ` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
@@ -779,31 +2125,105 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
779
2125
  ` tagsIndexPath={tagsIndexPath}`,
780
2126
  ` tagPrefixes={siteConfig.tagPrefixes}`,
781
2127
  ` tagLabels={siteConfig.tagLabels}`,
2128
+ ` tagAutoColor={siteConfig.tagAutoColor}`,
782
2129
  ` taxonomyIndexPaths={siteConfig.taxonomyIndexPaths}`,
783
2130
  ` taxonomyDisplay={siteConfig.taxonomyDisplay}`,
784
2131
  ` status={pageStatus ?? undefined}`,
785
2132
  ` pageType={pageType ?? undefined}`,
786
2133
  ` audience={pageAudience ?? undefined}`,
787
2134
  ` category={pageCategory ?? undefined}`,
2135
+ ` lastUpdated={lastUpdated}`,
788
2136
  ` taxonomies={pageTaxonomies ?? undefined}`,
789
2137
  ` autoH1={${autoH1}}`,
790
2138
  ` autoLede={${autoLede}}`,
791
2139
  ` llmActions={llmActionsProps}`,
792
- ` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
2140
+ ` linkIcons={siteConfig.linkIcons}`,
2141
+ // Default ON: an absent key means yes, `false` means no.
2142
+ ` poweredBy={siteConfig.poweredBy !== false}`,
2143
+ ` multiSource={${pageLiteral(page.multiSource ?? null)} ?? undefined}`,
793
2144
  ` switcherMap={switcherMapData}`,
794
- ` basePath={${JSON.stringify(basePath || "/docs")}}`,
2145
+ // basePath here is the COMBINED URL prefix (urlBase from
2146
+ // site.url's path + dogsbay basePath). DocsLayout uses it
2147
+ // for switcher links, the footer llms.txt link, and the
2148
+ // <head> alternate link — all three need the full URL
2149
+ // prefix the host actually serves under. Empty string is
2150
+ // valid (root-served sites with no urlBase or basePath);
2151
+ // don't fall back to "/docs" — that would 404 for those.
2152
+ ` basePath={${pageLiteral(combined)}}`,
2153
+ // navMode — controls whether DocsLayout server-renders the
2154
+ // full sidebar nav tree per page (`ssr-full`) or emits a
2155
+ // client-hydrated placeholder (`client`, default). The
2156
+ // client mode shrinks per-page HTML dramatically at scale.
2157
+ // See plans/client-rendered-nav.md.
2158
+ ` navMode={${pageLiteral(options.navMode ?? "client")}}`,
2159
+ // Pagefind index URL — must include the combined prefix or
2160
+ // the loader 404s on subpath-mounted deploys. The pagefind
2161
+ // CLI writes to <astroOutput>/dist/pagefind/ which Astro
2162
+ // serves under its `base` (= urlBase); dogsbay's basePath
2163
+ // adds the second prefix layer. Empty combined → `/pagefind/`.
2164
+ ` pagefindUrl={${pageLiteral(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
2165
+ // Base Pagefind prefixes RESULT urls with. The index stores URLs
2166
+ // relative to dist/, so the base is whatever served path dist/'s
2167
+ // root maps to. A Workers subpath mount builds pages INTO
2168
+ // dist/<mount>/, so its index already carries the mount → "/".
2169
+ // GitHub Pages builds to dist/ and Astro adds `base` at serve time
2170
+ // → "<urlBase>/". Pagefind's default (the folder above the bundle)
2171
+ // is right only for the second, and doubled the mount on the first:
2172
+ // /dogsbay-xml-docs/dogsbay-xml-docs/…, measured. Same rule as
2173
+ // docs-layout's pagefindBaseUrl() — keep the two in step.
2174
+ ` pagefindBaseUrl={${pageLiteral(searchBaseUrl)}}`,
2175
+ // Favicon — composed with combined prefix so the
2176
+ // <link rel="icon"> resolves on subpath-mounted deploys.
2177
+ //
2178
+ // Emitted ONLY when the file actually exists in public/. The
2179
+ // scaffold ships no favicon, so an unconditional link meant every
2180
+ // generated site advertised an icon that 404s. Authors drop
2181
+ // `public/favicon.ico` (or `.svg`) in their Astro project and the
2182
+ // link appears on the next build; DocsLayout takes `false` to mean
2183
+ // "emit no <link rel=icon>".
2184
+ ` favicon={${pageLiteral(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
795
2185
  ` wideLayout={${wideLayout}}`,
2186
+ // Blog chrome — sidebar dropped, byline on. Every other prop above
2187
+ // is unchanged, which is what keeps the blog visually continuous
2188
+ // with the docs rather than a lookalike.
2189
+ ...(options.blog
2190
+ ? [
2191
+ ` chrome="blog"`,
2192
+ ` author={${pageLiteral(page.meta?.author ?? null)} ?? undefined}`,
2193
+ ` publishedDate={${pageLiteral(page.meta?.[options.blog.dateField] ?? null)} ?? undefined}`,
2194
+ ` updatedDate={${pageLiteral(page.meta?.updated ?? null)} ?? undefined}`,
2195
+ ` readingMinutes={${blogMinutes.get(page.slug) ?? 1}}`,
2196
+ // Series position, derived in buildBlogData from publication
2197
+ // order — never hand-typed in prose, which goes stale the
2198
+ // moment another part lands.
2199
+ // String-guarded like buildBlogData's firstString: a
2200
+ // `heroImage: {src, alt}` object would otherwise reach
2201
+ // DocsLayout's `heroImage?: string` prop and render as
2202
+ // src="[object Object]".
2203
+ ` heroImage={${pageLiteral(typeof (page.frontmatter ?? {}).heroImage === "string"
2204
+ ? (page.frontmatter ?? {}).heroImage
2205
+ : null)} ?? undefined}`,
2206
+ ]
2207
+ : []),
2208
+ ` toc={${pageLiteral(tocMode)}}`,
796
2209
  `>`,
2210
+ // RightRail region — assigned to DocsLayout's `right-rail` named
2211
+ // slot. Inert (empty passthrough) until a plugin contributes a
2212
+ // RightRail wrapper. Omitted in "rail" mode (TOC owns the rail).
2213
+ ...(tocMode !== "rail"
2214
+ ? [` <RightRailStack slot="right-rail" />`]
2215
+ : []),
797
2216
  ` <MarkdownContentStack>`,
798
- wideLayout
799
- ? result.body.split("\n").map((l) => ` ${l}`).join("\n")
800
- : ` <article class="docs-prose">`,
801
- ...(wideLayout
802
- ? []
803
- : [
804
- result.body.split("\n").map((l) => ` ${l}`).join("\n"),
805
- ` </article>`,
806
- ]),
2217
+ // Every page body gets the docs-prose typography wrapper — wide
2218
+ // (endpoint) pages included. Mixed prose+endpoint pages (MDX
2219
+ // imports) previously lost ALL typography because the wrapper
2220
+ // was skipped; API-region internals opt out via the `dba-api`
2221
+ // marker on ApiLayout + the `:not(.dba-api *)` guards in the
2222
+ // generated .docs-prose rules.
2223
+ ` <article class="docs-prose">`,
2224
+ ...singlePageLinkLines(singlePageBySlug.get(page.slug), 6),
2225
+ result.body.split("\n").map((l) => ` ${l}`).join("\n"),
2226
+ ` </article>`,
807
2227
  ` </MarkdownContentStack>`,
808
2228
  `</DocsLayout>`,
809
2229
  ];
@@ -828,6 +2248,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
828
2248
  mkdirSync(dirname(pagePath), { recursive: true });
829
2249
  writeFileSync(pagePath, pageLines.join("\n") + "\n");
830
2250
  generated++;
2251
+ if (!section && page.slug === "index")
2252
+ rootIndexEmitted = true;
2253
+ generatedPaths.add(relative(outputDir, pagePath));
831
2254
  // Companion .md endpoint for content negotiation. Prerendered, so
832
2255
  // it's served as a static asset at runtime — no Worker overhead.
833
2256
  //
@@ -843,6 +2266,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
843
2266
  mkdirSync(dirname(mdEndpointPath), { recursive: true });
844
2267
  const endpointBody = buildMdEndpoint(page, sourceRel);
845
2268
  writeFileSync(mdEndpointPath, endpointBody);
2269
+ generatedPaths.add(relative(outputDir, mdEndpointPath));
846
2270
  // Sibling-level mirror for the index page under a non-empty
847
2271
  // basePath. baseSegments is empty when basePath is empty
848
2272
  // (root-served sites); in that case the index slug is just
@@ -855,22 +2279,141 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
855
2279
  const siblingPath = join(outputDir, "src", "pages", ...parentSegments, `${lastSeg}.md.ts`);
856
2280
  mkdirSync(dirname(siblingPath), { recursive: true });
857
2281
  writeFileSync(siblingPath, endpointBody);
2282
+ generatedPaths.add(relative(outputDir, siblingPath));
858
2283
  }
859
2284
  }
860
2285
  }
861
2286
  catch (err) {
862
2287
  console.warn(`Warning: failed to generate ${page.slug}: ${err.message}`);
2288
+ // A failed page is absent from generatedPaths, which is
2289
+ // indistinguishable from a deleted one. Pruning is suppressed for
2290
+ // the whole run rather than deleting last build's still-good copy.
2291
+ pageFailures++;
863
2292
  }
864
2293
  }
865
2294
  // Generate index redirect at src/pages/index.astro — sends `/` to the
866
- // first nav href. Skipped when basePath is empty: with root-served
867
- // sites, src/pages/index.astro is the actual home page (not a
868
- // redirect target), and writing a redirect would clobber it.
869
- if (basePath !== "") {
870
- const firstHref = findFirstNavHref(nav, basePath);
871
- writeFileSync(join(outputDir, "src", "pages", "index.astro"), `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
872
- }
873
- return { generated, outputNav };
2295
+ // first nav href. Two cases need it:
2296
+ // - basePath set: `/` (host apex) isn't a content page, so redirect
2297
+ // into the basePath subtree.
2298
+ // - basePath empty BUT no root home page emitted (no
2299
+ // `content/index.md`): the corpus's entry point is a nested page
2300
+ // (e.g. OpenShift's `welcome/index`), so `/` would 404 without a
2301
+ // redirect. Only emit here for non-section builds and never when a
2302
+ // real root index page exists (writing a redirect would clobber it).
2303
+ // Use the base-prefixed nav (the same hrefs written to nav.json), not the
2304
+ // raw input `nav` — otherwise importers whose nav starts root-relative (e.g.
2305
+ // the docusaurus convert path) emit a redirect that drops the URL base and
2306
+ // sends `/` to the host root instead of into the site's subpath.
2307
+ // ─── Per-version subtree index redirects ─────────────────────────
2308
+ // A version subtree (`/3.29/…`, `/latest/…`) owns no page of its
2309
+ // own, so `/latest/` 404s while `/latest/about` resolves — and
2310
+ // `/latest/` is exactly what a reader types, what a switcher link
2311
+ // truncates to, and what a "latest" alias is FOR. Give each bucket
2312
+ // the same contract the site root gets: redirect to its first nav
2313
+ // entry.
2314
+ const subtreeRoots = axisSubtreeRoots(pages);
2315
+ // A subtree's entries leave out the deeper subtrees nested in it: with the
2316
+ // default version at the root (`path: ""`) and a locale axis, `/en/` is
2317
+ // that version's subtree and `/en/v3/` another version's.
2318
+ const nestedIn = (slugPrefix) => [...subtreeRoots.keys()]
2319
+ .filter((q) => slugPrefix === "" ? q !== "" : q.startsWith(`${slugPrefix}/`))
2320
+ .map((q) => `${combined}/${q}`);
2321
+ for (const slugPrefix of subtreeRoots.keys()) {
2322
+ const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`], nestedIn(slugPrefix));
2323
+ if (!target)
2324
+ continue;
2325
+ const subtreeIndexPath = join(pagesDir, section ? section : "", slugPrefix, "index.astro");
2326
+ const subtreeIndexRel = relative(outputDir, subtreeIndexPath);
2327
+ // A real page at this route wins — never clobber content.
2328
+ if (generatedPaths.has(subtreeIndexRel))
2329
+ continue;
2330
+ mkdirSync(dirname(subtreeIndexPath), { recursive: true });
2331
+ writeFileSync(subtreeIndexPath,
2332
+ // Marked as ours so a later emitter (the blog index claims this
2333
+ // exact route) can tell a generated stub from a page a writer
2334
+ // wrote. Without the marker the stub looked hand-authored and
2335
+ // silently blocked the blog index from ever being emitted.
2336
+ `---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${target}");\n---\n`);
2337
+ generatedPaths.add(subtreeIndexRel);
2338
+ }
2339
+ // The site root targets the DEFAULT version's first entry, not the
2340
+ // first entry overall. Nav is assembled oldest-version-first, so
2341
+ // `findFirstNavHref` alone sends every reader arriving at `/` into
2342
+ // the OLDEST docs — the opposite of what `defaultVersion` declares.
2343
+ const defaultVersionPrefixes = options.defaultVersion
2344
+ ? [...subtreeRoots]
2345
+ .filter(([, version]) => version === options.defaultVersion)
2346
+ .map(([slugPrefix]) => `${combined}/${slugPrefix}`)
2347
+ : [];
2348
+ // With the default version at the root (`path: ""`) and no locale or
2349
+ // namespace, it has no subtree: its entries are the ones outside every
2350
+ // other version's subtree.
2351
+ const firstHref = (defaultVersionPrefixes.length > 0
2352
+ ? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes, [...subtreeRoots]
2353
+ .filter(([q]) => defaultVersionPrefixes.some((d) => `${combined}/${q}`.startsWith(`${d}/`)))
2354
+ .map(([q]) => `${combined}/${q}`))
2355
+ : rootVersion && subtreeRoots.size > 0
2356
+ ? findFirstNavHrefUnder(outputNav, [combined], nestedIn(""))
2357
+ : undefined) ?? findFirstNavHref(outputNav, basePath);
2358
+ const needsRootRedirect = basePath !== "" ||
2359
+ (!section && !rootIndexEmitted && firstHref !== "");
2360
+ if (needsRootRedirect) {
2361
+ const indexPath = join(outputDir, "src", "pages", "index.astro");
2362
+ writeFileSync(indexPath, `---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${firstHref}");\n---\n`);
2363
+ generatedPaths.add(relative(outputDir, indexPath));
2364
+ }
2365
+ // See pruneOrphanedPages for why each of these guards exists. They are
2366
+ // cheap; the failure they prevent is deleting a site.
2367
+ if (generatedPaths.size === 0) {
2368
+ // Zero pages is a broken input, not an empty site.
2369
+ }
2370
+ else if (pageFailures > 0) {
2371
+ console.warn(` Skipped pruning: ${pageFailures} page(s) failed to generate, so an ` +
2372
+ `orphan cannot be told from a failure.`);
2373
+ }
2374
+ else {
2375
+ const pruned = pruneOrphanedPages(outputDir, join(pagesDir, section ?? ""), generatedPaths);
2376
+ if (pruned.length > 0) {
2377
+ console.log(` Pruned ${pruned.length} page(s) whose source was deleted: ` +
2378
+ `${pruned.slice(0, 3).join(", ")}${pruned.length > 3 ? ", …" : ""}`);
2379
+ }
2380
+ }
2381
+ // After the prune, so it is never mistaken for an orphan, and after
2382
+ // outputNav exists so its links match the site that was just built.
2383
+ emitNotFoundPage(outputDir, options);
2384
+ return { generated, outputNav, generatedPaths };
2385
+ }
2386
+ /**
2387
+ * Copy each passthrough `.astro` source to its computed output path.
2388
+ * Aborts with a clear error if the destination is already in
2389
+ * `generatedPaths` (a generated page from `emitAstroPages` would
2390
+ * silently overwrite the hand-authored file otherwise).
2391
+ */
2392
+ export function emitPassthroughAstroPages(copies, outputDir, generatedPaths) {
2393
+ if (copies.length === 0)
2394
+ return { copied: 0 };
2395
+ // Collision detection — a generated page and a passthrough page
2396
+ // would write to the same file. Refuse to overwrite; tell the
2397
+ // author exactly which two files conflict.
2398
+ const collisions = [];
2399
+ for (const copy of copies) {
2400
+ if (generatedPaths.has(copy.outputRelPath)) {
2401
+ collisions.push(copy.outputRelPath);
2402
+ }
2403
+ }
2404
+ if (collisions.length > 0) {
2405
+ throw new Error(`Passthrough Astro page collides with a generated page:\n` +
2406
+ collisions.map((c) => ` - ${c}`).join("\n") + "\n" +
2407
+ `Rename the .astro source or remove the colliding entry from nav.yml.`);
2408
+ }
2409
+ let copied = 0;
2410
+ for (const copy of copies) {
2411
+ const dest = join(outputDir, copy.outputRelPath);
2412
+ mkdirSync(dirname(dest), { recursive: true });
2413
+ copyFileSync(copy.sourceAbs, dest);
2414
+ copied++;
2415
+ }
2416
+ return { copied };
874
2417
  }
875
2418
  // ─── Tier 1: config-derived ─────────────────────────────────────────────
876
2419
  // Files driven entirely by config + flags. Always regenerated; site
@@ -881,10 +2424,116 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
881
2424
  * from `options`. Maintainers wanting custom Disallow rules layer
882
2425
  * them at the CDN level.
883
2426
  */
884
- export function emitConfigDerivedFiles(outputDir, options) {
2427
+ export function emitConfigDerivedFiles(outputDir, options, pages = []) {
885
2428
  mkdirSync(join(outputDir, "public"), { recursive: true });
886
2429
  const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
887
2430
  writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
2431
+ emitRedirects(outputDir, options, pages);
2432
+ }
2433
+ /**
2434
+ * Redirects, per deploy target.
2435
+ *
2436
+ * Emitted from the CONFIG-derived tier, not the agent-readiness tier, on
2437
+ * purpose: `_headers` lives in the latter behind `options.llmsTxt !== false`,
2438
+ * so turning off llms.txt silently drops the Link header with it. Redirects
2439
+ * must not inherit that coupling — a site with no llms.txt still needs its
2440
+ * old URLs to resolve.
2441
+ *
2442
+ * | deploy target | mechanism | status |
2443
+ * |----------------------|------------------------------|--------|
2444
+ * | `cloudflare-workers` | `_redirects` | real |
2445
+ * | anything else | meta-refresh stub pages | 200 |
2446
+ *
2447
+ * The stub is a real downgrade and says so at build time rather than being
2448
+ * presented as equivalent.
2449
+ */
2450
+ function emitRedirects(outputDir, options, pages) {
2451
+ const prefix = combinedPrefix(options);
2452
+ const { rules, errors, warnings } = collectRedirects({
2453
+ pages: pages.map((p) => ({
2454
+ slug: p.slug,
2455
+ redirect: p.redirect,
2456
+ frontmatter: p.frontmatter,
2457
+ // Front matter paths are relative to the page's language and version (#096): collectRedirects needs its original slug.
2458
+ multiSource: p.multiSource,
2459
+ })),
2460
+ config: options.redirects,
2461
+ prefix,
2462
+ });
2463
+ if (errors.length > 0) {
2464
+ throw new Error(`redirects: ${errors.length} problem(s) — refusing to build.\n - ${errors.join("\n - ")}`);
2465
+ }
2466
+ for (const w of warnings)
2467
+ console.warn(` Warning: ${w}`);
2468
+ if (rules.length === 0)
2469
+ return;
2470
+ const publicDir = join(outputDir, "public");
2471
+ if (options.deploy === "cloudflare-workers") {
2472
+ // Workers Static Assets reads _redirects from the ROOT of the assets
2473
+ // directory. On a subpath mount `public/` lands at dist/<mount>/, so the
2474
+ // file has to be lifted afterwards — dogsbay-mount.mjs finalize does it,
2475
+ // the same way it already lifts _headers.
2476
+ writeFileSync(join(publicDir, "_redirects"), renderRedirectsFile(rules));
2477
+ console.log(` Redirects: ${rules.length} rule(s) → public/_redirects`);
2478
+ return;
2479
+ }
2480
+ for (const rule of rules) {
2481
+ const rel = stubPathForRule(rule, prefix);
2482
+ const dest = join(publicDir, ...rel.split("/"));
2483
+ mkdirSync(dirname(dest), { recursive: true });
2484
+ writeFileSync(dest, renderMetaRefreshPage(rule, options.siteUrl));
2485
+ }
2486
+ console.warn(` Redirects: ${rules.length} rule(s) emitted as meta-refresh stub pages. ` +
2487
+ `These answer 200, not 301 — ${options.deploy === "github-pages" ? "GitHub Pages" : "this deploy target"} ` +
2488
+ `has no redirect mechanism. Set deploy.target: cloudflare-workers for real status codes.`);
2489
+ }
2490
+ /**
2491
+ * Per-deploy-target artifact emission.
2492
+ *
2493
+ * Called from `emitSiteScaffold` (with `forceOverwrite=writeScaffold`
2494
+ * so `--force` regenerates from template) and from `dogsbay site
2495
+ * build` (with `forceOverwrite=false` so an existing site can adopt
2496
+ * a deploy target by editing config and rebuilding — the missing
2497
+ * artifact gets created on the next build).
2498
+ *
2499
+ * Emit policy is the union: write when forced OR when the file is
2500
+ * missing. Author edits to e.g. the workflow YAML survive every
2501
+ * regular build.
2502
+ *
2503
+ * Currently handles `github-pages` (workflow + .nojekyll). The
2504
+ * existing `cloudflare-workers` artifacts (wrangler.jsonc + package
2505
+ * scripts) stay in the scaffold-only path because they overlap with
2506
+ * scaffold-only files (package.json scripts, devDependencies). A
2507
+ * future refactor could fold them in here too.
2508
+ */
2509
+ export function emitDeployArtifacts(outputDir, options, opts = { forceOverwrite: false }) {
2510
+ if (options.deploy === "github-pages") {
2511
+ // GitHub reads workflows from <repo-root>/.github/workflows/, NOT
2512
+ // from inside subdirectories. Use projectDir (the repo root) for
2513
+ // the workflow file; fall back to outputDir when unset (flat
2514
+ // `dogsbay convert` flows where the Astro project IS the repo).
2515
+ const projectDir = options.projectDir ?? outputDir;
2516
+ // Path of the Astro output relative to the project root. Used by
2517
+ // the workflow's working-directory + cache-dependency-path so
2518
+ // pnpm install / pnpm run build target the right place. Empty
2519
+ // string when outputDir === projectDir (flat layout).
2520
+ const astroDirRel = relative(projectDir, outputDir).replace(/\\/g, "/");
2521
+ const workflowPath = join(projectDir, ".github", "workflows", "deploy.yml");
2522
+ if (opts.forceOverwrite || !existsSync(workflowPath)) {
2523
+ mkdirSync(dirname(workflowPath), { recursive: true });
2524
+ writeFileSync(workflowPath, buildGitHubPagesWorkflow(astroDirRel));
2525
+ }
2526
+ // .nojekyll — must exist in the deployed artifact root so GH
2527
+ // Pages skips Jekyll's `_underscored-paths` filter (Astro's
2528
+ // `_astro/` chunk dir gets eaten otherwise). Lives inside the
2529
+ // Astro project's `public/` so it's copied into `dist/` at
2530
+ // build time.
2531
+ const nojekyllPath = join(outputDir, "public", ".nojekyll");
2532
+ mkdirSync(dirname(nojekyllPath), { recursive: true });
2533
+ if (opts.forceOverwrite || !existsSync(nojekyllPath)) {
2534
+ writeFileSync(nojekyllPath, "");
2535
+ }
2536
+ }
888
2537
  }
889
2538
  /**
890
2539
  * Emit `src/data/switcherMap.json` describing per-page
@@ -902,7 +2551,10 @@ export function emitConfigDerivedFiles(outputDir, options) {
902
2551
  * baseline page in a multi-version site).
903
2552
  */
904
2553
  export function emitSwitcherMap(pages, outputDir, options) {
905
- const basePath = normalizeBasePath(options.basePath);
2554
+ // Switcher URLs use combined so the link the dropdown emits
2555
+ // resolves under the host's served subpath (GH Pages project
2556
+ // pages, multi-mount Cloudflare).
2557
+ const combined = combinedPrefix(options);
906
2558
  const dataDir = join(outputDir, "src", "data");
907
2559
  const outPath = join(dataDir, "switcherMap.json");
908
2560
  // Detect axis activation by inspecting the data the loader
@@ -925,8 +2577,9 @@ export function emitSwitcherMap(pages, outputDir, options) {
925
2577
  writeFileSync(outPath, JSON.stringify({ versions: [], locales: [], byLogicalKey: {} }, null, 2));
926
2578
  return;
927
2579
  }
2580
+ const rootVersion = rootVersionOf(options);
928
2581
  const versions = composeAxisHeader(options.versions ?? [], versionsSeen, options.defaultVersion,
929
- /* allowEol */ true);
2582
+ /* allowEol */ true).map((v) => (v.id === rootVersion ? { ...v, root: true } : v));
930
2583
  const locales = composeAxisHeader(options.locales ?? [], localesSeen, options.defaultLocale,
931
2584
  /* allowEol */ false);
932
2585
  // Per-logical-key list of variants — every (locale, version)
@@ -941,7 +2594,7 @@ export function emitSwitcherMap(pages, outputDir, options) {
941
2594
  const variant = {
942
2595
  ...(ms.locale !== undefined ? { locale: ms.locale } : {}),
943
2596
  ...(ms.version !== undefined ? { version: ms.version } : {}),
944
- url: `${basePath}/${page.slug}`,
2597
+ url: `${combined}/${page.slug}`,
945
2598
  };
946
2599
  if (!byLogicalKey[key])
947
2600
  byLogicalKey[key] = [];
@@ -962,11 +2615,22 @@ function composeAxisHeader(declared, seen, defaultId, allowEol) {
962
2615
  const out = [];
963
2616
  const seenInDeclared = new Set();
964
2617
  for (const d of declared) {
965
- if (seen.has(d.id)) {
2618
+ // A `hidden` version is the NUMBER an alias stands in for — the
2619
+ // Docusaurus `lastVersion` + `path: "latest"` pair. Its pages are
2620
+ // served under the alias, so no page ever carries its id and `seen`
2621
+ // will never contain it. Admit it anyway: it is label-only metadata
2622
+ // (the switcher filters hidden rows out of the dropdown), and it is
2623
+ // the sole source of the "3.32 (latest)" label. Gating it on `seen`
2624
+ // silently degrades that row to a bare "latest".
2625
+ const aliasedNumber = allowEol && d.hidden === true;
2626
+ if (seen.has(d.id) || aliasedNumber) {
966
2627
  out.push({
967
2628
  id: d.id,
968
2629
  ...(d.label !== undefined ? { label: d.label } : {}),
2630
+ // eol + prerelease + hidden are version-only marks (allowEol gates them).
969
2631
  ...(allowEol && d.eol === true ? { eol: true } : {}),
2632
+ ...(allowEol && d.prerelease === true ? { prerelease: true } : {}),
2633
+ ...(allowEol && d.hidden === true ? { hidden: true } : {}),
970
2634
  ...(defaultId === d.id ? { default: true } : {}),
971
2635
  });
972
2636
  seenInDeclared.add(d.id);
@@ -1007,61 +2671,50 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
1007
2671
  const defaultLocale = options.defaultLocale;
1008
2672
  if (!defaultLocale)
1009
2673
  return;
1010
- const knownLocales = new Set();
1011
- for (const p of pages) {
1012
- const l = p.multiSource?.locale;
1013
- if (l)
1014
- knownLocales.add(l);
1015
- }
1016
- if (knownLocales.size < 2)
1017
- return;
1018
- if (!knownLocales.has(defaultLocale))
1019
- return;
1020
2674
  const basePath = normalizeBasePath(options.basePath);
1021
2675
  const baseSegments = basePathSegments(basePath);
1022
- // Index existing pages by (slug after locale segment) so we
1023
- // can detect missing translations cheaply. Key shape:
1024
- // `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
1025
- // is the version + namespace segments (when those axes are
1026
- // active for the page).
1027
- //
1028
- // For each default-locale page, we strip the leading `<defaultLocale>/`
1029
- // off the slug to get the "neutral" tail, then for every other
1030
- // locale, the expected URL is `<basePath>/<otherLocale>/<tail>`.
1031
- const existingByUrl = new Set();
2676
+ // combined drives the redirect URL (the user-facing path they
2677
+ // get bounced to); basePath stays the filesystem path under
2678
+ // src/pages/ where the stub lives.
2679
+ const combined = combinedPrefix(options);
2680
+ // Which pages need a stub is @dogsbay/site-files' missingTranslations,
2681
+ // shared with dogsbay-ssg. A page's key is its slug after the locale
2682
+ // segment, inside its version. Slugs on a namespaced site start with the
2683
+ // namespace, not the locale, so such a site gets no stubs. Only pages that
2684
+ // are routed get one: not include-only fragments, `excludeFromRoutes`
2685
+ // folders or `redirect:` pages. In another language, a `redirect:` page
2686
+ // holds the URL, so it counts as present.
2687
+ const units = new Map();
2688
+ const defaultPages = new Map();
2689
+ const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
2690
+ const groupOf = (ms) => `${ms.namespace ?? ""}\0${ms.version ?? ""}`;
1032
2691
  for (const p of pages) {
1033
- existingByUrl.add(`${basePath}/${p.slug}`);
1034
- }
1035
- let emitted = 0;
1036
- for (const defaultPage of pages) {
1037
- const ms = defaultPage.multiSource;
1038
- if (!ms || ms.locale !== defaultLocale)
2692
+ const ms = p.multiSource;
2693
+ if (!ms?.locale || !p.slug.startsWith(`${ms.locale}/`))
1039
2694
  continue;
1040
- // Strip the leading `<defaultLocale>/` from the slug to get
1041
- // the locale-neutral tail. Defensive: if for some reason the
1042
- // slug doesn't start with the locale (e.g. unprefixed
1043
- // baseline page in a half-active site), skip.
1044
- const localePrefix = `${defaultLocale}/`;
1045
- if (!defaultPage.slug.startsWith(localePrefix))
2695
+ if (p.fragment === true || p.frontmatter?._fragment === true || isExcludedSlug(p.slug))
1046
2696
  continue;
1047
- const tail = defaultPage.slug.slice(localePrefix.length);
1048
- for (const otherLocale of knownLocales) {
1049
- if (otherLocale === defaultLocale)
1050
- continue;
1051
- const targetSlug = `${otherLocale}/${tail}`;
1052
- const targetUrl = `${basePath}/${targetSlug}`;
1053
- if (existingByUrl.has(targetUrl))
1054
- continue; // already translated
1055
- const defaultUrl = `${basePath}/${defaultPage.slug}`;
1056
- const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
1057
- // Ensure parent dir exists; write a redirect-stub Astro
1058
- // file. Adding `.astro` to the leaf turns it into a
1059
- // routable page.
1060
- const dir = filePath.substring(0, filePath.lastIndexOf("/"));
1061
- mkdirSync(dir, { recursive: true });
1062
- writeFileSync(`${filePath}.astro`, buildMissingTranslationStub(defaultUrl, otherLocale, defaultLocale));
1063
- emitted++;
1064
- }
2697
+ if (typeof p.redirect === "string" && p.redirect.trim() && ms.locale === defaultLocale)
2698
+ continue;
2699
+ const tail = p.slug.slice(ms.locale.length + 1);
2700
+ const group = groupOf(ms);
2701
+ const k = `${group}\0${ms.locale}`;
2702
+ if (!units.has(k))
2703
+ units.set(k, { locale: ms.locale, version: group, pages: [] });
2704
+ units.get(k).pages.push(tail);
2705
+ if (ms.locale === defaultLocale)
2706
+ defaultPages.set(`${group}\0${tail}`, p);
2707
+ }
2708
+ let emitted = 0;
2709
+ for (const m of missingTranslations([...units.values()], defaultLocale)) {
2710
+ const defaultPage = defaultPages.get(`${m.version}\0${m.page}`);
2711
+ const targetSlug = `${m.locale}/${m.page}`;
2712
+ // The page's URL: an `index` slug is its folder (`/en/v3/index` is not served).
2713
+ const defaultUrl = `${combined}/${defaultPage.slug.replace(/(^|\/)index$/, "")}`;
2714
+ const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
2715
+ mkdirSync(dirname(filePath), { recursive: true });
2716
+ writeFileSync(`${filePath}.astro`, buildMissingTranslationStub(defaultUrl, m.locale, defaultLocale));
2717
+ emitted++;
1065
2718
  }
1066
2719
  if (emitted > 0) {
1067
2720
  console.log(`Emitted ${emitted} missing-translation stub(s) → redirect to default locale.`);
@@ -1094,10 +2747,20 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1094
2747
  if (options.llmsTxt !== false) {
1095
2748
  emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
1096
2749
  // public/_headers — Cloudflare Workers / Pages convention. Adds an
1097
- // RFC 8288 Link header pointing agents at /llms.txt without parsing
1098
- // HTML. Emitted alongside llms.txt so the two files travel together.
2750
+ // RFC 8288 Link header pointing agents at this mount's llms.txt
2751
+ // (basePath-prefixed) without parsing HTML. Emitted alongside
2752
+ // llms.txt so the two files travel together.
1099
2753
  mkdirSync(join(outputDir, "public"), { recursive: true });
1100
- writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile());
2754
+ // _headers Link header points at the per-mount llms.txt at
2755
+ // <combined>/llms.txt — the URL agents would actually fetch.
2756
+ writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(withBasePath(combinedPrefix(options), "/llms.txt")));
2757
+ }
2758
+ // Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
2759
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
2760
+ // don't collide at host root. Gated on a valid http(s) siteUrl
2761
+ // because <loc> entries must be absolute.
2762
+ if (options.siteUrl && /^https?:\/\//.test(options.siteUrl)) {
2763
+ emitSitemapFiles(outputDir, options, pages);
1101
2764
  }
1102
2765
  // src/middleware.ts — Tier 1 (always update). Drives both the
1103
2766
  // `Accept: text/markdown` content-negotiation rewrite (via
@@ -1109,12 +2772,45 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1109
2772
  // OR the version axis has a defaultVersion set with ≥2
1110
2773
  // declared versions. When neither applies, no middleware is
1111
2774
  // emitted (keeps single-feature sites' src/ tidy).
1112
- const mdMirrorOn = options.mdMirror !== false;
2775
+ // Middleware only does something on an SSR deploy. In Astro's static
2776
+ // output — which is what `deploy: cloudflare-workers` produces, and
2777
+ // what every Dogsbay site ships today — there is no runtime to invoke
2778
+ // it: every route is prerendered, and the guard inside the generated
2779
+ // middleware short-circuits at BUILD time because there is no client
2780
+ // whose Accept header could be honoured.
2781
+ //
2782
+ // It used to be emitted anyway. That is worse than not emitting it:
2783
+ // an external agent audit reported markdown content negotiation as
2784
+ // failing, and the file sitting in src/ implied the feature existed
2785
+ // and was broken, rather than being inapplicable to the target. Dead
2786
+ // code that claims a capability costs more than a missing feature.
2787
+ //
2788
+ // The `.md` mirrors are unaffected — they are real files, and they
2789
+ // are how agents actually fetch markdown here. What is lost on a
2790
+ // static deploy is only `Accept: text/markdown` negotiation on the
2791
+ // HTML URL, which needs a Worker running before asset serving.
2792
+ // See docs-dev/markdown-negotiation.md.
2793
+ const staticDeploy = options.deploy === "cloudflare-workers";
2794
+ const mdMirrorOn = options.mdMirror !== false && !staticDeploy;
1113
2795
  const knownVersions = pageVersions(pages);
1114
2796
  const knownLocales = pageLocales(pages);
1115
- const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2;
2797
+ // With the default version at the root (`path: ""`), a versionless URL IS
2798
+ // one of its pages: there is nothing to redirect.
2799
+ const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2 && !rootVersionOf(options);
1116
2800
  const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
1117
2801
  const axisRedirectOn = versionRedirectOn || localeRedirectOn;
2802
+ const middlewarePath = join(outputDir, "src", "middleware.ts");
2803
+ if (!mdMirrorOn && !axisRedirectOn && existsSync(middlewarePath)) {
2804
+ // Remove a middleware emitted by an EARLIER build, from before this
2805
+ // stopped emitting for static targets. Left in place it keeps
2806
+ // implying a capability the deploy cannot provide, and a site that
2807
+ // commits its generated output would carry it indefinitely. Only
2808
+ // ours — an author-written middleware is left alone.
2809
+ const existing = readFileSync(middlewarePath, "utf-8");
2810
+ if (existing.includes("AUTO-GENERATED by `dogsbay site build`")) {
2811
+ rmSync(middlewarePath);
2812
+ }
2813
+ }
1118
2814
  if (mdMirrorOn || axisRedirectOn) {
1119
2815
  // Taxonomy index paths share a single global namespace across
1120
2816
  // locales / versions (one `/tags/` for the whole site, not one
@@ -1133,9 +2829,16 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1133
2829
  mkdirSync(join(outputDir, "src"), { recursive: true });
1134
2830
  writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
1135
2831
  mdMirror: mdMirrorOn,
2832
+ // Same combined prefix the axis redirect uses — the middleware
2833
+ // compares against the request URL, which carries the served
2834
+ // subpath.
2835
+ mdMirrorBasePath: combinedPrefix(options),
1136
2836
  axisRedirect: axisRedirectOn
1137
2837
  ? {
1138
- basePath: normalizeBasePath(options.basePath),
2838
+ // Middleware compares paths against the request URL,
2839
+ // which carries the host's served subpath — so use the
2840
+ // combined prefix here.
2841
+ basePath: combinedPrefix(options),
1139
2842
  ...(versionRedirectOn
1140
2843
  ? {
1141
2844
  defaultVersion: options.defaultVersion,
@@ -1205,47 +2908,94 @@ function serializePageMd(page) {
1205
2908
  * opt-in.
1206
2909
  */
1207
2910
  function buildRobotsTxt(options, hasSiteUrl) {
1208
- const search = options.aiSearch ?? "yes";
1209
- const aiInput = options.aiInput ?? "yes";
1210
- const aiTrain = options.aiTrain ?? "no";
1211
- const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
1212
- const sitemap = hasSiteUrl
1213
- ? `Sitemap: ${options.siteUrl.replace(/\/$/, "")}/sitemap-index.xml\n`
1214
- : "";
1215
- return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}`;
2911
+ // Per-mount URLs: each Dogsbay site emits its sitemap index and
2912
+ // llms.txt under <basePath>/, so robots.txt points there. The origin
2913
+ // is taken without its path (site.url may carry one when hosting on
2914
+ // a subpath) and the combined prefix (urlBase + basePath) added, so
2915
+ // the path is not counted twice.
2916
+ const { origin } = parseSiteUrl(options.siteUrl);
2917
+ const combined = combinedPrefix(options);
2918
+ const absolute = hasSiteUrl && origin;
2919
+ return buildRobotsTxtFile({
2920
+ signals: { search: options.aiSearch, aiInput: options.aiInput, aiTrain: options.aiTrain },
2921
+ preamble: options.contentSignalPreamble === true,
2922
+ sitemapUrl: absolute ? `${origin}${withBasePath(combined, "/sitemap-index.xml")}` : undefined,
2923
+ // `Llms-Txt:` is opt-in (`agent.llmsTxtDirective`): Lighthouse reports
2924
+ // it as an unknown directive and fails "robots.txt is valid"
2925
+ // (measured: SEO 100 -> 92 on every page), and nothing consumes it.
2926
+ llmsTxtUrl: absolute && options.llmsTxtDirective === true && options.llmsTxt !== false
2927
+ ? `${origin}${withBasePath(combined, "/llms.txt")}`
2928
+ : undefined,
2929
+ });
1216
2930
  }
1217
2931
  /**
1218
2932
  * Build the contents of `public/_headers` (Cloudflare Pages / Workers
1219
2933
  * Static Assets convention). Emits a global RFC 8288 Link header
1220
- * pointing at the site's llms.txt index, so agents don't need to
2934
+ * pointing at this mount's llms.txt index, so agents don't need to
1221
2935
  * parse HTML to discover the LLM-friendly content listing.
2936
+ *
2937
+ * The Link target is basePath-prefixed (`</docs/llms.txt>` for a
2938
+ * `/docs` mount) — matches where the platform actually emits
2939
+ * llms.txt under the per-mount layout.
1222
2940
  */
1223
- function buildHeadersFile() {
1224
- return [
1225
- "/*",
1226
- ' Link: </llms.txt>; rel="describedby"; type="text/plain"',
1227
- "",
1228
- ].join("\n");
2941
+ /**
2942
+ * URL for `<link rel="icon">`, or `false` when the project ships no
2943
+ * favicon.
2944
+ *
2945
+ * The scaffold deliberately ships none — authors drop their own into
2946
+ * `public/`. Emitting the link unconditionally therefore advertised a
2947
+ * 404 on every generated site. Checks `.ico` then `.svg`, matching the
2948
+ * two the scaffold documents.
2949
+ */
2950
+ export function faviconHref(outputDir, urlBase) {
2951
+ // urlBase, NOT the combined prefix. `public/` is copied to the ROOT of
2952
+ // outDir, so a favicon serves from <urlBase>/favicon.ico on all three
2953
+ // layouts — mounted (outDir = dist/<urlBase>), GitHub Pages (host
2954
+ // supplies <urlBase>), and host-root. Composing with basePath as well
2955
+ // produced /blog/docs/favicon.ico for a file that serves from
2956
+ // /blog/favicon.ico: a 404 with a passing existence check in front of
2957
+ // it. Per-mount files (sitemap, llms.txt, nav.json) DO live under
2958
+ // public/<basePath>/ and legitimately use the combined prefix; the
2959
+ // root-level ones (favicon, robots.txt, _headers) do not.
2960
+ //
2961
+ // .png is in the list because it is the most common form — both
2962
+ // packages/cli/templates and apps/material-docs hardcode a .png — and
2963
+ // omitting it would silently emit no icon for authors who did the right
2964
+ // thing.
2965
+ for (const name of ["favicon.ico", "favicon.svg", "favicon.png"]) {
2966
+ if (existsSync(join(outputDir, "public", name))) {
2967
+ return urlBase ? `${urlBase}/${name}` : `/${name}`;
2968
+ }
2969
+ }
2970
+ return false;
1229
2971
  }
1230
2972
  function buildMiddlewareSource(config) {
1231
2973
  const lines = [
1232
2974
  "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
1233
2975
  "// Composes the docs-layout middleware helpers.",
1234
2976
  "//",
1235
- "// Markdown content negotiation:",
1236
- "// This middleware fires on every request, but in Astro's static",
1237
- "// prerender mode (output: \"static\") request headers are NOT",
1238
- "// forwarded — Astro warns about \"Astro.request.headers was used",
1239
- "// when rendering...\" and serves a prerendered HTML response.",
1240
- "// That means `Accept: text/markdown` negotiation only kicks in",
1241
- "// under SSR (output: \"server\") or via an edge function on the",
1242
- "// deployment layer (Cloudflare Worker, Netlify Edge, etc.).",
1243
- "// For pure-static deploys, agents should follow the page's",
1244
- "// <link rel=\"alternate\" type=\"text/markdown\"> href to fetch",
1245
- "// the .md mirror directly (e.g. /docs.md).",
2977
+ "// Static-prerender guard:",
2978
+ "// In Astro's static output mode, this middleware is invoked",
2979
+ "// for every prerendered route at build time. Reading",
2980
+ "// `context.request.headers` there triggers an Astro warning",
2981
+ "// per page (\"Astro.request.headers was used during static",
2982
+ "// render\"), which floods `dogsbay site build` / `site preview`",
2983
+ "// output. Worse, the negotiation can't actually happen at",
2984
+ "// build time — there's no runtime client whose Accept header",
2985
+ "// we'd be honoring.",
2986
+ "//",
2987
+ "// We guard with `context.isPrerendered` so prerendered routes",
2988
+ "// short-circuit to `next()` immediately. At runtime in static",
2989
+ "// deploys, middleware doesn't fire at all (no server); at",
2990
+ "// runtime in SSR / hybrid deploys, only dynamic routes fire,",
2991
+ "// which is exactly when negotiation makes sense.",
1246
2992
  "//",
1247
- "// The Cloudflare-Worker-driven full fix is tracked in",
1248
- "// plans/cloudflare-deploy-content-negotiation.md.",
2993
+ "// Markdown content negotiation:",
2994
+ "// For pure-static deploys, `Accept: text/markdown` is honored",
2995
+ "// by the platform (Cloudflare _headers + Worker, Netlify Edge",
2996
+ "// functions). Agents that can't send Accept headers should",
2997
+ "// follow the page's <link rel=\"alternate\" type=\"text/markdown\">",
2998
+ "// to fetch the .md mirror directly (e.g. /docs.md).",
1249
2999
  'import { defineMiddleware } from "astro:middleware";',
1250
3000
  ];
1251
3001
  if (config.mdMirror) {
@@ -1259,9 +3009,14 @@ function buildMiddlewareSource(config) {
1259
3009
  lines.push(`const AXIS_REDIRECT_CONFIG = ${JSON.stringify(config.axisRedirect, null, 2)};`, "");
1260
3010
  }
1261
3011
  lines.push("export const onRequest = defineMiddleware((context, next) => {");
3012
+ // Skip prerendered routes — see file-top comment for the rationale.
3013
+ // Avoids per-page Astro.request.headers warnings during build, and
3014
+ // matches runtime semantics (middleware doesn't fire on prerendered
3015
+ // routes when deployed).
3016
+ lines.push(" if (context.isPrerendered) return next();");
1262
3017
  lines.push(" const url = new URL(context.request.url);");
1263
3018
  if (config.mdMirror) {
1264
- lines.push(' const accept = context.request.headers.get("accept");', " const mdTarget = shouldRewriteToMarkdown(accept, url.pathname);", " if (mdTarget) return context.rewrite(mdTarget);");
3019
+ 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);");
1265
3020
  }
1266
3021
  if (config.axisRedirect) {
1267
3022
  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);", " }");
@@ -1290,8 +3045,18 @@ function buildMdEndpoint(page, sourceRel) {
1290
3045
  ].join("\n");
1291
3046
  }
1292
3047
  /**
1293
- * Emit `public/llms.txt`, `public/llms-full.txt`, and per-section
1294
- * `public/<dir>/llms.txt` files for the site.
3048
+ * Emit per-mount llms.txt + llms-full.txt + per-section indexes.
3049
+ *
3050
+ * Files live under `public/<basePath>/...` so multiple Dogsbay sites
3051
+ * can mount on the same host (`/docs/llms.txt` + `/api/llms.txt` +
3052
+ * `/handbook/llms.txt`) without colliding at the root. When basePath
3053
+ * is empty, this collapses to `public/llms.txt` — the single-site
3054
+ * llmstxt.org-spec layout.
3055
+ *
3056
+ * The host root `/llms.txt` is intentionally NOT emitted by the
3057
+ * platform: it's the user's umbrella file, analogous to
3058
+ * `sitemap-index.xml`. Multi-mount deploys hand-write a top-level
3059
+ * `/llms.txt` that links to each per-mount index.
1295
3060
  *
1296
3061
  * Per-section files are written for every top-level nav group that
1297
3062
  * resolves to a site directory (either via `group.href` or via the
@@ -1303,26 +3068,120 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
1303
3068
  description: options.description,
1304
3069
  siteUrl: options.siteUrl,
1305
3070
  };
1306
- const publicDir = join(outputDir, "public");
1307
- mkdirSync(publicDir, { recursive: true });
1308
- const hrefPrefix = normalizeBasePath(options.basePath);
1309
- writeFileSync(join(publicDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
1310
- writeFileSync(join(publicDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
3071
+ // hrefPrefix is the COMBINED prefix — used for the URL paths that
3072
+ // appear inside the llms.txt body (so agents fetch the correct
3073
+ // host-relative URLs). Filesystem layout uses basePath alone:
3074
+ // `public/<basePath>/llms.txt` matches the existing per-mount
3075
+ // delivery shape.
3076
+ const hrefPrefix = combinedPrefix(options);
3077
+ const basePath = normalizeBasePath(options.basePath);
3078
+ const baseSegments = basePathSegments(basePath);
3079
+ const mountDir = join(outputDir, "public", ...baseSegments);
3080
+ mkdirSync(mountDir, { recursive: true });
3081
+ writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, {
3082
+ hrefPrefix,
3083
+ aggregates: options.aggregates,
3084
+ whenToUse: options.llmsWhenToUse,
3085
+ }));
3086
+ writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
1311
3087
  summary: "body",
1312
3088
  serializePage: serializePageMd,
1313
3089
  hrefPrefix,
1314
3090
  }));
3091
+ // Per-section files. `deriveSectionDir` returns a host-absolute
3092
+ // path derived from nav hrefs, which since the combined-prefix
3093
+ // refactor (commit 132891e) include urlBase + basePath — NOT just
3094
+ // basePath. So joining its return onto public/ directly would
3095
+ // double-prefix into `public/<urlBase>/<basePath>/<section>/llms.txt`,
3096
+ // which then serves at `<urlBase>/<urlBase>/<basePath>/<section>/...`
3097
+ // once Astro's base prefix is applied at request time.
3098
+ //
3099
+ // Strip the combined prefix off the section dir to get just the
3100
+ // section tail, then re-prepend basePath via mountDir. Result:
3101
+ // `public/<basePath>/<section>/llms.txt`, served under the deploy's
3102
+ // base mount as `<urlBase>/<basePath>/<section>/llms.txt`.
3103
+ const combinedSegs = hrefPrefix.replace(/^\//, "");
1315
3104
  for (const group of nav) {
1316
3105
  if (!group.children || group.children.length === 0)
1317
3106
  continue;
1318
3107
  const dir = deriveSectionDir(group);
1319
3108
  if (!dir)
1320
3109
  continue;
1321
- const sectionPath = join(publicDir, dir, "llms.txt");
3110
+ let relDir;
3111
+ if (combinedSegs && dir === combinedSegs) {
3112
+ // The section's pages share no directory below the mount — they
3113
+ // are spread across several, so the common prefix collapsed to
3114
+ // the mount itself. There is no directory that belongs to this
3115
+ // section, and writing here would land on `<mount>/llms.txt`:
3116
+ // the site index, which was written just above. Skip it. The
3117
+ // index already lists the section, so nothing is lost.
3118
+ continue;
3119
+ }
3120
+ else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
3121
+ relDir = dir.slice(combinedSegs.length + 1);
3122
+ }
3123
+ else {
3124
+ // Defensive: if for some reason the dir doesn't carry the
3125
+ // combined prefix (older importer, manual nav.yml, etc.), fall
3126
+ // back to the raw value rather than rooting at /.
3127
+ relDir = dir;
3128
+ }
3129
+ const sectionPath = join(mountDir, relDir, "llms.txt");
1322
3130
  mkdirSync(dirname(sectionPath), { recursive: true });
1323
3131
  writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
1324
3132
  }
1325
3133
  }
3134
+ /**
3135
+ * Emit per-mount sitemap files.
3136
+ *
3137
+ * Writes `public/<basePath>/sitemap-index.xml` + `sitemap-0.xml`.
3138
+ * The index lists the single sub-sitemap today; future splits add
3139
+ * more sub-sitemap entries as the page count grows past
3140
+ * sitemaps.org's 50K-URL recommendation.
3141
+ *
3142
+ * Caller has already guarded on a valid http(s) `siteUrl` — without
3143
+ * one, `<loc>` entries can't be absolute and crawlers reject the
3144
+ * file. Skip emission rather than write a broken sitemap.
3145
+ */
3146
+ function emitSitemapFiles(outputDir, options, pages) {
3147
+ // Filesystem path uses basePath (sitemap files live in
3148
+ // public/<basePath>/sitemap-*.xml). The URL prefix encoded into
3149
+ // each <loc> uses combined so the absolute URLs resolve under the
3150
+ // host's served subpath. buildSitemapFiles strips path off siteUrl
3151
+ // internally, so passing siteUrl + combined as basePath gives
3152
+ // origin + combined as the final URL.
3153
+ const basePath = normalizeBasePath(options.basePath);
3154
+ const combined = combinedPrefix(options);
3155
+ const baseSegments = basePathSegments(basePath);
3156
+ const mountDir = join(outputDir, "public", ...baseSegments);
3157
+ mkdirSync(mountDir, { recursive: true });
3158
+ // An older version's page whose canonical is the default version's, or a noindex version's page, is left out.
3159
+ const excluded = makeSlugExcluder(options.excludeFromRoutes);
3160
+ const plan = canonicalPlan(pages, {
3161
+ isEmitted: (p) => !(p.fragment === true || p.frontmatter?._fragment === true) && !excluded(p.slug),
3162
+ siteUrl: options.siteUrl,
3163
+ basePath: combined,
3164
+ versions: options.versions,
3165
+ defaultVersion: options.defaultVersion,
3166
+ redirects: options.redirects,
3167
+ });
3168
+ // More than one file past 50,000 URLs, the protocol's limit per file.
3169
+ const files = buildSitemapFiles(pages.filter((p) => plan.get(p.slug)?.sitemap !== false), {
3170
+ siteUrl: options.siteUrl,
3171
+ basePath: combined,
3172
+ siteNoindex: options.noindex === true,
3173
+ });
3174
+ for (const [name, xml] of files)
3175
+ writeFileSync(join(mountDir, name), xml);
3176
+ writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({
3177
+ siteUrl: options.siteUrl,
3178
+ basePath: combined,
3179
+ sitemapCount: files.length,
3180
+ extraSitemaps: (options.aggregates ?? [])
3181
+ .map((a) => a.sitemap)
3182
+ .filter((u) => !!u),
3183
+ }));
3184
+ }
1326
3185
  /**
1327
3186
  * Pick a directory under `public/` for a top-level nav group. Prefers
1328
3187
  * the group's own href (already a `/docs/x/y` path); otherwise falls
@@ -1375,10 +3234,80 @@ function findFirstNavHref(items, fallback) {
1375
3234
  }
1376
3235
  return fallback;
1377
3236
  }
1378
- function copyComponents(outputDir) {
3237
+ /**
3238
+ * URL subtree root of every multi-source axis bucket, mapped to the
3239
+ * version that bucket carries. Keys are slug prefixes (no leading or
3240
+ * trailing slash), e.g. `3.29` or `calico/en/3.29`.
3241
+ *
3242
+ * `multiSource.originalSlug` is the slug as the importer produced it,
3243
+ * BEFORE the loader applied the axis prefix — so stripping it off the
3244
+ * emitted slug yields that bucket's prefix exactly. Deriving it that
3245
+ * way rather than by segment index matters because how many segments
3246
+ * precede the version depends on which axes are active
3247
+ * (`/<namespace>/<locale>/<version>/<slug>`), and a longest-common-
3248
+ * prefix guess collapses to nothing when two products share a version
3249
+ * id.
3250
+ */
3251
+ function axisSubtreeRoots(pages) {
3252
+ const roots = new Map();
3253
+ for (const page of pages) {
3254
+ const meta = page.multiSource;
3255
+ if (!meta?.version || meta.originalSlug === undefined)
3256
+ continue;
3257
+ if (!page.slug.endsWith(meta.originalSlug))
3258
+ continue;
3259
+ const prefix = page.slug
3260
+ .slice(0, page.slug.length - meta.originalSlug.length)
3261
+ .replace(/^\/+|\/+$/g, "");
3262
+ if (prefix)
3263
+ roots.set(prefix, meta.version);
3264
+ }
3265
+ return roots;
3266
+ }
3267
+ /**
3268
+ * The version served at the site root: the `defaultVersion` when its
3269
+ * `versions` entry has `path: ""` (the CLI's config load allows it on no
3270
+ * other version). Its pages have no version segment.
3271
+ */
3272
+ export function rootVersionOf(options) {
3273
+ const def = options.defaultVersion;
3274
+ if (!def)
3275
+ return undefined;
3276
+ return options.versions?.find((v) => v.id === def)?.path === "" ? def : undefined;
3277
+ }
3278
+ /**
3279
+ * First nav href (in nav order) living under any of `prefixes`, which
3280
+ * are URL paths without a trailing slash. Used to pick a redirect
3281
+ * target for a subtree root that has no page of its own.
3282
+ */
3283
+ function findFirstNavHrefUnder(items, prefixes,
3284
+ /** Deeper subtrees to skip (another version's, nested in the root version's). */
3285
+ exclude = []) {
3286
+ for (const item of items) {
3287
+ const href = item.href;
3288
+ if (href &&
3289
+ prefixes.some((prefix) => href.startsWith(`${prefix}/`)) &&
3290
+ !exclude.some((x) => href === x || href.startsWith(`${x}/`))) {
3291
+ return href;
3292
+ }
3293
+ if (item.children) {
3294
+ const found = findFirstNavHrefUnder(item.children, prefixes, exclude);
3295
+ if (found)
3296
+ return found;
3297
+ }
3298
+ }
3299
+ return undefined;
3300
+ }
3301
+ /**
3302
+ * Copy the UI component folders the serializer can emit into the site's
3303
+ * `src/components/ui/`, skipping any that exist (project-owned, maybe
3304
+ * edited). Returns the names added. `site build` calls it so a component
3305
+ * introduced in a release (Kbd, FileTree) reaches sites scaffolded earlier.
3306
+ */
3307
+ export function copyComponents(outputDir) {
1379
3308
  const componentsSource = resolveComponentsSource();
1380
3309
  if (!componentsSource)
1381
- return;
3310
+ return [];
1382
3311
  // Components copied into the generated project. These are the ones
1383
3312
  // format-astro's serializer emits imports for, plus a few used by the
1384
3313
  // project shell (DocsLayout, pagination, theme-toggle).
@@ -1405,32 +3334,84 @@ function copyComponents(outputDir) {
1405
3334
  // inline `:icon[name]` directive. Without this entry every page
1406
3335
  // emitting the icon import 500s with "module not found".
1407
3336
  "icon",
3337
+ // Inline :kbd and the :::files tree.
3338
+ "kbd", "files",
1408
3339
  ];
3340
+ const added = [];
1409
3341
  for (const name of needed) {
1410
3342
  const src = join(componentsSource, name);
1411
3343
  const dest = join(outputDir, "src", "components", "ui", name);
1412
3344
  if (existsSync(src) && !existsSync(dest)) {
1413
3345
  cpSync(src, dest, { recursive: true });
3346
+ added.push(name);
1414
3347
  }
1415
3348
  }
3349
+ return added;
1416
3350
  }
1417
- function copyAssets(sourceDir, outputDir, imageOptimization) {
3351
+ /** Media files worth carrying from a `mediaOnly` asset mount. */
3352
+ const MOUNT_MEDIA_EXTS = new Set([
3353
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
3354
+ ".svg", ".ico", ".pdf", ".mp4", ".webm",
3355
+ ]);
3356
+ /** Recursively copy only media files, preserving relative structure. */
3357
+ function copyMediaTree(srcDir, destDir) {
3358
+ for (const entry of readdirSync(srcDir)) {
3359
+ const full = join(srcDir, entry);
3360
+ if (statSync(full).isDirectory()) {
3361
+ copyMediaTree(full, join(destDir, entry));
3362
+ continue;
3363
+ }
3364
+ const dot = entry.lastIndexOf(".");
3365
+ if (dot === -1 || !MOUNT_MEDIA_EXTS.has(entry.slice(dot).toLowerCase()))
3366
+ continue;
3367
+ mkdirSync(destDir, { recursive: true });
3368
+ cpSync(full, join(destDir, entry));
3369
+ }
3370
+ }
3371
+ function copyAssets(sourceDir, outputDir, imageOptimization, urlPrefix) {
1418
3372
  // sourceDir is already the docs dir (e.g. .../fastapi/docs/en/docs)
1419
3373
  const searchDir = sourceDir;
3374
+ // A version/locale/namespace URL segment (e.g. "8.8") the content's
3375
+ // asset refs were rewritten with — so each source's assets land under
3376
+ // public/<prefix>/... and same-path images across versions never
3377
+ // collide. Empty for single-source builds (today's flat behaviour).
3378
+ const prefixParts = (urlPrefix ?? "")
3379
+ .split("/")
3380
+ .map((p) => p.trim())
3381
+ .filter(Boolean);
3382
+ const withPrefix = (rel) => prefixParts.length > 0 ? join(...prefixParts, rel) : rel;
1420
3383
  // Raster images benefit from Astro optimization (WebP, dimensions)
1421
3384
  const optimizableExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
1422
3385
  // SVGs, icons, PDFs always go to public/ (no optimization needed)
1423
3386
  const passthroughExts = new Set([".svg", ".ico", ".pdf"]);
1424
3387
  function walk(dir) {
1425
- for (const entry of readdirSync(dir)) {
1426
- const full = join(dir, entry);
1427
- if (statSync(full).isDirectory()) {
3388
+ // withFileTypes gives LSTAT semantics, so a symlink reports
3389
+ // isSymbolicLink() and never isDirectory(). That matters more than it
3390
+ // looks: AsciiBinder corpora symlink every section back to a shared
3391
+ // images/ (331 of them in openshift-docs), and statSync FOLLOWED those
3392
+ // links — copying the whole 64 MB image tree once per link. Measured:
3393
+ // kebab.png landed in public/ 77 times, public/ reached 4.6 GB from a
3394
+ // 273 MB checkout, full expansion ~21 GB. On CI the runner filled its
3395
+ // disk and died so hard it could not write its own log.
3396
+ //
3397
+ // Dormant only because those links pointed at content/images/ while
3398
+ // images lived in content/_assets/images/, so they dangled and the walk
3399
+ // skipped them. Moving images to content/images/ made all 331 resolve
3400
+ // and the latent bug fired.
3401
+ //
3402
+ // A symlinked directory is a second path to files this walk already
3403
+ // reaches by their real one, so skipping links copies each file once.
3404
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
3405
+ if (entry.isSymbolicLink())
3406
+ continue;
3407
+ const full = join(dir, entry.name);
3408
+ if (entry.isDirectory()) {
1428
3409
  walk(full);
1429
3410
  }
1430
3411
  else {
1431
- const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
3412
+ const ext = entry.name.substring(entry.name.lastIndexOf(".")).toLowerCase();
1432
3413
  if (optimizableExts.has(ext)) {
1433
- const rel = relative(searchDir, full);
3414
+ const rel = withPrefix(relative(searchDir, full));
1434
3415
  // Always copy to public/ so inline <img src="/..."> works
1435
3416
  const pubDest = join(outputDir, "public", rel);
1436
3417
  mkdirSync(dirname(pubDest), { recursive: true });
@@ -1445,7 +3426,7 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
1445
3426
  }
1446
3427
  else if (passthroughExts.has(ext)) {
1447
3428
  // SVGs, icons, PDFs always go to public/
1448
- const rel = relative(searchDir, full);
3429
+ const rel = withPrefix(relative(searchDir, full));
1449
3430
  const dest = join(outputDir, "public", rel);
1450
3431
  mkdirSync(dirname(dest), { recursive: true });
1451
3432
  cpSync(full, dest);
@@ -1458,418 +3439,8 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
1458
3439
  }
1459
3440
  catch { /* source may not exist */ }
1460
3441
  }
1461
- // ── CSS generation (ported from import-mkdocs.ts) ───────
1462
- function generateGlobalCss() {
1463
- return `@import "tailwindcss";
1464
- @import "./theme.css";
1465
-
1466
- /* Scan @dogsbay packages for Tailwind classes */
1467
- @source "../../node_modules/@dogsbay/ui/src";
1468
- @source "../../node_modules/@dogsbay/docs-layout/src";
1469
-
1470
- /* Prose typography for rendered content */
1471
- .docs-prose {
1472
- line-height: 1.7;
1473
-
1474
- & 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; }
1475
- & 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; }
1476
- & h3 { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
1477
- & h4 { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
1478
-
1479
- & p { margin-top: 0.75rem; margin-bottom: 0.75rem; }
1480
-
1481
- & a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
1482
- & a:hover { opacity: 0.8; }
1483
-
1484
- & strong { font-weight: 600; }
1485
- & 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); }
1486
- & pre code { padding: 0; background: none; font-size: 1em; }
1487
-
1488
- /* Spacing between consecutive block elements (code blocks, alerts, etc.) */
1489
- & > * + * { margin-top: 0.75rem; }
1490
- & li > * + * { margin-top: 0.75rem; }
1491
-
1492
- & ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
1493
- & ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
1494
- & li { margin: 0.25rem 0; }
1495
- & li > ul, & li > ol { margin: 0.25rem 0; }
1496
-
1497
- & blockquote { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
1498
-
1499
- & hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
1500
-
1501
- & table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
1502
- & th { text-align: left; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
1503
- & td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
1504
-
1505
- & img { max-width: 100%; border-radius: 0.5rem; }
1506
-
1507
- & .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
1508
- & h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
1509
-
1510
- & details { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
1511
- & summary { cursor: pointer; font-weight: 600; }
1512
-
1513
- & dl { margin: 1rem 0; }
1514
- & dt { font-weight: 600; margin-top: 0.75rem; }
1515
- & dd { margin-left: 1.5rem; color: var(--muted-foreground); }
1516
- }
1517
-
1518
- @utility scrollbar-none {
1519
- -ms-overflow-style: none;
1520
- scrollbar-width: none;
1521
- &::-webkit-scrollbar {
1522
- display: none;
1523
- }
1524
- }
1525
- `;
1526
- }
1527
- // ── Theme presets ───────────────────────────────────────
1528
- const THEME_DEFAULT = {
1529
- light: `:root {
1530
- --background: oklch(1 0 0);
1531
- --foreground: oklch(0.145 0.014 285.823);
1532
- --card: oklch(1 0 0);
1533
- --card-foreground: oklch(0.145 0.014 285.823);
1534
- --popover: oklch(1 0 0);
1535
- --popover-foreground: oklch(0.145 0.014 285.823);
1536
- --primary: oklch(0.205 0.014 285.823);
1537
- --primary-foreground: oklch(0.985 0 0);
1538
- --secondary: oklch(0.965 0.001 286.375);
1539
- --secondary-foreground: oklch(0.205 0.014 285.823);
1540
- --muted: oklch(0.965 0.001 286.375);
1541
- --muted-foreground: oklch(0.45 0.014 285.938);
1542
- --accent: oklch(0.965 0.001 286.375);
1543
- --accent-foreground: oklch(0.205 0.014 285.823);
1544
- --destructive: oklch(0.45 0.245 27.325);
1545
- --destructive-foreground: oklch(0.985 0 0);
1546
- --border: oklch(0.922 0.004 286.32);
1547
- --input: oklch(0.922 0.004 286.32);
1548
- --ring: oklch(0.708 0.014 285.823);
1549
- --radius: 0.625rem;
1550
- --note: oklch(0.637 0.174 259.126);
1551
- --note-foreground: var(--foreground);
1552
- --abstract: oklch(0.691 0.148 221.723);
1553
- --info: oklch(0.722 0.128 207.084);
1554
- --tip: oklch(0.731 0.112 178.627);
1555
- --success: oklch(0.754 0.175 149.577);
1556
- --question: oklch(0.809 0.19 126.526);
1557
- --warning: oklch(0.74 0.175 62.778);
1558
- --failure: oklch(0.614 0.194 21.602);
1559
- --danger: oklch(0.577 0.245 16.439);
1560
- --bug: oklch(0.548 0.258 349.761);
1561
- --example: oklch(0.534 0.222 286.033);
1562
- --quote: oklch(0.65 0 0);
1563
- --sidebar: oklch(0.985 0 0);
1564
- --sidebar-foreground: oklch(0.145 0.014 285.823);
1565
- --sidebar-primary: oklch(0.205 0.014 285.823);
1566
- --sidebar-primary-foreground: oklch(0.985 0 0);
1567
- --sidebar-accent: oklch(0.965 0.001 286.375);
1568
- --sidebar-accent-foreground: oklch(0.205 0.014 285.823);
1569
- --sidebar-border: oklch(0.922 0.004 286.32);
1570
- --sidebar-ring: oklch(0.708 0.014 285.823);
1571
- --code-background: oklch(0.965 0.004 264);
1572
- --code-foreground: oklch(0.3 0.014 253.833);
1573
-
1574
- /* API reference — always-dark code panel */
1575
- --api-panel-bg: oklch(0.145 0.014 253.833);
1576
- --api-panel-fg: oklch(0.926 0.013 253.833);
1577
-
1578
- /* API semantic colors — HTTP methods, types, status codes */
1579
- --api-get: oklch(0.723 0.15 162);
1580
- --api-post: oklch(0.637 0.174 259);
1581
- --api-put: oklch(0.74 0.175 63);
1582
- --api-patch: oklch(0.7 0.18 45);
1583
- --api-delete: oklch(0.614 0.194 22);
1584
- --api-head: oklch(0.534 0.222 286);
1585
- --api-required: oklch(0.614 0.194 22);
1586
- --api-deprecated: oklch(0.74 0.175 63);
1587
- --api-type-string: oklch(0.723 0.15 162);
1588
- --api-type-number: oklch(0.637 0.174 259);
1589
- --api-type-boolean: oklch(0.74 0.175 63);
1590
- --api-type-object: oklch(0.534 0.222 286);
1591
- --api-type-array: oklch(0.7 0.18 45);
1592
- --api-status-2xx: oklch(0.723 0.15 162);
1593
- --api-status-3xx: oklch(0.637 0.174 259);
1594
- --api-status-4xx: oklch(0.74 0.175 63);
1595
- --api-status-5xx: oklch(0.614 0.194 22);
1596
-
1597
- --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1598
- --font-heading: var(--font-sans);
1599
- --font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1600
- }`,
1601
- dark: `.dark {
1602
- --background: oklch(0.145 0.014 285.823);
1603
- --foreground: oklch(0.985 0 0);
1604
- --card: oklch(0.145 0.014 285.823);
1605
- --card-foreground: oklch(0.985 0 0);
1606
- --popover: oklch(0.145 0.014 285.823);
1607
- --popover-foreground: oklch(0.985 0 0);
1608
- --primary: oklch(0.985 0 0);
1609
- --primary-foreground: oklch(0.205 0.014 285.823);
1610
- --secondary: oklch(0.269 0.007 286.033);
1611
- --secondary-foreground: oklch(0.985 0 0);
1612
- --muted: oklch(0.269 0.007 286.033);
1613
- --muted-foreground: oklch(0.708 0.014 285.823);
1614
- --accent: oklch(0.269 0.007 286.033);
1615
- --accent-foreground: oklch(0.985 0 0);
1616
- --destructive: oklch(0.704 0.191 22.216);
1617
- --destructive-foreground: oklch(0.145 0.014 285.823);
1618
- --border: oklch(0.269 0.007 286.033);
1619
- --input: oklch(0.269 0.007 286.033);
1620
- --ring: oklch(0.439 0.01 286.375);
1621
- --note-foreground: var(--foreground);
1622
- --sidebar: oklch(0.205 0.014 285.823);
1623
- --sidebar-foreground: oklch(0.985 0 0);
1624
- --sidebar-primary: oklch(0.488 0.243 264.376);
1625
- --sidebar-primary-foreground: oklch(0.985 0 0);
1626
- --sidebar-accent: oklch(0.269 0.007 286.033);
1627
- --sidebar-accent-foreground: oklch(0.985 0 0);
1628
- --sidebar-border: oklch(0.269 0.007 286.033);
1629
- --sidebar-ring: oklch(0.439 0.01 286.375);
1630
- --code-background: oklch(0.18 0.014 253.833);
1631
- --code-foreground: oklch(0.926 0.013 253.833);
1632
- }`,
1633
- };
1634
- /**
1635
- * Mintlify-inspired theme — clean, neutral palette matching modern doc sites.
1636
- *
1637
- * Extracted from Claude Code docs (Mintlify):
1638
- * - White background, near-black text
1639
- * - Serif display font for H1, sans-serif body
1640
- * - Soft colored callout backgrounds (blue note, green tip, amber warning)
1641
- * - Rounded callout borders (16px radius)
1642
- * - Light gray code background, JetBrains Mono
1643
- */
1644
- const THEME_MINTLIFY = {
1645
- light: `:root {
1646
- --background: oklch(0.993 0.005 95);
1647
- --foreground: oklch(0.15 0.01 260);
1648
- --card: oklch(0.993 0.005 95);
1649
- --card-foreground: oklch(0.15 0.01 260);
1650
- --popover: oklch(0.993 0.005 95);
1651
- --popover-foreground: oklch(0.15 0.01 260);
1652
- --primary: oklch(0.15 0.01 260);
1653
- --primary-foreground: oklch(0.985 0 0);
1654
- --secondary: oklch(0.97 0.002 260);
1655
- --secondary-foreground: oklch(0.15 0.01 260);
1656
- --muted: oklch(0.97 0.002 260);
1657
- --muted-foreground: oklch(0.45 0.01 260);
1658
- --accent: oklch(0.97 0.002 260);
1659
- --accent-foreground: oklch(0.15 0.01 260);
1660
- --destructive: oklch(0.55 0.22 25);
1661
- --destructive-foreground: oklch(0.985 0 0);
1662
- --border: oklch(0.91 0.004 260);
1663
- --input: oklch(0.91 0.004 260);
1664
- --ring: oklch(0.7 0.01 260);
1665
- --radius: 1rem;
1666
- /* Callout colors — soft pastel backgrounds matching Mintlify */
1667
- --note: oklch(0.62 0.18 260);
1668
- --note-foreground: var(--foreground);
1669
- --abstract: oklch(0.65 0.14 220);
1670
- --info: oklch(0.65 0.14 230);
1671
- --tip: oklch(0.65 0.16 155);
1672
- --success: oklch(0.65 0.16 155);
1673
- --question: oklch(0.7 0.15 270);
1674
- --warning: oklch(0.7 0.15 75);
1675
- --failure: oklch(0.6 0.2 25);
1676
- --danger: oklch(0.55 0.22 25);
1677
- --bug: oklch(0.55 0.22 350);
1678
- --example: oklch(0.55 0.2 285);
1679
- --quote: oklch(0.55 0 0);
1680
- /* Sidebar */
1681
- --sidebar: oklch(0.993 0.005 95);
1682
- --sidebar-foreground: oklch(0.15 0.01 260);
1683
- --sidebar-primary: oklch(0.15 0.01 260);
1684
- --sidebar-primary-foreground: oklch(0.985 0 0);
1685
- --sidebar-accent: oklch(0.97 0.002 260);
1686
- --sidebar-accent-foreground: oklch(0.15 0.01 260);
1687
- --sidebar-border: oklch(0.93 0.004 260);
1688
- --sidebar-ring: oklch(0.7 0.01 260);
1689
- /* Code — light gray background, matching Mintlify's inline code */
1690
- --code-background: oklch(0.96 0.003 260);
1691
- --code-foreground: oklch(0.25 0.01 260);
1692
-
1693
- /* API reference — always-dark code panel */
1694
- --api-panel-bg: oklch(0.145 0.014 253.833);
1695
- --api-panel-fg: oklch(0.926 0.013 253.833);
1696
-
1697
- /* API semantic colors — HTTP methods, types, status codes */
1698
- --api-get: oklch(0.723 0.15 162);
1699
- --api-post: oklch(0.637 0.174 259);
1700
- --api-put: oklch(0.74 0.175 63);
1701
- --api-patch: oklch(0.7 0.18 45);
1702
- --api-delete: oklch(0.614 0.194 22);
1703
- --api-head: oklch(0.534 0.222 286);
1704
- --api-required: oklch(0.614 0.194 22);
1705
- --api-deprecated: oklch(0.74 0.175 63);
1706
- --api-type-string: oklch(0.723 0.15 162);
1707
- --api-type-number: oklch(0.637 0.174 259);
1708
- --api-type-boolean: oklch(0.74 0.175 63);
1709
- --api-type-object: oklch(0.534 0.222 286);
1710
- --api-type-array: oklch(0.7 0.18 45);
1711
- --api-status-2xx: oklch(0.723 0.15 162);
1712
- --api-status-3xx: oklch(0.637 0.174 259);
1713
- --api-status-4xx: oklch(0.74 0.175 63);
1714
- --api-status-5xx: oklch(0.614 0.194 22);
1715
-
1716
- /* Fonts — system sans for body, JetBrains Mono for code */
1717
- --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1718
- --font-heading: var(--font-sans);
1719
- --font-code: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1720
- }`,
1721
- dark: `.dark {
1722
- --background: oklch(0.16 0.01 260);
1723
- --foreground: oklch(0.93 0.005 260);
1724
- --card: oklch(0.18 0.01 260);
1725
- --card-foreground: oklch(0.93 0.005 260);
1726
- --popover: oklch(0.18 0.01 260);
1727
- --popover-foreground: oklch(0.93 0.005 260);
1728
- --primary: oklch(0.93 0.005 260);
1729
- --primary-foreground: oklch(0.16 0.01 260);
1730
- --secondary: oklch(0.22 0.008 260);
1731
- --secondary-foreground: oklch(0.93 0.005 260);
1732
- --muted: oklch(0.22 0.008 260);
1733
- --muted-foreground: oklch(0.65 0.01 260);
1734
- --accent: oklch(0.22 0.008 260);
1735
- --accent-foreground: oklch(0.93 0.005 260);
1736
- --destructive: oklch(0.65 0.2 25);
1737
- --destructive-foreground: oklch(0.16 0.01 260);
1738
- --border: oklch(0.28 0.008 260);
1739
- --input: oklch(0.28 0.008 260);
1740
- --ring: oklch(0.45 0.01 260);
1741
- --note-foreground: var(--foreground);
1742
- --sidebar: oklch(0.16 0.01 260);
1743
- --sidebar-foreground: oklch(0.93 0.005 260);
1744
- --sidebar-primary: oklch(0.55 0.2 260);
1745
- --sidebar-primary-foreground: oklch(0.93 0.005 260);
1746
- --sidebar-accent: oklch(0.22 0.008 260);
1747
- --sidebar-accent-foreground: oklch(0.93 0.005 260);
1748
- --sidebar-border: oklch(0.28 0.008 260);
1749
- --sidebar-ring: oklch(0.45 0.01 260);
1750
- --code-background: oklch(0.2 0.01 260);
1751
- --code-foreground: oklch(0.9 0.01 260);
1752
- }`,
1753
- };
1754
3442
  function writeThemeFile(path, themeName) {
1755
- const tokens = themeName === "mintlify" ? THEME_MINTLIFY : THEME_DEFAULT;
1756
- writeFileSync(path, `@custom-variant dark (&:where(.dark, .dark *));
1757
-
1758
- @theme inline {
1759
- --color-background: var(--background);
1760
- --color-foreground: var(--foreground);
1761
- --color-card: var(--card);
1762
- --color-card-foreground: var(--card-foreground);
1763
- --color-popover: var(--popover);
1764
- --color-popover-foreground: var(--popover-foreground);
1765
- --color-primary: var(--primary);
1766
- --color-primary-foreground: var(--primary-foreground);
1767
- --color-secondary: var(--secondary);
1768
- --color-secondary-foreground: var(--secondary-foreground);
1769
- --color-muted: var(--muted);
1770
- --color-muted-foreground: var(--muted-foreground);
1771
- --color-accent: var(--accent);
1772
- --color-accent-foreground: var(--accent-foreground);
1773
- --color-destructive: var(--destructive);
1774
- --color-destructive-foreground: var(--destructive-foreground);
1775
- --color-border: var(--border);
1776
- --color-input: var(--input);
1777
- --color-ring: var(--ring);
1778
- --radius-sm: calc(var(--radius) - 4px);
1779
- --radius-md: calc(var(--radius) - 2px);
1780
- --radius-lg: var(--radius);
1781
- --radius-xl: calc(var(--radius) + 4px);
1782
-
1783
- /* Sidebar */
1784
- --color-sidebar: var(--sidebar);
1785
- --color-sidebar-foreground: var(--sidebar-foreground);
1786
- --color-sidebar-primary: var(--sidebar-primary);
1787
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
1788
- --color-sidebar-accent: var(--sidebar-accent);
1789
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
1790
- --color-sidebar-border: var(--sidebar-border);
1791
- --color-sidebar-ring: var(--sidebar-ring);
1792
-
1793
- /* Admonition colors */
1794
- --color-note: var(--note);
1795
- --color-note-foreground: var(--note-foreground);
1796
- --color-abstract: var(--abstract);
1797
- --color-info: var(--info);
1798
- --color-tip: var(--tip);
1799
- --color-success: var(--success);
1800
- --color-question: var(--question);
1801
- --color-warning: var(--warning);
1802
- --color-failure: var(--failure);
1803
- --color-danger: var(--danger);
1804
- --color-bug: var(--bug);
1805
- --color-example: var(--example);
1806
- --color-quote: var(--quote);
1807
-
1808
- /* Code */
1809
- --color-code-bg: var(--code-background);
1810
- --color-code-fg: var(--code-foreground);
1811
-
1812
- /* API reference — HTTP method, type, status, role colors */
1813
- --color-api-get: var(--api-get);
1814
- --color-api-post: var(--api-post);
1815
- --color-api-put: var(--api-put);
1816
- --color-api-patch: var(--api-patch);
1817
- --color-api-delete: var(--api-delete);
1818
- --color-api-head: var(--api-head);
1819
- --color-api-required: var(--api-required);
1820
- --color-api-deprecated: var(--api-deprecated);
1821
- --color-api-type-string: var(--api-type-string);
1822
- --color-api-type-number: var(--api-type-number);
1823
- --color-api-type-boolean: var(--api-type-boolean);
1824
- --color-api-type-object: var(--api-type-object);
1825
- --color-api-type-array: var(--api-type-array);
1826
- --color-api-status-2xx: var(--api-status-2xx);
1827
- --color-api-status-3xx: var(--api-status-3xx);
1828
- --color-api-status-4xx: var(--api-status-4xx);
1829
- --color-api-status-5xx: var(--api-status-5xx);
1830
-
1831
- /* Fonts */
1832
- --font-sans: var(--font-sans);
1833
- --font-mono: var(--font-code);
1834
- --font-family-code: var(--font-code);
1835
- }
1836
-
1837
- ${tokens.light}
1838
-
1839
- ${tokens.dark}
1840
-
1841
- @layer base {
1842
- * { @apply border-border outline-ring/50; }
1843
- body { @apply bg-background text-foreground font-sans; }
1844
- }
1845
-
1846
- /* Shiki dual-theme: light by default, dark when .dark class is active */
1847
- .shiki,
1848
- .shiki span,
1849
- .astro-code,
1850
- .astro-code span {
1851
- color: var(--shiki-light) !important;
1852
- }
1853
- .shiki,
1854
- .astro-code {
1855
- background-color: var(--shiki-light-bg) !important;
1856
- }
1857
- .dark .shiki,
1858
- .dark .shiki span,
1859
- .dark .astro-code,
1860
- .dark .astro-code span {
1861
- color: var(--shiki-dark) !important;
1862
- }
1863
- .dark .shiki,
1864
- .dark .astro-code {
1865
- background-color: var(--shiki-dark-bg) !important;
1866
- }
1867
- /* Fix comment color contrast for WCAG AA */
1868
- .shiki span[style*="--shiki-light:#6E7781"],
1869
- .astro-code span[style*="--shiki-light:#6E7781"] {
1870
- --shiki-light: #576069 !important;
1871
- }
1872
- `);
3443
+ writeFileSync(path, themeCss(themeName));
1873
3444
  }
1874
3445
  // ── Resolution helpers ──────────────────────────────────
1875
3446
  /**
@@ -1899,6 +3470,37 @@ function isInsideWorkspace(outputDir) {
1899
3470
  }
1900
3471
  return false;
1901
3472
  }
3473
+ /**
3474
+ * Every @dogsbay/* package in this checkout, mapped to its `file:` path.
3475
+ *
3476
+ * Enumerated from disk rather than hard-coded: the set of packages changes, and
3477
+ * a stale list fails as a confusing install error naming a package nobody
3478
+ * mentioned.
3479
+ */
3480
+ function monorepoOverrides() {
3481
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
3482
+ const overrides = {};
3483
+ if (!existsSync(root))
3484
+ return overrides;
3485
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
3486
+ if (!entry.isDirectory())
3487
+ continue;
3488
+ const manifest = join(root, entry.name, "package.json");
3489
+ if (!existsSync(manifest))
3490
+ continue;
3491
+ try {
3492
+ const { name } = JSON.parse(readFileSync(manifest, "utf-8"));
3493
+ if (name?.startsWith("@dogsbay/"))
3494
+ overrides[name] = `file:${join(root, entry.name)}`;
3495
+ }
3496
+ catch {
3497
+ // A malformed manifest in the checkout is not this function's problem;
3498
+ // skipping it degrades to the previous behaviour for that one package
3499
+ // rather than failing the whole export.
3500
+ }
3501
+ }
3502
+ return overrides;
3503
+ }
1902
3504
  function resolveMonorepoPkg(name) {
1903
3505
  const thisDir = dirname(fileURLToPath(import.meta.url));
1904
3506
  // From packages/format-astro/src/ → ../../{name}