@dogsbay/format-astro 0.2.0-beta.114 → 0.2.0-beta.116

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/api-markdown.d.ts +8 -0
  2. package/dist/api-markdown.d.ts.map +1 -0
  3. package/dist/api-markdown.js +41 -0
  4. package/dist/api-markdown.js.map +1 -0
  5. package/dist/blog.d.ts.map +1 -1
  6. package/dist/blog.js +5 -1
  7. package/dist/blog.js.map +1 -1
  8. package/dist/browser.d.ts +14 -0
  9. package/dist/browser.d.ts.map +1 -0
  10. package/dist/browser.js +14 -0
  11. package/dist/browser.js.map +1 -0
  12. package/dist/canonical.d.ts +34 -0
  13. package/dist/canonical.d.ts.map +1 -0
  14. package/dist/canonical.js +107 -0
  15. package/dist/canonical.js.map +1 -0
  16. package/dist/head-metadata.d.ts +26 -0
  17. package/dist/head-metadata.d.ts.map +1 -0
  18. package/dist/head-metadata.js +56 -0
  19. package/dist/head-metadata.js.map +1 -0
  20. package/dist/index.d.ts +4 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +7 -3
  23. package/dist/index.js.map +1 -1
  24. package/dist/llms-txt.d.ts +3 -3
  25. package/dist/llms-txt.d.ts.map +1 -1
  26. package/dist/llms-txt.js +48 -242
  27. package/dist/llms-txt.js.map +1 -1
  28. package/dist/plugins.d.ts.map +1 -1
  29. package/dist/plugins.js +3 -1
  30. package/dist/plugins.js.map +1 -1
  31. package/dist/project.d.ts +36 -1
  32. package/dist/project.d.ts.map +1 -1
  33. package/dist/project.js +292 -840
  34. package/dist/project.js.map +1 -1
  35. package/dist/redirects.d.ts +22 -0
  36. package/dist/redirects.d.ts.map +1 -1
  37. package/dist/redirects.js +70 -123
  38. package/dist/redirects.js.map +1 -1
  39. package/dist/serialize.d.ts +4 -13
  40. package/dist/serialize.d.ts.map +1 -1
  41. package/dist/serialize.js +305 -103
  42. package/dist/serialize.js.map +1 -1
  43. package/dist/sitemap.d.ts +44 -17
  44. package/dist/sitemap.d.ts.map +1 -1
  45. package/dist/sitemap.js +31 -104
  46. package/dist/sitemap.js.map +1 -1
  47. package/dist/taxonomy.d.ts.map +1 -1
  48. package/dist/taxonomy.js +7 -1
  49. package/dist/taxonomy.js.map +1 -1
  50. package/dist/theme-css.d.ts +26 -0
  51. package/dist/theme-css.d.ts.map +1 -0
  52. package/dist/theme-css.js +642 -0
  53. package/dist/theme-css.js.map +1 -0
  54. package/dist/tone-classes.d.ts +14 -0
  55. package/dist/tone-classes.d.ts.map +1 -0
  56. package/dist/tone-classes.js +30 -0
  57. package/dist/tone-classes.js.map +1 -0
  58. package/dist/write-if-changed.d.ts +21 -0
  59. package/dist/write-if-changed.d.ts.map +1 -0
  60. package/dist/write-if-changed.js +58 -0
  61. package/dist/write-if-changed.js.map +1 -0
  62. package/package.json +13 -5
package/dist/project.js CHANGED
@@ -4,14 +4,31 @@
4
4
  * Takes ExportPage[] + NavItem[] and generates a complete Astro project
5
5
  * with static .astro pages using real Dogsbay components.
6
6
  */
7
- import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, cpSync, readdirSync, statSync, rmSync, rmdirSync, openSync, readSync, closeSync, } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, cpSync, readdirSync, statSync, rmSync, rmdirSync, openSync, readSync, closeSync, } from "node:fs";
8
+ // Unchanged files are not rewritten, so `site dev` reloads only what changed
9
+ import { writeFileSync, copyFileSync } from "./write-if-changed.js";
8
10
  import { join, dirname, relative, resolve } from "node:path";
9
11
  import { fileURLToPath } from "node:url";
10
12
  import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
11
- import { treeToAstro, TONE_CLASSES } from "./serialize.js";
13
+ import { treeToAstro } from "./serialize.js";
14
+ import { missingTranslations } from "@dogsbay/site-files";
15
+ import { sanitizeTree } from "@dogsbay/render-html/sanitize";
16
+ import { generateGlobalCss, themeCss } from "./theme-css.js";
17
+ /**
18
+ * A value as a JavaScript literal in a generated page. `<` becomes \u003c: Astro reads
19
+ * a page's frontmatter and template looking for markup, so a `<script>` inside a string
20
+ * (a page's own Markdown, a code block showing one) would be taken for a real script tag
21
+ * and its text compiled as JavaScript, which fails Vite's dependency scan.
22
+ */
23
+ function pageLiteral(value, indent) {
24
+ return (JSON.stringify(value, null, indent) ?? "undefined").replace(/</g, "\\u003c");
25
+ }
12
26
  import { emitPluginRuntime } from "./plugins.js";
13
27
  import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
14
- import { buildSitemap, buildSitemapIndex } from "./sitemap.js";
28
+ import { buildSitemapFiles, buildSitemapIndex } from "./sitemap.js";
29
+ import { canonicalPlan } from "./canonical.js";
30
+ import { headMetadata } from "./head-metadata.js";
31
+ import { buildHeadersFile, buildRobotsTxt as buildRobotsTxtFile } from "@dogsbay/site-files";
15
32
  import { assembleBook, estimateTreeBytes } from "./granularity.js";
16
33
  /**
17
34
  * The per-topic "Read as single page" affordance (granularity book view).
@@ -571,6 +588,8 @@ function buildSiteConfig(siteName, options, outputDir) {
571
588
  cfg.editUri = options.editUri;
572
589
  if (options.copyright)
573
590
  cfg.copyright = options.copyright;
591
+ if (options.tagline)
592
+ cfg.tagline = options.tagline;
574
593
  // Written explicitly, both ways: the default is ON, so an ABSENT key in
575
594
  // site.json would be indistinguishable from "not yet regenerated" and the
576
595
  // page template would have to guess. `false` is what the opt-out looks like.
@@ -591,6 +610,9 @@ function buildSiteConfig(siteName, options, outputDir) {
591
610
  if (options.tagLabels && Object.keys(options.tagLabels).length > 0) {
592
611
  cfg.tagLabels = options.tagLabels;
593
612
  }
613
+ if (options.tagAutoColor) {
614
+ cfg.tagAutoColor = true;
615
+ }
594
616
  // Link-icon glyphs — only emitted when configured, so sites without
595
617
  // the feature keep byte-identical site.json.
596
618
  if (options.linkIcons && (options.linkIcons.external || options.linkIcons.internal)) {
@@ -1406,15 +1428,12 @@ export default defineConfig({
1406
1428
  else {
1407
1429
  scaffoldFilesSkipped += 2; // global.css + theme.css
1408
1430
  }
1409
- // UI components — scaffold-once. Per shadcn pattern, copies are owned
1410
- // by the project and meant to be edited. Re-running convert leaves the
1411
- // existing tree alone.
1412
- if (writeScaffold) {
1413
- copyComponents(outputDir);
1414
- }
1415
- else {
1416
- scaffoldFilesSkipped++;
1417
- }
1431
+ // UI components. Per shadcn pattern, copies are owned by the project and
1432
+ // meant to be edited, so an existing component folder is never touched.
1433
+ // Missing ones are added on every build: the serializer starts emitting a
1434
+ // new component (Kbd, FileTree) in a release, and a site scaffolded before
1435
+ // it would otherwise fail with "failed to resolve @ui/kbd/Kbd.astro".
1436
+ copyComponents(outputDir);
1418
1437
  return scaffoldFilesSkipped;
1419
1438
  }
1420
1439
  // ─── Tier 1: content ────────────────────────────────────────────────────
@@ -1473,6 +1492,7 @@ const favicon = (siteConfigData as { favicon?: string }).favicon;
1473
1492
 
1474
1493
  <DocsLayout
1475
1494
  siteName={siteConfig.siteName}
1495
+ siteTagline={siteConfig.tagline}
1476
1496
  title="Page not found"
1477
1497
  description="That page does not exist. Here is where to look instead."
1478
1498
  nav={navData as never}
@@ -1498,7 +1518,30 @@ const favicon = (siteConfigData as { favicon?: string }).favicon;
1498
1518
  </DocsLayout>
1499
1519
  `);
1500
1520
  }
1521
+ /**
1522
+ * Nav entries come from a project's own TOC files (toctrees, `_toc.yml`,
1523
+ * `myst.yml`, nav.yml), i.e. from content. An href with a scheme other than
1524
+ * http(s)/mailto/tel — `javascript:` above all — is dropped (the entry keeps
1525
+ * its label and children), so it cannot reach the sidebar, nav.json or a
1526
+ * page's previous/next link.
1527
+ */
1528
+ export function dropUnsafeNavHrefs(items) {
1529
+ return items.map((item) => {
1530
+ const out = { ...item };
1531
+ if (typeof out.href === "string") {
1532
+ // Browsers strip tabs, newlines and other C0 controls from a URL
1533
+ // (`java\tscript:` IS javascript:), so the scheme is tested without them.
1534
+ const probe = out.href.replace(/[\u0000-\u0020]/g, "");
1535
+ if (/^[a-z][a-z0-9+.-]*:/i.test(probe) && !/^(?:https?|mailto|tel):/i.test(probe))
1536
+ delete out.href;
1537
+ }
1538
+ if (out.children)
1539
+ out.children = dropUnsafeNavHrefs(out.children);
1540
+ return out;
1541
+ });
1542
+ }
1501
1543
  export async function emitAstroPages(pages, nav, outputDir, options) {
1544
+ nav = dropUnsafeNavHrefs(nav);
1502
1545
  const siteName = options.siteName || "Documentation";
1503
1546
  // basePath = filesystem layout prefix (where pages live under
1504
1547
  // src/pages/...). combined = the URL prefix HTML hrefs need
@@ -1507,6 +1550,13 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1507
1550
  const basePath = normalizeBasePath(options.basePath);
1508
1551
  const combined = combinedPrefix(options);
1509
1552
  const baseSegments = basePathSegments(basePath);
1553
+ // Base Pagefind prefixes RESULT urls with: the served path dist/'s root
1554
+ // maps to. Same rule as docs-layout's pagefindBaseUrl(); see the comment
1555
+ // on the pagefindBaseUrl prop below. Computed once, not per page.
1556
+ const pageUrlBase = parseSiteUrl(options.siteUrl).urlBase;
1557
+ const searchBaseUrl = workersSubpathOutDir(options.deploy, pageUrlBase)
1558
+ ? "/"
1559
+ : `${pageUrlBase}/`;
1510
1560
  // Ensure dirs exist (callers may invoke us without going through the
1511
1561
  // full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
1512
1562
  mkdirSync(join(outputDir, "src", "pages", ...baseSegments), { recursive: true });
@@ -1711,6 +1761,33 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1711
1761
  : undefined;
1712
1762
  const blogAdj = blogData ? blogAdjacency(blogData) : undefined;
1713
1763
  const blogMinutes = new Map(blogData ? blogData.posts.map((post) => [post.slug, post.readingMinutes]) : []);
1764
+ // Older versions' pages name the default version's page as canonical (src/canonical.ts).
1765
+ const isFragmentPage = (p) => p.fragment === true || p.frontmatter?._fragment === true;
1766
+ // The version served at the root (`path: ""`) and the other versions'
1767
+ // segments: the nav filter keeps the root version's bucket by excluding them.
1768
+ const rootVersion = rootVersionOf(options);
1769
+ const otherVersionSegments = rootVersion
1770
+ ? pageVersions(pages).filter((v) => v !== rootVersion)
1771
+ : [];
1772
+ const canonical = canonicalPlan(pages, {
1773
+ isEmitted: (p) => !isFragmentPage(p) && !isExcludedSlug(p.slug),
1774
+ siteUrl: options.siteUrl,
1775
+ basePath: combined,
1776
+ versions: options.versions,
1777
+ defaultVersion: options.defaultVersion,
1778
+ redirects: options.redirects,
1779
+ });
1780
+ // hreflang alternates, WebSite on home pages, BreadcrumbList from the nav (src/head-metadata.ts).
1781
+ const head = headMetadata(pages, outputNav, {
1782
+ siteUrl: options.siteUrl,
1783
+ basePath: combined,
1784
+ defaultLocale: options.defaultLocale,
1785
+ isEmitted: (p) => !isFragmentPage(p) && !isExcludedSlug(p.slug),
1786
+ indexable: (p) => {
1787
+ const c = canonical.get(p.slug);
1788
+ return !options.noindex && !c?.url && !c?.noindex && p.frontmatter?.noindex !== true;
1789
+ },
1790
+ });
1714
1791
  for (const page of emitPages) {
1715
1792
  try {
1716
1793
  // Skip excluded pages before any expensive work (tree rewrite,
@@ -1734,6 +1811,14 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1734
1811
  if (typeof page.redirect === "string" && page.redirect.trim()) {
1735
1812
  continue;
1736
1813
  }
1814
+ // content.html: sanitize filters raw HTML and directive attributes, as dogsbay-ssg
1815
+ // does (script links are refused by the serializer either way: isSafeHref).
1816
+ if (options.html === "sanitize") {
1817
+ const removed = new Set();
1818
+ page.tree = sanitizeTree(page.tree, (_code, message) => removed.add(message));
1819
+ for (const m of removed)
1820
+ console.warn(`[html] ${page.slug || "/"}: ${m}`);
1821
+ }
1737
1822
  // Rewrite internal hrefs to match the output URL structure
1738
1823
  rewriteTreeHrefs(page.tree, hrefPrefix);
1739
1824
  // Same for raw image srcs — Astro doesn't auto-prefix
@@ -1744,6 +1829,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1744
1829
  const result = treeToAstro(page.tree, {
1745
1830
  imageOptimization: useImageOpt,
1746
1831
  codeBlockTitle: options.codeBlockTitle ?? true,
1832
+ imageCaptions: options.imageCaptions === "title",
1747
1833
  combinedPrefix: hrefPrefix,
1748
1834
  });
1749
1835
  const imageSetup = useImageOpt ? [
@@ -1779,9 +1865,12 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1779
1865
  // cannot opt out of a site-level lockdown. `draft: true` keeps
1780
1866
  // its existing role as a noindex shorthand. See
1781
1867
  // plans/site-level-robots-meta.md.
1868
+ const pageCanonical = canonical.get(page.slug);
1869
+ const pageHead = head.get(page.slug);
1782
1870
  const pageNoindex = options.noindex === true ||
1783
1871
  fm.noindex === true ||
1784
- fm.draft === true;
1872
+ fm.draft === true ||
1873
+ pageCanonical?.noindex === true;
1785
1874
  const pageNofollow = options.nofollow === true ||
1786
1875
  fm.nofollow === true;
1787
1876
  // Independent of noindex: pages can be excluded from in-site
@@ -1934,7 +2023,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1934
2023
  ...result.imports,
1935
2024
  ...imageSetup,
1936
2025
  "",
1937
- `const headings = ${JSON.stringify(page.headings || [])};`,
2026
+ `const headings = ${pageLiteral(page.headings || [])};`,
1938
2027
  `const nav = navData;`,
1939
2028
  // currentPath uses combined so it matches nav.json hrefs
1940
2029
  // (which are also combined-prefixed). getPagination compares
@@ -1952,10 +2041,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1952
2041
  // → "/docs" makes the filter look for `/docs/…` bucket hrefs that
1953
2042
  // don't exist, emptying the pagination nav so prev/next vanish on
1954
2043
  // every page of a root-served multi-source site.
1955
- ` basePath: ${JSON.stringify(basePath ?? "")},`,
1956
- ` namespace: ${JSON.stringify(page.multiSource?.namespace ?? null)} ?? undefined,`,
1957
- ` version: ${JSON.stringify(page.multiSource?.version ?? null)} ?? undefined,`,
1958
- ` locale: ${JSON.stringify(page.multiSource?.locale ?? null)} ?? undefined,`,
2044
+ ` basePath: ${pageLiteral(basePath ?? "")},`,
2045
+ ` namespace: ${pageLiteral(page.multiSource?.namespace ?? null)} ?? undefined,`,
2046
+ ` version: ${pageLiteral(page.multiSource?.version ?? null)} ?? undefined,`,
2047
+ ` locale: ${pageLiteral(page.multiSource?.locale ?? null)} ?? undefined,`,
2048
+ ...(rootVersion
2049
+ ? [
2050
+ ` rootVersion: ${pageLiteral(rootVersion)},`,
2051
+ ` versionSegments: ${pageLiteral(otherVersionSegments)},`,
2052
+ ]
2053
+ : []),
1959
2054
  `});`,
1960
2055
  // Blog adjacency is CHRONOLOGICAL, not navigational. getPagination
1961
2056
  // walks NavItem[] — sidebar order — and a blog has no sidebar; its
@@ -1964,25 +2059,28 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1964
2059
  // runtime.
1965
2060
  ...(blogAdj
1966
2061
  ? [
1967
- `const prev = ${JSON.stringify(blogAdj.get(page.slug)?.prev ?? null)} ?? undefined;`,
1968
- `const next = ${JSON.stringify(blogAdj.get(page.slug)?.next ?? null)} ?? undefined;`,
2062
+ `const prev = ${pageLiteral(blogAdj.get(page.slug)?.prev ?? null)} ?? undefined;`,
2063
+ `const next = ${pageLiteral(blogAdj.get(page.slug)?.next ?? null)} ?? undefined;`,
1969
2064
  ]
1970
2065
  : [`const { prev, next } = getPagination(currentPath, _navForPagination);`]),
1971
- `const title = ${JSON.stringify(page.title)};`,
1972
- `const description = ${JSON.stringify(pageDescription)} || undefined;`,
1973
- `const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
1974
- `const noindex = ${JSON.stringify(pageNoindex)};`,
1975
- `const nofollow = ${JSON.stringify(pageNofollow)};`,
1976
- `const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
1977
- `const pageTags = ${JSON.stringify(pageTags ?? null)};`,
1978
- `const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
1979
- `const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
1980
- `const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
1981
- `const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
1982
- `const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
1983
- `const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
1984
- `const lastUpdated = ${JSON.stringify(pageLastUpdated)} || undefined;`,
1985
- `const llmActionsProps = ${JSON.stringify(llmActionsEnabled
2066
+ `const title = ${pageLiteral(page.title)};`,
2067
+ `const description = ${pageLiteral(pageDescription)} || undefined;`,
2068
+ `const ogImage = ${pageLiteral(pageOgImage)} || undefined;`,
2069
+ `const noindex = ${pageLiteral(pageNoindex)};`,
2070
+ `const canonicalUrl = ${pageLiteral(pageCanonical?.url ?? null)} ?? undefined;`,
2071
+ `const alternates = ${pageLiteral(pageHead?.alternates ?? [])};`,
2072
+ `const breadcrumbJsonLd = ${pageLiteral(pageHead?.breadcrumbJsonLd ?? null)} ?? undefined;`,
2073
+ `const nofollow = ${pageLiteral(pageNofollow)};`,
2074
+ `const excludeFromSearch = ${pageLiteral(pageExcludeFromSearch)};`,
2075
+ `const pageTags = ${pageLiteral(pageTags ?? null)};`,
2076
+ `const pageStatus = ${pageLiteral(pageStatus ?? null)};`,
2077
+ `const pageType = ${pageLiteral(pageTypeStr ?? null)};`,
2078
+ `const pageAudience = ${pageLiteral(pageAudience ?? null)};`,
2079
+ `const pageCategory = ${pageLiteral(pageCategory ?? null)};`,
2080
+ `const pageTaxonomies = ${pageLiteral(pageTaxonomies ?? null)};`,
2081
+ `const tagsIndexPath = ${pageLiteral(tagsIndexPath)};`,
2082
+ `const lastUpdated = ${pageLiteral(pageLastUpdated)} || undefined;`,
2083
+ `const llmActionsProps = ${pageLiteral(llmActionsEnabled
1986
2084
  ? {
1987
2085
  enabled: true,
1988
2086
  providers: options.llmActions?.providers,
@@ -1998,6 +2096,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1998
2096
  "",
1999
2097
  `<DocsLayout`,
2000
2098
  ` siteName={siteConfig.siteName}`,
2099
+ ` siteTagline={siteConfig.tagline}`,
2001
2100
  ` title={title}`,
2002
2101
  ` nav={nav as any[]}`,
2003
2102
  ` headings={headings}`,
@@ -2012,6 +2111,10 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2012
2111
  ` themeColor={siteConfig.themeColor || undefined}`,
2013
2112
  ` noindex={noindex}`,
2014
2113
  ` nofollow={nofollow}`,
2114
+ ` canonicalUrl={canonicalUrl}`,
2115
+ ` alternates={alternates}`,
2116
+ ` breadcrumbJsonLd={breadcrumbJsonLd}`,
2117
+ ...(pageHead?.home ? [` ogType="website"`] : []),
2015
2118
  ` excludeFromSearch={excludeFromSearch}`,
2016
2119
  ` plausibleDomain={siteConfig.plausible?.domain}`,
2017
2120
  ` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
@@ -2021,6 +2124,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2021
2124
  ` tagsIndexPath={tagsIndexPath}`,
2022
2125
  ` tagPrefixes={siteConfig.tagPrefixes}`,
2023
2126
  ` tagLabels={siteConfig.tagLabels}`,
2127
+ ` tagAutoColor={siteConfig.tagAutoColor}`,
2024
2128
  ` taxonomyIndexPaths={siteConfig.taxonomyIndexPaths}`,
2025
2129
  ` taxonomyDisplay={siteConfig.taxonomyDisplay}`,
2026
2130
  ` status={pageStatus ?? undefined}`,
@@ -2035,7 +2139,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2035
2139
  ` linkIcons={siteConfig.linkIcons}`,
2036
2140
  // Default ON: an absent key means yes, `false` means no.
2037
2141
  ` poweredBy={siteConfig.poweredBy !== false}`,
2038
- ` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
2142
+ ` multiSource={${pageLiteral(page.multiSource ?? null)} ?? undefined}`,
2039
2143
  ` switcherMap={switcherMapData}`,
2040
2144
  // basePath here is the COMBINED URL prefix (urlBase from
2041
2145
  // site.url's path + dogsbay basePath). DocsLayout uses it
@@ -2044,19 +2148,29 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2044
2148
  // prefix the host actually serves under. Empty string is
2045
2149
  // valid (root-served sites with no urlBase or basePath);
2046
2150
  // don't fall back to "/docs" — that would 404 for those.
2047
- ` basePath={${JSON.stringify(combined)}}`,
2151
+ ` basePath={${pageLiteral(combined)}}`,
2048
2152
  // navMode — controls whether DocsLayout server-renders the
2049
2153
  // full sidebar nav tree per page (`ssr-full`) or emits a
2050
2154
  // client-hydrated placeholder (`client`, default). The
2051
2155
  // client mode shrinks per-page HTML dramatically at scale.
2052
2156
  // See plans/client-rendered-nav.md.
2053
- ` navMode={${JSON.stringify(options.navMode ?? "client")}}`,
2157
+ ` navMode={${pageLiteral(options.navMode ?? "client")}}`,
2054
2158
  // Pagefind index URL — must include the combined prefix or
2055
2159
  // the loader 404s on subpath-mounted deploys. The pagefind
2056
2160
  // CLI writes to <astroOutput>/dist/pagefind/ which Astro
2057
2161
  // serves under its `base` (= urlBase); dogsbay's basePath
2058
2162
  // adds the second prefix layer. Empty combined → `/pagefind/`.
2059
- ` pagefindUrl={${JSON.stringify(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
2163
+ ` pagefindUrl={${pageLiteral(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
2164
+ // Base Pagefind prefixes RESULT urls with. The index stores URLs
2165
+ // relative to dist/, so the base is whatever served path dist/'s
2166
+ // root maps to. A Workers subpath mount builds pages INTO
2167
+ // dist/<mount>/, so its index already carries the mount → "/".
2168
+ // GitHub Pages builds to dist/ and Astro adds `base` at serve time
2169
+ // → "<urlBase>/". Pagefind's default (the folder above the bundle)
2170
+ // is right only for the second, and doubled the mount on the first:
2171
+ // /dogsbay-xml-docs/dogsbay-xml-docs/…, measured. Same rule as
2172
+ // docs-layout's pagefindBaseUrl() — keep the two in step.
2173
+ ` pagefindBaseUrl={${pageLiteral(searchBaseUrl)}}`,
2060
2174
  // Favicon — composed with combined prefix so the
2061
2175
  // <link rel="icon"> resolves on subpath-mounted deploys.
2062
2176
  //
@@ -2066,7 +2180,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2066
2180
  // `public/favicon.ico` (or `.svg`) in their Astro project and the
2067
2181
  // link appears on the next build; DocsLayout takes `false` to mean
2068
2182
  // "emit no <link rel=icon>".
2069
- ` favicon={${JSON.stringify(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
2183
+ ` favicon={${pageLiteral(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
2070
2184
  ` wideLayout={${wideLayout}}`,
2071
2185
  // Blog chrome — sidebar dropped, byline on. Every other prop above
2072
2186
  // is unchanged, which is what keeps the blog visually continuous
@@ -2074,9 +2188,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2074
2188
  ...(options.blog
2075
2189
  ? [
2076
2190
  ` chrome="blog"`,
2077
- ` author={${JSON.stringify(page.meta?.author ?? null)} ?? undefined}`,
2078
- ` publishedDate={${JSON.stringify(page.meta?.[options.blog.dateField] ?? null)} ?? undefined}`,
2079
- ` updatedDate={${JSON.stringify(page.meta?.updated ?? null)} ?? undefined}`,
2191
+ ` author={${pageLiteral(page.meta?.author ?? null)} ?? undefined}`,
2192
+ ` publishedDate={${pageLiteral(page.meta?.[options.blog.dateField] ?? null)} ?? undefined}`,
2193
+ ` updatedDate={${pageLiteral(page.meta?.updated ?? null)} ?? undefined}`,
2080
2194
  ` readingMinutes={${blogMinutes.get(page.slug) ?? 1}}`,
2081
2195
  // Series position, derived in buildBlogData from publication
2082
2196
  // order — never hand-typed in prose, which goes stale the
@@ -2085,12 +2199,12 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2085
2199
  // `heroImage: {src, alt}` object would otherwise reach
2086
2200
  // DocsLayout's `heroImage?: string` prop and render as
2087
2201
  // src="[object Object]".
2088
- ` heroImage={${JSON.stringify(typeof (page.frontmatter ?? {}).heroImage === "string"
2202
+ ` heroImage={${pageLiteral(typeof (page.frontmatter ?? {}).heroImage === "string"
2089
2203
  ? (page.frontmatter ?? {}).heroImage
2090
2204
  : null)} ?? undefined}`,
2091
2205
  ]
2092
2206
  : []),
2093
- ` toc={${JSON.stringify(tocMode)}}`,
2207
+ ` toc={${pageLiteral(tocMode)}}`,
2094
2208
  `>`,
2095
2209
  // RightRail region — assigned to DocsLayout's `right-rail` named
2096
2210
  // slot. Inert (empty passthrough) until a plugin contributes a
@@ -2197,8 +2311,14 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2197
2311
  // the same contract the site root gets: redirect to its first nav
2198
2312
  // entry.
2199
2313
  const subtreeRoots = axisSubtreeRoots(pages);
2314
+ // A subtree's entries leave out the deeper subtrees nested in it: with the
2315
+ // default version at the root (`path: ""`) and a locale axis, `/en/` is
2316
+ // that version's subtree and `/en/v3/` another version's.
2317
+ const nestedIn = (slugPrefix) => [...subtreeRoots.keys()]
2318
+ .filter((q) => slugPrefix === "" ? q !== "" : q.startsWith(`${slugPrefix}/`))
2319
+ .map((q) => `${combined}/${q}`);
2200
2320
  for (const slugPrefix of subtreeRoots.keys()) {
2201
- const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`]);
2321
+ const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`], nestedIn(slugPrefix));
2202
2322
  if (!target)
2203
2323
  continue;
2204
2324
  const subtreeIndexPath = join(pagesDir, section ? section : "", slugPrefix, "index.astro");
@@ -2224,9 +2344,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2224
2344
  .filter(([, version]) => version === options.defaultVersion)
2225
2345
  .map(([slugPrefix]) => `${combined}/${slugPrefix}`)
2226
2346
  : [];
2347
+ // With the default version at the root (`path: ""`) and no locale or
2348
+ // namespace, it has no subtree: its entries are the ones outside every
2349
+ // other version's subtree.
2227
2350
  const firstHref = (defaultVersionPrefixes.length > 0
2228
- ? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes)
2229
- : undefined) ?? findFirstNavHref(outputNav, basePath);
2351
+ ? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes, [...subtreeRoots]
2352
+ .filter(([q]) => defaultVersionPrefixes.some((d) => `${combined}/${q}`.startsWith(`${d}/`)))
2353
+ .map(([q]) => `${combined}/${q}`))
2354
+ : rootVersion && subtreeRoots.size > 0
2355
+ ? findFirstNavHrefUnder(outputNav, [combined], nestedIn(""))
2356
+ : undefined) ?? findFirstNavHref(outputNav, basePath);
2230
2357
  const needsRootRedirect = basePath !== "" ||
2231
2358
  (!section && !rootIndexEmitted && firstHref !== "");
2232
2359
  if (needsRootRedirect) {
@@ -2326,6 +2453,8 @@ function emitRedirects(outputDir, options, pages) {
2326
2453
  slug: p.slug,
2327
2454
  redirect: p.redirect,
2328
2455
  frontmatter: p.frontmatter,
2456
+ // Front matter paths are relative to the page's language and version (#096): collectRedirects needs its original slug.
2457
+ multiSource: p.multiSource,
2329
2458
  })),
2330
2459
  config: options.redirects,
2331
2460
  prefix,
@@ -2447,8 +2576,9 @@ export function emitSwitcherMap(pages, outputDir, options) {
2447
2576
  writeFileSync(outPath, JSON.stringify({ versions: [], locales: [], byLogicalKey: {} }, null, 2));
2448
2577
  return;
2449
2578
  }
2579
+ const rootVersion = rootVersionOf(options);
2450
2580
  const versions = composeAxisHeader(options.versions ?? [], versionsSeen, options.defaultVersion,
2451
- /* allowEol */ true);
2581
+ /* allowEol */ true).map((v) => (v.id === rootVersion ? { ...v, root: true } : v));
2452
2582
  const locales = composeAxisHeader(options.locales ?? [], localesSeen, options.defaultLocale,
2453
2583
  /* allowEol */ false);
2454
2584
  // Per-logical-key list of variants — every (locale, version)
@@ -2540,65 +2670,50 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
2540
2670
  const defaultLocale = options.defaultLocale;
2541
2671
  if (!defaultLocale)
2542
2672
  return;
2543
- const knownLocales = new Set();
2544
- for (const p of pages) {
2545
- const l = p.multiSource?.locale;
2546
- if (l)
2547
- knownLocales.add(l);
2548
- }
2549
- if (knownLocales.size < 2)
2550
- return;
2551
- if (!knownLocales.has(defaultLocale))
2552
- return;
2553
2673
  const basePath = normalizeBasePath(options.basePath);
2554
2674
  const baseSegments = basePathSegments(basePath);
2555
2675
  // combined drives the redirect URL (the user-facing path they
2556
2676
  // get bounced to); basePath stays the filesystem path under
2557
2677
  // src/pages/ where the stub lives.
2558
2678
  const combined = combinedPrefix(options);
2559
- // Index existing pages by (slug after locale segment) so we
2560
- // can detect missing translations cheaply. Key shape:
2561
- // `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
2562
- // is the version + namespace segments (when those axes are
2563
- // active for the page).
2564
- //
2565
- // For each default-locale page, we strip the leading `<defaultLocale>/`
2566
- // off the slug to get the "neutral" tail, then for every other
2567
- // locale, the expected URL is `<basePath>/<otherLocale>/<tail>`.
2568
- const existingByUrl = new Set();
2679
+ // Which pages need a stub is @dogsbay/site-files' missingTranslations,
2680
+ // shared with dogsbay-ssg. A page's key is its slug after the locale
2681
+ // segment, inside its version. Slugs on a namespaced site start with the
2682
+ // namespace, not the locale, so such a site gets no stubs. Only pages that
2683
+ // are routed get one: not include-only fragments, `excludeFromRoutes`
2684
+ // folders or `redirect:` pages. In another language, a `redirect:` page
2685
+ // holds the URL, so it counts as present.
2686
+ const units = new Map();
2687
+ const defaultPages = new Map();
2688
+ const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
2689
+ const groupOf = (ms) => `${ms.namespace ?? ""}\0${ms.version ?? ""}`;
2569
2690
  for (const p of pages) {
2570
- existingByUrl.add(`${basePath}/${p.slug}`);
2571
- }
2572
- let emitted = 0;
2573
- for (const defaultPage of pages) {
2574
- const ms = defaultPage.multiSource;
2575
- if (!ms || ms.locale !== defaultLocale)
2691
+ const ms = p.multiSource;
2692
+ if (!ms?.locale || !p.slug.startsWith(`${ms.locale}/`))
2576
2693
  continue;
2577
- // Strip the leading `<defaultLocale>/` from the slug to get
2578
- // the locale-neutral tail. Defensive: if for some reason the
2579
- // slug doesn't start with the locale (e.g. unprefixed
2580
- // baseline page in a half-active site), skip.
2581
- const localePrefix = `${defaultLocale}/`;
2582
- if (!defaultPage.slug.startsWith(localePrefix))
2694
+ if (p.fragment === true || p.frontmatter?._fragment === true || isExcludedSlug(p.slug))
2583
2695
  continue;
2584
- const tail = defaultPage.slug.slice(localePrefix.length);
2585
- for (const otherLocale of knownLocales) {
2586
- if (otherLocale === defaultLocale)
2587
- continue;
2588
- const targetSlug = `${otherLocale}/${tail}`;
2589
- const targetUrl = `${basePath}/${targetSlug}`;
2590
- if (existingByUrl.has(targetUrl))
2591
- continue; // already translated
2592
- const defaultUrl = `${combined}/${defaultPage.slug}`;
2593
- const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
2594
- // Ensure parent dir exists; write a redirect-stub Astro
2595
- // file. Adding `.astro` to the leaf turns it into a
2596
- // routable page.
2597
- const dir = filePath.substring(0, filePath.lastIndexOf("/"));
2598
- mkdirSync(dir, { recursive: true });
2599
- writeFileSync(`${filePath}.astro`, buildMissingTranslationStub(defaultUrl, otherLocale, defaultLocale));
2600
- emitted++;
2601
- }
2696
+ if (typeof p.redirect === "string" && p.redirect.trim() && ms.locale === defaultLocale)
2697
+ continue;
2698
+ const tail = p.slug.slice(ms.locale.length + 1);
2699
+ const group = groupOf(ms);
2700
+ const k = `${group}\0${ms.locale}`;
2701
+ if (!units.has(k))
2702
+ units.set(k, { locale: ms.locale, version: group, pages: [] });
2703
+ units.get(k).pages.push(tail);
2704
+ if (ms.locale === defaultLocale)
2705
+ defaultPages.set(`${group}\0${tail}`, p);
2706
+ }
2707
+ let emitted = 0;
2708
+ for (const m of missingTranslations([...units.values()], defaultLocale)) {
2709
+ const defaultPage = defaultPages.get(`${m.version}\0${m.page}`);
2710
+ const targetSlug = `${m.locale}/${m.page}`;
2711
+ // The page's URL: an `index` slug is its folder (`/en/v3/index` is not served).
2712
+ const defaultUrl = `${combined}/${defaultPage.slug.replace(/(^|\/)index$/, "")}`;
2713
+ const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
2714
+ mkdirSync(dirname(filePath), { recursive: true });
2715
+ writeFileSync(`${filePath}.astro`, buildMissingTranslationStub(defaultUrl, m.locale, defaultLocale));
2716
+ emitted++;
2602
2717
  }
2603
2718
  if (emitted > 0) {
2604
2719
  console.log(`Emitted ${emitted} missing-translation stub(s) → redirect to default locale.`);
@@ -2637,7 +2752,7 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
2637
2752
  mkdirSync(join(outputDir, "public"), { recursive: true });
2638
2753
  // _headers Link header points at the per-mount llms.txt at
2639
2754
  // <combined>/llms.txt — the URL agents would actually fetch.
2640
- writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(combinedPrefix(options)));
2755
+ writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(withBasePath(combinedPrefix(options), "/llms.txt")));
2641
2756
  }
2642
2757
  // Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
2643
2758
  // public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
@@ -2678,7 +2793,9 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
2678
2793
  const mdMirrorOn = options.mdMirror !== false && !staticDeploy;
2679
2794
  const knownVersions = pageVersions(pages);
2680
2795
  const knownLocales = pageLocales(pages);
2681
- const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2;
2796
+ // With the default version at the root (`path: ""`), a versionless URL IS
2797
+ // one of its pages: there is nothing to redirect.
2798
+ const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2 && !rootVersionOf(options);
2682
2799
  const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
2683
2800
  const axisRedirectOn = versionRedirectOn || localeRedirectOn;
2684
2801
  const middlewarePath = join(outputDir, "src", "middleware.ts");
@@ -2790,104 +2907,25 @@ function serializePageMd(page) {
2790
2907
  * opt-in.
2791
2908
  */
2792
2909
  function buildRobotsTxt(options, hasSiteUrl) {
2793
- const search = options.aiSearch ?? "yes";
2794
- const aiInput = options.aiInput ?? "yes";
2795
- const aiTrain = options.aiTrain ?? "no";
2796
- const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
2797
- // Per-mount sitemap path: each Dogsbay site emits its sitemap
2798
- // index under <basePath>/, so robots.txt must point there too.
2799
- // (Multi-mount deploys end up with one robots.txt per site at
2800
- // their respective hosts / paths; each correctly references its
2801
- // own mount's sitemap-index.)
2802
- // Sitemap URL = origin + combined + /sitemap-index.xml. Use the
2803
- // origin (no path) from site.url and the combined prefix (urlBase
2804
- // + basePath); siteUrl could itself include a path component when
2805
- // hosting on a subpath (GH Pages project page), so we strip it
2806
- // here to avoid double-counting.
2910
+ // Per-mount URLs: each Dogsbay site emits its sitemap index and
2911
+ // llms.txt under <basePath>/, so robots.txt points there. The origin
2912
+ // is taken without its path (site.url may carry one when hosting on
2913
+ // a subpath) and the combined prefix (urlBase + basePath) added, so
2914
+ // the path is not counted twice.
2807
2915
  const { origin } = parseSiteUrl(options.siteUrl);
2808
2916
  const combined = combinedPrefix(options);
2809
- const sitemap = hasSiteUrl && origin
2810
- ? `Sitemap: ${origin}${withBasePath(combined, "/sitemap-index.xml")}\n`
2811
- : "";
2812
- // Llms-Txt: line — non-standard but follows the same shape as
2813
- // `Sitemap:`. Crawlers and agents that scan robots.txt before
2814
- // fetching pages get a direct pointer at the per-mount llms.txt.
2815
- // RFC 9309 explicitly permits unknown directives ("intentionally
2816
- // permissive of such future extensions") so this is harmless to
2817
- // standards-compliant parsers. Emitted alongside Sitemap when
2818
- // siteUrl is set; absolute URLs only (relative paths would be
2819
- // ambiguous without a base).
2820
- // OPT-IN (`agent.llmsTxtDirective`), default off. RFC 9309 permits
2821
- // unknown directives, but Lighthouse's validator reports this one as
2822
- // `Unknown directive` and fails the "robots.txt is valid" audit —
2823
- // measured: SEO 100 -> 92 on every page. Nothing consumes the line
2824
- // either; `Llms-Txt:` is not part of the llms.txt proposal, and
2825
- // agents find the file at its well-known path. A measured penalty
2826
- // for a speculative benefit is not a sensible default.
2827
- //
2828
- // `llms.txt` itself is still emitted; this controls only the pointer.
2829
- const llmsTxt = options.llmsTxtDirective === true &&
2830
- options.llmsTxt !== false &&
2831
- hasSiteUrl &&
2832
- origin
2833
- ? `Llms-Txt: ${origin}${withBasePath(combined, "/llms.txt")}\n`
2834
- : "";
2835
- return `${contentSignalPreamble(options)}User-agent: *\nAllow: /\n${contentSignal}${sitemap}${llmsTxt}`;
2836
- }
2837
- /**
2838
- * The Article 4 reservation-of-rights preamble, as robots.txt comments.
2839
- *
2840
- * Opt-in via `agent.contentSignal.preamble`. Default off: it is 25
2841
- * lines of legal text on every site, and it asserts a legal position
2842
- * that is the operator's to take, not ours to take for them.
2843
- *
2844
- * Why it is worth having at all — `Content-Signal: ai-train=no` on its
2845
- * own is a preference. Article 4(3) of EU Directive 2019/790 makes the
2846
- * text-and-data-mining exception conditional on rights not having been
2847
- * "expressly reserved in an appropriate manner", including
2848
- * machine-readable means. The preamble is what turns the signal into
2849
- * that express reservation, which is the difference between a request
2850
- * and a reservation with legal effect in the EU.
2851
- *
2852
- * The wording is Cloudflare's Content Signals Policy text, which is
2853
- * published under CC0 precisely so it can be reproduced. It is
2854
- * reproduced rather than linked because a reservation has to be present
2855
- * where the machine reads it.
2856
- *
2857
- * Kept BYTE-IDENTICAL to the published policy on purpose. It is a legal
2858
- * instrument, not prose we own: reword it and a site is asserting
2859
- * something subtly different from what its operator believes it is
2860
- * asserting. `tests/robots-preamble.test.ts` pins the text.
2861
- */
2862
- function contentSignalPreamble(options) {
2863
- if (options.contentSignalPreamble !== true)
2864
- return "";
2865
- return `# As a condition of accessing this website, you agree to abide by the following
2866
- # content signals:
2867
-
2868
- # (a) If a Content-Signal = yes, you may collect content for the corresponding
2869
- # use.
2870
- # (b) If a Content-Signal = no, you may not collect content for the
2871
- # corresponding use.
2872
- # (c) If the website operator does not include a Content-Signal for a
2873
- # corresponding use, the website operator neither grants nor restricts
2874
- # permission via Content-Signal with respect to the corresponding use.
2875
-
2876
- # The content signals and their meanings are:
2877
-
2878
- # search: building a search index and providing search results (e.g., returning
2879
- # hyperlinks and short excerpts from your website's contents). Search does not
2880
- # include providing AI-generated search summaries.
2881
- # ai-input: inputting content into one or more AI models (e.g., retrieval
2882
- # augmented generation, grounding, or other real-time taking of content for
2883
- # generative AI search answers).
2884
- # ai-train: training or fine-tuning AI models.
2885
-
2886
- # ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF
2887
- # RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT
2888
- # AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET.
2889
-
2890
- `;
2917
+ const absolute = hasSiteUrl && origin;
2918
+ return buildRobotsTxtFile({
2919
+ signals: { search: options.aiSearch, aiInput: options.aiInput, aiTrain: options.aiTrain },
2920
+ preamble: options.contentSignalPreamble === true,
2921
+ sitemapUrl: absolute ? `${origin}${withBasePath(combined, "/sitemap-index.xml")}` : undefined,
2922
+ // `Llms-Txt:` is opt-in (`agent.llmsTxtDirective`): Lighthouse reports
2923
+ // it as an unknown directive and fails "robots.txt is valid"
2924
+ // (measured: SEO 100 -> 92 on every page), and nothing consumes it.
2925
+ llmsTxtUrl: absolute && options.llmsTxtDirective === true && options.llmsTxt !== false
2926
+ ? `${origin}${withBasePath(combined, "/llms.txt")}`
2927
+ : undefined,
2928
+ });
2891
2929
  }
2892
2930
  /**
2893
2931
  * Build the contents of `public/_headers` (Cloudflare Pages / Workers
@@ -2930,14 +2968,6 @@ export function faviconHref(outputDir, urlBase) {
2930
2968
  }
2931
2969
  return false;
2932
2970
  }
2933
- function buildHeadersFile(basePath) {
2934
- const llmsHref = withBasePath(basePath, "/llms.txt");
2935
- return [
2936
- "/*",
2937
- ` Link: <${llmsHref}>; rel="describedby"; type="text/plain"`,
2938
- "",
2939
- ].join("\n");
2940
- }
2941
2971
  function buildMiddlewareSource(config) {
2942
2972
  const lines = [
2943
2973
  "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
@@ -3078,7 +3108,13 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
3078
3108
  continue;
3079
3109
  let relDir;
3080
3110
  if (combinedSegs && dir === combinedSegs) {
3081
- relDir = "";
3111
+ // The section's pages share no directory below the mount — they
3112
+ // are spread across several, so the common prefix collapsed to
3113
+ // the mount itself. There is no directory that belongs to this
3114
+ // section, and writing here would land on `<mount>/llms.txt`:
3115
+ // the site index, which was written just above. Skip it. The
3116
+ // index already lists the section, so nothing is lost.
3117
+ continue;
3082
3118
  }
3083
3119
  else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
3084
3120
  relDir = dir.slice(combinedSegs.length + 1);
@@ -3089,9 +3125,7 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
3089
3125
  // back to the raw value rather than rooting at /.
3090
3126
  relDir = dir;
3091
3127
  }
3092
- const sectionPath = relDir
3093
- ? join(mountDir, relDir, "llms.txt")
3094
- : join(mountDir, "llms.txt");
3128
+ const sectionPath = join(mountDir, relDir, "llms.txt");
3095
3129
  mkdirSync(dirname(sectionPath), { recursive: true });
3096
3130
  writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
3097
3131
  }
@@ -3112,7 +3146,7 @@ function emitSitemapFiles(outputDir, options, pages) {
3112
3146
  // Filesystem path uses basePath (sitemap files live in
3113
3147
  // public/<basePath>/sitemap-*.xml). The URL prefix encoded into
3114
3148
  // each <loc> uses combined so the absolute URLs resolve under the
3115
- // host's served subpath. buildSitemap strips path off siteUrl
3149
+ // host's served subpath. buildSitemapFiles strips path off siteUrl
3116
3150
  // internally, so passing siteUrl + combined as basePath gives
3117
3151
  // origin + combined as the final URL.
3118
3152
  const basePath = normalizeBasePath(options.basePath);
@@ -3120,14 +3154,28 @@ function emitSitemapFiles(outputDir, options, pages) {
3120
3154
  const baseSegments = basePathSegments(basePath);
3121
3155
  const mountDir = join(outputDir, "public", ...baseSegments);
3122
3156
  mkdirSync(mountDir, { recursive: true });
3123
- writeFileSync(join(mountDir, "sitemap-0.xml"), buildSitemap(pages, {
3157
+ // An older version's page whose canonical is the default version's, or a noindex version's page, is left out.
3158
+ const excluded = makeSlugExcluder(options.excludeFromRoutes);
3159
+ const plan = canonicalPlan(pages, {
3160
+ isEmitted: (p) => !(p.fragment === true || p.frontmatter?._fragment === true) && !excluded(p.slug),
3161
+ siteUrl: options.siteUrl,
3162
+ basePath: combined,
3163
+ versions: options.versions,
3164
+ defaultVersion: options.defaultVersion,
3165
+ redirects: options.redirects,
3166
+ });
3167
+ // More than one file past 50,000 URLs, the protocol's limit per file.
3168
+ const files = buildSitemapFiles(pages.filter((p) => plan.get(p.slug)?.sitemap !== false), {
3124
3169
  siteUrl: options.siteUrl,
3125
3170
  basePath: combined,
3126
3171
  siteNoindex: options.noindex === true,
3127
- }));
3172
+ });
3173
+ for (const [name, xml] of files)
3174
+ writeFileSync(join(mountDir, name), xml);
3128
3175
  writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({
3129
3176
  siteUrl: options.siteUrl,
3130
3177
  basePath: combined,
3178
+ sitemapCount: files.length,
3131
3179
  extraSitemaps: (options.aggregates ?? [])
3132
3180
  .map((a) => a.sitemap)
3133
3181
  .filter((u) => !!u),
@@ -3215,29 +3263,50 @@ function axisSubtreeRoots(pages) {
3215
3263
  }
3216
3264
  return roots;
3217
3265
  }
3266
+ /**
3267
+ * The version served at the site root: the `defaultVersion` when its
3268
+ * `versions` entry has `path: ""` (the CLI's config load allows it on no
3269
+ * other version). Its pages have no version segment.
3270
+ */
3271
+ export function rootVersionOf(options) {
3272
+ const def = options.defaultVersion;
3273
+ if (!def)
3274
+ return undefined;
3275
+ return options.versions?.find((v) => v.id === def)?.path === "" ? def : undefined;
3276
+ }
3218
3277
  /**
3219
3278
  * First nav href (in nav order) living under any of `prefixes`, which
3220
3279
  * are URL paths without a trailing slash. Used to pick a redirect
3221
3280
  * target for a subtree root that has no page of its own.
3222
3281
  */
3223
- function findFirstNavHrefUnder(items, prefixes) {
3282
+ function findFirstNavHrefUnder(items, prefixes,
3283
+ /** Deeper subtrees to skip (another version's, nested in the root version's). */
3284
+ exclude = []) {
3224
3285
  for (const item of items) {
3225
3286
  const href = item.href;
3226
- if (href && prefixes.some((prefix) => href.startsWith(`${prefix}/`))) {
3287
+ if (href &&
3288
+ prefixes.some((prefix) => href.startsWith(`${prefix}/`)) &&
3289
+ !exclude.some((x) => href === x || href.startsWith(`${x}/`))) {
3227
3290
  return href;
3228
3291
  }
3229
3292
  if (item.children) {
3230
- const found = findFirstNavHrefUnder(item.children, prefixes);
3293
+ const found = findFirstNavHrefUnder(item.children, prefixes, exclude);
3231
3294
  if (found)
3232
3295
  return found;
3233
3296
  }
3234
3297
  }
3235
3298
  return undefined;
3236
3299
  }
3237
- function copyComponents(outputDir) {
3300
+ /**
3301
+ * Copy the UI component folders the serializer can emit into the site's
3302
+ * `src/components/ui/`, skipping any that exist (project-owned, maybe
3303
+ * edited). Returns the names added. `site build` calls it so a component
3304
+ * introduced in a release (Kbd, FileTree) reaches sites scaffolded earlier.
3305
+ */
3306
+ export function copyComponents(outputDir) {
3238
3307
  const componentsSource = resolveComponentsSource();
3239
3308
  if (!componentsSource)
3240
- return;
3309
+ return [];
3241
3310
  // Components copied into the generated project. These are the ones
3242
3311
  // format-astro's serializer emits imports for, plus a few used by the
3243
3312
  // project shell (DocsLayout, pagination, theme-toggle).
@@ -3264,14 +3333,19 @@ function copyComponents(outputDir) {
3264
3333
  // inline `:icon[name]` directive. Without this entry every page
3265
3334
  // emitting the icon import 500s with "module not found".
3266
3335
  "icon",
3336
+ // Inline :kbd and the :::files tree.
3337
+ "kbd", "files",
3267
3338
  ];
3339
+ const added = [];
3268
3340
  for (const name of needed) {
3269
3341
  const src = join(componentsSource, name);
3270
3342
  const dest = join(outputDir, "src", "components", "ui", name);
3271
3343
  if (existsSync(src) && !existsSync(dest)) {
3272
3344
  cpSync(src, dest, { recursive: true });
3345
+ added.push(name);
3273
3346
  }
3274
3347
  }
3348
+ return added;
3275
3349
  }
3276
3350
  /** Media files worth carrying from a `mediaOnly` asset mount. */
3277
3351
  const MOUNT_MEDIA_EXTS = new Set([
@@ -3364,630 +3438,8 @@ function copyAssets(sourceDir, outputDir, imageOptimization, urlPrefix) {
3364
3438
  }
3365
3439
  catch { /* source may not exist */ }
3366
3440
  }
3367
- // ── CSS generation (ported from import-mkdocs.ts) ───────
3368
- /**
3369
- * Build the `@source inline("...")` directive that pins the
3370
- * grid-tone palette into the generated stylesheet.
3371
- *
3372
- * Why we need it: tone classes like `bg-primary/10` only appear in
3373
- * `.astro` pages emitted by Dogsbay's grid-item serializer. When
3374
- * Tailwind's content scanner doesn't pick them up — because the
3375
- * page lives outside the default scan globs, or because a class
3376
- * is composed at the boundary of an interpolation — they get
3377
- * purged. Result observed in dogsbay-docs-markdown audit: half
3378
- * the grid demo cells render with no background. Pinning forces
3379
- * generation regardless of scanner reach.
3380
- *
3381
- * Single source of truth: derived from TONE_CLASSES so any new
3382
- * tone added to the palette is automatically safelisted.
3383
- */
3384
- function buildToneSafelist() {
3385
- const seen = new Set();
3386
- for (const classes of Object.values(TONE_CLASSES)) {
3387
- for (const cls of classes.split(/\s+/)) {
3388
- if (cls)
3389
- seen.add(cls);
3390
- }
3391
- }
3392
- return [...seen].sort().join(" ");
3393
- }
3394
- function generateGlobalCss() {
3395
- return `@import "tailwindcss";
3396
- @import "./theme.css";
3397
-
3398
- /* Custom elements have no user-agent style, so an unstyled one is
3399
- display:inline — on which w-full does nothing and a border paints
3400
- as detached fragments rather than around the content. @dogsbay/elements
3401
- injects these same defaults at runtime, which is what reaches EXISTING
3402
- sites (global.css is scaffolded once and never rewritten). Having them
3403
- here too means a new site is correct at CSS time, with no flash before
3404
- the module script runs.
3405
-
3406
- :where() keeps specificity at zero, so any utility class wins. */
3407
- :where(db-tabs, db-accordion, db-collapsible, db-card, db-steps, db-code-block) {
3408
- display: block;
3409
- }
3410
- :where(db-link-button) {
3411
- display: inline-block;
3412
- }
3413
-
3414
- /* Scan @dogsbay packages for Tailwind classes */
3415
- @source "../../node_modules/@dogsbay/ui/src";
3416
- @source "../../node_modules/@dogsbay/docs-layout/src";
3417
-
3418
- /* Pin the grid-tone palette. These classes are emitted into
3419
- markdown-generated .astro pages by the grid-item serializer
3420
- (TONE_CLASSES in @dogsbay/format-astro). Without inlining,
3421
- opacity-modified utilities like bg-primary/10 get purged when
3422
- Tailwind doesn't see them in the scanned globs, leaving grid
3423
- demo cells with no visible background. */
3424
- @source inline("${buildToneSafelist()}");
3425
-
3426
- /* Prose typography for rendered content.
3427
- Every element rule is guarded with :not(.dba-api *): API-reference
3428
- regions (ApiLayout root carries the dba-api marker) compose their own
3429
- typography from utility classes, and unguarded .docs-prose rules would
3430
- out-specify them. The guard lets mixed prose+endpoint pages (MDX
3431
- imports) keep full typography outside the cards. */
3432
- .docs-prose {
3433
- line-height: 1.7;
3434
-
3435
- & h1:not(.dba-api *) { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; line-height: 1.2; letter-spacing: -0.025em; }
3436
- & h2:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 0.75rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; letter-spacing: -0.015em; }
3437
- & h3:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
3438
- & h4:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
3439
-
3440
- & p:not(.dba-api *) { margin-top: 0.75rem; margin-bottom: 0.75rem; }
3441
-
3442
- /* The :not([data-variant]) guard exempts anchors that ARE components. A
3443
- link-button renders as an <a> carrying bg-primary + text-primary-foreground
3444
- and a data-variant attribute, and this rule repainted its text with
3445
- var(--primary) — primary on primary: an unreadable solid block in light
3446
- mode, a blank one in dark. The secondary variant survived only because its
3447
- background is light enough to read primary-coloured text, which is why it
3448
- looked like a colour bug in one button rather than a cascade bug in both.
3449
- (No backticks in this comment: the whole stylesheet is a template
3450
- literal.) */
3451
- & a:not(.dba-api *):not([data-variant]) { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
3452
- & a:hover:not(.dba-api *):not([data-variant]) { opacity: 0.8; }
3453
-
3454
- & strong:not(.dba-api *) { font-weight: 600; }
3455
- & code:not(.dba-api *) { font-size: 0.875em; padding: 0.15em 0.35em; border-radius: 0.25rem; background: var(--muted); font-family: var(--font-code, ui-monospace, monospace); }
3456
- & pre code:not(.dba-api *) { padding: 0; background: none; font-size: 1em; }
3457
-
3458
- /* Spacing between consecutive block elements (code blocks, alerts, etc.) */
3459
- & > * + * { margin-top: 0.75rem; }
3460
- & li > * + *:not(.dba-api *) { margin-top: 0.75rem; }
3461
-
3462
- & ul:not(.dba-api *) { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
3463
- & ol:not(.dba-api *) { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
3464
- & li:not(.dba-api *) { margin: 0.25rem 0; }
3465
- & li > ul:not(.dba-api *), & li > ol:not(.dba-api *) { margin: 0.25rem 0; }
3466
-
3467
- & blockquote:not(.dba-api *) { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
3468
-
3469
- & hr:not(.dba-api *) { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
3470
-
3471
- & table:not(.dba-api *) { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
3472
- & th:not(.dba-api *) { text-align: left; vertical-align: bottom; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
3473
- & td:not(.dba-api *) { padding: 0.5rem; vertical-align: top; border-bottom: 1px solid var(--border); }
3474
-
3475
- /* Tailwind Preflight sets display: block on every img, which turns an
3476
- INLINE icon into its own line and splits the sentence around it —
3477
- AsciiDoc UI macros render mid-step and were breaking every procedure
3478
- that used one. inline-block restores the flow and leaves a standalone
3479
- image (the only child of its paragraph) looking exactly as before. */
3480
- & img:not(.dba-api *) { display: inline-block; max-width: 100%; border-radius: 0.5rem; }
3481
- /* A standalone image — the only child of its paragraph — keeps block
3482
- layout, so the vertical rhythm and the absence of baseline descender
3483
- space are exactly what they were before inline icons were fixed. */
3484
- & p > img:only-child:not(.dba-api *) { display: block; }
3485
-
3486
- & .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
3487
- & h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
3488
-
3489
- & details:not(.dba-api *) { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
3490
- & summary:not(.dba-api *) { cursor: pointer; font-weight: 600; }
3491
-
3492
- /* mark is emitted by the highlight directive AND can arrive as raw HTML
3493
- from an importer. Without a rule here it renders as the browser default
3494
- yellow, which clashes in light mode and is unreadable on a dark page
3495
- (nothing declares color-scheme). Token-based so it follows the theme. */
3496
- & mark:not(.dba-api *) { background: var(--dsb-mark-bg, rgb(254 240 138 / 0.6)); color: inherit; border-radius: 0.125rem; padding: 0 0.125rem; }
3497
- &:where(.dark *) mark:not(.dba-api *), .dark & mark:not(.dba-api *) { background: var(--dsb-mark-bg-dark, rgb(234 179 8 / 0.3)); }
3498
-
3499
- & dl:not(.dba-api *) { margin: 1rem 0; }
3500
- & dt:not(.dba-api *) { font-weight: 600; margin-top: 0.75rem; }
3501
- & dd:not(.dba-api *) { margin-left: 1.5rem; color: var(--muted-foreground); }
3502
-
3503
- /* In-cell admonitions. Inside a raw HTML table cell nothing
3504
- markdown-native works, so the AsciiDoc engine emits a neutral
3505
- dl.db-admonition shape (DD-010) and this rule renders it as a compact
3506
- callout box — same contract the Obsidian plugin styles. Per-type
3507
- accent from theme tokens; overrides the generic dl/dt/dd rules above. */
3508
- & dl.db-admonition {
3509
- margin: 0.75rem 0;
3510
- padding: 0.5rem 0.75rem;
3511
- border-left: 3px solid var(--info);
3512
- border-radius: 0.25rem;
3513
- background: color-mix(in oklab, var(--info) 8%, transparent);
3514
- }
3515
- /* The LABEL blends the accent toward the foreground; the border and the
3516
- tint keep the accent at full strength, so the callout still reads as
3517
- its colour while the text stays legible.
3518
-
3519
- Measured from painted pixels on a real page, light mode, 13px/600 —
3520
- the accent used raw is a genuine AA failure, not one of axe's oklch
3521
- misreads:
3522
-
3523
- --info raw 2.18:1 mixed 5.66:1
3524
- --warning raw 2.25:1 mixed 5.74:1
3525
- --destructive raw 5.97:1 mixed 10.98:1
3526
-
3527
- Mixed with var(--foreground), not black, on purpose. Black fixes
3528
- light and BREAKS dark: 70% black measures 5.21:1 light but 3.25:1
3529
- dark, because the tint it sits on is dark too. --foreground is
3530
- near-black in light and near-white in dark, so one rule serves both
3531
- (dark measures 10.96 / 10.87 / 9.79). A fixed colour used in both
3532
- themes is a bug waiting. */
3533
- & dl.db-admonition > dt {
3534
- margin-top: 0;
3535
- font-weight: 600;
3536
- font-size: 0.8125rem;
3537
- text-transform: uppercase;
3538
- letter-spacing: 0.03em;
3539
- color: color-mix(in oklab, var(--info) 60%, var(--foreground));
3540
- }
3541
- & dl.db-admonition > dd { margin-left: 0; margin-top: 0.25rem; color: var(--foreground); }
3542
- & dl.db-admonition-warning, & dl.db-admonition-caution {
3543
- border-left-color: var(--warning);
3544
- background: color-mix(in oklab, var(--warning) 8%, transparent);
3545
- }
3546
- & dl.db-admonition-warning > dt, & dl.db-admonition-caution > dt { color: color-mix(in oklab, var(--warning) 60%, var(--foreground)); }
3547
- & dl.db-admonition-important {
3548
- border-left-color: var(--destructive);
3549
- background: color-mix(in oklab, var(--destructive) 8%, transparent);
3550
- }
3551
- & dl.db-admonition-important > dt { color: color-mix(in oklab, var(--destructive) 60%, var(--foreground)); }
3552
-
3553
- /* Granularity book-view affordances (plans/granularity-views.md):
3554
- "Read as single page →" on topics, "Open as page ↗" on book sections.
3555
- Deliberately subtle/muted — the book has one per section. */
3556
- & .dsb-read-single-page {
3557
- display: inline-block;
3558
- margin: 0 0 1.25rem;
3559
- font-size: 0.8125rem;
3560
- color: var(--muted-foreground);
3561
- text-decoration: none;
3562
- }
3563
- & .dsb-read-single-page:hover { color: var(--foreground); text-decoration: underline; }
3564
- & .dsb-open-as-page { margin: -0.25rem 0 0.5rem; font-size: 0.75rem; }
3565
- & .dsb-open-as-page a { color: var(--muted-foreground); text-decoration: none; }
3566
- & .dsb-open-as-page a:hover { color: var(--foreground); text-decoration: underline; }
3567
-
3568
- /* AsciiDoc block roles ([role="x"] / [.x]) preserved as classes (#384).
3569
- _abstract marks a module's lead/intro paragraph; .lead/.small are
3570
- Asciidoctor built-ins; _additional-resources marks the trailing
3571
- "Additional resources" section heading. */
3572
- & ._abstract, & .abstract, & .lead {
3573
- font-size: 1.05rem;
3574
- line-height: 1.6;
3575
- color: var(--muted-foreground);
3576
- }
3577
- & .small { font-size: 0.875rem; }
3578
- & h2._additional-resources, & h3._additional-resources {
3579
- font-size: 1.15rem;
3580
- margin-top: 2rem;
3581
- }
3582
-
3583
- /* Related resources (#384 R3): [role="_additional-resources"] + .Title +
3584
- list folds to a <section class="related-resources"> (→ DITA related-links). */
3585
- & .related-resources {
3586
- margin-top: 2rem;
3587
- padding-top: 1rem;
3588
- border-top: 1px solid var(--border);
3589
- }
3590
- & .related-resources-title {
3591
- font-weight: 600;
3592
- margin: 0 0 0.5rem;
3593
- }
3594
- }
3595
-
3596
- @utility scrollbar-none {
3597
- -ms-overflow-style: none;
3598
- scrollbar-width: none;
3599
- &::-webkit-scrollbar {
3600
- display: none;
3601
- }
3602
- }
3603
- `;
3604
- }
3605
- // ── Theme presets ───────────────────────────────────────
3606
- const THEME_DEFAULT = {
3607
- light: `:root {
3608
- --background: oklch(1 0 0);
3609
- --foreground: oklch(0.145 0.014 285.823);
3610
- --card: oklch(1 0 0);
3611
- --card-foreground: oklch(0.145 0.014 285.823);
3612
- --popover: oklch(1 0 0);
3613
- --popover-foreground: oklch(0.145 0.014 285.823);
3614
- --primary: oklch(0.205 0.014 285.823);
3615
- --primary-foreground: oklch(0.985 0 0);
3616
- --secondary: oklch(0.965 0.001 286.375);
3617
- --secondary-foreground: oklch(0.205 0.014 285.823);
3618
- --muted: oklch(0.965 0.001 286.375);
3619
- --muted-foreground: oklch(0.45 0.014 285.938);
3620
- --accent: oklch(0.965 0.001 286.375);
3621
- --accent-foreground: oklch(0.205 0.014 285.823);
3622
- --destructive: oklch(0.45 0.245 27.325);
3623
- --destructive-foreground: oklch(0.985 0 0);
3624
- --border: oklch(0.922 0.004 286.32);
3625
- --input: oklch(0.922 0.004 286.32);
3626
- --ring: oklch(0.708 0.014 285.823);
3627
- --radius: 0.625rem;
3628
- --note: oklch(0.637 0.174 259.126);
3629
- --note-foreground: var(--foreground);
3630
- --abstract: oklch(0.691 0.148 221.723);
3631
- --info: oklch(0.722 0.128 207.084);
3632
- --tip: oklch(0.731 0.112 178.627);
3633
- --success: oklch(0.754 0.175 149.577);
3634
- --question: oklch(0.809 0.19 126.526);
3635
- --warning: oklch(0.74 0.175 62.778);
3636
- --failure: oklch(0.614 0.194 21.602);
3637
- --danger: oklch(0.577 0.245 16.439);
3638
- --bug: oklch(0.548 0.258 349.761);
3639
- --example: oklch(0.534 0.222 286.033);
3640
- --quote: oklch(0.65 0 0);
3641
- --sidebar: oklch(0.985 0 0);
3642
- --sidebar-foreground: oklch(0.145 0.014 285.823);
3643
- --sidebar-primary: oklch(0.205 0.014 285.823);
3644
- --sidebar-primary-foreground: oklch(0.985 0 0);
3645
- --sidebar-accent: oklch(0.965 0.001 286.375);
3646
- --sidebar-accent-foreground: oklch(0.205 0.014 285.823);
3647
- --sidebar-border: oklch(0.922 0.004 286.32);
3648
- --sidebar-ring: oklch(0.708 0.014 285.823);
3649
- --code-background: oklch(0.965 0.004 264);
3650
- --code-foreground: oklch(0.3 0.014 253.833);
3651
-
3652
- /* API reference — always-dark code panel */
3653
- --api-panel-bg: oklch(0.145 0.014 253.833);
3654
- --api-panel-fg: oklch(0.926 0.013 253.833);
3655
-
3656
- /* API semantic colors — HTTP methods, types, status codes */
3657
- /* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
3658
- --api-get: oklch(0.435 0.095 162);
3659
- --api-post: oklch(0.45 0.174 259);
3660
- --api-put: oklch(0.455 0.105 63);
3661
- --api-patch: oklch(0.455 0.13 45);
3662
- --api-delete: oklch(0.45 0.179 22);
3663
- --api-head: oklch(0.47 0.222 286);
3664
- --api-required: oklch(0.45 0.179 22);
3665
- --api-deprecated: oklch(0.455 0.105 63);
3666
- --api-type-string: oklch(0.435 0.095 162);
3667
- --api-type-number: oklch(0.45 0.174 259);
3668
- --api-type-boolean: oklch(0.455 0.105 63);
3669
- --api-type-object: oklch(0.47 0.222 286);
3670
- --api-type-array: oklch(0.455 0.13 45);
3671
- --api-status-2xx: oklch(0.435 0.095 162);
3672
- --api-status-3xx: oklch(0.45 0.174 259);
3673
- --api-status-4xx: oklch(0.455 0.105 63);
3674
- --api-status-5xx: oklch(0.45 0.179 22);
3675
-
3676
- --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
3677
- --font-heading: var(--font-sans);
3678
- --font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
3679
- }`,
3680
- dark: `.dark {
3681
- --background: oklch(0.145 0.014 285.823);
3682
- --foreground: oklch(0.985 0 0);
3683
- --card: oklch(0.145 0.014 285.823);
3684
- --card-foreground: oklch(0.985 0 0);
3685
- --popover: oklch(0.145 0.014 285.823);
3686
- --popover-foreground: oklch(0.985 0 0);
3687
- --primary: oklch(0.985 0 0);
3688
- --primary-foreground: oklch(0.205 0.014 285.823);
3689
- --secondary: oklch(0.269 0.007 286.033);
3690
- --secondary-foreground: oklch(0.985 0 0);
3691
- --muted: oklch(0.269 0.007 286.033);
3692
- --muted-foreground: oklch(0.708 0.014 285.823);
3693
- --accent: oklch(0.269 0.007 286.033);
3694
- --accent-foreground: oklch(0.985 0 0);
3695
- --destructive: oklch(0.704 0.191 22.216);
3696
- --destructive-foreground: oklch(0.145 0.014 285.823);
3697
- --border: oklch(0.269 0.007 286.033);
3698
- --input: oklch(0.269 0.007 286.033);
3699
- --ring: oklch(0.439 0.01 286.375);
3700
- --note-foreground: var(--foreground);
3701
- --sidebar: oklch(0.205 0.014 285.823);
3702
- --sidebar-foreground: oklch(0.985 0 0);
3703
- --sidebar-primary: oklch(0.488 0.243 264.376);
3704
- --sidebar-primary-foreground: oklch(0.985 0 0);
3705
- --sidebar-accent: oklch(0.269 0.007 286.033);
3706
- --sidebar-accent-foreground: oklch(0.985 0 0);
3707
- --sidebar-border: oklch(0.269 0.007 286.033);
3708
- --sidebar-ring: oklch(0.439 0.01 286.375);
3709
- --code-background: oklch(0.18 0.014 253.833);
3710
- --code-foreground: oklch(0.926 0.013 253.833);
3711
-
3712
- /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
3713
- --api-get: oklch(0.720 0.135 162);
3714
- --api-post: oklch(0.755 0.174 259);
3715
- --api-put: oklch(0.745 0.15 63);
3716
- --api-patch: oklch(0.725 0.18 45);
3717
- --api-delete: oklch(0.805 0.194 22);
3718
- --api-head: oklch(0.740 0.182 286);
3719
- --api-required: oklch(0.805 0.194 22);
3720
- --api-deprecated: oklch(0.745 0.15 63);
3721
- --api-type-string: oklch(0.690 0.135 162);
3722
- --api-type-number: oklch(0.725 0.174 259);
3723
- --api-type-boolean: oklch(0.720 0.15 63);
3724
- --api-type-object: oklch(0.740 0.182 286);
3725
- --api-type-array: oklch(0.725 0.18 45);
3726
- --api-status-2xx: oklch(0.720 0.135 162);
3727
- --api-status-3xx: oklch(0.755 0.174 259);
3728
- --api-status-4xx: oklch(0.770 0.15 63);
3729
- --api-status-5xx: oklch(0.805 0.194 22);
3730
- }`,
3731
- };
3732
- /**
3733
- * Mintlify-inspired theme — clean, neutral palette matching modern doc sites.
3734
- *
3735
- * Extracted from Claude Code docs (Mintlify):
3736
- * - White background, near-black text
3737
- * - Serif display font for H1, sans-serif body
3738
- * - Soft colored callout backgrounds (blue note, green tip, amber warning)
3739
- * - Rounded callout borders (16px radius)
3740
- * - Light gray code background, JetBrains Mono
3741
- */
3742
- const THEME_MINTLIFY = {
3743
- light: `:root {
3744
- --background: oklch(0.993 0.005 95);
3745
- --foreground: oklch(0.15 0.01 260);
3746
- --card: oklch(0.993 0.005 95);
3747
- --card-foreground: oklch(0.15 0.01 260);
3748
- --popover: oklch(0.993 0.005 95);
3749
- --popover-foreground: oklch(0.15 0.01 260);
3750
- --primary: oklch(0.15 0.01 260);
3751
- --primary-foreground: oklch(0.985 0 0);
3752
- --secondary: oklch(0.97 0.002 260);
3753
- --secondary-foreground: oklch(0.15 0.01 260);
3754
- --muted: oklch(0.97 0.002 260);
3755
- --muted-foreground: oklch(0.45 0.01 260);
3756
- --accent: oklch(0.97 0.002 260);
3757
- --accent-foreground: oklch(0.15 0.01 260);
3758
- --destructive: oklch(0.55 0.22 25);
3759
- --destructive-foreground: oklch(0.985 0 0);
3760
- --border: oklch(0.91 0.004 260);
3761
- --input: oklch(0.91 0.004 260);
3762
- --ring: oklch(0.7 0.01 260);
3763
- --radius: 1rem;
3764
- /* Callout colors — soft pastel backgrounds matching Mintlify */
3765
- --note: oklch(0.62 0.18 260);
3766
- --note-foreground: var(--foreground);
3767
- --abstract: oklch(0.65 0.14 220);
3768
- --info: oklch(0.65 0.14 230);
3769
- --tip: oklch(0.65 0.16 155);
3770
- --success: oklch(0.65 0.16 155);
3771
- --question: oklch(0.7 0.15 270);
3772
- --warning: oklch(0.7 0.15 75);
3773
- --failure: oklch(0.6 0.2 25);
3774
- --danger: oklch(0.55 0.22 25);
3775
- --bug: oklch(0.55 0.22 350);
3776
- --example: oklch(0.55 0.2 285);
3777
- --quote: oklch(0.55 0 0);
3778
- /* Sidebar */
3779
- --sidebar: oklch(0.993 0.005 95);
3780
- --sidebar-foreground: oklch(0.15 0.01 260);
3781
- --sidebar-primary: oklch(0.15 0.01 260);
3782
- --sidebar-primary-foreground: oklch(0.985 0 0);
3783
- --sidebar-accent: oklch(0.97 0.002 260);
3784
- --sidebar-accent-foreground: oklch(0.15 0.01 260);
3785
- --sidebar-border: oklch(0.93 0.004 260);
3786
- --sidebar-ring: oklch(0.7 0.01 260);
3787
- /* Code — light gray background, matching Mintlify's inline code */
3788
- --code-background: oklch(0.96 0.003 260);
3789
- --code-foreground: oklch(0.25 0.01 260);
3790
-
3791
- /* API reference — always-dark code panel */
3792
- --api-panel-bg: oklch(0.145 0.014 253.833);
3793
- --api-panel-fg: oklch(0.926 0.013 253.833);
3794
-
3795
- /* API semantic colors — HTTP methods, types, status codes */
3796
- /* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
3797
- --api-get: oklch(0.435 0.095 162);
3798
- --api-post: oklch(0.45 0.174 259);
3799
- --api-put: oklch(0.455 0.105 63);
3800
- --api-patch: oklch(0.455 0.13 45);
3801
- --api-delete: oklch(0.45 0.179 22);
3802
- --api-head: oklch(0.47 0.222 286);
3803
- --api-required: oklch(0.45 0.179 22);
3804
- --api-deprecated: oklch(0.455 0.105 63);
3805
- --api-type-string: oklch(0.435 0.095 162);
3806
- --api-type-number: oklch(0.45 0.174 259);
3807
- --api-type-boolean: oklch(0.455 0.105 63);
3808
- --api-type-object: oklch(0.47 0.222 286);
3809
- --api-type-array: oklch(0.455 0.13 45);
3810
- --api-status-2xx: oklch(0.435 0.095 162);
3811
- --api-status-3xx: oklch(0.45 0.174 259);
3812
- --api-status-4xx: oklch(0.455 0.105 63);
3813
- --api-status-5xx: oklch(0.45 0.179 22);
3814
-
3815
- /* Fonts — system sans for body, JetBrains Mono for code */
3816
- --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
3817
- --font-heading: var(--font-sans);
3818
- --font-code: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3819
- }`,
3820
- dark: `.dark {
3821
- --background: oklch(0.16 0.01 260);
3822
- --foreground: oklch(0.93 0.005 260);
3823
- --card: oklch(0.18 0.01 260);
3824
- --card-foreground: oklch(0.93 0.005 260);
3825
- --popover: oklch(0.18 0.01 260);
3826
- --popover-foreground: oklch(0.93 0.005 260);
3827
- --primary: oklch(0.93 0.005 260);
3828
- --primary-foreground: oklch(0.16 0.01 260);
3829
- --secondary: oklch(0.22 0.008 260);
3830
- --secondary-foreground: oklch(0.93 0.005 260);
3831
- --muted: oklch(0.22 0.008 260);
3832
- --muted-foreground: oklch(0.65 0.01 260);
3833
- --accent: oklch(0.22 0.008 260);
3834
- --accent-foreground: oklch(0.93 0.005 260);
3835
- --destructive: oklch(0.65 0.2 25);
3836
- --destructive-foreground: oklch(0.16 0.01 260);
3837
- --border: oklch(0.28 0.008 260);
3838
- --input: oklch(0.28 0.008 260);
3839
- --ring: oklch(0.45 0.01 260);
3840
- --note-foreground: var(--foreground);
3841
- --sidebar: oklch(0.16 0.01 260);
3842
- --sidebar-foreground: oklch(0.93 0.005 260);
3843
- --sidebar-primary: oklch(0.55 0.2 260);
3844
- --sidebar-primary-foreground: oklch(0.93 0.005 260);
3845
- --sidebar-accent: oklch(0.22 0.008 260);
3846
- --sidebar-accent-foreground: oklch(0.93 0.005 260);
3847
- --sidebar-border: oklch(0.28 0.008 260);
3848
- --sidebar-ring: oklch(0.45 0.01 260);
3849
- --code-background: oklch(0.2 0.01 260);
3850
- --code-foreground: oklch(0.9 0.01 260);
3851
-
3852
- /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
3853
- --api-get: oklch(0.720 0.135 162);
3854
- --api-post: oklch(0.755 0.174 259);
3855
- --api-put: oklch(0.745 0.15 63);
3856
- --api-patch: oklch(0.725 0.18 45);
3857
- --api-delete: oklch(0.805 0.194 22);
3858
- --api-head: oklch(0.740 0.182 286);
3859
- --api-required: oklch(0.805 0.194 22);
3860
- --api-deprecated: oklch(0.745 0.15 63);
3861
- --api-type-string: oklch(0.690 0.135 162);
3862
- --api-type-number: oklch(0.725 0.174 259);
3863
- --api-type-boolean: oklch(0.720 0.15 63);
3864
- --api-type-object: oklch(0.740 0.182 286);
3865
- --api-type-array: oklch(0.725 0.18 45);
3866
- --api-status-2xx: oklch(0.720 0.135 162);
3867
- --api-status-3xx: oklch(0.755 0.174 259);
3868
- --api-status-4xx: oklch(0.770 0.15 63);
3869
- --api-status-5xx: oklch(0.805 0.194 22);
3870
- }`,
3871
- };
3872
3441
  function writeThemeFile(path, themeName) {
3873
- const tokens = themeName === "mintlify" ? THEME_MINTLIFY : THEME_DEFAULT;
3874
- writeFileSync(path, `@custom-variant dark (&:where(.dark, .dark *));
3875
-
3876
- @theme inline {
3877
- --color-background: var(--background);
3878
- --color-foreground: var(--foreground);
3879
- --color-card: var(--card);
3880
- --color-card-foreground: var(--card-foreground);
3881
- --color-popover: var(--popover);
3882
- --color-popover-foreground: var(--popover-foreground);
3883
- --color-primary: var(--primary);
3884
- --color-primary-foreground: var(--primary-foreground);
3885
- --color-secondary: var(--secondary);
3886
- --color-secondary-foreground: var(--secondary-foreground);
3887
- --color-muted: var(--muted);
3888
- --color-muted-foreground: var(--muted-foreground);
3889
- --color-accent: var(--accent);
3890
- --color-accent-foreground: var(--accent-foreground);
3891
- --color-destructive: var(--destructive);
3892
- --color-destructive-foreground: var(--destructive-foreground);
3893
- --color-border: var(--border);
3894
- --color-input: var(--input);
3895
- --color-ring: var(--ring);
3896
- --radius-sm: calc(var(--radius) - 4px);
3897
- --radius-md: calc(var(--radius) - 2px);
3898
- --radius-lg: var(--radius);
3899
- --radius-xl: calc(var(--radius) + 4px);
3900
-
3901
- /* Sidebar */
3902
- --color-sidebar: var(--sidebar);
3903
- --color-sidebar-foreground: var(--sidebar-foreground);
3904
- --color-sidebar-primary: var(--sidebar-primary);
3905
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
3906
- --color-sidebar-accent: var(--sidebar-accent);
3907
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
3908
- --color-sidebar-border: var(--sidebar-border);
3909
- --color-sidebar-ring: var(--sidebar-ring);
3910
-
3911
- /* Admonition colors */
3912
- --color-note: var(--note);
3913
- --color-note-foreground: var(--note-foreground);
3914
- --color-abstract: var(--abstract);
3915
- --color-info: var(--info);
3916
- --color-tip: var(--tip);
3917
- --color-success: var(--success);
3918
- --color-question: var(--question);
3919
- --color-warning: var(--warning);
3920
- --color-failure: var(--failure);
3921
- --color-danger: var(--danger);
3922
- --color-bug: var(--bug);
3923
- --color-example: var(--example);
3924
- --color-quote: var(--quote);
3925
-
3926
- /* Code */
3927
- --color-code-bg: var(--code-background);
3928
- --color-code-fg: var(--code-foreground);
3929
-
3930
- /* API reference — HTTP method, type, status, role colors */
3931
- --color-api-get: var(--api-get);
3932
- --color-api-post: var(--api-post);
3933
- --color-api-put: var(--api-put);
3934
- --color-api-patch: var(--api-patch);
3935
- --color-api-delete: var(--api-delete);
3936
- --color-api-head: var(--api-head);
3937
- --color-api-required: var(--api-required);
3938
- --color-api-deprecated: var(--api-deprecated);
3939
- --color-api-type-string: var(--api-type-string);
3940
- --color-api-type-number: var(--api-type-number);
3941
- --color-api-type-boolean: var(--api-type-boolean);
3942
- --color-api-type-object: var(--api-type-object);
3943
- --color-api-type-array: var(--api-type-array);
3944
- --color-api-status-2xx: var(--api-status-2xx);
3945
- --color-api-status-3xx: var(--api-status-3xx);
3946
- --color-api-status-4xx: var(--api-status-4xx);
3947
- --color-api-status-5xx: var(--api-status-5xx);
3948
-
3949
- /* Fonts */
3950
- --font-sans: var(--font-sans);
3951
- --font-mono: var(--font-code);
3952
- --font-family-code: var(--font-code);
3953
- }
3954
-
3955
- ${tokens.light}
3956
-
3957
- ${tokens.dark}
3958
-
3959
- @layer base {
3960
- * { @apply border-border outline-ring/50; }
3961
- body { @apply bg-background text-foreground font-sans; }
3962
- }
3963
-
3964
- /* Shiki dual-theme: light by default, dark when .dark class is active */
3965
- .shiki,
3966
- .shiki span,
3967
- .astro-code,
3968
- .astro-code span {
3969
- color: var(--shiki-light) !important;
3970
- }
3971
- .shiki,
3972
- .astro-code {
3973
- background-color: var(--shiki-light-bg) !important;
3974
- }
3975
- .dark .shiki,
3976
- .dark .shiki span,
3977
- .dark .astro-code,
3978
- .dark .astro-code span {
3979
- color: var(--shiki-dark) !important;
3980
- }
3981
- .dark .shiki,
3982
- .dark .astro-code {
3983
- background-color: var(--shiki-dark-bg) !important;
3984
- }
3985
- /* Fix comment color contrast for WCAG AA */
3986
- .shiki span[style*="--shiki-light:#6E7781"],
3987
- .astro-code span[style*="--shiki-light:#6E7781"] {
3988
- --shiki-light: #576069 !important;
3989
- }
3990
- `);
3442
+ writeFileSync(path, themeCss(themeName));
3991
3443
  }
3992
3444
  // ── Resolution helpers ──────────────────────────────────
3993
3445
  /**