@dogsbay/format-astro 0.2.0-beta.2 → 0.2.0-beta.20

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,13 +4,28 @@
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";
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
+ }
14
29
  import { detectLeadingNodes } from "./lead.js";
15
30
  /**
16
31
  * Recursively prefix all hrefs in a nav tree.
@@ -147,6 +162,122 @@ function buildWranglerConfig(siteName, options) {
147
162
  lines.push(`}`);
148
163
  return lines.join("\n") + "\n";
149
164
  }
165
+ /**
166
+ * Build the GitHub Actions workflow YAML for `actions/deploy-pages`.
167
+ *
168
+ * The workflow:
169
+ * 1. Checks out the repo on every push to the default branch.
170
+ * 2. Installs node + pnpm at the Astro project directory, runs
171
+ * `dogsbay site build` (via `pnpm dlx` since Dogsbay is a
172
+ * global CLI, not a project dep), then `pnpm run build`
173
+ * (which runs `astro build && pagefind`).
174
+ * 3. Uploads `<astroDirRel>/dist` as a Pages artifact via
175
+ * `actions/upload-pages-artifact`.
176
+ * 4. Deploys via `actions/deploy-pages`.
177
+ *
178
+ * `astroDirRel` is the path of the Astro output relative to the
179
+ * repo root (typically "astro" — the default config has
180
+ * `output: ./astro`). Empty string is allowed when the project is
181
+ * flat (outputDir === projectDir); the workflow degrades naturally
182
+ * by omitting the `defaults: working-directory` block.
183
+ *
184
+ * Author edits — extra build steps, secrets, deploy gating — survive
185
+ * subsequent `dogsbay site build` runs because the file is written
186
+ * write-if-missing (see emitDeployArtifacts). To start over, delete
187
+ * the workflow file and rebuild.
188
+ *
189
+ * Note on basePath: GitHub Pages serves project sites at
190
+ * `https://<user>.github.io/<repo>/`. Authors who want their docs at
191
+ * the repo root should set `site.basePath: /<repo-name>` (or empty
192
+ * for user/org pages). The platform's basePath plumbing handles all
193
+ * URL rewriting; this workflow doesn't need to know about it.
194
+ */
195
+ function buildGitHubPagesWorkflow(astroDirRel) {
196
+ // When the Astro output IS the project root, drop the working-
197
+ // directory block and reference cache + artifact paths without a
198
+ // prefix. This is the flat-layout case (rare for site-init flows;
199
+ // common for `dogsbay convert` outputs that get manually wired up).
200
+ const isFlat = astroDirRel === "" || astroDirRel === ".";
201
+ const workingDirBlock = isFlat
202
+ ? ""
203
+ : `
204
+ defaults:
205
+ run:
206
+ working-directory: ${astroDirRel}`;
207
+ const cacheDep = isFlat
208
+ ? "pnpm-lock.yaml"
209
+ : `${astroDirRel}/pnpm-lock.yaml`;
210
+ const artifactPath = isFlat ? "dist" : `${astroDirRel}/dist`;
211
+ return `# Deploy to GitHub Pages.
212
+ # Generated by \`dogsbay site init --deploy=github-pages\` (or by
213
+ # adding \`deploy: { target: github-pages }\` to dogsbay.config.yml
214
+ # and running \`dogsbay site build\`). Author edits survive every
215
+ # subsequent build — the file is never overwritten. To regenerate
216
+ # from template, delete the file and rebuild.
217
+ #
218
+ # Repo settings: Settings → Pages → Source = "GitHub Actions".
219
+ name: Deploy to GitHub Pages
220
+
221
+ on:
222
+ push:
223
+ branches: [main]
224
+ workflow_dispatch:
225
+
226
+ permissions:
227
+ contents: read
228
+ pages: write
229
+ id-token: write
230
+
231
+ # Allow only one concurrent deployment, skipping queued runs.
232
+ concurrency:
233
+ group: pages
234
+ cancel-in-progress: false
235
+
236
+ jobs:
237
+ build:
238
+ runs-on: ubuntu-latest${workingDirBlock}
239
+ steps:
240
+ - uses: actions/checkout@v4
241
+
242
+ - uses: pnpm/action-setup@v4
243
+ with:
244
+ version: 10
245
+
246
+ - uses: actions/setup-node@v4
247
+ with:
248
+ node-version: 20
249
+ cache: pnpm
250
+ cache-dependency-path: ${cacheDep}
251
+
252
+ - name: Install dependencies
253
+ run: pnpm install --frozen-lockfile
254
+
255
+ # \`dogsbay\` is a global CLI, not a project dep — pnpm dlx
256
+ # fetches it on demand. To pin a version, replace with e.g.
257
+ # \`pnpm dlx dogsbay@0.2.0-beta.18 site build\`.
258
+ - name: Build with Dogsbay
259
+ run: pnpm dlx dogsbay@beta site build
260
+
261
+ - name: Build Astro site
262
+ run: pnpm run build
263
+
264
+ - name: Upload Pages artifact
265
+ uses: actions/upload-pages-artifact@v3
266
+ with:
267
+ path: ${artifactPath}
268
+
269
+ deploy:
270
+ needs: build
271
+ runs-on: ubuntu-latest
272
+ environment:
273
+ name: github-pages
274
+ url: \${{ steps.deployment.outputs.page_url }}
275
+ steps:
276
+ - name: Deploy to GitHub Pages
277
+ id: deployment
278
+ uses: actions/deploy-pages@v4
279
+ `;
280
+ }
150
281
  /**
151
282
  * Construct the SiteConfig object that gets serialized to
152
283
  * `src/data/site.json`. Backward-compatible: existing fields keep their
@@ -187,7 +318,23 @@ function buildSiteConfig(siteName, options) {
187
318
  }
188
319
  if (options.taxonomyIndexPaths &&
189
320
  Object.keys(options.taxonomyIndexPaths).length > 0) {
190
- cfg.taxonomyIndexPaths = options.taxonomyIndexPaths;
321
+ // Bake basePath into every emitted indexPath so consumers
322
+ // (TypeBadge / StatusBadge / future components) compose hrefs
323
+ // like `${indexPath}/<value>/` and resolve under the configured
324
+ // site base. Without the prefix, `/by-type/tutorial/` 404s on
325
+ // any site with `site.basePath` set. Caller passes raw config
326
+ // values (`/by-type`, `/tags`, etc.) — basePath threading is
327
+ // this emitter's responsibility, matching how `page.url` is
328
+ // already prefixed in the taxonomy data file.
329
+ // Taxonomy index paths are baked into site.json so components
330
+ // (TagList, TaxonomyIndex, TypeBadge) emit correct hrefs at
331
+ // runtime. Use combined so these resolve under the host's
332
+ // served subpath.
333
+ const taxoPrefix = combinedPrefix(options);
334
+ cfg.taxonomyIndexPaths = Object.fromEntries(Object.entries(options.taxonomyIndexPaths).map(([name, raw]) => [
335
+ name,
336
+ withBasePath(taxoPrefix, raw),
337
+ ]));
191
338
  }
192
339
  if (options.taxonomyDisplay &&
193
340
  Object.keys(options.taxonomyDisplay).length > 0) {
@@ -257,6 +404,28 @@ function ensureDirectoryStructure(outputDir, basePath) {
257
404
  export function emitSiteConfig(outputDir, siteName, options) {
258
405
  mkdirSync(join(outputDir, "src", "data"), { recursive: true });
259
406
  writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
407
+ // Auto-generated companion to astro.config.mjs. Carries the
408
+ // site/base values derived from dogsbay.config.yml's site.url so
409
+ // changes propagate without --force-rescaffolding the main
410
+ // astro.config.mjs (which is scaffold-once and may have author
411
+ // edits — custom integrations, build hooks, etc.). The main
412
+ // config imports `dogsbaySite` + `dogsbayBase` from here.
413
+ // See plans/astro-base-from-site-url.md.
414
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
415
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
416
+ const dogsbaySiteJson = hasSiteUrl
417
+ ? JSON.stringify(origin ?? options.siteUrl)
418
+ : "undefined";
419
+ const dogsbayBaseJson = astroBase ? JSON.stringify(astroBase) : "undefined";
420
+ writeFileSync(join(outputDir, "astro.config.dogsbay.mjs"), [
421
+ "// Auto-generated by `dogsbay site build` — DO NOT EDIT.",
422
+ "// Tracks site.url + derived Astro base from dogsbay.config.yml.",
423
+ "// Edit dogsbay.config.yml (site.url, site.basePath) and rebuild;",
424
+ "// edits to this file will be overwritten on the next build.",
425
+ `export const dogsbaySite = ${dogsbaySiteJson};`,
426
+ `export const dogsbayBase = ${dogsbayBaseJson};`,
427
+ "",
428
+ ].join("\n"));
260
429
  }
261
430
  export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
262
431
  let scaffoldFilesSkipped = 0;
@@ -271,15 +440,36 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
271
440
  // - --local outside workspace: use file: paths pointing at monorepo packages
272
441
  // - Otherwise: versioned specs (expects registry-published packages)
273
442
  const insideWs = isInsideWorkspace(outputDir);
443
+ // Read format-astro's own version at runtime — every @dogsbay/*
444
+ // package ships in lockstep at the same version (publish-beta.sh
445
+ // bumps them together), so format-astro's version is the right
446
+ // peer spec for `@dogsbay/docs-layout`, `@dogsbay/ui`, etc. in
447
+ // the scaffolded astro/package.json. Fixes the "hardcoded
448
+ // ^0.1.0" trap that caused npm install to fail post-publish.
449
+ const dogsbayPeerVersion = (() => {
450
+ try {
451
+ const here = dirname(fileURLToPath(import.meta.url));
452
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
453
+ // Caret on a stable version, exact pin on a prerelease (npm
454
+ // treats `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3` — the
455
+ // prerelease semantics force exact-or-explicit-range. Pinning
456
+ // prereleases avoids surprise resolves to incompatible betas.)
457
+ return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
458
+ }
459
+ catch {
460
+ return "^0.1.0"; // last-resort fallback
461
+ }
462
+ })();
274
463
  const dogsbayDep = (name) => {
275
464
  if (insideWs)
276
465
  return "workspace:*";
277
466
  if (options.local)
278
467
  return `file:${resolveMonorepoPkg(name)}`;
279
- return "^0.1.0";
468
+ return dogsbayPeerVersion;
280
469
  };
281
470
  // Per-deploy-target additions to package.json
282
471
  const isCloudflare = options.deploy === "cloudflare-workers";
472
+ const isGitHubPages = options.deploy === "github-pages";
283
473
  const deployScripts = isCloudflare
284
474
  ? { deploy: "pnpm build && wrangler deploy" }
285
475
  : {};
@@ -305,7 +495,11 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
305
495
  },
306
496
  dependencies: {
307
497
  astro: "^6.0.0",
308
- "@astrojs/sitemap": "^3.0.0",
498
+ // Sitemap is emitted directly by Dogsbay into
499
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount
500
+ // deploys don't collide at the host root. We deliberately
501
+ // do NOT depend on @astrojs/sitemap (it hardcodes output to
502
+ // dist/ root, which is what we're moving away from).
309
503
  // Pagefind is invoked from the build script (see scripts.build above).
310
504
  // Lives in dependencies (not devDependencies) so production builds
311
505
  // include it; the produced search index is shipped statically and
@@ -329,6 +523,13 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
329
523
  "@dogsbay/icons": dogsbayDep("icons"),
330
524
  "@dogsbay/elements": dogsbayDep("elements"),
331
525
  },
526
+ // Pin transitive Vite to 7. Vite 8 just released; Astro 6
527
+ // peer-deps Vite 7 and prints a warning when 8 is hoisted.
528
+ // Without this override npm picks up Vite 8 by default.
529
+ // Drop this when Astro 7 ships and bumps its peer.
530
+ overrides: {
531
+ vite: "^7",
532
+ },
332
533
  ...(Object.keys(deployDevDeps).length > 0
333
534
  ? { devDependencies: deployDevDeps }
334
535
  : {}),
@@ -347,6 +548,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
347
548
  scaffoldFilesSkipped++;
348
549
  }
349
550
  }
551
+ // GitHub Pages deploy artifacts — workflow + .nojekyll. The actual
552
+ // emission lives in `emitDeployArtifacts` so site-build can also
553
+ // call it on existing sites without going through scaffold (a user
554
+ // adds `deploy: github-pages` to dogsbay.config.yml and reruns
555
+ // `site build` to get the workflow). At scaffold-time we pass
556
+ // forceOverwrite=writeScaffold so `--force` regenerates from
557
+ // template; on regular builds it stays write-if-missing.
558
+ if (isGitHubPages) {
559
+ emitDeployArtifacts(outputDir, options, {
560
+ forceOverwrite: writeScaffold,
561
+ });
562
+ }
350
563
  // Generate astro.config.mjs
351
564
  // `preserveSymlinks: true` is used with --local to pin local file: deps to
352
565
  // their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
@@ -358,52 +571,46 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
358
571
  preserveSymlinks: true,
359
572
  },`
360
573
  : "";
361
- // Sitemap integration is conditional: requires an absolute site URL so
362
- // <loc> entries can be properly absolute. Without siteUrl, the sitemap
363
- // step is skipped (the import + integration call are simply omitted from
364
- // the generated config). Sitemap also filters out frontmatter-noindex pages.
365
- const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
366
- const sitemapImport = hasSiteUrl ? `import sitemap from "@astrojs/sitemap";\n` : "";
367
- // Strip any path component from site.url before emitting. The
368
- // config validator already rejects `site.url` containing a path
369
- // when `basePath` is non-empty (canonical URLs would double-count
370
- // the prefix); this is a defensive normalisation for the case
371
- // where the validator is bypassed or basePath is empty.
574
+ // siteUrl gates absolute-URL emission (sitemap <loc> entries,
575
+ // canonical tags). Without one, both are skipped relative URLs
576
+ // are still correct, the sitemap is just not generated.
372
577
  //
373
- // Note: we deliberately do NOT emit Astro's `base:` field. With
374
- // the current file emission (pages live under
375
- // `src/pages/<basePath>/...`), adding `base` would cause Astro
376
- // to doubly-prefix every route. Switching to `base`-driven
377
- // routing is a separate refactor — see plans/configurable-base-path.md.
378
- let siteField = "";
379
- if (hasSiteUrl) {
380
- let originOnly;
381
- try {
382
- const u = new URL(options.siteUrl);
383
- originOnly = `${u.protocol}//${u.host}`;
384
- }
385
- catch {
386
- originOnly = options.siteUrl;
387
- }
388
- siteField = `\n site: ${JSON.stringify(originOnly)},`;
389
- }
390
- const integrationsField = hasSiteUrl ? `\n integrations: [sitemap()],` : "";
391
- // astro.config.mjs scaffold-once. Maintainer adds custom integrations.
392
- // The plugin-aliases import is for the Dogsbay plugin API: each
393
- // build emits `astro.config.plugins.mjs` exporting `pluginAliases`,
394
- // a Vite alias map for `virtual:dogsbay-plugin-config/<id>` modules.
395
- // When no plugins use defineClientConfig the map is empty and the
396
- // spread is a no-op. See plans/plugin-api.md.
578
+ // Sitemap is emitted directly by Dogsbay (see emitSitemapFiles)
579
+ // into public/<basePath>/sitemap-*.xml. We deliberately do NOT
580
+ // wire @astrojs/sitemap here; that integration hardcodes output
581
+ // to dist/ root, breaking multi-mount deploys.
582
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
583
+ // site.url's path component (if any) becomes Astro's `base`. The
584
+ // origin alone goes into `site`. This split lets dogsbay model
585
+ // both axes independently:
586
+ // - Astro's `base` (= urlBase) controls the URL prefix Astro
587
+ // bakes into HTML asset references (`<basePath>/_astro/...`)
588
+ // and the routes Astro generates from src/pages.
589
+ // - dogsbay's basePath controls the filesystem layout
590
+ // (`src/pages/<basePath>/...`).
591
+ // The two compose at emit time — combining for nav hrefs,
592
+ // sitemap, llms.txt, etc. See plans/astro-base-from-site-url.md.
593
+ const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
594
+ // astro.config.mjs — scaffold-once, but the site/base values flow
595
+ // through a separate auto-generated file (`astro.config.dogsbay.mjs`,
596
+ // emitted unconditionally below) so dogsbay-derived values stay in
597
+ // sync with `dogsbay.config.yml` even on existing sites where the
598
+ // main config is preserved. Same pattern as
599
+ // `astro.config.plugins.mjs` the import line is the load-bearing
600
+ // bit; the auto-file is what changes.
397
601
  if (writeScaffold) {
398
602
  writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
399
603
  import tailwindcss from "@tailwindcss/vite";
400
- ${sitemapImport}import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
604
+ import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
605
+ import { dogsbaySite, dogsbayBase } from "./astro.config.dogsbay.mjs";
401
606
 
402
- export default defineConfig({${siteField}
607
+ export default defineConfig({
608
+ ...(dogsbaySite ? { site: dogsbaySite } : {}),
609
+ ...(dogsbayBase ? { base: dogsbayBase } : {}),
403
610
  output: "static",
404
611
  build: {
405
612
  inlineStylesheets: "always",
406
- },${integrationsField}
613
+ },
407
614
  vite: {
408
615
  plugins: [tailwindcss()],
409
616
  resolve: {
@@ -425,6 +632,9 @@ export default defineConfig({${siteField}
425
632
  else {
426
633
  scaffoldFilesSkipped++;
427
634
  }
635
+ // astro.config.dogsbay.mjs is emitted by emitSiteConfig (called
636
+ // above and on every site build) so site/base values stay in
637
+ // sync without a re-scaffold. See its definition for rationale.
428
638
  // Always seed an empty astro.config.plugins.mjs so the import in
429
639
  // astro.config.mjs resolves before the first plugin-emitting
430
640
  // build. Subsequent builds replace it via emitPluginRuntime.
@@ -495,7 +705,12 @@ export default defineConfig({${siteField}
495
705
  */
496
706
  export async function emitAstroPages(pages, nav, outputDir, options) {
497
707
  const siteName = options.siteName || "Documentation";
708
+ // basePath = filesystem layout prefix (where pages live under
709
+ // src/pages/...). combined = the URL prefix HTML hrefs need
710
+ // (urlBase + basePath). The two diverge whenever site.url has a
711
+ // path component (GH Pages project pages, multi-mount Cloudflare).
498
712
  const basePath = normalizeBasePath(options.basePath);
713
+ const combined = combinedPrefix(options);
499
714
  const baseSegments = basePathSegments(basePath);
500
715
  // Ensure dirs exist (callers may invoke us without going through the
501
716
  // full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
@@ -516,7 +731,11 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
516
731
  // Remove existing entry for this section (full replace)
517
732
  existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
518
733
  && item.label?.toLowerCase() !== section.toLowerCase());
519
- const prefixedNav = prefixNavHrefs(nav, section, basePath);
734
+ // Nav hrefs already carry the `combined` prefix (the importer
735
+ // emits them via fileToHref(file, hrefPrefix=combined)).
736
+ // prefixNavHrefs takes the existing prefix and weaves a section
737
+ // segment into it.
738
+ const prefixedNav = prefixNavHrefs(nav, section, combined);
520
739
  const sectionLabel = siteName
521
740
  || section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
522
741
  existingNav.push({ label: sectionLabel, children: prefixedNav });
@@ -531,13 +750,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
531
750
  copyAssets(options.sourceDir, outputDir, options.imageOptimization);
532
751
  }
533
752
  let generated = 0;
753
+ const generatedPaths = new Set();
534
754
  const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
535
755
  const useImageOpt = options.imageOptimization ?? false;
536
- // hrefPrefix is the same string as basePath. rewriteHref handles the
537
- // empty-basePath case correctly: any link starting with "/" matches
538
- // the early-return guard, so root-relative links pass through
539
- // unrewritten when the site is served at host root.
540
- const hrefPrefix = basePath;
756
+ // hrefPrefix is the COMBINED prefix (urlBase + basePath) what
757
+ // rendered HTML hrefs need so internal links resolve under the
758
+ // host's served subpath AND under the dogsbay basePath. For
759
+ // simple host-apex deploys with basePath, urlBase is empty so
760
+ // combined === basePath (back-compat). For GH Pages project pages
761
+ // and multi-mount Cloudflare, combined adds the urlBase layer.
762
+ const hrefPrefix = combined;
541
763
  for (const page of pages) {
542
764
  try {
543
765
  // Rewrite internal hrefs to match the output URL structure
@@ -581,7 +803,23 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
581
803
  const pageCategory = Array.isArray(pageMeta?.category)
582
804
  ? pageMeta.category
583
805
  : undefined;
584
- const tagsIndexPath = options.tagsIndexPath ?? "/tags";
806
+ // Custom-taxonomy values lifted from frontmatter into
807
+ // `meta.taxonomies` by the importer (see `parseMeta` in
808
+ // `@dogsbay/types`). Surfaced to DocsLayout so it can emit one
809
+ // `<div data-pagefind-filter="<name>:<value>">` per entry — this
810
+ // is what makes user-declared taxonomies (`difficulty`, `team`,
811
+ // anything not in the five built-ins) appear as visible facet
812
+ // checkboxes in the search dialog. Without this passthrough
813
+ // they're silently dropped after the importer.
814
+ const pageTaxonomies = pageMeta?.taxonomies && Object.keys(pageMeta.taxonomies).length > 0
815
+ ? pageMeta.taxonomies
816
+ : undefined;
817
+ // `tagsIndexPath` flows to `<TagList>` for chip hrefs
818
+ // (`${indexPath}/${tag}/`). Caller passes the raw config value
819
+ // (e.g. `/tags`); we bake basePath here so chips resolve under
820
+ // the configured site base. Without the prefix, every tag chip
821
+ // 404s on any site with `site.basePath` set.
822
+ const tagsIndexPath = withBasePath(basePath, options.tagsIndexPath ?? "/tags");
585
823
  // Auto-lede detection. If the markdown body doesn't already
586
824
  // start with an H1 / leading paragraph, we ask DocsLayout to
587
825
  // render the frontmatter title / description at the top of
@@ -613,12 +851,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
613
851
  // available via other means. The "Open in" deep links work
614
852
  // regardless of mirror availability — agents that can't fetch
615
853
  // the page just see the URL in their chat.
854
+ // pageHrefBase uses combined (urlBase + basePath) so the URL
855
+ // resolves correctly when the host serves dist/ at a subpath
856
+ // (GH Pages project page, multi-mount Cloudflare).
616
857
  const pageHrefBase = section
617
- ? (basePath ? `${basePath}/${section}/${page.slug}` : `/${section}/${page.slug}`)
618
- : (basePath ? `${basePath}/${page.slug}` : `/${page.slug}`);
858
+ ? (combined ? `${combined}/${section}/${page.slug}` : `/${section}/${page.slug}`)
859
+ : (combined ? `${combined}/${page.slug}` : `/${page.slug}`);
619
860
  const pageMdHref = `${pageHrefBase}.md`;
620
- const pageMdAbsoluteUrl = options.siteUrl
621
- ? options.siteUrl.replace(/\/$/, "") + pageMdHref
861
+ // For absolute URLs (the "Copy as MD" deep link), use the
862
+ // origin (no path) + the full combined path; siteUrl alone
863
+ // would double-include the urlBase since pageHrefBase already
864
+ // contains it.
865
+ const { origin } = parseSiteUrl(options.siteUrl);
866
+ const pageMdAbsoluteUrl = origin
867
+ ? origin + pageMdHref
622
868
  : pageMdHref;
623
869
  // Markdown body for the Copy button. Reuse the same serializer
624
870
  // that produces the .md mirror so what the user copies matches
@@ -660,7 +906,10 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
660
906
  "",
661
907
  `const headings = ${JSON.stringify(page.headings || [])};`,
662
908
  `const nav = navData;`,
663
- `const currentPath = "${buildCurrentPath(basePath, section, page.slug)}";`,
909
+ // currentPath uses combined so it matches nav.json hrefs
910
+ // (which are also combined-prefixed). getPagination compares
911
+ // them as strings; mismatched prefixes break prev/next.
912
+ `const currentPath = "${buildCurrentPath(combined, section, page.slug)}";`,
664
913
  // Filter nav to the current (locale, version) bucket
665
914
  // before computing prev/next — without this, pagination
666
915
  // walks the global nav and a "Next" link can leak from
@@ -684,6 +933,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
684
933
  `const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
685
934
  `const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
686
935
  `const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
936
+ `const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
687
937
  `const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
688
938
  `const llmActionsProps = ${JSON.stringify(llmActionsEnabled
689
939
  ? {
@@ -729,6 +979,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
729
979
  ` pageType={pageType ?? undefined}`,
730
980
  ` audience={pageAudience ?? undefined}`,
731
981
  ` category={pageCategory ?? undefined}`,
982
+ ` taxonomies={pageTaxonomies ?? undefined}`,
732
983
  ` autoH1={${autoH1}}`,
733
984
  ` autoLede={${autoLede}}`,
734
985
  ` llmActions={llmActionsProps}`,
@@ -771,6 +1022,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
771
1022
  mkdirSync(dirname(pagePath), { recursive: true });
772
1023
  writeFileSync(pagePath, pageLines.join("\n") + "\n");
773
1024
  generated++;
1025
+ generatedPaths.add(relative(outputDir, pagePath));
774
1026
  // Companion .md endpoint for content negotiation. Prerendered, so
775
1027
  // it's served as a static asset at runtime — no Worker overhead.
776
1028
  //
@@ -811,9 +1063,43 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
811
1063
  // redirect target), and writing a redirect would clobber it.
812
1064
  if (basePath !== "") {
813
1065
  const firstHref = findFirstNavHref(nav, basePath);
814
- writeFileSync(join(outputDir, "src", "pages", "index.astro"), `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
1066
+ const indexPath = join(outputDir, "src", "pages", "index.astro");
1067
+ writeFileSync(indexPath, `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
1068
+ generatedPaths.add(relative(outputDir, indexPath));
815
1069
  }
816
- return { generated, outputNav };
1070
+ return { generated, outputNav, generatedPaths };
1071
+ }
1072
+ /**
1073
+ * Copy each passthrough `.astro` source to its computed output path.
1074
+ * Aborts with a clear error if the destination is already in
1075
+ * `generatedPaths` (a generated page from `emitAstroPages` would
1076
+ * silently overwrite the hand-authored file otherwise).
1077
+ */
1078
+ export function emitPassthroughAstroPages(copies, outputDir, generatedPaths) {
1079
+ if (copies.length === 0)
1080
+ return { copied: 0 };
1081
+ // Collision detection — a generated page and a passthrough page
1082
+ // would write to the same file. Refuse to overwrite; tell the
1083
+ // author exactly which two files conflict.
1084
+ const collisions = [];
1085
+ for (const copy of copies) {
1086
+ if (generatedPaths.has(copy.outputRelPath)) {
1087
+ collisions.push(copy.outputRelPath);
1088
+ }
1089
+ }
1090
+ if (collisions.length > 0) {
1091
+ throw new Error(`Passthrough Astro page collides with a generated page:\n` +
1092
+ collisions.map((c) => ` - ${c}`).join("\n") + "\n" +
1093
+ `Rename the .astro source or remove the colliding entry from nav.yml.`);
1094
+ }
1095
+ let copied = 0;
1096
+ for (const copy of copies) {
1097
+ const dest = join(outputDir, copy.outputRelPath);
1098
+ mkdirSync(dirname(dest), { recursive: true });
1099
+ copyFileSync(copy.sourceAbs, dest);
1100
+ copied++;
1101
+ }
1102
+ return { copied };
817
1103
  }
818
1104
  // ─── Tier 1: config-derived ─────────────────────────────────────────────
819
1105
  // Files driven entirely by config + flags. Always regenerated; site
@@ -829,6 +1115,54 @@ export function emitConfigDerivedFiles(outputDir, options) {
829
1115
  const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
830
1116
  writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
831
1117
  }
1118
+ /**
1119
+ * Per-deploy-target artifact emission.
1120
+ *
1121
+ * Called from `emitSiteScaffold` (with `forceOverwrite=writeScaffold`
1122
+ * so `--force` regenerates from template) and from `dogsbay site
1123
+ * build` (with `forceOverwrite=false` so an existing site can adopt
1124
+ * a deploy target by editing config and rebuilding — the missing
1125
+ * artifact gets created on the next build).
1126
+ *
1127
+ * Emit policy is the union: write when forced OR when the file is
1128
+ * missing. Author edits to e.g. the workflow YAML survive every
1129
+ * regular build.
1130
+ *
1131
+ * Currently handles `github-pages` (workflow + .nojekyll). The
1132
+ * existing `cloudflare-workers` artifacts (wrangler.jsonc + package
1133
+ * scripts) stay in the scaffold-only path because they overlap with
1134
+ * scaffold-only files (package.json scripts, devDependencies). A
1135
+ * future refactor could fold them in here too.
1136
+ */
1137
+ export function emitDeployArtifacts(outputDir, options, opts = { forceOverwrite: false }) {
1138
+ if (options.deploy === "github-pages") {
1139
+ // GitHub reads workflows from <repo-root>/.github/workflows/, NOT
1140
+ // from inside subdirectories. Use projectDir (the repo root) for
1141
+ // the workflow file; fall back to outputDir when unset (flat
1142
+ // `dogsbay convert` flows where the Astro project IS the repo).
1143
+ const projectDir = options.projectDir ?? outputDir;
1144
+ // Path of the Astro output relative to the project root. Used by
1145
+ // the workflow's working-directory + cache-dependency-path so
1146
+ // pnpm install / pnpm run build target the right place. Empty
1147
+ // string when outputDir === projectDir (flat layout).
1148
+ const astroDirRel = relative(projectDir, outputDir).replace(/\\/g, "/");
1149
+ const workflowPath = join(projectDir, ".github", "workflows", "deploy.yml");
1150
+ if (opts.forceOverwrite || !existsSync(workflowPath)) {
1151
+ mkdirSync(dirname(workflowPath), { recursive: true });
1152
+ writeFileSync(workflowPath, buildGitHubPagesWorkflow(astroDirRel));
1153
+ }
1154
+ // .nojekyll — must exist in the deployed artifact root so GH
1155
+ // Pages skips Jekyll's `_underscored-paths` filter (Astro's
1156
+ // `_astro/` chunk dir gets eaten otherwise). Lives inside the
1157
+ // Astro project's `public/` so it's copied into `dist/` at
1158
+ // build time.
1159
+ const nojekyllPath = join(outputDir, "public", ".nojekyll");
1160
+ mkdirSync(dirname(nojekyllPath), { recursive: true });
1161
+ if (opts.forceOverwrite || !existsSync(nojekyllPath)) {
1162
+ writeFileSync(nojekyllPath, "");
1163
+ }
1164
+ }
1165
+ }
832
1166
  /**
833
1167
  * Emit `src/data/switcherMap.json` describing per-page
834
1168
  * version + locale equivalents. Always writes the file —
@@ -845,7 +1179,10 @@ export function emitConfigDerivedFiles(outputDir, options) {
845
1179
  * baseline page in a multi-version site).
846
1180
  */
847
1181
  export function emitSwitcherMap(pages, outputDir, options) {
848
- const basePath = normalizeBasePath(options.basePath);
1182
+ // Switcher URLs use combined so the link the dropdown emits
1183
+ // resolves under the host's served subpath (GH Pages project
1184
+ // pages, multi-mount Cloudflare).
1185
+ const combined = combinedPrefix(options);
849
1186
  const dataDir = join(outputDir, "src", "data");
850
1187
  const outPath = join(dataDir, "switcherMap.json");
851
1188
  // Detect axis activation by inspecting the data the loader
@@ -884,7 +1221,7 @@ export function emitSwitcherMap(pages, outputDir, options) {
884
1221
  const variant = {
885
1222
  ...(ms.locale !== undefined ? { locale: ms.locale } : {}),
886
1223
  ...(ms.version !== undefined ? { version: ms.version } : {}),
887
- url: `${basePath}/${page.slug}`,
1224
+ url: `${combined}/${page.slug}`,
888
1225
  };
889
1226
  if (!byLogicalKey[key])
890
1227
  byLogicalKey[key] = [];
@@ -962,6 +1299,10 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
962
1299
  return;
963
1300
  const basePath = normalizeBasePath(options.basePath);
964
1301
  const baseSegments = basePathSegments(basePath);
1302
+ // combined drives the redirect URL (the user-facing path they
1303
+ // get bounced to); basePath stays the filesystem path under
1304
+ // src/pages/ where the stub lives.
1305
+ const combined = combinedPrefix(options);
965
1306
  // Index existing pages by (slug after locale segment) so we
966
1307
  // can detect missing translations cheaply. Key shape:
967
1308
  // `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
@@ -995,7 +1336,7 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
995
1336
  const targetUrl = `${basePath}/${targetSlug}`;
996
1337
  if (existingByUrl.has(targetUrl))
997
1338
  continue; // already translated
998
- const defaultUrl = `${basePath}/${defaultPage.slug}`;
1339
+ const defaultUrl = `${combined}/${defaultPage.slug}`;
999
1340
  const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
1000
1341
  // Ensure parent dir exists; write a redirect-stub Astro
1001
1342
  // file. Adding `.astro` to the leaf turns it into a
@@ -1037,10 +1378,20 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1037
1378
  if (options.llmsTxt !== false) {
1038
1379
  emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
1039
1380
  // public/_headers — Cloudflare Workers / Pages convention. Adds an
1040
- // RFC 8288 Link header pointing agents at /llms.txt without parsing
1041
- // HTML. Emitted alongside llms.txt so the two files travel together.
1381
+ // RFC 8288 Link header pointing agents at this mount's llms.txt
1382
+ // (basePath-prefixed) without parsing HTML. Emitted alongside
1383
+ // llms.txt so the two files travel together.
1042
1384
  mkdirSync(join(outputDir, "public"), { recursive: true });
1043
- writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile());
1385
+ // _headers Link header points at the per-mount llms.txt at
1386
+ // <combined>/llms.txt — the URL agents would actually fetch.
1387
+ writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(combinedPrefix(options)));
1388
+ }
1389
+ // Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
1390
+ // public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
1391
+ // don't collide at host root. Gated on a valid http(s) siteUrl
1392
+ // because <loc> entries must be absolute.
1393
+ if (options.siteUrl && /^https?:\/\//.test(options.siteUrl)) {
1394
+ emitSitemapFiles(outputDir, options, pages);
1044
1395
  }
1045
1396
  // src/middleware.ts — Tier 1 (always update). Drives both the
1046
1397
  // `Accept: text/markdown` content-negotiation rewrite (via
@@ -1059,12 +1410,29 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1059
1410
  const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
1060
1411
  const axisRedirectOn = versionRedirectOn || localeRedirectOn;
1061
1412
  if (mdMirrorOn || axisRedirectOn) {
1413
+ // Taxonomy index paths share a single global namespace across
1414
+ // locales / versions (one `/tags/` for the whole site, not one
1415
+ // per locale). The redirect helper has to know to skip them or
1416
+ // it will 302 chip hrefs to non-existent locale-prefixed routes.
1417
+ // Strip leading `/` and pull just the first segment so a config
1418
+ // like `/tags` becomes the global-prefix entry `tags`.
1419
+ const globalPrefixes = [];
1420
+ if (options.taxonomyIndexPaths) {
1421
+ for (const raw of Object.values(options.taxonomyIndexPaths)) {
1422
+ const first = raw.replace(/^\/+/, "").split("/")[0];
1423
+ if (first)
1424
+ globalPrefixes.push(first);
1425
+ }
1426
+ }
1062
1427
  mkdirSync(join(outputDir, "src"), { recursive: true });
1063
1428
  writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
1064
1429
  mdMirror: mdMirrorOn,
1065
1430
  axisRedirect: axisRedirectOn
1066
1431
  ? {
1067
- basePath: normalizeBasePath(options.basePath),
1432
+ // Middleware compares paths against the request URL,
1433
+ // which carries the host's served subpath — so use the
1434
+ // combined prefix here.
1435
+ basePath: combinedPrefix(options),
1068
1436
  ...(versionRedirectOn
1069
1437
  ? {
1070
1438
  defaultVersion: options.defaultVersion,
@@ -1077,6 +1445,7 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
1077
1445
  knownLocales,
1078
1446
  }
1079
1447
  : {}),
1448
+ ...(globalPrefixes.length > 0 ? { globalPrefixes } : {}),
1080
1449
  }
1081
1450
  : undefined,
1082
1451
  }));
@@ -1137,21 +1506,37 @@ function buildRobotsTxt(options, hasSiteUrl) {
1137
1506
  const aiInput = options.aiInput ?? "yes";
1138
1507
  const aiTrain = options.aiTrain ?? "no";
1139
1508
  const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
1140
- const sitemap = hasSiteUrl
1141
- ? `Sitemap: ${options.siteUrl.replace(/\/$/, "")}/sitemap-index.xml\n`
1509
+ // Per-mount sitemap path: each Dogsbay site emits its sitemap
1510
+ // index under <basePath>/, so robots.txt must point there too.
1511
+ // (Multi-mount deploys end up with one robots.txt per site at
1512
+ // their respective hosts / paths; each correctly references its
1513
+ // own mount's sitemap-index.)
1514
+ // Sitemap URL = origin + combined + /sitemap-index.xml. Use the
1515
+ // origin (no path) from site.url and the combined prefix (urlBase
1516
+ // + basePath); siteUrl could itself include a path component when
1517
+ // hosting on a subpath (GH Pages project page), so we strip it
1518
+ // here to avoid double-counting.
1519
+ const { origin } = parseSiteUrl(options.siteUrl);
1520
+ const sitemap = hasSiteUrl && origin
1521
+ ? `Sitemap: ${origin}${withBasePath(combinedPrefix(options), "/sitemap-index.xml")}\n`
1142
1522
  : "";
1143
1523
  return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}`;
1144
1524
  }
1145
1525
  /**
1146
1526
  * Build the contents of `public/_headers` (Cloudflare Pages / Workers
1147
1527
  * Static Assets convention). Emits a global RFC 8288 Link header
1148
- * pointing at the site's llms.txt index, so agents don't need to
1528
+ * pointing at this mount's llms.txt index, so agents don't need to
1149
1529
  * parse HTML to discover the LLM-friendly content listing.
1530
+ *
1531
+ * The Link target is basePath-prefixed (`</docs/llms.txt>` for a
1532
+ * `/docs` mount) — matches where the platform actually emits
1533
+ * llms.txt under the per-mount layout.
1150
1534
  */
1151
- function buildHeadersFile() {
1535
+ function buildHeadersFile(basePath) {
1536
+ const llmsHref = withBasePath(basePath, "/llms.txt");
1152
1537
  return [
1153
1538
  "/*",
1154
- ' Link: </llms.txt>; rel="describedby"; type="text/plain"',
1539
+ ` Link: <${llmsHref}>; rel="describedby"; type="text/plain"`,
1155
1540
  "",
1156
1541
  ].join("\n");
1157
1542
  }
@@ -1160,20 +1545,28 @@ function buildMiddlewareSource(config) {
1160
1545
  "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
1161
1546
  "// Composes the docs-layout middleware helpers.",
1162
1547
  "//",
1163
- "// Markdown content negotiation:",
1164
- "// This middleware fires on every request, but in Astro's static",
1165
- "// prerender mode (output: \"static\") request headers are NOT",
1166
- "// forwarded Astro warns about \"Astro.request.headers was used",
1167
- "// when rendering...\" and serves a prerendered HTML response.",
1168
- "// That means `Accept: text/markdown` negotiation only kicks in",
1169
- "// under SSR (output: \"server\") or via an edge function on the",
1170
- "// deployment layer (Cloudflare Worker, Netlify Edge, etc.).",
1171
- "// For pure-static deploys, agents should follow the page's",
1172
- "// <link rel=\"alternate\" type=\"text/markdown\"> href to fetch",
1173
- "// the .md mirror directly (e.g. /docs.md).",
1548
+ "// Static-prerender guard:",
1549
+ "// In Astro's static output mode, this middleware is invoked",
1550
+ "// for every prerendered route at build time. Reading",
1551
+ "// `context.request.headers` there triggers an Astro warning",
1552
+ "// per page (\"Astro.request.headers was used during static",
1553
+ "// render\"), which floods `dogsbay site build` / `site preview`",
1554
+ "// output. Worse, the negotiation can't actually happen at",
1555
+ "// build time there's no runtime client whose Accept header",
1556
+ "// we'd be honoring.",
1557
+ "//",
1558
+ "// We guard with `context.isPrerendered` so prerendered routes",
1559
+ "// short-circuit to `next()` immediately. At runtime in static",
1560
+ "// deploys, middleware doesn't fire at all (no server); at",
1561
+ "// runtime in SSR / hybrid deploys, only dynamic routes fire,",
1562
+ "// which is exactly when negotiation makes sense.",
1174
1563
  "//",
1175
- "// The Cloudflare-Worker-driven full fix is tracked in",
1176
- "// plans/cloudflare-deploy-content-negotiation.md.",
1564
+ "// Markdown content negotiation:",
1565
+ "// For pure-static deploys, `Accept: text/markdown` is honored",
1566
+ "// by the platform (Cloudflare _headers + Worker, Netlify Edge",
1567
+ "// functions). Agents that can't send Accept headers should",
1568
+ "// follow the page's <link rel=\"alternate\" type=\"text/markdown\">",
1569
+ "// to fetch the .md mirror directly (e.g. /docs.md).",
1177
1570
  'import { defineMiddleware } from "astro:middleware";',
1178
1571
  ];
1179
1572
  if (config.mdMirror) {
@@ -1187,6 +1580,11 @@ function buildMiddlewareSource(config) {
1187
1580
  lines.push(`const AXIS_REDIRECT_CONFIG = ${JSON.stringify(config.axisRedirect, null, 2)};`, "");
1188
1581
  }
1189
1582
  lines.push("export const onRequest = defineMiddleware((context, next) => {");
1583
+ // Skip prerendered routes — see file-top comment for the rationale.
1584
+ // Avoids per-page Astro.request.headers warnings during build, and
1585
+ // matches runtime semantics (middleware doesn't fire on prerendered
1586
+ // routes when deployed).
1587
+ lines.push(" if (context.isPrerendered) return next();");
1190
1588
  lines.push(" const url = new URL(context.request.url);");
1191
1589
  if (config.mdMirror) {
1192
1590
  lines.push(' const accept = context.request.headers.get("accept");', " const mdTarget = shouldRewriteToMarkdown(accept, url.pathname);", " if (mdTarget) return context.rewrite(mdTarget);");
@@ -1218,8 +1616,18 @@ function buildMdEndpoint(page, sourceRel) {
1218
1616
  ].join("\n");
1219
1617
  }
1220
1618
  /**
1221
- * Emit `public/llms.txt`, `public/llms-full.txt`, and per-section
1222
- * `public/<dir>/llms.txt` files for the site.
1619
+ * Emit per-mount llms.txt + llms-full.txt + per-section indexes.
1620
+ *
1621
+ * Files live under `public/<basePath>/...` so multiple Dogsbay sites
1622
+ * can mount on the same host (`/docs/llms.txt` + `/api/llms.txt` +
1623
+ * `/handbook/llms.txt`) without colliding at the root. When basePath
1624
+ * is empty, this collapses to `public/llms.txt` — the single-site
1625
+ * llmstxt.org-spec layout.
1626
+ *
1627
+ * The host root `/llms.txt` is intentionally NOT emitted by the
1628
+ * platform: it's the user's umbrella file, analogous to
1629
+ * `sitemap-index.xml`. Multi-mount deploys hand-write a top-level
1630
+ * `/llms.txt` that links to each per-mount index.
1223
1631
  *
1224
1632
  * Per-section files are written for every top-level nav group that
1225
1633
  * resolves to a site directory (either via `group.href` or via the
@@ -1231,26 +1639,64 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
1231
1639
  description: options.description,
1232
1640
  siteUrl: options.siteUrl,
1233
1641
  };
1234
- const publicDir = join(outputDir, "public");
1235
- mkdirSync(publicDir, { recursive: true });
1236
- const hrefPrefix = normalizeBasePath(options.basePath);
1237
- writeFileSync(join(publicDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
1238
- writeFileSync(join(publicDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
1642
+ // hrefPrefix is the COMBINED prefix — used for the URL paths that
1643
+ // appear inside the llms.txt body (so agents fetch the correct
1644
+ // host-relative URLs). Filesystem layout uses basePath alone:
1645
+ // `public/<basePath>/llms.txt` matches the existing per-mount
1646
+ // delivery shape.
1647
+ const hrefPrefix = combinedPrefix(options);
1648
+ const basePath = normalizeBasePath(options.basePath);
1649
+ const baseSegments = basePathSegments(basePath);
1650
+ const mountDir = join(outputDir, "public", ...baseSegments);
1651
+ mkdirSync(mountDir, { recursive: true });
1652
+ writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
1653
+ writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
1239
1654
  summary: "body",
1240
1655
  serializePage: serializePageMd,
1241
1656
  hrefPrefix,
1242
1657
  }));
1658
+ // Per-section files: `deriveSectionDir` returns a host-absolute
1659
+ // path (already includes basePath because nav hrefs do). Join it to
1660
+ // public/ directly — joining onto mountDir would double-prefix into
1661
+ // `public/<basePath>/<basePath>/<section>/llms.txt`.
1243
1662
  for (const group of nav) {
1244
1663
  if (!group.children || group.children.length === 0)
1245
1664
  continue;
1246
1665
  const dir = deriveSectionDir(group);
1247
1666
  if (!dir)
1248
1667
  continue;
1249
- const sectionPath = join(publicDir, dir, "llms.txt");
1668
+ const sectionPath = join(outputDir, "public", dir, "llms.txt");
1250
1669
  mkdirSync(dirname(sectionPath), { recursive: true });
1251
1670
  writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
1252
1671
  }
1253
1672
  }
1673
+ /**
1674
+ * Emit per-mount sitemap files.
1675
+ *
1676
+ * Writes `public/<basePath>/sitemap-index.xml` + `sitemap-0.xml`.
1677
+ * The index lists the single sub-sitemap today; future splits add
1678
+ * more sub-sitemap entries as the page count grows past
1679
+ * sitemaps.org's 50K-URL recommendation.
1680
+ *
1681
+ * Caller has already guarded on a valid http(s) `siteUrl` — without
1682
+ * one, `<loc>` entries can't be absolute and crawlers reject the
1683
+ * file. Skip emission rather than write a broken sitemap.
1684
+ */
1685
+ function emitSitemapFiles(outputDir, options, pages) {
1686
+ // Filesystem path uses basePath (sitemap files live in
1687
+ // public/<basePath>/sitemap-*.xml). The URL prefix encoded into
1688
+ // each <loc> uses combined so the absolute URLs resolve under the
1689
+ // host's served subpath. buildSitemap strips path off siteUrl
1690
+ // internally, so passing siteUrl + combined as basePath gives
1691
+ // origin + combined as the final URL.
1692
+ const basePath = normalizeBasePath(options.basePath);
1693
+ const combined = combinedPrefix(options);
1694
+ const baseSegments = basePathSegments(basePath);
1695
+ const mountDir = join(outputDir, "public", ...baseSegments);
1696
+ mkdirSync(mountDir, { recursive: true });
1697
+ writeFileSync(join(mountDir, "sitemap-0.xml"), buildSitemap(pages, { siteUrl: options.siteUrl, basePath: combined }));
1698
+ writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({ siteUrl: options.siteUrl, basePath: combined }));
1699
+ }
1254
1700
  /**
1255
1701
  * Pick a directory under `public/` for a top-level nav group. Prefers
1256
1702
  * the group's own href (already a `/docs/x/y` path); otherwise falls
@@ -1328,6 +1774,11 @@ function copyComponents(outputDir) {
1328
1774
  "response-tabs", "schema-viewer", "code-samples", "copy-button",
1329
1775
  "markdown-example",
1330
1776
  "accordion", "link-card", "avatar", "math",
1777
+ // Icon resolves @ui/icon/Icon.astro → built-time SVG inlining
1778
+ // via @dogsbay/icons. Used by `:::cards` `{icon=...}` and the
1779
+ // inline `:icon[name]` directive. Without this entry every page
1780
+ // emitting the icon import 500s with "module not found".
1781
+ "icon",
1331
1782
  ];
1332
1783
  for (const name of needed) {
1333
1784
  const src = join(componentsSource, name);
@@ -1382,6 +1833,32 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
1382
1833
  catch { /* source may not exist */ }
1383
1834
  }
1384
1835
  // ── CSS generation (ported from import-mkdocs.ts) ───────
1836
+ /**
1837
+ * Build the `@source inline("...")` directive that pins the
1838
+ * grid-tone palette into the generated stylesheet.
1839
+ *
1840
+ * Why we need it: tone classes like `bg-primary/10` only appear in
1841
+ * `.astro` pages emitted by Dogsbay's grid-item serializer. When
1842
+ * Tailwind's content scanner doesn't pick them up — because the
1843
+ * page lives outside the default scan globs, or because a class
1844
+ * is composed at the boundary of an interpolation — they get
1845
+ * purged. Result observed in dogsbay-docs-markdown audit: half
1846
+ * the grid demo cells render with no background. Pinning forces
1847
+ * generation regardless of scanner reach.
1848
+ *
1849
+ * Single source of truth: derived from TONE_CLASSES so any new
1850
+ * tone added to the palette is automatically safelisted.
1851
+ */
1852
+ function buildToneSafelist() {
1853
+ const seen = new Set();
1854
+ for (const classes of Object.values(TONE_CLASSES)) {
1855
+ for (const cls of classes.split(/\s+/)) {
1856
+ if (cls)
1857
+ seen.add(cls);
1858
+ }
1859
+ }
1860
+ return [...seen].sort().join(" ");
1861
+ }
1385
1862
  function generateGlobalCss() {
1386
1863
  return `@import "tailwindcss";
1387
1864
  @import "./theme.css";
@@ -1390,6 +1867,14 @@ function generateGlobalCss() {
1390
1867
  @source "../../node_modules/@dogsbay/ui/src";
1391
1868
  @source "../../node_modules/@dogsbay/docs-layout/src";
1392
1869
 
1870
+ /* Pin the grid-tone palette. These classes are emitted into
1871
+ markdown-generated .astro pages by the grid-item serializer
1872
+ (TONE_CLASSES in @dogsbay/format-astro). Without inlining,
1873
+ opacity-modified utilities like bg-primary/10 get purged when
1874
+ Tailwind doesn't see them in the scanned globs, leaving grid
1875
+ demo cells with no visible background. */
1876
+ @source inline("${buildToneSafelist()}");
1877
+
1393
1878
  /* Prose typography for rendered content */
1394
1879
  .docs-prose {
1395
1880
  line-height: 1.7;