@dogsbay/format-astro 0.2.0-beta.9 → 0.2.0-beta.91

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/project.js CHANGED
@@ -4,14 +4,29 @@
4
4
  * Takes ExportPage[] + NavItem[] and generates a complete Astro project
5
5
  * with static .astro pages using real Dogsbay components.
6
6
  */
7
- import { existsSync, mkdirSync, writeFileSync, readFileSync, cpSync, readdirSync, statSync, } from "node:fs";
7
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, cpSync, readdirSync, statSync, } from "node:fs";
8
8
  import { join, dirname, relative, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
11
- import { treeToAstro } from "./serialize.js";
11
+ import { treeToAstro, TONE_CLASSES } from "./serialize.js";
12
12
  import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
13
- import { normalizeBasePath, basePathSegments, buildCurrentPath } from "./base-path.js";
14
- import { detectLeadingNodes } from "./lead.js";
13
+ import { buildSitemap, buildSitemapIndex } from "./sitemap.js";
14
+ import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath, parseSiteUrl, combinePrefix, } from "./base-path.js";
15
+ /**
16
+ * Combined URL prefix = urlBase (Astro `base` from site.url path) +
17
+ * basePath (filesystem layout prefix). Every URL emitter (nav,
18
+ * sitemap, llms.txt, .md mirror, _headers, taxonomy) uses this for
19
+ * href output. Filesystem-layout consumers (mkdir, page output
20
+ * paths) keep using basePath alone — Astro's `base` config adds the
21
+ * urlBase prefix at route time.
22
+ *
23
+ * See plans/astro-base-from-site-url.md.
24
+ */
25
+ function combinedPrefix(options) {
26
+ const { urlBase } = parseSiteUrl(options.siteUrl);
27
+ return combinePrefix(urlBase, normalizeBasePath(options.basePath));
28
+ }
29
+ import { detectLeadingNodes, deriveDescription } from "./lead.js";
15
30
  /**
16
31
  * Recursively prefix all hrefs in a nav tree.
17
32
  * Turns `<basePath>/foo` into `<basePath>/{section}/foo`. The
@@ -49,6 +64,39 @@ function prefixNavHrefs(items, section, basePath) {
49
64
  function escapeRegex(s) {
50
65
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
66
  }
67
+ /**
68
+ * Prefix the combined URL base onto root-absolute nav hrefs.
69
+ *
70
+ * Most importers emit nav hrefs already carrying the combined prefix —
71
+ * the dogsbay-md importer walks source paths through
72
+ * `fileToHref(file, hrefPrefix=combined)`, so OpenShift's `nav.yml`
73
+ * (`welcome/foo.md`) resolves straight to `/<base>/welcome/foo`. But an
74
+ * importer that produces a nav.json of *pre-resolved* hrefs at a
75
+ * different base — e.g. `dogsbay convert --from docusaurus --to
76
+ * dogsbay-md`, whose hrefs are root-absolute (`/about/foo`) — would
77
+ * otherwise emit unprefixed nav links that 404 under a site base, and
78
+ * break prev/next (pagination matches against the combined-prefixed
79
+ * `currentPath`).
80
+ *
81
+ * Running every nav href through `rewriteHref` here is the single
82
+ * chokepoint that makes nav base-correct for ALL importers. It's
83
+ * idempotent — hrefs already starting with the prefix (or external /
84
+ * anchor / protocol-relative) are left untouched — so importer-side
85
+ * prefixing keeps working unchanged, and an empty prefix is a no-op.
86
+ */
87
+ function prefixNavBaseHrefs(items, prefix) {
88
+ if (!prefix)
89
+ return items;
90
+ return items.map((item) => {
91
+ const result = { ...item };
92
+ if (result.href)
93
+ result.href = rewriteHref(result.href, prefix);
94
+ if (result.children) {
95
+ result.children = prefixNavBaseHrefs(result.children, prefix);
96
+ }
97
+ return result;
98
+ });
99
+ }
52
100
  /**
53
101
  * Rewrite internal hrefs in a page's tree nodes.
54
102
  * Prepends `prefix` to root-relative links (e.g. `/workers/...` → `/docs/workers/...`).
@@ -97,6 +145,55 @@ function rewriteHref(href, prefix) {
97
145
  return href;
98
146
  return prefix + href;
99
147
  }
148
+ /**
149
+ * Rewrite image srcs in inline nodes + raw HTML to include the
150
+ * combined URL prefix.
151
+ *
152
+ * Astro auto-prefixes `<a href>` and image imports going through
153
+ * `<AstroImage>`, but raw `<img src="...">` HTML in template
154
+ * output is left untouched. The serializer emits raw `<img>` for
155
+ * inline images and falls back to it for non-optimized block
156
+ * images, so we have to prefix manually before serialization to
157
+ * make `/_assets/...` paths resolve under subpath-mounted deploys
158
+ * (GH Pages project pages, multi-mount Cloudflare).
159
+ *
160
+ * Symmetric with rewriteTreeHrefs — same skip-rules (external,
161
+ * anchors, already-prefixed). Block images keep their prefix
162
+ * stripped back off for the `imageMap[...]` lookup key (see
163
+ * paragraphToAstro in serialize.ts) so Astro's image optimization
164
+ * still finds the source.
165
+ */
166
+ function rewriteTreeImageSrcs(nodes, prefix) {
167
+ for (const node of nodes) {
168
+ if (node.inline) {
169
+ rewriteInlineImageSrcs(node.inline, prefix);
170
+ }
171
+ if (node.html) {
172
+ node.html = rewriteHtmlImageSrcs(node.html, prefix);
173
+ }
174
+ if (node.children) {
175
+ rewriteTreeImageSrcs(node.children, prefix);
176
+ }
177
+ }
178
+ }
179
+ function rewriteInlineImageSrcs(nodes, prefix) {
180
+ for (const node of nodes) {
181
+ if (node.type === "image" && typeof node.src === "string") {
182
+ node.src = rewriteHref(node.src, prefix);
183
+ }
184
+ else if (node.type === "link") {
185
+ // Links wrap inline children (which may include images) — same
186
+ // recursion shape as rewriteInlineHrefs.
187
+ rewriteInlineImageSrcs(node.children, prefix);
188
+ }
189
+ else if (node.type === "highlight" && node.children) {
190
+ rewriteInlineImageSrcs(node.children, prefix);
191
+ }
192
+ }
193
+ }
194
+ function rewriteHtmlImageSrcs(html, prefix) {
195
+ return html.replace(/(<img\b[^>]*\ssrc=")(\/[^"]+)"/g, (_match, before, src) => `${before}${rewriteHref(src, prefix)}"`);
196
+ }
100
197
  /**
101
198
  * Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
102
199
  *
@@ -147,6 +244,125 @@ function buildWranglerConfig(siteName, options) {
147
244
  lines.push(`}`);
148
245
  return lines.join("\n") + "\n";
149
246
  }
247
+ /**
248
+ * Build the GitHub Actions workflow YAML for `actions/deploy-pages`.
249
+ *
250
+ * The workflow:
251
+ * 1. Checks out the repo on every push to the default branch.
252
+ * 2. Installs node + pnpm at the Astro project directory, runs
253
+ * `dogsbay site build` (via `pnpm dlx` since Dogsbay is a
254
+ * global CLI, not a project dep), then `pnpm run build`
255
+ * (which runs `astro build && pagefind`).
256
+ * 3. Uploads `<astroDirRel>/dist` as a Pages artifact via
257
+ * `actions/upload-pages-artifact`.
258
+ * 4. Deploys via `actions/deploy-pages`.
259
+ *
260
+ * `astroDirRel` is the path of the Astro output relative to the
261
+ * repo root (typically "astro" — the default config has
262
+ * `output: ./astro`). Empty string is allowed when the project is
263
+ * flat (outputDir === projectDir); the workflow degrades naturally
264
+ * by omitting the `defaults: working-directory` block.
265
+ *
266
+ * Author edits — extra build steps, secrets, deploy gating — survive
267
+ * subsequent `dogsbay site build` runs because the file is written
268
+ * write-if-missing (see emitDeployArtifacts). To start over, delete
269
+ * the workflow file and rebuild.
270
+ *
271
+ * Note on basePath: GitHub Pages serves project sites at
272
+ * `https://<user>.github.io/<repo>/`. Authors who want their docs at
273
+ * the repo root should set `site.basePath: /<repo-name>` (or empty
274
+ * for user/org pages). The platform's basePath plumbing handles all
275
+ * URL rewriting; this workflow doesn't need to know about it.
276
+ */
277
+ function buildGitHubPagesWorkflow(astroDirRel) {
278
+ // When the Astro output IS the project root, drop the working-
279
+ // directory block and reference cache + artifact paths without a
280
+ // prefix. This is the flat-layout case (rare for site-init flows;
281
+ // common for `dogsbay convert` outputs that get manually wired up).
282
+ const isFlat = astroDirRel === "" || astroDirRel === ".";
283
+ const workingDirBlock = isFlat
284
+ ? ""
285
+ : `
286
+ defaults:
287
+ run:
288
+ working-directory: ${astroDirRel}`;
289
+ const cacheDep = isFlat
290
+ ? "pnpm-lock.yaml"
291
+ : `${astroDirRel}/pnpm-lock.yaml`;
292
+ const artifactPath = isFlat ? "dist" : `${astroDirRel}/dist`;
293
+ return `# Deploy to GitHub Pages.
294
+ # Generated by \`dogsbay site init --deploy=github-pages\` (or by
295
+ # adding \`deploy: { target: github-pages }\` to dogsbay.config.yml
296
+ # and running \`dogsbay site build\`). Author edits survive every
297
+ # subsequent build — the file is never overwritten. To regenerate
298
+ # from template, delete the file and rebuild.
299
+ #
300
+ # Repo settings: Settings → Pages → Source = "GitHub Actions".
301
+ name: Deploy to GitHub Pages
302
+
303
+ on:
304
+ push:
305
+ branches: [main]
306
+ workflow_dispatch:
307
+
308
+ permissions:
309
+ contents: read
310
+ pages: write
311
+ id-token: write
312
+
313
+ # Allow only one concurrent deployment, skipping queued runs.
314
+ concurrency:
315
+ group: pages
316
+ cancel-in-progress: false
317
+
318
+ jobs:
319
+ build:
320
+ runs-on: ubuntu-latest${workingDirBlock}
321
+ steps:
322
+ - uses: actions/checkout@v4
323
+
324
+ - uses: pnpm/action-setup@v4
325
+ with:
326
+ version: 10
327
+
328
+ - uses: actions/setup-node@v4
329
+ with:
330
+ # Astro 6 requires Node ^20.19.5 || >=22.12.0; pin 22 for
331
+ # forward-compat (Node 20 LTS is fine for Astro 5 sites
332
+ # but the Dogsbay scaffold targets Astro 6).
333
+ node-version: 22
334
+ cache: pnpm
335
+ cache-dependency-path: ${cacheDep}
336
+
337
+ - name: Install dependencies
338
+ run: pnpm install --frozen-lockfile
339
+
340
+ # \`dogsbay\` is a global CLI, not a project dep — pnpm dlx
341
+ # fetches it on demand. To pin a version, replace with e.g.
342
+ # \`pnpm dlx dogsbay@0.2.0-beta.18 site build\`.
343
+ - name: Build with Dogsbay
344
+ run: pnpm dlx dogsbay@beta site build
345
+
346
+ - name: Build Astro site
347
+ run: pnpm run build
348
+
349
+ - name: Upload Pages artifact
350
+ uses: actions/upload-pages-artifact@v3
351
+ with:
352
+ path: ${artifactPath}
353
+
354
+ deploy:
355
+ needs: build
356
+ runs-on: ubuntu-latest
357
+ environment:
358
+ name: github-pages
359
+ url: \${{ steps.deployment.outputs.page_url }}
360
+ steps:
361
+ - name: Deploy to GitHub Pages
362
+ id: deployment
363
+ uses: actions/deploy-pages@v4
364
+ `;
365
+ }
150
366
  /**
151
367
  * Construct the SiteConfig object that gets serialized to
152
368
  * `src/data/site.json`. Backward-compatible: existing fields keep their
@@ -156,8 +372,6 @@ function buildSiteConfig(siteName, options) {
156
372
  const cfg = {
157
373
  siteName,
158
374
  repoUrl: options.repoUrl || "",
159
- editUri: options.editUri || "blob/main/docs/",
160
- copyright: options.copyright || "",
161
375
  };
162
376
  if (options.siteUrl)
163
377
  cfg.siteUrl = options.siteUrl;
@@ -169,6 +383,15 @@ function buildSiteConfig(siteName, options) {
169
383
  cfg.twitterHandle = options.twitterHandle;
170
384
  if (options.themeColor)
171
385
  cfg.themeColor = options.themeColor;
386
+ // editUri + copyright follow the same omit-on-empty pattern as the
387
+ // optional fields above; previously they were always written
388
+ // (editUri defaulted to "blob/main/docs/", copyright to ""), which
389
+ // left zombie config in src/data/site.json. Downstream guards already
390
+ // treat empty / undefined as "don't render" so this is purely a tidy.
391
+ if (options.editUri)
392
+ cfg.editUri = options.editUri;
393
+ if (options.copyright)
394
+ cfg.copyright = options.copyright;
172
395
  if (options.brandKeywords && options.brandKeywords.length > 0) {
173
396
  cfg.brandKeywords = options.brandKeywords;
174
397
  }
@@ -187,11 +410,58 @@ function buildSiteConfig(siteName, options) {
187
410
  }
188
411
  if (options.taxonomyIndexPaths &&
189
412
  Object.keys(options.taxonomyIndexPaths).length > 0) {
190
- cfg.taxonomyIndexPaths = options.taxonomyIndexPaths;
413
+ // Bake basePath into every emitted indexPath so consumers
414
+ // (TypeBadge / StatusBadge / future components) compose hrefs
415
+ // like `${indexPath}/<value>/` and resolve under the configured
416
+ // site base. Without the prefix, `/by-type/tutorial/` 404s on
417
+ // any site with `site.basePath` set. Caller passes raw config
418
+ // values (`/by-type`, `/tags`, etc.) — basePath threading is
419
+ // this emitter's responsibility, matching how `page.url` is
420
+ // already prefixed in the taxonomy data file.
421
+ // Taxonomy index paths are baked into site.json so components
422
+ // (TagList, TaxonomyIndex, TypeBadge) emit correct hrefs at
423
+ // runtime. Use combined so these resolve under the host's
424
+ // served subpath.
425
+ const taxoPrefix = combinedPrefix(options);
426
+ cfg.taxonomyIndexPaths = Object.fromEntries(Object.entries(options.taxonomyIndexPaths).map(([name, raw]) => [
427
+ name,
428
+ withBasePath(taxoPrefix, raw),
429
+ ]));
191
430
  }
192
431
  if (options.taxonomyDisplay &&
193
432
  Object.keys(options.taxonomyDisplay).length > 0) {
194
- cfg.taxonomyDisplay = options.taxonomyDisplay;
433
+ // Flatten prefix labels into top-level entries so the
434
+ // search-facets resolver finds them after DocsLayout splits
435
+ // slash-nested tags into per-prefix Pagefind filter divs.
436
+ //
437
+ // Input:
438
+ // tags.prefixes = { difficulty: { label, color }, ... }
439
+ // tags.labels = { "difficulty/1": "Beginner", "difficulty/2": ... }
440
+ // Output additions (kept alongside the original `tags` entry):
441
+ // difficulty.labels = { "1": "Beginner", "2": ... }
442
+ //
443
+ // Resolver does `display[facetName].labels[value]` — facet name
444
+ // is now `difficulty`, value is `1`, → "Beginner". See
445
+ // plans/per-prefix-search-facets.md.
446
+ const flat = { ...options.taxonomyDisplay };
447
+ const tagsDisplay = options.taxonomyDisplay.tags;
448
+ if (tagsDisplay?.prefixes) {
449
+ for (const prefix of Object.keys(tagsDisplay.prefixes)) {
450
+ if (flat[prefix])
451
+ continue; // top-level entry wins
452
+ const leafLabels = {};
453
+ if (tagsDisplay.labels) {
454
+ const needle = `${prefix}/`;
455
+ for (const [slug, label] of Object.entries(tagsDisplay.labels)) {
456
+ if (slug.startsWith(needle)) {
457
+ leafLabels[slug.slice(needle.length)] = label;
458
+ }
459
+ }
460
+ }
461
+ flat[prefix] = { labels: leafLabels };
462
+ }
463
+ }
464
+ cfg.taxonomyDisplay = flat;
195
465
  }
196
466
  return cfg;
197
467
  }
@@ -257,6 +527,106 @@ function ensureDirectoryStructure(outputDir, basePath) {
257
527
  export function emitSiteConfig(outputDir, siteName, options) {
258
528
  mkdirSync(join(outputDir, "src", "data"), { recursive: true });
259
529
  writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
530
+ // Auto-generated companion to astro.config.mjs. Carries the
531
+ // site/base values derived from dogsbay.config.yml's site.url so
532
+ // changes propagate without --force-rescaffolding the main
533
+ // astro.config.mjs (which is scaffold-once and may have author
534
+ // edits — custom integrations, build hooks, etc.). The main
535
+ // config imports `dogsbaySite` + `dogsbayBase` from here.
536
+ // See plans/astro-base-from-site-url.md.
537
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
538
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
539
+ const dogsbaySiteJson = hasSiteUrl
540
+ ? JSON.stringify(origin ?? options.siteUrl)
541
+ : "undefined";
542
+ const dogsbayBaseJson = astroBase ? JSON.stringify(astroBase) : "undefined";
543
+ // build.inlineStylesheets — defaults to "auto" (Astro's own
544
+ // default; matches our docs-first bias since theme.css is ~120KB
545
+ // and externalizing it lets the file cache cross-page). Authors
546
+ // wanting "always" / "never" set it via dogsbay.config.yml's
547
+ // build.inlineStylesheets. See docs/perf-tuning.md.
548
+ const dogsbayInline = options.inlineStylesheets ?? "auto";
549
+ writeFileSync(join(outputDir, "astro.config.dogsbay.mjs"), [
550
+ "// Auto-generated by `dogsbay site build` — DO NOT EDIT.",
551
+ "// Tracks site.url + derived Astro base + build behaviour from",
552
+ "// dogsbay.config.yml. Edit dogsbay.config.yml and rebuild;",
553
+ "// edits to this file will be overwritten on the next build.",
554
+ `export const dogsbaySite = ${dogsbaySiteJson};`,
555
+ `export const dogsbayBase = ${dogsbayBaseJson};`,
556
+ `export const dogsbayInlineStylesheets = ${JSON.stringify(dogsbayInline)};`,
557
+ "",
558
+ ].join("\n"));
559
+ // Migration check: pre-beta.20 sites have an astro.config.mjs that
560
+ // doesn't import the companion. Without the import, the values
561
+ // emitted above are unused and Astro's `base` stays unset — the
562
+ // exact bug this work was meant to close. Warn loudly, with the
563
+ // patch the user needs to apply, until astro.config.mjs is
564
+ // updated. We don't auto-patch because the file may have author
565
+ // edits (custom integrations, build hooks).
566
+ const astroConfigPath = join(outputDir, "astro.config.mjs");
567
+ if (existsSync(astroConfigPath)) {
568
+ const astroConfigSrc = readFileSync(astroConfigPath, "utf-8");
569
+ if (!astroConfigSrc.includes("astro.config.dogsbay.mjs")) {
570
+ console.warn([
571
+ "",
572
+ " ⚠ astro.config.mjs is missing the dogsbay companion import.",
573
+ " Without it, Astro's `base` config stays unset and assets",
574
+ " served from a host subpath (GH Pages project pages,",
575
+ " multi-mount Cloudflare) will 404.",
576
+ "",
577
+ " Add these two lines to astro.config.mjs:",
578
+ "",
579
+ ' import {',
580
+ ' dogsbaySite,',
581
+ ' dogsbayBase,',
582
+ ' dogsbayInlineStylesheets,',
583
+ ' } from "./astro.config.dogsbay.mjs";',
584
+ "",
585
+ " export default defineConfig({",
586
+ " ...(dogsbaySite ? { site: dogsbaySite } : {}),",
587
+ " ...(dogsbayBase ? { base: dogsbayBase } : {}),",
588
+ " build: { inlineStylesheets: dogsbayInlineStylesheets },",
589
+ " // ...your existing config...",
590
+ " });",
591
+ "",
592
+ " OR regenerate from template (overwrites your edits):",
593
+ " dogsbay site init . --scaffold-only --force",
594
+ "",
595
+ ].join("\n"));
596
+ }
597
+ }
598
+ }
599
+ /**
600
+ * Re-pin every `@dogsbay/*` dependency in an existing package.json to
601
+ * `version` (the building CLI's lockstep version), leaving non-Dogsbay deps
602
+ * and any `workspace:` / `file:` dev links untouched. Returns the changes made.
603
+ *
604
+ * package.json is scaffold-once, so without this an upgraded CLI keeps building
605
+ * against the lib versions written at first scaffold — the drift that strands a
606
+ * site on a stale DocsLayout that can't render the new emitter's output.
607
+ */
608
+ function syncDogsbayDepVersions(pkgPath, version) {
609
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
610
+ const changed = [];
611
+ for (const field of ["dependencies", "devDependencies"]) {
612
+ const deps = pkg[field];
613
+ if (!deps)
614
+ continue;
615
+ for (const [name, spec] of Object.entries(deps)) {
616
+ if (!name.startsWith("@dogsbay/"))
617
+ continue;
618
+ if (spec.startsWith("workspace:") || spec.startsWith("file:"))
619
+ continue;
620
+ if (spec !== version) {
621
+ deps[name] = version;
622
+ changed.push(`${name}: ${spec} -> ${version}`);
623
+ }
624
+ }
625
+ }
626
+ if (changed.length > 0) {
627
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
628
+ }
629
+ return changed;
260
630
  }
261
631
  export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
262
632
  let scaffoldFilesSkipped = 0;
@@ -300,6 +670,7 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
300
670
  };
301
671
  // Per-deploy-target additions to package.json
302
672
  const isCloudflare = options.deploy === "cloudflare-workers";
673
+ const isGitHubPages = options.deploy === "github-pages";
303
674
  const deployScripts = isCloudflare
304
675
  ? { deploy: "pnpm build && wrangler deploy" }
305
676
  : {};
@@ -325,14 +696,25 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
325
696
  },
326
697
  dependencies: {
327
698
  astro: "^6.0.0",
328
- "@astrojs/sitemap": "^3.0.0",
699
+ // Sitemap is emitted directly by Dogsbay into
700
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount
701
+ // deploys don't collide at the host root. We deliberately
702
+ // do NOT depend on @astrojs/sitemap (it hardcodes output to
703
+ // dist/ root, which is what we're moving away from).
329
704
  // Pagefind is invoked from the build script (see scripts.build above).
330
705
  // Lives in dependencies (not devDependencies) so production builds
331
706
  // include it; the produced search index is shipped statically and
332
707
  // doesn't load this dep at runtime.
333
708
  pagefind: "^1.4.0",
334
709
  tailwindcss: "^4.0.0",
335
- "@tailwindcss/vite": "^4.0.0",
710
+ // Pinned to 4.2.x — `@tailwindcss/vite` 4.3.x ships an
711
+ // oxcResolvePlugin shape that breaks Astro 6's
712
+ // rolldown-vite ("Missing field tsconfigPaths in
713
+ // oxcResolvePlugin"). Surfaced during the FastAPI import
714
+ // (~150-page MkDocs site) on a fresh `dogsbay site init`.
715
+ // Drop the ~ when Astro 6 picks up a compatible rolldown
716
+ // build OR @tailwindcss/vite restores the prior shape.
717
+ "@tailwindcss/vite": "~4.2.2",
336
718
  "tailwind-variants": "^0.3.0",
337
719
  shiki: "^4.0.0",
338
720
  "@shikijs/transformers": "^4.0.0",
@@ -348,6 +730,15 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
348
730
  "@dogsbay/primitives": dogsbayDep("primitives"),
349
731
  "@dogsbay/icons": dogsbayDep("icons"),
350
732
  "@dogsbay/elements": dogsbayDep("elements"),
733
+ // Transitive of `@dogsbay/primitives` (via
734
+ // `@floating-ui/dom`). Listed at the top level because
735
+ // npm doesn't hoist the second-level transitive when
736
+ // `@dogsbay/primitives` is linked via `file:` (the
737
+ // `--local` monorepo mode + the canary publish flow on
738
+ // GH Pages CI both hit this). Surfaced during the
739
+ // FastAPI import: Rollup failed with "Cannot resolve
740
+ // @floating-ui/core" at astro build time.
741
+ "@floating-ui/core": "^1.7.0",
351
742
  },
352
743
  // Pin transitive Vite to 7. Vite 8 just released; Astro 6
353
744
  // peer-deps Vite 7 and prints a warning when 8 is hoisted.
@@ -363,6 +754,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
363
754
  }
364
755
  else {
365
756
  scaffoldFilesSkipped++;
757
+ // package.json is scaffold-once, but the @dogsbay/* libraries ship in
758
+ // lockstep with the CLI. Re-sync their pins to the building CLI's version
759
+ // on every build so upgrading the CLI (e.g. `npm i -g dogsbay@beta`) can't
760
+ // leave the astro project on stale libraries whose older components don't
761
+ // render the new emitter's markup. Versioned (published) style only —
762
+ // `workspace:*` / `file:` dev links are left untouched.
763
+ if (!insideWs && !options.local) {
764
+ const synced = syncDogsbayDepVersions(join(outputDir, "package.json"), dogsbayPeerVersion);
765
+ if (synced.length > 0) {
766
+ console.log(`Synced ${synced.length} @dogsbay/* dependency pin(s) to ${dogsbayPeerVersion}`);
767
+ }
768
+ }
366
769
  }
367
770
  // wrangler.jsonc — scaffold-once. Bindings, secrets, custom routes
368
771
  // belong here post-generation.
@@ -374,6 +777,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
374
777
  scaffoldFilesSkipped++;
375
778
  }
376
779
  }
780
+ // GitHub Pages deploy artifacts — workflow + .nojekyll. The actual
781
+ // emission lives in `emitDeployArtifacts` so site-build can also
782
+ // call it on existing sites without going through scaffold (a user
783
+ // adds `deploy: github-pages` to dogsbay.config.yml and reruns
784
+ // `site build` to get the workflow). At scaffold-time we pass
785
+ // forceOverwrite=writeScaffold so `--force` regenerates from
786
+ // template; on regular builds it stays write-if-missing.
787
+ if (isGitHubPages) {
788
+ emitDeployArtifacts(outputDir, options, {
789
+ forceOverwrite: writeScaffold,
790
+ });
791
+ }
377
792
  // Generate astro.config.mjs
378
793
  // `preserveSymlinks: true` is used with --local to pin local file: deps to
379
794
  // their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
@@ -385,52 +800,50 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
385
800
  preserveSymlinks: true,
386
801
  },`
387
802
  : "";
388
- // Sitemap integration is conditional: requires an absolute site URL so
389
- // <loc> entries can be properly absolute. Without siteUrl, the sitemap
390
- // step is skipped (the import + integration call are simply omitted from
391
- // the generated config). Sitemap also filters out frontmatter-noindex pages.
392
- const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
393
- const sitemapImport = hasSiteUrl ? `import sitemap from "@astrojs/sitemap";\n` : "";
394
- // Strip any path component from site.url before emitting. The
395
- // config validator already rejects `site.url` containing a path
396
- // when `basePath` is non-empty (canonical URLs would double-count
397
- // the prefix); this is a defensive normalisation for the case
398
- // where the validator is bypassed or basePath is empty.
803
+ // siteUrl gates absolute-URL emission (sitemap <loc> entries,
804
+ // canonical tags). Without one, both are skipped relative URLs
805
+ // are still correct, the sitemap is just not generated.
399
806
  //
400
- // Note: we deliberately do NOT emit Astro's `base:` field. With
401
- // the current file emission (pages live under
402
- // `src/pages/<basePath>/...`), adding `base` would cause Astro
403
- // to doubly-prefix every route. Switching to `base`-driven
404
- // routing is a separate refactor — see plans/configurable-base-path.md.
405
- let siteField = "";
406
- if (hasSiteUrl) {
407
- let originOnly;
408
- try {
409
- const u = new URL(options.siteUrl);
410
- originOnly = `${u.protocol}//${u.host}`;
411
- }
412
- catch {
413
- originOnly = options.siteUrl;
414
- }
415
- siteField = `\n site: ${JSON.stringify(originOnly)},`;
416
- }
417
- const integrationsField = hasSiteUrl ? `\n integrations: [sitemap()],` : "";
418
- // astro.config.mjs scaffold-once. Maintainer adds custom integrations.
419
- // The plugin-aliases import is for the Dogsbay plugin API: each
420
- // build emits `astro.config.plugins.mjs` exporting `pluginAliases`,
421
- // a Vite alias map for `virtual:dogsbay-plugin-config/<id>` modules.
422
- // When no plugins use defineClientConfig the map is empty and the
423
- // spread is a no-op. See plans/plugin-api.md.
807
+ // Sitemap is emitted directly by Dogsbay (see emitSitemapFiles)
808
+ // into public/<basePath>/sitemap-*.xml. We deliberately do NOT
809
+ // wire @astrojs/sitemap here; that integration hardcodes output
810
+ // to dist/ root, breaking multi-mount deploys.
811
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
812
+ // site.url's path component (if any) becomes Astro's `base`. The
813
+ // origin alone goes into `site`. This split lets dogsbay model
814
+ // both axes independently:
815
+ // - Astro's `base` (= urlBase) controls the URL prefix Astro
816
+ // bakes into HTML asset references (`<basePath>/_astro/...`)
817
+ // and the routes Astro generates from src/pages.
818
+ // - dogsbay's basePath controls the filesystem layout
819
+ // (`src/pages/<basePath>/...`).
820
+ // The two compose at emit time — combining for nav hrefs,
821
+ // sitemap, llms.txt, etc. See plans/astro-base-from-site-url.md.
822
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
823
+ // astro.config.mjs — scaffold-once, but the site/base values flow
824
+ // through a separate auto-generated file (`astro.config.dogsbay.mjs`,
825
+ // emitted unconditionally below) so dogsbay-derived values stay in
826
+ // sync with `dogsbay.config.yml` even on existing sites where the
827
+ // main config is preserved. Same pattern as
828
+ // `astro.config.plugins.mjs` the import line is the load-bearing
829
+ // bit; the auto-file is what changes.
424
830
  if (writeScaffold) {
425
831
  writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
426
832
  import tailwindcss from "@tailwindcss/vite";
427
- ${sitemapImport}import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
833
+ import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
834
+ import {
835
+ dogsbaySite,
836
+ dogsbayBase,
837
+ dogsbayInlineStylesheets,
838
+ } from "./astro.config.dogsbay.mjs";
428
839
 
429
- export default defineConfig({${siteField}
840
+ export default defineConfig({
841
+ ...(dogsbaySite ? { site: dogsbaySite } : {}),
842
+ ...(dogsbayBase ? { base: dogsbayBase } : {}),
430
843
  output: "static",
431
844
  build: {
432
- inlineStylesheets: "always",
433
- },${integrationsField}
845
+ inlineStylesheets: dogsbayInlineStylesheets,
846
+ },
434
847
  vite: {
435
848
  plugins: [tailwindcss()],
436
849
  resolve: {
@@ -452,6 +865,9 @@ export default defineConfig({${siteField}
452
865
  else {
453
866
  scaffoldFilesSkipped++;
454
867
  }
868
+ // astro.config.dogsbay.mjs is emitted by emitSiteConfig (called
869
+ // above and on every site build) so site/base values stay in
870
+ // sync without a re-scaffold. See its definition for rationale.
455
871
  // Always seed an empty astro.config.plugins.mjs so the import in
456
872
  // astro.config.mjs resolves before the first plugin-emitting
457
873
  // build. Subsequent builds replace it via emitPluginRuntime.
@@ -522,7 +938,12 @@ export default defineConfig({${siteField}
522
938
  */
523
939
  export async function emitAstroPages(pages, nav, outputDir, options) {
524
940
  const siteName = options.siteName || "Documentation";
941
+ // basePath = filesystem layout prefix (where pages live under
942
+ // src/pages/...). combined = the URL prefix HTML hrefs need
943
+ // (urlBase + basePath). The two diverge whenever site.url has a
944
+ // path component (GH Pages project pages, multi-mount Cloudflare).
525
945
  const basePath = normalizeBasePath(options.basePath);
946
+ const combined = combinedPrefix(options);
526
947
  const baseSegments = basePathSegments(basePath);
527
948
  // Ensure dirs exist (callers may invoke us without going through the
528
949
  // full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
@@ -543,13 +964,36 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
543
964
  // Remove existing entry for this section (full replace)
544
965
  existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
545
966
  && item.label?.toLowerCase() !== section.toLowerCase());
546
- const prefixedNav = prefixNavHrefs(nav, section, basePath);
967
+ // Nav hrefs already carry the `combined` prefix (the importer
968
+ // emits them via fileToHref(file, hrefPrefix=combined)).
969
+ // prefixNavHrefs takes the existing prefix and weaves a section
970
+ // segment into it.
971
+ const prefixedNav = prefixNavHrefs(nav, section, combined);
547
972
  const sectionLabel = siteName
548
973
  || section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
549
974
  existingNav.push({ label: sectionLabel, children: prefixedNav });
550
975
  outputNav = existingNav;
551
976
  }
977
+ // Safety net: ensure every nav href carries the combined URL base,
978
+ // regardless of which importer produced the nav. Idempotent — a nav
979
+ // already prefixed at import time (dogsbay-md walking source paths) is
980
+ // left untouched; a nav of pre-baked root-absolute hrefs (docusaurus
981
+ // convert) gets the base it was missing. Keeps nav hrefs aligned with
982
+ // the combined-prefixed `currentPath` so prev/next still matches.
983
+ outputNav = prefixNavBaseHrefs(outputNav, combined);
552
984
  writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(outputNav, null, 2));
985
+ // Also publish nav.json under public/_dogsbay/ so the
986
+ // client-mode <DocsNavClient /> can fetch it at runtime
987
+ // (Astro copies public/ to dist/ as-is at build). The src/data
988
+ // copy stays for build-time imports (pagination, prev/next
989
+ // calculation in each page) — the public copy is the on-the-wire
990
+ // copy that the browser ever sees. Kept identical (same bytes,
991
+ // same shape); the duplicate is cheap (one file) and keeps the
992
+ // build-time and runtime worlds cleanly separated. See
993
+ // plans/client-rendered-nav.md.
994
+ const publicNavDir = join(outputDir, "public", "_dogsbay");
995
+ mkdirSync(publicNavDir, { recursive: true });
996
+ writeFileSync(join(publicNavDir, "nav.json"), JSON.stringify(outputNav));
553
997
  // Static assets (images etc.) — content-tier; always copy from the
554
998
  // user's source dir. If they removed an asset, we want it gone here
555
999
  // too. Skipped when sourceDir isn't supplied (programmatic callers
@@ -557,21 +1001,93 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
557
1001
  if (options.sourceDir) {
558
1002
  copyAssets(options.sourceDir, outputDir, options.imageOptimization);
559
1003
  }
1004
+ // External asset mounts (e.g. a Docusaurus `static/` dir) → public/_assets/,
1005
+ // preserving internal structure so the importer's `/_assets/...` image refs
1006
+ // resolve. Used by the convert --to astro path; the dogsbay-md → site build
1007
+ // path instead lands these under content/_assets and copyAssets picks them up.
1008
+ if (options.assetMounts) {
1009
+ for (const mount of options.assetMounts) {
1010
+ if (existsSync(mount.dir)) {
1011
+ cpSync(mount.dir, join(outputDir, "public", "_assets"), { recursive: true });
1012
+ }
1013
+ }
1014
+ }
560
1015
  let generated = 0;
1016
+ // Tracks whether a real root home page (`content/index.md` → slug
1017
+ // "index") was emitted. Drives the root-served-site redirect fallback
1018
+ // below: a corpus whose nav root is e.g. `welcome/index` (OpenShift)
1019
+ // has no `/` page, so without a redirect `/` 404s. See
1020
+ // plans/dir-index-slug-nav-drop.md.
1021
+ let rootIndexEmitted = false;
1022
+ const generatedPaths = new Set();
561
1023
  const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
562
1024
  const useImageOpt = options.imageOptimization ?? false;
563
- // hrefPrefix is the same string as basePath. rewriteHref handles the
564
- // empty-basePath case correctly: any link starting with "/" matches
565
- // the early-return guard, so root-relative links pass through
566
- // unrewritten when the site is served at host root.
567
- const hrefPrefix = basePath;
1025
+ // hrefPrefix is the COMBINED prefix (urlBase + basePath) what
1026
+ // rendered HTML hrefs need so internal links resolve under the
1027
+ // host's served subpath AND under the dogsbay basePath. For
1028
+ // simple host-apex deploys with basePath, urlBase is empty so
1029
+ // combined === basePath (back-compat). For GH Pages project pages
1030
+ // and multi-mount Cloudflare, combined adds the urlBase layer.
1031
+ const hrefPrefix = combined;
1032
+ // Route-exclusion gate. Two signals, either is enough:
1033
+ // - frontmatter `_fragment: true` (loader-stamped — the importer
1034
+ // knew this .md was include-only, not a navigable page)
1035
+ // - excludeFromRoutes match against the slug (project-declared,
1036
+ // in dogsbay.config.yml — for content under conventional
1037
+ // fragment dirs like `modules/`, `_attributes/`, `snippets/`)
1038
+ // Excluded pages stay on disk under content/ (so includes resolve)
1039
+ // but don't produce .astro / .md.ts routes. See plans/build-at-scale.md.
1040
+ //
1041
+ // Match semantics:
1042
+ // - A single-segment pattern (no `/`) matches ANY occurrence of
1043
+ // that segment in the slug path. So `modules` excludes both
1044
+ // `modules/foo` AND `welcome/modules/foo` AND
1045
+ // `drupal-build/openshift-enterprise/ai/includes/x`. This is
1046
+ // what AsciiBinder corpora want — fragment dirs symlink into
1047
+ // section dirs so the same `includes/` etc. appears at many
1048
+ // depths.
1049
+ // - A multi-segment pattern (contains `/`) matches as a leading
1050
+ // prefix (exact path-prefix). `welcome/_internal` excludes
1051
+ // only that specific path tree, not arbitrary `_internal/`
1052
+ // elsewhere.
1053
+ const excludePatterns = (options.excludeFromRoutes ?? []).map((p) => p.replace(/^\/+/, "").replace(/\/+$/, ""));
1054
+ function isExcludedSlug(slug) {
1055
+ if (excludePatterns.length === 0)
1056
+ return false;
1057
+ const segments = slug.split("/");
1058
+ for (const pattern of excludePatterns) {
1059
+ if (pattern.includes("/")) {
1060
+ // Multi-segment → leading-prefix match.
1061
+ if (slug === pattern || slug.startsWith(pattern + "/"))
1062
+ return true;
1063
+ }
1064
+ else {
1065
+ // Single-segment → any-segment match.
1066
+ if (segments.includes(pattern))
1067
+ return true;
1068
+ }
1069
+ }
1070
+ return false;
1071
+ }
568
1072
  for (const page of pages) {
569
1073
  try {
1074
+ // Skip excluded pages before any expensive work (tree rewrite,
1075
+ // serialize, IO).
1076
+ const isFragment = page.frontmatter && page.frontmatter._fragment === true;
1077
+ if (isFragment || isExcludedSlug(page.slug)) {
1078
+ continue;
1079
+ }
570
1080
  // Rewrite internal hrefs to match the output URL structure
571
1081
  rewriteTreeHrefs(page.tree, hrefPrefix);
1082
+ // Same for raw image srcs — Astro doesn't auto-prefix
1083
+ // `<img src="/_assets/...">` so we do it here. Block images
1084
+ // strip the prefix back off for the `imageMap[...]` lookup
1085
+ // (see paragraphToAstro in serialize.ts).
1086
+ rewriteTreeImageSrcs(page.tree, hrefPrefix);
572
1087
  const result = treeToAstro(page.tree, {
573
1088
  imageOptimization: useImageOpt,
574
1089
  codeBlockTitle: options.codeBlockTitle ?? true,
1090
+ combinedPrefix: hrefPrefix,
575
1091
  });
576
1092
  const imageSetup = useImageOpt ? [
577
1093
  '',
@@ -585,11 +1101,32 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
585
1101
  ' imageMap[publicPath] = mod.default;',
586
1102
  '}',
587
1103
  ] : [];
588
- // Per-page meta from frontmatter
1104
+ // Per-page meta from frontmatter. When no description is authored,
1105
+ // derive one from the first prose paragraph so every page emits a
1106
+ // <meta name="description"> (Lighthouse flags pages without one).
1107
+ // See plans/auto-meta-description.md.
589
1108
  const fm = (page.frontmatter ?? {});
590
- const pageDescription = fm.description ?? "";
1109
+ // A writer-authored frontmatter description is meta-only (not in the body),
1110
+ // so it's safe to also render as a visible lede. A DERIVED description is
1111
+ // pulled FROM the body, so rendering it as a lede always duplicates content
1112
+ // (e.g. a page whose first paragraph sits under "## Big picture"). Keep the
1113
+ // two separate: derive for the <meta> tag, but only lede the authored one.
1114
+ const fmDescription = typeof fm.description === "string" && fm.description.trim().length > 0
1115
+ ? fm.description
1116
+ : undefined;
1117
+ const pageDescription = fmDescription || deriveDescription(page.tree) || "";
591
1118
  const pageOgImage = fm.ogImage ?? "";
592
- const pageNoindex = fm.noindex === true || fm.draft === true;
1119
+ // Noindex / nofollow are independent meta directives. Site-level
1120
+ // forces both bits site-wide (staging / compliance lockdown);
1121
+ // page frontmatter can ESCALATE either bit independently but
1122
+ // cannot opt out of a site-level lockdown. `draft: true` keeps
1123
+ // its existing role as a noindex shorthand. See
1124
+ // plans/site-level-robots-meta.md.
1125
+ const pageNoindex = options.noindex === true ||
1126
+ fm.noindex === true ||
1127
+ fm.draft === true;
1128
+ const pageNofollow = options.nofollow === true ||
1129
+ fm.nofollow === true;
593
1130
  // Independent of noindex: pages can be excluded from in-site
594
1131
  // Pagefind search even when external SEs should index them
595
1132
  // (or vice versa). See DocsLayout's prop docs for the
@@ -608,7 +1145,28 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
608
1145
  const pageCategory = Array.isArray(pageMeta?.category)
609
1146
  ? pageMeta.category
610
1147
  : undefined;
611
- const tagsIndexPath = options.tagsIndexPath ?? "/tags";
1148
+ // Custom-taxonomy values lifted from frontmatter into
1149
+ // `meta.taxonomies` by the importer (see `parseMeta` in
1150
+ // `@dogsbay/types`). Surfaced to DocsLayout so it can emit one
1151
+ // `<div data-pagefind-filter="<name>:<value>">` per entry — this
1152
+ // is what makes user-declared taxonomies (`difficulty`, `team`,
1153
+ // anything not in the five built-ins) appear as visible facet
1154
+ // checkboxes in the search dialog. Without this passthrough
1155
+ // they're silently dropped after the importer.
1156
+ const pageTaxonomies = pageMeta?.taxonomies && Object.keys(pageMeta.taxonomies).length > 0
1157
+ ? pageMeta.taxonomies
1158
+ : undefined;
1159
+ // `tagsIndexPath` flows to `<TagList>` for chip hrefs
1160
+ // (`${indexPath}/${tag}/`). Caller passes the raw config value
1161
+ // (e.g. `/tags`); we bake the COMBINED prefix (urlBase from
1162
+ // site.url's path + basePath) here so chips resolve under both
1163
+ // the host's served subpath AND the dogsbay basePath. With
1164
+ // basePath alone, chips 404 on GH Pages project deploys
1165
+ // (basePath="" + non-empty urlBase) — same shape as the
1166
+ // typeBadgeHref / statusBadgeHref composition in DocsLayout,
1167
+ // which already reads combined-prefixed values out of
1168
+ // siteConfig.taxonomyIndexPaths (baked in buildSiteConfig).
1169
+ const tagsIndexPath = withBasePath(combined, options.tagsIndexPath ?? "/tags");
612
1170
  // Auto-lede detection. If the markdown body doesn't already
613
1171
  // start with an H1 / leading paragraph, we ask DocsLayout to
614
1172
  // render the frontmatter title / description at the top of
@@ -622,7 +1180,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
622
1180
  page.title.length > 0;
623
1181
  const autoLede = !isRedirect &&
624
1182
  !bodyHasLede &&
625
- pageDescription.length > 0;
1183
+ fmDescription !== undefined;
626
1184
  // Per-page LLM action UI. Site-wide config in
627
1185
  // `options.llmActions`; per-page opt-out via frontmatter
628
1186
  // `llmActions: false`. Skipped on redirect pages (no real
@@ -640,12 +1198,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
640
1198
  // available via other means. The "Open in" deep links work
641
1199
  // regardless of mirror availability — agents that can't fetch
642
1200
  // the page just see the URL in their chat.
1201
+ // pageHrefBase uses combined (urlBase + basePath) so the URL
1202
+ // resolves correctly when the host serves dist/ at a subpath
1203
+ // (GH Pages project page, multi-mount Cloudflare).
643
1204
  const pageHrefBase = section
644
- ? (basePath ? `${basePath}/${section}/${page.slug}` : `/${section}/${page.slug}`)
645
- : (basePath ? `${basePath}/${page.slug}` : `/${page.slug}`);
1205
+ ? (combined ? `${combined}/${section}/${page.slug}` : `/${section}/${page.slug}`)
1206
+ : (combined ? `${combined}/${page.slug}` : `/${page.slug}`);
646
1207
  const pageMdHref = `${pageHrefBase}.md`;
647
- const pageMdAbsoluteUrl = options.siteUrl
648
- ? options.siteUrl.replace(/\/$/, "") + pageMdHref
1208
+ // For absolute URLs (the "Copy as MD" deep link), use the
1209
+ // origin (no path) + the full combined path; siteUrl alone
1210
+ // would double-include the urlBase since pageHrefBase already
1211
+ // contains it.
1212
+ const { origin } = parseSiteUrl(options.siteUrl);
1213
+ const pageMdAbsoluteUrl = origin
1214
+ ? origin + pageMdHref
649
1215
  : pageMdHref;
650
1216
  // Markdown body for the Copy button. Reuse the same serializer
651
1217
  // that produces the .md mirror so what the user copies matches
@@ -662,6 +1228,12 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
662
1228
  // full inset width; the prose-readable cap and the right TOC
663
1229
  // would just steal pixels. See plans/openapi-builtin.md.
664
1230
  const wideLayout = (page.tree ?? []).some((n) => n && n.type === "endpoint");
1231
+ // TOC placement (DocsLayout `toc` prop). Default "top" — the
1232
+ // expandable "On this page" disclosure that frees the right rail
1233
+ // for plugin content (e.g. Ask AI). "rail" keeps the classic
1234
+ // right-hand TOC; in that mode the rail belongs to the TOC, so the
1235
+ // RightRail plugin region (slot + stack) is not emitted.
1236
+ const tocMode = options.toc ?? "top";
665
1237
  const pageLines = [
666
1238
  "---",
667
1239
  "// AUTO-GENERATED by `dogsbay convert` — do not edit.",
@@ -681,13 +1253,22 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
681
1253
  // Always exists; passthrough <slot /> when no plugin
682
1254
  // contributes a wrapper for the MarkdownContent slot.
683
1255
  'import MarkdownContentStack from "@/components/wrappers/MarkdownContentStack.astro";',
1256
+ // RightRailStack — hosts plugin content (e.g. an Ask AI panel) in
1257
+ // the freed right rail via DocsLayout's `right-rail` named slot.
1258
+ // Only imported/used when the rail isn't the classic TOC's.
1259
+ ...(tocMode !== "rail"
1260
+ ? ['import RightRailStack from "@/components/wrappers/RightRailStack.astro";']
1261
+ : []),
684
1262
  'const siteConfig = siteConfigData as SiteConfig;',
685
1263
  ...result.imports,
686
1264
  ...imageSetup,
687
1265
  "",
688
1266
  `const headings = ${JSON.stringify(page.headings || [])};`,
689
1267
  `const nav = navData;`,
690
- `const currentPath = "${buildCurrentPath(basePath, section, page.slug)}";`,
1268
+ // currentPath uses combined so it matches nav.json hrefs
1269
+ // (which are also combined-prefixed). getPagination compares
1270
+ // them as strings; mismatched prefixes break prev/next.
1271
+ `const currentPath = "${buildCurrentPath(combined, section, page.slug)}";`,
691
1272
  // Filter nav to the current (locale, version) bucket
692
1273
  // before computing prev/next — without this, pagination
693
1274
  // walks the global nav and a "Next" link can leak from
@@ -705,12 +1286,14 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
705
1286
  `const description = ${JSON.stringify(pageDescription)} || undefined;`,
706
1287
  `const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
707
1288
  `const noindex = ${JSON.stringify(pageNoindex)};`,
1289
+ `const nofollow = ${JSON.stringify(pageNofollow)};`,
708
1290
  `const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
709
1291
  `const pageTags = ${JSON.stringify(pageTags ?? null)};`,
710
1292
  `const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
711
1293
  `const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
712
1294
  `const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
713
1295
  `const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
1296
+ `const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
714
1297
  `const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
715
1298
  `const llmActionsProps = ${JSON.stringify(llmActionsEnabled
716
1299
  ? {
@@ -741,6 +1324,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
741
1324
  ` twitterHandle={siteConfig.twitterHandle || undefined}`,
742
1325
  ` themeColor={siteConfig.themeColor || undefined}`,
743
1326
  ` noindex={noindex}`,
1327
+ ` nofollow={nofollow}`,
744
1328
  ` excludeFromSearch={excludeFromSearch}`,
745
1329
  ` plausibleDomain={siteConfig.plausible?.domain}`,
746
1330
  ` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
@@ -756,14 +1340,48 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
756
1340
  ` pageType={pageType ?? undefined}`,
757
1341
  ` audience={pageAudience ?? undefined}`,
758
1342
  ` category={pageCategory ?? undefined}`,
1343
+ ` taxonomies={pageTaxonomies ?? undefined}`,
759
1344
  ` autoH1={${autoH1}}`,
760
1345
  ` autoLede={${autoLede}}`,
761
1346
  ` llmActions={llmActionsProps}`,
762
1347
  ` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
763
1348
  ` switcherMap={switcherMapData}`,
764
- ` basePath={${JSON.stringify(basePath || "/docs")}}`,
1349
+ // basePath here is the COMBINED URL prefix (urlBase from
1350
+ // site.url's path + dogsbay basePath). DocsLayout uses it
1351
+ // for switcher links, the footer llms.txt link, and the
1352
+ // <head> alternate link — all three need the full URL
1353
+ // prefix the host actually serves under. Empty string is
1354
+ // valid (root-served sites with no urlBase or basePath);
1355
+ // don't fall back to "/docs" — that would 404 for those.
1356
+ ` basePath={${JSON.stringify(combined)}}`,
1357
+ // navMode — controls whether DocsLayout server-renders the
1358
+ // full sidebar nav tree per page (`ssr-full`) or emits a
1359
+ // client-hydrated placeholder (`client`, default). The
1360
+ // client mode shrinks per-page HTML dramatically at scale.
1361
+ // See plans/client-rendered-nav.md.
1362
+ ` navMode={${JSON.stringify(options.navMode ?? "client")}}`,
1363
+ // Pagefind index URL — must include the combined prefix or
1364
+ // the loader 404s on subpath-mounted deploys. The pagefind
1365
+ // CLI writes to <astroOutput>/dist/pagefind/ which Astro
1366
+ // serves under its `base` (= urlBase); dogsbay's basePath
1367
+ // adds the second prefix layer. Empty combined → `/pagefind/`.
1368
+ ` pagefindUrl={${JSON.stringify(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
1369
+ // Favicon — composed with combined prefix so the
1370
+ // <link rel="icon"> resolves on subpath-mounted deploys.
1371
+ // Authors who want a different favicon override via the
1372
+ // `favicon` slot on DocsLayout, or drop the file at
1373
+ // `public/favicon.ico` in their Astro project (which is
1374
+ // what the default points at).
1375
+ ` favicon={${JSON.stringify(combined ? `${combined}/favicon.ico` : "/favicon.ico")}}`,
765
1376
  ` wideLayout={${wideLayout}}`,
1377
+ ` toc={${JSON.stringify(tocMode)}}`,
766
1378
  `>`,
1379
+ // RightRail region — assigned to DocsLayout's `right-rail` named
1380
+ // slot. Inert (empty passthrough) until a plugin contributes a
1381
+ // RightRail wrapper. Omitted in "rail" mode (TOC owns the rail).
1382
+ ...(tocMode !== "rail"
1383
+ ? [` <RightRailStack slot="right-rail" />`]
1384
+ : []),
767
1385
  ` <MarkdownContentStack>`,
768
1386
  wideLayout
769
1387
  ? result.body.split("\n").map((l) => ` ${l}`).join("\n")
@@ -798,6 +1416,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
798
1416
  mkdirSync(dirname(pagePath), { recursive: true });
799
1417
  writeFileSync(pagePath, pageLines.join("\n") + "\n");
800
1418
  generated++;
1419
+ if (!section && page.slug === "index")
1420
+ rootIndexEmitted = true;
1421
+ generatedPaths.add(relative(outputDir, pagePath));
801
1422
  // Companion .md endpoint for content negotiation. Prerendered, so
802
1423
  // it's served as a static asset at runtime — no Worker overhead.
803
1424
  //
@@ -833,14 +1454,59 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
833
1454
  }
834
1455
  }
835
1456
  // Generate index redirect at src/pages/index.astro — sends `/` to the
836
- // first nav href. Skipped when basePath is empty: with root-served
837
- // sites, src/pages/index.astro is the actual home page (not a
838
- // redirect target), and writing a redirect would clobber it.
839
- if (basePath !== "") {
840
- const firstHref = findFirstNavHref(nav, basePath);
841
- writeFileSync(join(outputDir, "src", "pages", "index.astro"), `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
842
- }
843
- return { generated, outputNav };
1457
+ // first nav href. Two cases need it:
1458
+ // - basePath set: `/` (host apex) isn't a content page, so redirect
1459
+ // into the basePath subtree.
1460
+ // - basePath empty BUT no root home page emitted (no
1461
+ // `content/index.md`): the corpus's entry point is a nested page
1462
+ // (e.g. OpenShift's `welcome/index`), so `/` would 404 without a
1463
+ // redirect. Only emit here for non-section builds and never when a
1464
+ // real root index page exists (writing a redirect would clobber it).
1465
+ // Use the base-prefixed nav (the same hrefs written to nav.json), not the
1466
+ // raw input `nav` — otherwise importers whose nav starts root-relative (e.g.
1467
+ // the docusaurus convert path) emit a redirect that drops the URL base and
1468
+ // sends `/` to the host root instead of into the site's subpath.
1469
+ const firstHref = findFirstNavHref(outputNav, basePath);
1470
+ const needsRootRedirect = basePath !== "" ||
1471
+ (!section && !rootIndexEmitted && firstHref !== "");
1472
+ if (needsRootRedirect) {
1473
+ const indexPath = join(outputDir, "src", "pages", "index.astro");
1474
+ writeFileSync(indexPath, `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
1475
+ generatedPaths.add(relative(outputDir, indexPath));
1476
+ }
1477
+ return { generated, outputNav, generatedPaths };
1478
+ }
1479
+ /**
1480
+ * Copy each passthrough `.astro` source to its computed output path.
1481
+ * Aborts with a clear error if the destination is already in
1482
+ * `generatedPaths` (a generated page from `emitAstroPages` would
1483
+ * silently overwrite the hand-authored file otherwise).
1484
+ */
1485
+ export function emitPassthroughAstroPages(copies, outputDir, generatedPaths) {
1486
+ if (copies.length === 0)
1487
+ return { copied: 0 };
1488
+ // Collision detection — a generated page and a passthrough page
1489
+ // would write to the same file. Refuse to overwrite; tell the
1490
+ // author exactly which two files conflict.
1491
+ const collisions = [];
1492
+ for (const copy of copies) {
1493
+ if (generatedPaths.has(copy.outputRelPath)) {
1494
+ collisions.push(copy.outputRelPath);
1495
+ }
1496
+ }
1497
+ if (collisions.length > 0) {
1498
+ throw new Error(`Passthrough Astro page collides with a generated page:\n` +
1499
+ collisions.map((c) => ` - ${c}`).join("\n") + "\n" +
1500
+ `Rename the .astro source or remove the colliding entry from nav.yml.`);
1501
+ }
1502
+ let copied = 0;
1503
+ for (const copy of copies) {
1504
+ const dest = join(outputDir, copy.outputRelPath);
1505
+ mkdirSync(dirname(dest), { recursive: true });
1506
+ copyFileSync(copy.sourceAbs, dest);
1507
+ copied++;
1508
+ }
1509
+ return { copied };
844
1510
  }
845
1511
  // ─── Tier 1: config-derived ─────────────────────────────────────────────
846
1512
  // Files driven entirely by config + flags. Always regenerated; site
@@ -856,6 +1522,54 @@ export function emitConfigDerivedFiles(outputDir, options) {
856
1522
  const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
857
1523
  writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
858
1524
  }
1525
+ /**
1526
+ * Per-deploy-target artifact emission.
1527
+ *
1528
+ * Called from `emitSiteScaffold` (with `forceOverwrite=writeScaffold`
1529
+ * so `--force` regenerates from template) and from `dogsbay site
1530
+ * build` (with `forceOverwrite=false` so an existing site can adopt
1531
+ * a deploy target by editing config and rebuilding — the missing
1532
+ * artifact gets created on the next build).
1533
+ *
1534
+ * Emit policy is the union: write when forced OR when the file is
1535
+ * missing. Author edits to e.g. the workflow YAML survive every
1536
+ * regular build.
1537
+ *
1538
+ * Currently handles `github-pages` (workflow + .nojekyll). The
1539
+ * existing `cloudflare-workers` artifacts (wrangler.jsonc + package
1540
+ * scripts) stay in the scaffold-only path because they overlap with
1541
+ * scaffold-only files (package.json scripts, devDependencies). A
1542
+ * future refactor could fold them in here too.
1543
+ */
1544
+ export function emitDeployArtifacts(outputDir, options, opts = { forceOverwrite: false }) {
1545
+ if (options.deploy === "github-pages") {
1546
+ // GitHub reads workflows from <repo-root>/.github/workflows/, NOT
1547
+ // from inside subdirectories. Use projectDir (the repo root) for
1548
+ // the workflow file; fall back to outputDir when unset (flat
1549
+ // `dogsbay convert` flows where the Astro project IS the repo).
1550
+ const projectDir = options.projectDir ?? outputDir;
1551
+ // Path of the Astro output relative to the project root. Used by
1552
+ // the workflow's working-directory + cache-dependency-path so
1553
+ // pnpm install / pnpm run build target the right place. Empty
1554
+ // string when outputDir === projectDir (flat layout).
1555
+ const astroDirRel = relative(projectDir, outputDir).replace(/\\/g, "/");
1556
+ const workflowPath = join(projectDir, ".github", "workflows", "deploy.yml");
1557
+ if (opts.forceOverwrite || !existsSync(workflowPath)) {
1558
+ mkdirSync(dirname(workflowPath), { recursive: true });
1559
+ writeFileSync(workflowPath, buildGitHubPagesWorkflow(astroDirRel));
1560
+ }
1561
+ // .nojekyll — must exist in the deployed artifact root so GH
1562
+ // Pages skips Jekyll's `_underscored-paths` filter (Astro's
1563
+ // `_astro/` chunk dir gets eaten otherwise). Lives inside the
1564
+ // Astro project's `public/` so it's copied into `dist/` at
1565
+ // build time.
1566
+ const nojekyllPath = join(outputDir, "public", ".nojekyll");
1567
+ mkdirSync(dirname(nojekyllPath), { recursive: true });
1568
+ if (opts.forceOverwrite || !existsSync(nojekyllPath)) {
1569
+ writeFileSync(nojekyllPath, "");
1570
+ }
1571
+ }
1572
+ }
859
1573
  /**
860
1574
  * Emit `src/data/switcherMap.json` describing per-page
861
1575
  * version + locale equivalents. Always writes the file —
@@ -872,7 +1586,10 @@ export function emitConfigDerivedFiles(outputDir, options) {
872
1586
  * baseline page in a multi-version site).
873
1587
  */
874
1588
  export function emitSwitcherMap(pages, outputDir, options) {
875
- const basePath = normalizeBasePath(options.basePath);
1589
+ // Switcher URLs use combined so the link the dropdown emits
1590
+ // resolves under the host's served subpath (GH Pages project
1591
+ // pages, multi-mount Cloudflare).
1592
+ const combined = combinedPrefix(options);
876
1593
  const dataDir = join(outputDir, "src", "data");
877
1594
  const outPath = join(dataDir, "switcherMap.json");
878
1595
  // Detect axis activation by inspecting the data the loader
@@ -911,7 +1628,7 @@ export function emitSwitcherMap(pages, outputDir, options) {
911
1628
  const variant = {
912
1629
  ...(ms.locale !== undefined ? { locale: ms.locale } : {}),
913
1630
  ...(ms.version !== undefined ? { version: ms.version } : {}),
914
- url: `${basePath}/${page.slug}`,
1631
+ url: `${combined}/${page.slug}`,
915
1632
  };
916
1633
  if (!byLogicalKey[key])
917
1634
  byLogicalKey[key] = [];
@@ -989,6 +1706,10 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
989
1706
  return;
990
1707
  const basePath = normalizeBasePath(options.basePath);
991
1708
  const baseSegments = basePathSegments(basePath);
1709
+ // combined drives the redirect URL (the user-facing path they
1710
+ // get bounced to); basePath stays the filesystem path under
1711
+ // src/pages/ where the stub lives.
1712
+ const combined = combinedPrefix(options);
992
1713
  // Index existing pages by (slug after locale segment) so we
993
1714
  // can detect missing translations cheaply. Key shape:
994
1715
  // `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
@@ -1022,7 +1743,7 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
1022
1743
  const targetUrl = `${basePath}/${targetSlug}`;
1023
1744
  if (existingByUrl.has(targetUrl))
1024
1745
  continue; // already translated
1025
- const defaultUrl = `${basePath}/${defaultPage.slug}`;
1746
+ const defaultUrl = `${combined}/${defaultPage.slug}`;
1026
1747
  const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
1027
1748
  // Ensure parent dir exists; write a redirect-stub Astro
1028
1749
  // file. Adding `.astro` to the leaf turns it into a
@@ -1064,10 +1785,20 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1064
1785
  if (options.llmsTxt !== false) {
1065
1786
  emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
1066
1787
  // public/_headers — Cloudflare Workers / Pages convention. Adds an
1067
- // RFC 8288 Link header pointing agents at /llms.txt without parsing
1068
- // HTML. Emitted alongside llms.txt so the two files travel together.
1788
+ // RFC 8288 Link header pointing agents at this mount's llms.txt
1789
+ // (basePath-prefixed) without parsing HTML. Emitted alongside
1790
+ // llms.txt so the two files travel together.
1069
1791
  mkdirSync(join(outputDir, "public"), { recursive: true });
1070
- writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile());
1792
+ // _headers Link header points at the per-mount llms.txt at
1793
+ // <combined>/llms.txt — the URL agents would actually fetch.
1794
+ writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(combinedPrefix(options)));
1795
+ }
1796
+ // Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
1797
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
1798
+ // don't collide at host root. Gated on a valid http(s) siteUrl
1799
+ // because <loc> entries must be absolute.
1800
+ if (options.siteUrl && /^https?:\/\//.test(options.siteUrl)) {
1801
+ emitSitemapFiles(outputDir, options, pages);
1071
1802
  }
1072
1803
  // src/middleware.ts — Tier 1 (always update). Drives both the
1073
1804
  // `Accept: text/markdown` content-negotiation rewrite (via
@@ -1086,12 +1817,29 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1086
1817
  const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
1087
1818
  const axisRedirectOn = versionRedirectOn || localeRedirectOn;
1088
1819
  if (mdMirrorOn || axisRedirectOn) {
1820
+ // Taxonomy index paths share a single global namespace across
1821
+ // locales / versions (one `/tags/` for the whole site, not one
1822
+ // per locale). The redirect helper has to know to skip them or
1823
+ // it will 302 chip hrefs to non-existent locale-prefixed routes.
1824
+ // Strip leading `/` and pull just the first segment so a config
1825
+ // like `/tags` becomes the global-prefix entry `tags`.
1826
+ const globalPrefixes = [];
1827
+ if (options.taxonomyIndexPaths) {
1828
+ for (const raw of Object.values(options.taxonomyIndexPaths)) {
1829
+ const first = raw.replace(/^\/+/, "").split("/")[0];
1830
+ if (first)
1831
+ globalPrefixes.push(first);
1832
+ }
1833
+ }
1089
1834
  mkdirSync(join(outputDir, "src"), { recursive: true });
1090
1835
  writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
1091
1836
  mdMirror: mdMirrorOn,
1092
1837
  axisRedirect: axisRedirectOn
1093
1838
  ? {
1094
- basePath: normalizeBasePath(options.basePath),
1839
+ // Middleware compares paths against the request URL,
1840
+ // which carries the host's served subpath — so use the
1841
+ // combined prefix here.
1842
+ basePath: combinedPrefix(options),
1095
1843
  ...(versionRedirectOn
1096
1844
  ? {
1097
1845
  defaultVersion: options.defaultVersion,
@@ -1104,6 +1852,7 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1104
1852
  knownLocales,
1105
1853
  }
1106
1854
  : {}),
1855
+ ...(globalPrefixes.length > 0 ? { globalPrefixes } : {}),
1107
1856
  }
1108
1857
  : undefined,
1109
1858
  }));
@@ -1164,21 +1913,49 @@ function buildRobotsTxt(options, hasSiteUrl) {
1164
1913
  const aiInput = options.aiInput ?? "yes";
1165
1914
  const aiTrain = options.aiTrain ?? "no";
1166
1915
  const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
1167
- const sitemap = hasSiteUrl
1168
- ? `Sitemap: ${options.siteUrl.replace(/\/$/, "")}/sitemap-index.xml\n`
1916
+ // Per-mount sitemap path: each Dogsbay site emits its sitemap
1917
+ // index under <basePath>/, so robots.txt must point there too.
1918
+ // (Multi-mount deploys end up with one robots.txt per site at
1919
+ // their respective hosts / paths; each correctly references its
1920
+ // own mount's sitemap-index.)
1921
+ // Sitemap URL = origin + combined + /sitemap-index.xml. Use the
1922
+ // origin (no path) from site.url and the combined prefix (urlBase
1923
+ // + basePath); siteUrl could itself include a path component when
1924
+ // hosting on a subpath (GH Pages project page), so we strip it
1925
+ // here to avoid double-counting.
1926
+ const { origin } = parseSiteUrl(options.siteUrl);
1927
+ const combined = combinedPrefix(options);
1928
+ const sitemap = hasSiteUrl && origin
1929
+ ? `Sitemap: ${origin}${withBasePath(combined, "/sitemap-index.xml")}\n`
1169
1930
  : "";
1170
- return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}`;
1931
+ // Llms-Txt: line — non-standard but follows the same shape as
1932
+ // `Sitemap:`. Crawlers and agents that scan robots.txt before
1933
+ // fetching pages get a direct pointer at the per-mount llms.txt.
1934
+ // RFC 9309 explicitly permits unknown directives ("intentionally
1935
+ // permissive of such future extensions") so this is harmless to
1936
+ // standards-compliant parsers. Emitted alongside Sitemap when
1937
+ // siteUrl is set; absolute URLs only (relative paths would be
1938
+ // ambiguous without a base).
1939
+ const llmsTxt = options.llmsTxt !== false && hasSiteUrl && origin
1940
+ ? `Llms-Txt: ${origin}${withBasePath(combined, "/llms.txt")}\n`
1941
+ : "";
1942
+ return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}${llmsTxt}`;
1171
1943
  }
1172
1944
  /**
1173
1945
  * Build the contents of `public/_headers` (Cloudflare Pages / Workers
1174
1946
  * Static Assets convention). Emits a global RFC 8288 Link header
1175
- * pointing at the site's llms.txt index, so agents don't need to
1947
+ * pointing at this mount's llms.txt index, so agents don't need to
1176
1948
  * parse HTML to discover the LLM-friendly content listing.
1949
+ *
1950
+ * The Link target is basePath-prefixed (`</docs/llms.txt>` for a
1951
+ * `/docs` mount) — matches where the platform actually emits
1952
+ * llms.txt under the per-mount layout.
1177
1953
  */
1178
- function buildHeadersFile() {
1954
+ function buildHeadersFile(basePath) {
1955
+ const llmsHref = withBasePath(basePath, "/llms.txt");
1179
1956
  return [
1180
1957
  "/*",
1181
- ' Link: </llms.txt>; rel="describedby"; type="text/plain"',
1958
+ ` Link: <${llmsHref}>; rel="describedby"; type="text/plain"`,
1182
1959
  "",
1183
1960
  ].join("\n");
1184
1961
  }
@@ -1187,20 +1964,28 @@ function buildMiddlewareSource(config) {
1187
1964
  "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
1188
1965
  "// Composes the docs-layout middleware helpers.",
1189
1966
  "//",
1190
- "// Markdown content negotiation:",
1191
- "// This middleware fires on every request, but in Astro's static",
1192
- "// prerender mode (output: \"static\") request headers are NOT",
1193
- "// forwarded Astro warns about \"Astro.request.headers was used",
1194
- "// when rendering...\" and serves a prerendered HTML response.",
1195
- "// That means `Accept: text/markdown` negotiation only kicks in",
1196
- "// under SSR (output: \"server\") or via an edge function on the",
1197
- "// deployment layer (Cloudflare Worker, Netlify Edge, etc.).",
1198
- "// For pure-static deploys, agents should follow the page's",
1199
- "// <link rel=\"alternate\" type=\"text/markdown\"> href to fetch",
1200
- "// the .md mirror directly (e.g. /docs.md).",
1967
+ "// Static-prerender guard:",
1968
+ "// In Astro's static output mode, this middleware is invoked",
1969
+ "// for every prerendered route at build time. Reading",
1970
+ "// `context.request.headers` there triggers an Astro warning",
1971
+ "// per page (\"Astro.request.headers was used during static",
1972
+ "// render\"), which floods `dogsbay site build` / `site preview`",
1973
+ "// output. Worse, the negotiation can't actually happen at",
1974
+ "// build time there's no runtime client whose Accept header",
1975
+ "// we'd be honoring.",
1201
1976
  "//",
1202
- "// The Cloudflare-Worker-driven full fix is tracked in",
1203
- "// plans/cloudflare-deploy-content-negotiation.md.",
1977
+ "// We guard with `context.isPrerendered` so prerendered routes",
1978
+ "// short-circuit to `next()` immediately. At runtime in static",
1979
+ "// deploys, middleware doesn't fire at all (no server); at",
1980
+ "// runtime in SSR / hybrid deploys, only dynamic routes fire,",
1981
+ "// which is exactly when negotiation makes sense.",
1982
+ "//",
1983
+ "// Markdown content negotiation:",
1984
+ "// For pure-static deploys, `Accept: text/markdown` is honored",
1985
+ "// by the platform (Cloudflare _headers + Worker, Netlify Edge",
1986
+ "// functions). Agents that can't send Accept headers should",
1987
+ "// follow the page's <link rel=\"alternate\" type=\"text/markdown\">",
1988
+ "// to fetch the .md mirror directly (e.g. /docs.md).",
1204
1989
  'import { defineMiddleware } from "astro:middleware";',
1205
1990
  ];
1206
1991
  if (config.mdMirror) {
@@ -1214,6 +1999,11 @@ function buildMiddlewareSource(config) {
1214
1999
  lines.push(`const AXIS_REDIRECT_CONFIG = ${JSON.stringify(config.axisRedirect, null, 2)};`, "");
1215
2000
  }
1216
2001
  lines.push("export const onRequest = defineMiddleware((context, next) => {");
2002
+ // Skip prerendered routes — see file-top comment for the rationale.
2003
+ // Avoids per-page Astro.request.headers warnings during build, and
2004
+ // matches runtime semantics (middleware doesn't fire on prerendered
2005
+ // routes when deployed).
2006
+ lines.push(" if (context.isPrerendered) return next();");
1217
2007
  lines.push(" const url = new URL(context.request.url);");
1218
2008
  if (config.mdMirror) {
1219
2009
  lines.push(' const accept = context.request.headers.get("accept");', " const mdTarget = shouldRewriteToMarkdown(accept, url.pathname);", " if (mdTarget) return context.rewrite(mdTarget);");
@@ -1245,8 +2035,18 @@ function buildMdEndpoint(page, sourceRel) {
1245
2035
  ].join("\n");
1246
2036
  }
1247
2037
  /**
1248
- * Emit `public/llms.txt`, `public/llms-full.txt`, and per-section
1249
- * `public/<dir>/llms.txt` files for the site.
2038
+ * Emit per-mount llms.txt + llms-full.txt + per-section indexes.
2039
+ *
2040
+ * Files live under `public/<basePath>/...` so multiple Dogsbay sites
2041
+ * can mount on the same host (`/docs/llms.txt` + `/api/llms.txt` +
2042
+ * `/handbook/llms.txt`) without colliding at the root. When basePath
2043
+ * is empty, this collapses to `public/llms.txt` — the single-site
2044
+ * llmstxt.org-spec layout.
2045
+ *
2046
+ * The host root `/llms.txt` is intentionally NOT emitted by the
2047
+ * platform: it's the user's umbrella file, analogous to
2048
+ * `sitemap-index.xml`. Multi-mount deploys hand-write a top-level
2049
+ * `/llms.txt` that links to each per-mount index.
1250
2050
  *
1251
2051
  * Per-section files are written for every top-level nav group that
1252
2052
  * resolves to a site directory (either via `group.href` or via the
@@ -1258,26 +2058,92 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
1258
2058
  description: options.description,
1259
2059
  siteUrl: options.siteUrl,
1260
2060
  };
1261
- const publicDir = join(outputDir, "public");
1262
- mkdirSync(publicDir, { recursive: true });
1263
- const hrefPrefix = normalizeBasePath(options.basePath);
1264
- writeFileSync(join(publicDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
1265
- writeFileSync(join(publicDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
2061
+ // hrefPrefix is the COMBINED prefix — used for the URL paths that
2062
+ // appear inside the llms.txt body (so agents fetch the correct
2063
+ // host-relative URLs). Filesystem layout uses basePath alone:
2064
+ // `public/<basePath>/llms.txt` matches the existing per-mount
2065
+ // delivery shape.
2066
+ const hrefPrefix = combinedPrefix(options);
2067
+ const basePath = normalizeBasePath(options.basePath);
2068
+ const baseSegments = basePathSegments(basePath);
2069
+ const mountDir = join(outputDir, "public", ...baseSegments);
2070
+ mkdirSync(mountDir, { recursive: true });
2071
+ writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
2072
+ writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
1266
2073
  summary: "body",
1267
2074
  serializePage: serializePageMd,
1268
2075
  hrefPrefix,
1269
2076
  }));
2077
+ // Per-section files. `deriveSectionDir` returns a host-absolute
2078
+ // path derived from nav hrefs, which since the combined-prefix
2079
+ // refactor (commit 132891e) include urlBase + basePath — NOT just
2080
+ // basePath. So joining its return onto public/ directly would
2081
+ // double-prefix into `public/<urlBase>/<basePath>/<section>/llms.txt`,
2082
+ // which then serves at `<urlBase>/<urlBase>/<basePath>/<section>/...`
2083
+ // once Astro's base prefix is applied at request time.
2084
+ //
2085
+ // Strip the combined prefix off the section dir to get just the
2086
+ // section tail, then re-prepend basePath via mountDir. Result:
2087
+ // `public/<basePath>/<section>/llms.txt`, served under the deploy's
2088
+ // base mount as `<urlBase>/<basePath>/<section>/llms.txt`.
2089
+ const combinedSegs = hrefPrefix.replace(/^\//, "");
1270
2090
  for (const group of nav) {
1271
2091
  if (!group.children || group.children.length === 0)
1272
2092
  continue;
1273
2093
  const dir = deriveSectionDir(group);
1274
2094
  if (!dir)
1275
2095
  continue;
1276
- const sectionPath = join(publicDir, dir, "llms.txt");
2096
+ let relDir;
2097
+ if (combinedSegs && dir === combinedSegs) {
2098
+ relDir = "";
2099
+ }
2100
+ else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
2101
+ relDir = dir.slice(combinedSegs.length + 1);
2102
+ }
2103
+ else {
2104
+ // Defensive: if for some reason the dir doesn't carry the
2105
+ // combined prefix (older importer, manual nav.yml, etc.), fall
2106
+ // back to the raw value rather than rooting at /.
2107
+ relDir = dir;
2108
+ }
2109
+ const sectionPath = relDir
2110
+ ? join(mountDir, relDir, "llms.txt")
2111
+ : join(mountDir, "llms.txt");
1277
2112
  mkdirSync(dirname(sectionPath), { recursive: true });
1278
2113
  writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
1279
2114
  }
1280
2115
  }
2116
+ /**
2117
+ * Emit per-mount sitemap files.
2118
+ *
2119
+ * Writes `public/<basePath>/sitemap-index.xml` + `sitemap-0.xml`.
2120
+ * The index lists the single sub-sitemap today; future splits add
2121
+ * more sub-sitemap entries as the page count grows past
2122
+ * sitemaps.org's 50K-URL recommendation.
2123
+ *
2124
+ * Caller has already guarded on a valid http(s) `siteUrl` — without
2125
+ * one, `<loc>` entries can't be absolute and crawlers reject the
2126
+ * file. Skip emission rather than write a broken sitemap.
2127
+ */
2128
+ function emitSitemapFiles(outputDir, options, pages) {
2129
+ // Filesystem path uses basePath (sitemap files live in
2130
+ // public/<basePath>/sitemap-*.xml). The URL prefix encoded into
2131
+ // each <loc> uses combined so the absolute URLs resolve under the
2132
+ // host's served subpath. buildSitemap strips path off siteUrl
2133
+ // internally, so passing siteUrl + combined as basePath gives
2134
+ // origin + combined as the final URL.
2135
+ const basePath = normalizeBasePath(options.basePath);
2136
+ const combined = combinedPrefix(options);
2137
+ const baseSegments = basePathSegments(basePath);
2138
+ const mountDir = join(outputDir, "public", ...baseSegments);
2139
+ mkdirSync(mountDir, { recursive: true });
2140
+ writeFileSync(join(mountDir, "sitemap-0.xml"), buildSitemap(pages, {
2141
+ siteUrl: options.siteUrl,
2142
+ basePath: combined,
2143
+ siteNoindex: options.noindex === true,
2144
+ }));
2145
+ writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({ siteUrl: options.siteUrl, basePath: combined }));
2146
+ }
1281
2147
  /**
1282
2148
  * Pick a directory under `public/` for a top-level nav group. Prefers
1283
2149
  * the group's own href (already a `/docs/x/y` path); otherwise falls
@@ -1355,6 +2221,11 @@ function copyComponents(outputDir) {
1355
2221
  "response-tabs", "schema-viewer", "code-samples", "copy-button",
1356
2222
  "markdown-example",
1357
2223
  "accordion", "link-card", "avatar", "math",
2224
+ // Icon resolves @ui/icon/Icon.astro → built-time SVG inlining
2225
+ // via @dogsbay/icons. Used by `:::cards` `{icon=...}` and the
2226
+ // inline `:icon[name]` directive. Without this entry every page
2227
+ // emitting the icon import 500s with "module not found".
2228
+ "icon",
1358
2229
  ];
1359
2230
  for (const name of needed) {
1360
2231
  const src = join(componentsSource, name);
@@ -1409,6 +2280,32 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
1409
2280
  catch { /* source may not exist */ }
1410
2281
  }
1411
2282
  // ── CSS generation (ported from import-mkdocs.ts) ───────
2283
+ /**
2284
+ * Build the `@source inline("...")` directive that pins the
2285
+ * grid-tone palette into the generated stylesheet.
2286
+ *
2287
+ * Why we need it: tone classes like `bg-primary/10` only appear in
2288
+ * `.astro` pages emitted by Dogsbay's grid-item serializer. When
2289
+ * Tailwind's content scanner doesn't pick them up — because the
2290
+ * page lives outside the default scan globs, or because a class
2291
+ * is composed at the boundary of an interpolation — they get
2292
+ * purged. Result observed in dogsbay-docs-markdown audit: half
2293
+ * the grid demo cells render with no background. Pinning forces
2294
+ * generation regardless of scanner reach.
2295
+ *
2296
+ * Single source of truth: derived from TONE_CLASSES so any new
2297
+ * tone added to the palette is automatically safelisted.
2298
+ */
2299
+ function buildToneSafelist() {
2300
+ const seen = new Set();
2301
+ for (const classes of Object.values(TONE_CLASSES)) {
2302
+ for (const cls of classes.split(/\s+/)) {
2303
+ if (cls)
2304
+ seen.add(cls);
2305
+ }
2306
+ }
2307
+ return [...seen].sort().join(" ");
2308
+ }
1412
2309
  function generateGlobalCss() {
1413
2310
  return `@import "tailwindcss";
1414
2311
  @import "./theme.css";
@@ -1417,6 +2314,14 @@ function generateGlobalCss() {
1417
2314
  @source "../../node_modules/@dogsbay/ui/src";
1418
2315
  @source "../../node_modules/@dogsbay/docs-layout/src";
1419
2316
 
2317
+ /* Pin the grid-tone palette. These classes are emitted into
2318
+ markdown-generated .astro pages by the grid-item serializer
2319
+ (TONE_CLASSES in @dogsbay/format-astro). Without inlining,
2320
+ opacity-modified utilities like bg-primary/10 get purged when
2321
+ Tailwind doesn't see them in the scanned globs, leaving grid
2322
+ demo cells with no visible background. */
2323
+ @source inline("${buildToneSafelist()}");
2324
+
1420
2325
  /* Prose typography for rendered content */
1421
2326
  .docs-prose {
1422
2327
  line-height: 1.7;
@@ -1463,6 +2368,33 @@ function generateGlobalCss() {
1463
2368
  & dl { margin: 1rem 0; }
1464
2369
  & dt { font-weight: 600; margin-top: 0.75rem; }
1465
2370
  & dd { margin-left: 1.5rem; color: var(--muted-foreground); }
2371
+
2372
+ /* AsciiDoc block roles ([role="x"] / [.x]) preserved as classes (#384).
2373
+ _abstract marks a module's lead/intro paragraph; .lead/.small are
2374
+ Asciidoctor built-ins; _additional-resources marks the trailing
2375
+ "Additional resources" section heading. */
2376
+ & ._abstract, & .abstract, & .lead {
2377
+ font-size: 1.05rem;
2378
+ line-height: 1.6;
2379
+ color: var(--muted-foreground);
2380
+ }
2381
+ & .small { font-size: 0.875rem; }
2382
+ & h2._additional-resources, & h3._additional-resources {
2383
+ font-size: 1.15rem;
2384
+ margin-top: 2rem;
2385
+ }
2386
+
2387
+ /* Related resources (#384 R3): [role="_additional-resources"] + .Title +
2388
+ list folds to a <section class="related-resources"> (→ DITA related-links). */
2389
+ & .related-resources {
2390
+ margin-top: 2rem;
2391
+ padding-top: 1rem;
2392
+ border-top: 1px solid var(--border);
2393
+ }
2394
+ & .related-resources-title {
2395
+ font-weight: 600;
2396
+ margin: 0 0 0.5rem;
2397
+ }
1466
2398
  }
1467
2399
 
1468
2400
  @utility scrollbar-none {