@dogsbay/format-astro 0.2.0-beta.115 → 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 +276 -841
  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 +304 -102
  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
@@ -1718,6 +1761,33 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1718
1761
  : undefined;
1719
1762
  const blogAdj = blogData ? blogAdjacency(blogData) : undefined;
1720
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
+ });
1721
1791
  for (const page of emitPages) {
1722
1792
  try {
1723
1793
  // Skip excluded pages before any expensive work (tree rewrite,
@@ -1741,6 +1811,14 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1741
1811
  if (typeof page.redirect === "string" && page.redirect.trim()) {
1742
1812
  continue;
1743
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
+ }
1744
1822
  // Rewrite internal hrefs to match the output URL structure
1745
1823
  rewriteTreeHrefs(page.tree, hrefPrefix);
1746
1824
  // Same for raw image srcs — Astro doesn't auto-prefix
@@ -1751,6 +1829,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1751
1829
  const result = treeToAstro(page.tree, {
1752
1830
  imageOptimization: useImageOpt,
1753
1831
  codeBlockTitle: options.codeBlockTitle ?? true,
1832
+ imageCaptions: options.imageCaptions === "title",
1754
1833
  combinedPrefix: hrefPrefix,
1755
1834
  });
1756
1835
  const imageSetup = useImageOpt ? [
@@ -1786,9 +1865,12 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1786
1865
  // cannot opt out of a site-level lockdown. `draft: true` keeps
1787
1866
  // its existing role as a noindex shorthand. See
1788
1867
  // plans/site-level-robots-meta.md.
1868
+ const pageCanonical = canonical.get(page.slug);
1869
+ const pageHead = head.get(page.slug);
1789
1870
  const pageNoindex = options.noindex === true ||
1790
1871
  fm.noindex === true ||
1791
- fm.draft === true;
1872
+ fm.draft === true ||
1873
+ pageCanonical?.noindex === true;
1792
1874
  const pageNofollow = options.nofollow === true ||
1793
1875
  fm.nofollow === true;
1794
1876
  // Independent of noindex: pages can be excluded from in-site
@@ -1941,7 +2023,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1941
2023
  ...result.imports,
1942
2024
  ...imageSetup,
1943
2025
  "",
1944
- `const headings = ${JSON.stringify(page.headings || [])};`,
2026
+ `const headings = ${pageLiteral(page.headings || [])};`,
1945
2027
  `const nav = navData;`,
1946
2028
  // currentPath uses combined so it matches nav.json hrefs
1947
2029
  // (which are also combined-prefixed). getPagination compares
@@ -1959,10 +2041,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1959
2041
  // → "/docs" makes the filter look for `/docs/…` bucket hrefs that
1960
2042
  // don't exist, emptying the pagination nav so prev/next vanish on
1961
2043
  // every page of a root-served multi-source site.
1962
- ` basePath: ${JSON.stringify(basePath ?? "")},`,
1963
- ` namespace: ${JSON.stringify(page.multiSource?.namespace ?? null)} ?? undefined,`,
1964
- ` version: ${JSON.stringify(page.multiSource?.version ?? null)} ?? undefined,`,
1965
- ` 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
+ : []),
1966
2054
  `});`,
1967
2055
  // Blog adjacency is CHRONOLOGICAL, not navigational. getPagination
1968
2056
  // walks NavItem[] — sidebar order — and a blog has no sidebar; its
@@ -1971,25 +2059,28 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
1971
2059
  // runtime.
1972
2060
  ...(blogAdj
1973
2061
  ? [
1974
- `const prev = ${JSON.stringify(blogAdj.get(page.slug)?.prev ?? null)} ?? undefined;`,
1975
- `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;`,
1976
2064
  ]
1977
2065
  : [`const { prev, next } = getPagination(currentPath, _navForPagination);`]),
1978
- `const title = ${JSON.stringify(page.title)};`,
1979
- `const description = ${JSON.stringify(pageDescription)} || undefined;`,
1980
- `const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
1981
- `const noindex = ${JSON.stringify(pageNoindex)};`,
1982
- `const nofollow = ${JSON.stringify(pageNofollow)};`,
1983
- `const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
1984
- `const pageTags = ${JSON.stringify(pageTags ?? null)};`,
1985
- `const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
1986
- `const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
1987
- `const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
1988
- `const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
1989
- `const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
1990
- `const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
1991
- `const lastUpdated = ${JSON.stringify(pageLastUpdated)} || undefined;`,
1992
- `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
1993
2084
  ? {
1994
2085
  enabled: true,
1995
2086
  providers: options.llmActions?.providers,
@@ -2005,6 +2096,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2005
2096
  "",
2006
2097
  `<DocsLayout`,
2007
2098
  ` siteName={siteConfig.siteName}`,
2099
+ ` siteTagline={siteConfig.tagline}`,
2008
2100
  ` title={title}`,
2009
2101
  ` nav={nav as any[]}`,
2010
2102
  ` headings={headings}`,
@@ -2019,6 +2111,10 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2019
2111
  ` themeColor={siteConfig.themeColor || undefined}`,
2020
2112
  ` noindex={noindex}`,
2021
2113
  ` nofollow={nofollow}`,
2114
+ ` canonicalUrl={canonicalUrl}`,
2115
+ ` alternates={alternates}`,
2116
+ ` breadcrumbJsonLd={breadcrumbJsonLd}`,
2117
+ ...(pageHead?.home ? [` ogType="website"`] : []),
2022
2118
  ` excludeFromSearch={excludeFromSearch}`,
2023
2119
  ` plausibleDomain={siteConfig.plausible?.domain}`,
2024
2120
  ` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
@@ -2028,6 +2124,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2028
2124
  ` tagsIndexPath={tagsIndexPath}`,
2029
2125
  ` tagPrefixes={siteConfig.tagPrefixes}`,
2030
2126
  ` tagLabels={siteConfig.tagLabels}`,
2127
+ ` tagAutoColor={siteConfig.tagAutoColor}`,
2031
2128
  ` taxonomyIndexPaths={siteConfig.taxonomyIndexPaths}`,
2032
2129
  ` taxonomyDisplay={siteConfig.taxonomyDisplay}`,
2033
2130
  ` status={pageStatus ?? undefined}`,
@@ -2042,7 +2139,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2042
2139
  ` linkIcons={siteConfig.linkIcons}`,
2043
2140
  // Default ON: an absent key means yes, `false` means no.
2044
2141
  ` poweredBy={siteConfig.poweredBy !== false}`,
2045
- ` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
2142
+ ` multiSource={${pageLiteral(page.multiSource ?? null)} ?? undefined}`,
2046
2143
  ` switcherMap={switcherMapData}`,
2047
2144
  // basePath here is the COMBINED URL prefix (urlBase from
2048
2145
  // site.url's path + dogsbay basePath). DocsLayout uses it
@@ -2051,19 +2148,19 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2051
2148
  // prefix the host actually serves under. Empty string is
2052
2149
  // valid (root-served sites with no urlBase or basePath);
2053
2150
  // don't fall back to "/docs" — that would 404 for those.
2054
- ` basePath={${JSON.stringify(combined)}}`,
2151
+ ` basePath={${pageLiteral(combined)}}`,
2055
2152
  // navMode — controls whether DocsLayout server-renders the
2056
2153
  // full sidebar nav tree per page (`ssr-full`) or emits a
2057
2154
  // client-hydrated placeholder (`client`, default). The
2058
2155
  // client mode shrinks per-page HTML dramatically at scale.
2059
2156
  // See plans/client-rendered-nav.md.
2060
- ` navMode={${JSON.stringify(options.navMode ?? "client")}}`,
2157
+ ` navMode={${pageLiteral(options.navMode ?? "client")}}`,
2061
2158
  // Pagefind index URL — must include the combined prefix or
2062
2159
  // the loader 404s on subpath-mounted deploys. The pagefind
2063
2160
  // CLI writes to <astroOutput>/dist/pagefind/ which Astro
2064
2161
  // serves under its `base` (= urlBase); dogsbay's basePath
2065
2162
  // adds the second prefix layer. Empty combined → `/pagefind/`.
2066
- ` pagefindUrl={${JSON.stringify(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
2163
+ ` pagefindUrl={${pageLiteral(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
2067
2164
  // Base Pagefind prefixes RESULT urls with. The index stores URLs
2068
2165
  // relative to dist/, so the base is whatever served path dist/'s
2069
2166
  // root maps to. A Workers subpath mount builds pages INTO
@@ -2073,7 +2170,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2073
2170
  // is right only for the second, and doubled the mount on the first:
2074
2171
  // /dogsbay-xml-docs/dogsbay-xml-docs/…, measured. Same rule as
2075
2172
  // docs-layout's pagefindBaseUrl() — keep the two in step.
2076
- ` pagefindBaseUrl={${JSON.stringify(searchBaseUrl)}}`,
2173
+ ` pagefindBaseUrl={${pageLiteral(searchBaseUrl)}}`,
2077
2174
  // Favicon — composed with combined prefix so the
2078
2175
  // <link rel="icon"> resolves on subpath-mounted deploys.
2079
2176
  //
@@ -2083,7 +2180,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2083
2180
  // `public/favicon.ico` (or `.svg`) in their Astro project and the
2084
2181
  // link appears on the next build; DocsLayout takes `false` to mean
2085
2182
  // "emit no <link rel=icon>".
2086
- ` favicon={${JSON.stringify(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
2183
+ ` favicon={${pageLiteral(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
2087
2184
  ` wideLayout={${wideLayout}}`,
2088
2185
  // Blog chrome — sidebar dropped, byline on. Every other prop above
2089
2186
  // is unchanged, which is what keeps the blog visually continuous
@@ -2091,9 +2188,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2091
2188
  ...(options.blog
2092
2189
  ? [
2093
2190
  ` chrome="blog"`,
2094
- ` author={${JSON.stringify(page.meta?.author ?? null)} ?? undefined}`,
2095
- ` publishedDate={${JSON.stringify(page.meta?.[options.blog.dateField] ?? null)} ?? undefined}`,
2096
- ` 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}`,
2097
2194
  ` readingMinutes={${blogMinutes.get(page.slug) ?? 1}}`,
2098
2195
  // Series position, derived in buildBlogData from publication
2099
2196
  // order — never hand-typed in prose, which goes stale the
@@ -2102,12 +2199,12 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2102
2199
  // `heroImage: {src, alt}` object would otherwise reach
2103
2200
  // DocsLayout's `heroImage?: string` prop and render as
2104
2201
  // src="[object Object]".
2105
- ` heroImage={${JSON.stringify(typeof (page.frontmatter ?? {}).heroImage === "string"
2202
+ ` heroImage={${pageLiteral(typeof (page.frontmatter ?? {}).heroImage === "string"
2106
2203
  ? (page.frontmatter ?? {}).heroImage
2107
2204
  : null)} ?? undefined}`,
2108
2205
  ]
2109
2206
  : []),
2110
- ` toc={${JSON.stringify(tocMode)}}`,
2207
+ ` toc={${pageLiteral(tocMode)}}`,
2111
2208
  `>`,
2112
2209
  // RightRail region — assigned to DocsLayout's `right-rail` named
2113
2210
  // slot. Inert (empty passthrough) until a plugin contributes a
@@ -2214,8 +2311,14 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2214
2311
  // the same contract the site root gets: redirect to its first nav
2215
2312
  // entry.
2216
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}`);
2217
2320
  for (const slugPrefix of subtreeRoots.keys()) {
2218
- const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`]);
2321
+ const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`], nestedIn(slugPrefix));
2219
2322
  if (!target)
2220
2323
  continue;
2221
2324
  const subtreeIndexPath = join(pagesDir, section ? section : "", slugPrefix, "index.astro");
@@ -2241,9 +2344,16 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
2241
2344
  .filter(([, version]) => version === options.defaultVersion)
2242
2345
  .map(([slugPrefix]) => `${combined}/${slugPrefix}`)
2243
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.
2244
2350
  const firstHref = (defaultVersionPrefixes.length > 0
2245
- ? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes)
2246
- : 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);
2247
2357
  const needsRootRedirect = basePath !== "" ||
2248
2358
  (!section && !rootIndexEmitted && firstHref !== "");
2249
2359
  if (needsRootRedirect) {
@@ -2343,6 +2453,8 @@ function emitRedirects(outputDir, options, pages) {
2343
2453
  slug: p.slug,
2344
2454
  redirect: p.redirect,
2345
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,
2346
2458
  })),
2347
2459
  config: options.redirects,
2348
2460
  prefix,
@@ -2464,8 +2576,9 @@ export function emitSwitcherMap(pages, outputDir, options) {
2464
2576
  writeFileSync(outPath, JSON.stringify({ versions: [], locales: [], byLogicalKey: {} }, null, 2));
2465
2577
  return;
2466
2578
  }
2579
+ const rootVersion = rootVersionOf(options);
2467
2580
  const versions = composeAxisHeader(options.versions ?? [], versionsSeen, options.defaultVersion,
2468
- /* allowEol */ true);
2581
+ /* allowEol */ true).map((v) => (v.id === rootVersion ? { ...v, root: true } : v));
2469
2582
  const locales = composeAxisHeader(options.locales ?? [], localesSeen, options.defaultLocale,
2470
2583
  /* allowEol */ false);
2471
2584
  // Per-logical-key list of variants — every (locale, version)
@@ -2557,65 +2670,50 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
2557
2670
  const defaultLocale = options.defaultLocale;
2558
2671
  if (!defaultLocale)
2559
2672
  return;
2560
- const knownLocales = new Set();
2561
- for (const p of pages) {
2562
- const l = p.multiSource?.locale;
2563
- if (l)
2564
- knownLocales.add(l);
2565
- }
2566
- if (knownLocales.size < 2)
2567
- return;
2568
- if (!knownLocales.has(defaultLocale))
2569
- return;
2570
2673
  const basePath = normalizeBasePath(options.basePath);
2571
2674
  const baseSegments = basePathSegments(basePath);
2572
2675
  // combined drives the redirect URL (the user-facing path they
2573
2676
  // get bounced to); basePath stays the filesystem path under
2574
2677
  // src/pages/ where the stub lives.
2575
2678
  const combined = combinedPrefix(options);
2576
- // Index existing pages by (slug after locale segment) so we
2577
- // can detect missing translations cheaply. Key shape:
2578
- // `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
2579
- // is the version + namespace segments (when those axes are
2580
- // active for the page).
2581
- //
2582
- // For each default-locale page, we strip the leading `<defaultLocale>/`
2583
- // off the slug to get the "neutral" tail, then for every other
2584
- // locale, the expected URL is `<basePath>/<otherLocale>/<tail>`.
2585
- 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 ?? ""}`;
2586
2690
  for (const p of pages) {
2587
- existingByUrl.add(`${basePath}/${p.slug}`);
2588
- }
2589
- let emitted = 0;
2590
- for (const defaultPage of pages) {
2591
- const ms = defaultPage.multiSource;
2592
- if (!ms || ms.locale !== defaultLocale)
2691
+ const ms = p.multiSource;
2692
+ if (!ms?.locale || !p.slug.startsWith(`${ms.locale}/`))
2593
2693
  continue;
2594
- // Strip the leading `<defaultLocale>/` from the slug to get
2595
- // the locale-neutral tail. Defensive: if for some reason the
2596
- // slug doesn't start with the locale (e.g. unprefixed
2597
- // baseline page in a half-active site), skip.
2598
- const localePrefix = `${defaultLocale}/`;
2599
- if (!defaultPage.slug.startsWith(localePrefix))
2694
+ if (p.fragment === true || p.frontmatter?._fragment === true || isExcludedSlug(p.slug))
2600
2695
  continue;
2601
- const tail = defaultPage.slug.slice(localePrefix.length);
2602
- for (const otherLocale of knownLocales) {
2603
- if (otherLocale === defaultLocale)
2604
- continue;
2605
- const targetSlug = `${otherLocale}/${tail}`;
2606
- const targetUrl = `${basePath}/${targetSlug}`;
2607
- if (existingByUrl.has(targetUrl))
2608
- continue; // already translated
2609
- const defaultUrl = `${combined}/${defaultPage.slug}`;
2610
- const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
2611
- // Ensure parent dir exists; write a redirect-stub Astro
2612
- // file. Adding `.astro` to the leaf turns it into a
2613
- // routable page.
2614
- const dir = filePath.substring(0, filePath.lastIndexOf("/"));
2615
- mkdirSync(dir, { recursive: true });
2616
- writeFileSync(`${filePath}.astro`, buildMissingTranslationStub(defaultUrl, otherLocale, defaultLocale));
2617
- emitted++;
2618
- }
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++;
2619
2717
  }
2620
2718
  if (emitted > 0) {
2621
2719
  console.log(`Emitted ${emitted} missing-translation stub(s) → redirect to default locale.`);
@@ -2654,7 +2752,7 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
2654
2752
  mkdirSync(join(outputDir, "public"), { recursive: true });
2655
2753
  // _headers Link header points at the per-mount llms.txt at
2656
2754
  // <combined>/llms.txt — the URL agents would actually fetch.
2657
- writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(combinedPrefix(options)));
2755
+ writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(withBasePath(combinedPrefix(options), "/llms.txt")));
2658
2756
  }
2659
2757
  // Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
2660
2758
  // public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
@@ -2695,7 +2793,9 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
2695
2793
  const mdMirrorOn = options.mdMirror !== false && !staticDeploy;
2696
2794
  const knownVersions = pageVersions(pages);
2697
2795
  const knownLocales = pageLocales(pages);
2698
- 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);
2699
2799
  const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
2700
2800
  const axisRedirectOn = versionRedirectOn || localeRedirectOn;
2701
2801
  const middlewarePath = join(outputDir, "src", "middleware.ts");
@@ -2807,104 +2907,25 @@ function serializePageMd(page) {
2807
2907
  * opt-in.
2808
2908
  */
2809
2909
  function buildRobotsTxt(options, hasSiteUrl) {
2810
- const search = options.aiSearch ?? "yes";
2811
- const aiInput = options.aiInput ?? "yes";
2812
- const aiTrain = options.aiTrain ?? "no";
2813
- const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
2814
- // Per-mount sitemap path: each Dogsbay site emits its sitemap
2815
- // index under <basePath>/, so robots.txt must point there too.
2816
- // (Multi-mount deploys end up with one robots.txt per site at
2817
- // their respective hosts / paths; each correctly references its
2818
- // own mount's sitemap-index.)
2819
- // Sitemap URL = origin + combined + /sitemap-index.xml. Use the
2820
- // origin (no path) from site.url and the combined prefix (urlBase
2821
- // + basePath); siteUrl could itself include a path component when
2822
- // hosting on a subpath (GH Pages project page), so we strip it
2823
- // 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.
2824
2915
  const { origin } = parseSiteUrl(options.siteUrl);
2825
2916
  const combined = combinedPrefix(options);
2826
- const sitemap = hasSiteUrl && origin
2827
- ? `Sitemap: ${origin}${withBasePath(combined, "/sitemap-index.xml")}\n`
2828
- : "";
2829
- // Llms-Txt: line — non-standard but follows the same shape as
2830
- // `Sitemap:`. Crawlers and agents that scan robots.txt before
2831
- // fetching pages get a direct pointer at the per-mount llms.txt.
2832
- // RFC 9309 explicitly permits unknown directives ("intentionally
2833
- // permissive of such future extensions") so this is harmless to
2834
- // standards-compliant parsers. Emitted alongside Sitemap when
2835
- // siteUrl is set; absolute URLs only (relative paths would be
2836
- // ambiguous without a base).
2837
- // OPT-IN (`agent.llmsTxtDirective`), default off. RFC 9309 permits
2838
- // unknown directives, but Lighthouse's validator reports this one as
2839
- // `Unknown directive` and fails the "robots.txt is valid" audit —
2840
- // measured: SEO 100 -> 92 on every page. Nothing consumes the line
2841
- // either; `Llms-Txt:` is not part of the llms.txt proposal, and
2842
- // agents find the file at its well-known path. A measured penalty
2843
- // for a speculative benefit is not a sensible default.
2844
- //
2845
- // `llms.txt` itself is still emitted; this controls only the pointer.
2846
- const llmsTxt = options.llmsTxtDirective === true &&
2847
- options.llmsTxt !== false &&
2848
- hasSiteUrl &&
2849
- origin
2850
- ? `Llms-Txt: ${origin}${withBasePath(combined, "/llms.txt")}\n`
2851
- : "";
2852
- return `${contentSignalPreamble(options)}User-agent: *\nAllow: /\n${contentSignal}${sitemap}${llmsTxt}`;
2853
- }
2854
- /**
2855
- * The Article 4 reservation-of-rights preamble, as robots.txt comments.
2856
- *
2857
- * Opt-in via `agent.contentSignal.preamble`. Default off: it is 25
2858
- * lines of legal text on every site, and it asserts a legal position
2859
- * that is the operator's to take, not ours to take for them.
2860
- *
2861
- * Why it is worth having at all — `Content-Signal: ai-train=no` on its
2862
- * own is a preference. Article 4(3) of EU Directive 2019/790 makes the
2863
- * text-and-data-mining exception conditional on rights not having been
2864
- * "expressly reserved in an appropriate manner", including
2865
- * machine-readable means. The preamble is what turns the signal into
2866
- * that express reservation, which is the difference between a request
2867
- * and a reservation with legal effect in the EU.
2868
- *
2869
- * The wording is Cloudflare's Content Signals Policy text, which is
2870
- * published under CC0 precisely so it can be reproduced. It is
2871
- * reproduced rather than linked because a reservation has to be present
2872
- * where the machine reads it.
2873
- *
2874
- * Kept BYTE-IDENTICAL to the published policy on purpose. It is a legal
2875
- * instrument, not prose we own: reword it and a site is asserting
2876
- * something subtly different from what its operator believes it is
2877
- * asserting. `tests/robots-preamble.test.ts` pins the text.
2878
- */
2879
- function contentSignalPreamble(options) {
2880
- if (options.contentSignalPreamble !== true)
2881
- return "";
2882
- return `# As a condition of accessing this website, you agree to abide by the following
2883
- # content signals:
2884
-
2885
- # (a) If a Content-Signal = yes, you may collect content for the corresponding
2886
- # use.
2887
- # (b) If a Content-Signal = no, you may not collect content for the
2888
- # corresponding use.
2889
- # (c) If the website operator does not include a Content-Signal for a
2890
- # corresponding use, the website operator neither grants nor restricts
2891
- # permission via Content-Signal with respect to the corresponding use.
2892
-
2893
- # The content signals and their meanings are:
2894
-
2895
- # search: building a search index and providing search results (e.g., returning
2896
- # hyperlinks and short excerpts from your website's contents). Search does not
2897
- # include providing AI-generated search summaries.
2898
- # ai-input: inputting content into one or more AI models (e.g., retrieval
2899
- # augmented generation, grounding, or other real-time taking of content for
2900
- # generative AI search answers).
2901
- # ai-train: training or fine-tuning AI models.
2902
-
2903
- # ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF
2904
- # RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT
2905
- # AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET.
2906
-
2907
- `;
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
+ });
2908
2929
  }
2909
2930
  /**
2910
2931
  * Build the contents of `public/_headers` (Cloudflare Pages / Workers
@@ -2947,14 +2968,6 @@ export function faviconHref(outputDir, urlBase) {
2947
2968
  }
2948
2969
  return false;
2949
2970
  }
2950
- function buildHeadersFile(basePath) {
2951
- const llmsHref = withBasePath(basePath, "/llms.txt");
2952
- return [
2953
- "/*",
2954
- ` Link: <${llmsHref}>; rel="describedby"; type="text/plain"`,
2955
- "",
2956
- ].join("\n");
2957
- }
2958
2971
  function buildMiddlewareSource(config) {
2959
2972
  const lines = [
2960
2973
  "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
@@ -3095,7 +3108,13 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
3095
3108
  continue;
3096
3109
  let relDir;
3097
3110
  if (combinedSegs && dir === combinedSegs) {
3098
- 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;
3099
3118
  }
3100
3119
  else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
3101
3120
  relDir = dir.slice(combinedSegs.length + 1);
@@ -3106,9 +3125,7 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
3106
3125
  // back to the raw value rather than rooting at /.
3107
3126
  relDir = dir;
3108
3127
  }
3109
- const sectionPath = relDir
3110
- ? join(mountDir, relDir, "llms.txt")
3111
- : join(mountDir, "llms.txt");
3128
+ const sectionPath = join(mountDir, relDir, "llms.txt");
3112
3129
  mkdirSync(dirname(sectionPath), { recursive: true });
3113
3130
  writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
3114
3131
  }
@@ -3129,7 +3146,7 @@ function emitSitemapFiles(outputDir, options, pages) {
3129
3146
  // Filesystem path uses basePath (sitemap files live in
3130
3147
  // public/<basePath>/sitemap-*.xml). The URL prefix encoded into
3131
3148
  // each <loc> uses combined so the absolute URLs resolve under the
3132
- // host's served subpath. buildSitemap strips path off siteUrl
3149
+ // host's served subpath. buildSitemapFiles strips path off siteUrl
3133
3150
  // internally, so passing siteUrl + combined as basePath gives
3134
3151
  // origin + combined as the final URL.
3135
3152
  const basePath = normalizeBasePath(options.basePath);
@@ -3137,14 +3154,28 @@ function emitSitemapFiles(outputDir, options, pages) {
3137
3154
  const baseSegments = basePathSegments(basePath);
3138
3155
  const mountDir = join(outputDir, "public", ...baseSegments);
3139
3156
  mkdirSync(mountDir, { recursive: true });
3140
- 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), {
3141
3169
  siteUrl: options.siteUrl,
3142
3170
  basePath: combined,
3143
3171
  siteNoindex: options.noindex === true,
3144
- }));
3172
+ });
3173
+ for (const [name, xml] of files)
3174
+ writeFileSync(join(mountDir, name), xml);
3145
3175
  writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({
3146
3176
  siteUrl: options.siteUrl,
3147
3177
  basePath: combined,
3178
+ sitemapCount: files.length,
3148
3179
  extraSitemaps: (options.aggregates ?? [])
3149
3180
  .map((a) => a.sitemap)
3150
3181
  .filter((u) => !!u),
@@ -3232,29 +3263,50 @@ function axisSubtreeRoots(pages) {
3232
3263
  }
3233
3264
  return roots;
3234
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
+ }
3235
3277
  /**
3236
3278
  * First nav href (in nav order) living under any of `prefixes`, which
3237
3279
  * are URL paths without a trailing slash. Used to pick a redirect
3238
3280
  * target for a subtree root that has no page of its own.
3239
3281
  */
3240
- 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 = []) {
3241
3285
  for (const item of items) {
3242
3286
  const href = item.href;
3243
- 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}/`))) {
3244
3290
  return href;
3245
3291
  }
3246
3292
  if (item.children) {
3247
- const found = findFirstNavHrefUnder(item.children, prefixes);
3293
+ const found = findFirstNavHrefUnder(item.children, prefixes, exclude);
3248
3294
  if (found)
3249
3295
  return found;
3250
3296
  }
3251
3297
  }
3252
3298
  return undefined;
3253
3299
  }
3254
- 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) {
3255
3307
  const componentsSource = resolveComponentsSource();
3256
3308
  if (!componentsSource)
3257
- return;
3309
+ return [];
3258
3310
  // Components copied into the generated project. These are the ones
3259
3311
  // format-astro's serializer emits imports for, plus a few used by the
3260
3312
  // project shell (DocsLayout, pagination, theme-toggle).
@@ -3281,14 +3333,19 @@ function copyComponents(outputDir) {
3281
3333
  // inline `:icon[name]` directive. Without this entry every page
3282
3334
  // emitting the icon import 500s with "module not found".
3283
3335
  "icon",
3336
+ // Inline :kbd and the :::files tree.
3337
+ "kbd", "files",
3284
3338
  ];
3339
+ const added = [];
3285
3340
  for (const name of needed) {
3286
3341
  const src = join(componentsSource, name);
3287
3342
  const dest = join(outputDir, "src", "components", "ui", name);
3288
3343
  if (existsSync(src) && !existsSync(dest)) {
3289
3344
  cpSync(src, dest, { recursive: true });
3345
+ added.push(name);
3290
3346
  }
3291
3347
  }
3348
+ return added;
3292
3349
  }
3293
3350
  /** Media files worth carrying from a `mediaOnly` asset mount. */
3294
3351
  const MOUNT_MEDIA_EXTS = new Set([
@@ -3381,630 +3438,8 @@ function copyAssets(sourceDir, outputDir, imageOptimization, urlPrefix) {
3381
3438
  }
3382
3439
  catch { /* source may not exist */ }
3383
3440
  }
3384
- // ── CSS generation (ported from import-mkdocs.ts) ───────
3385
- /**
3386
- * Build the `@source inline("...")` directive that pins the
3387
- * grid-tone palette into the generated stylesheet.
3388
- *
3389
- * Why we need it: tone classes like `bg-primary/10` only appear in
3390
- * `.astro` pages emitted by Dogsbay's grid-item serializer. When
3391
- * Tailwind's content scanner doesn't pick them up — because the
3392
- * page lives outside the default scan globs, or because a class
3393
- * is composed at the boundary of an interpolation — they get
3394
- * purged. Result observed in dogsbay-docs-markdown audit: half
3395
- * the grid demo cells render with no background. Pinning forces
3396
- * generation regardless of scanner reach.
3397
- *
3398
- * Single source of truth: derived from TONE_CLASSES so any new
3399
- * tone added to the palette is automatically safelisted.
3400
- */
3401
- function buildToneSafelist() {
3402
- const seen = new Set();
3403
- for (const classes of Object.values(TONE_CLASSES)) {
3404
- for (const cls of classes.split(/\s+/)) {
3405
- if (cls)
3406
- seen.add(cls);
3407
- }
3408
- }
3409
- return [...seen].sort().join(" ");
3410
- }
3411
- function generateGlobalCss() {
3412
- return `@import "tailwindcss";
3413
- @import "./theme.css";
3414
-
3415
- /* Custom elements have no user-agent style, so an unstyled one is
3416
- display:inline — on which w-full does nothing and a border paints
3417
- as detached fragments rather than around the content. @dogsbay/elements
3418
- injects these same defaults at runtime, which is what reaches EXISTING
3419
- sites (global.css is scaffolded once and never rewritten). Having them
3420
- here too means a new site is correct at CSS time, with no flash before
3421
- the module script runs.
3422
-
3423
- :where() keeps specificity at zero, so any utility class wins. */
3424
- :where(db-tabs, db-accordion, db-collapsible, db-card, db-steps, db-code-block) {
3425
- display: block;
3426
- }
3427
- :where(db-link-button) {
3428
- display: inline-block;
3429
- }
3430
-
3431
- /* Scan @dogsbay packages for Tailwind classes */
3432
- @source "../../node_modules/@dogsbay/ui/src";
3433
- @source "../../node_modules/@dogsbay/docs-layout/src";
3434
-
3435
- /* Pin the grid-tone palette. These classes are emitted into
3436
- markdown-generated .astro pages by the grid-item serializer
3437
- (TONE_CLASSES in @dogsbay/format-astro). Without inlining,
3438
- opacity-modified utilities like bg-primary/10 get purged when
3439
- Tailwind doesn't see them in the scanned globs, leaving grid
3440
- demo cells with no visible background. */
3441
- @source inline("${buildToneSafelist()}");
3442
-
3443
- /* Prose typography for rendered content.
3444
- Every element rule is guarded with :not(.dba-api *): API-reference
3445
- regions (ApiLayout root carries the dba-api marker) compose their own
3446
- typography from utility classes, and unguarded .docs-prose rules would
3447
- out-specify them. The guard lets mixed prose+endpoint pages (MDX
3448
- imports) keep full typography outside the cards. */
3449
- .docs-prose {
3450
- line-height: 1.7;
3451
-
3452
- & 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; }
3453
- & 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; }
3454
- & 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; }
3455
- & h4:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
3456
-
3457
- & p:not(.dba-api *) { margin-top: 0.75rem; margin-bottom: 0.75rem; }
3458
-
3459
- /* The :not([data-variant]) guard exempts anchors that ARE components. A
3460
- link-button renders as an <a> carrying bg-primary + text-primary-foreground
3461
- and a data-variant attribute, and this rule repainted its text with
3462
- var(--primary) — primary on primary: an unreadable solid block in light
3463
- mode, a blank one in dark. The secondary variant survived only because its
3464
- background is light enough to read primary-coloured text, which is why it
3465
- looked like a colour bug in one button rather than a cascade bug in both.
3466
- (No backticks in this comment: the whole stylesheet is a template
3467
- literal.) */
3468
- & a:not(.dba-api *):not([data-variant]) { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
3469
- & a:hover:not(.dba-api *):not([data-variant]) { opacity: 0.8; }
3470
-
3471
- & strong:not(.dba-api *) { font-weight: 600; }
3472
- & 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); }
3473
- & pre code:not(.dba-api *) { padding: 0; background: none; font-size: 1em; }
3474
-
3475
- /* Spacing between consecutive block elements (code blocks, alerts, etc.) */
3476
- & > * + * { margin-top: 0.75rem; }
3477
- & li > * + *:not(.dba-api *) { margin-top: 0.75rem; }
3478
-
3479
- & ul:not(.dba-api *) { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
3480
- & ol:not(.dba-api *) { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
3481
- & li:not(.dba-api *) { margin: 0.25rem 0; }
3482
- & li > ul:not(.dba-api *), & li > ol:not(.dba-api *) { margin: 0.25rem 0; }
3483
-
3484
- & blockquote:not(.dba-api *) { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
3485
-
3486
- & hr:not(.dba-api *) { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
3487
-
3488
- & table:not(.dba-api *) { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
3489
- & th:not(.dba-api *) { text-align: left; vertical-align: bottom; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
3490
- & td:not(.dba-api *) { padding: 0.5rem; vertical-align: top; border-bottom: 1px solid var(--border); }
3491
-
3492
- /* Tailwind Preflight sets display: block on every img, which turns an
3493
- INLINE icon into its own line and splits the sentence around it —
3494
- AsciiDoc UI macros render mid-step and were breaking every procedure
3495
- that used one. inline-block restores the flow and leaves a standalone
3496
- image (the only child of its paragraph) looking exactly as before. */
3497
- & img:not(.dba-api *) { display: inline-block; max-width: 100%; border-radius: 0.5rem; }
3498
- /* A standalone image — the only child of its paragraph — keeps block
3499
- layout, so the vertical rhythm and the absence of baseline descender
3500
- space are exactly what they were before inline icons were fixed. */
3501
- & p > img:only-child:not(.dba-api *) { display: block; }
3502
-
3503
- & .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
3504
- & h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
3505
-
3506
- & details:not(.dba-api *) { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
3507
- & summary:not(.dba-api *) { cursor: pointer; font-weight: 600; }
3508
-
3509
- /* mark is emitted by the highlight directive AND can arrive as raw HTML
3510
- from an importer. Without a rule here it renders as the browser default
3511
- yellow, which clashes in light mode and is unreadable on a dark page
3512
- (nothing declares color-scheme). Token-based so it follows the theme. */
3513
- & 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; }
3514
- &:where(.dark *) mark:not(.dba-api *), .dark & mark:not(.dba-api *) { background: var(--dsb-mark-bg-dark, rgb(234 179 8 / 0.3)); }
3515
-
3516
- & dl:not(.dba-api *) { margin: 1rem 0; }
3517
- & dt:not(.dba-api *) { font-weight: 600; margin-top: 0.75rem; }
3518
- & dd:not(.dba-api *) { margin-left: 1.5rem; color: var(--muted-foreground); }
3519
-
3520
- /* In-cell admonitions. Inside a raw HTML table cell nothing
3521
- markdown-native works, so the AsciiDoc engine emits a neutral
3522
- dl.db-admonition shape (DD-010) and this rule renders it as a compact
3523
- callout box — same contract the Obsidian plugin styles. Per-type
3524
- accent from theme tokens; overrides the generic dl/dt/dd rules above. */
3525
- & dl.db-admonition {
3526
- margin: 0.75rem 0;
3527
- padding: 0.5rem 0.75rem;
3528
- border-left: 3px solid var(--info);
3529
- border-radius: 0.25rem;
3530
- background: color-mix(in oklab, var(--info) 8%, transparent);
3531
- }
3532
- /* The LABEL blends the accent toward the foreground; the border and the
3533
- tint keep the accent at full strength, so the callout still reads as
3534
- its colour while the text stays legible.
3535
-
3536
- Measured from painted pixels on a real page, light mode, 13px/600 —
3537
- the accent used raw is a genuine AA failure, not one of axe's oklch
3538
- misreads:
3539
-
3540
- --info raw 2.18:1 mixed 5.66:1
3541
- --warning raw 2.25:1 mixed 5.74:1
3542
- --destructive raw 5.97:1 mixed 10.98:1
3543
-
3544
- Mixed with var(--foreground), not black, on purpose. Black fixes
3545
- light and BREAKS dark: 70% black measures 5.21:1 light but 3.25:1
3546
- dark, because the tint it sits on is dark too. --foreground is
3547
- near-black in light and near-white in dark, so one rule serves both
3548
- (dark measures 10.96 / 10.87 / 9.79). A fixed colour used in both
3549
- themes is a bug waiting. */
3550
- & dl.db-admonition > dt {
3551
- margin-top: 0;
3552
- font-weight: 600;
3553
- font-size: 0.8125rem;
3554
- text-transform: uppercase;
3555
- letter-spacing: 0.03em;
3556
- color: color-mix(in oklab, var(--info) 60%, var(--foreground));
3557
- }
3558
- & dl.db-admonition > dd { margin-left: 0; margin-top: 0.25rem; color: var(--foreground); }
3559
- & dl.db-admonition-warning, & dl.db-admonition-caution {
3560
- border-left-color: var(--warning);
3561
- background: color-mix(in oklab, var(--warning) 8%, transparent);
3562
- }
3563
- & dl.db-admonition-warning > dt, & dl.db-admonition-caution > dt { color: color-mix(in oklab, var(--warning) 60%, var(--foreground)); }
3564
- & dl.db-admonition-important {
3565
- border-left-color: var(--destructive);
3566
- background: color-mix(in oklab, var(--destructive) 8%, transparent);
3567
- }
3568
- & dl.db-admonition-important > dt { color: color-mix(in oklab, var(--destructive) 60%, var(--foreground)); }
3569
-
3570
- /* Granularity book-view affordances (plans/granularity-views.md):
3571
- "Read as single page →" on topics, "Open as page ↗" on book sections.
3572
- Deliberately subtle/muted — the book has one per section. */
3573
- & .dsb-read-single-page {
3574
- display: inline-block;
3575
- margin: 0 0 1.25rem;
3576
- font-size: 0.8125rem;
3577
- color: var(--muted-foreground);
3578
- text-decoration: none;
3579
- }
3580
- & .dsb-read-single-page:hover { color: var(--foreground); text-decoration: underline; }
3581
- & .dsb-open-as-page { margin: -0.25rem 0 0.5rem; font-size: 0.75rem; }
3582
- & .dsb-open-as-page a { color: var(--muted-foreground); text-decoration: none; }
3583
- & .dsb-open-as-page a:hover { color: var(--foreground); text-decoration: underline; }
3584
-
3585
- /* AsciiDoc block roles ([role="x"] / [.x]) preserved as classes (#384).
3586
- _abstract marks a module's lead/intro paragraph; .lead/.small are
3587
- Asciidoctor built-ins; _additional-resources marks the trailing
3588
- "Additional resources" section heading. */
3589
- & ._abstract, & .abstract, & .lead {
3590
- font-size: 1.05rem;
3591
- line-height: 1.6;
3592
- color: var(--muted-foreground);
3593
- }
3594
- & .small { font-size: 0.875rem; }
3595
- & h2._additional-resources, & h3._additional-resources {
3596
- font-size: 1.15rem;
3597
- margin-top: 2rem;
3598
- }
3599
-
3600
- /* Related resources (#384 R3): [role="_additional-resources"] + .Title +
3601
- list folds to a <section class="related-resources"> (→ DITA related-links). */
3602
- & .related-resources {
3603
- margin-top: 2rem;
3604
- padding-top: 1rem;
3605
- border-top: 1px solid var(--border);
3606
- }
3607
- & .related-resources-title {
3608
- font-weight: 600;
3609
- margin: 0 0 0.5rem;
3610
- }
3611
- }
3612
-
3613
- @utility scrollbar-none {
3614
- -ms-overflow-style: none;
3615
- scrollbar-width: none;
3616
- &::-webkit-scrollbar {
3617
- display: none;
3618
- }
3619
- }
3620
- `;
3621
- }
3622
- // ── Theme presets ───────────────────────────────────────
3623
- const THEME_DEFAULT = {
3624
- light: `:root {
3625
- --background: oklch(1 0 0);
3626
- --foreground: oklch(0.145 0.014 285.823);
3627
- --card: oklch(1 0 0);
3628
- --card-foreground: oklch(0.145 0.014 285.823);
3629
- --popover: oklch(1 0 0);
3630
- --popover-foreground: oklch(0.145 0.014 285.823);
3631
- --primary: oklch(0.205 0.014 285.823);
3632
- --primary-foreground: oklch(0.985 0 0);
3633
- --secondary: oklch(0.965 0.001 286.375);
3634
- --secondary-foreground: oklch(0.205 0.014 285.823);
3635
- --muted: oklch(0.965 0.001 286.375);
3636
- --muted-foreground: oklch(0.45 0.014 285.938);
3637
- --accent: oklch(0.965 0.001 286.375);
3638
- --accent-foreground: oklch(0.205 0.014 285.823);
3639
- --destructive: oklch(0.45 0.245 27.325);
3640
- --destructive-foreground: oklch(0.985 0 0);
3641
- --border: oklch(0.922 0.004 286.32);
3642
- --input: oklch(0.922 0.004 286.32);
3643
- --ring: oklch(0.708 0.014 285.823);
3644
- --radius: 0.625rem;
3645
- --note: oklch(0.637 0.174 259.126);
3646
- --note-foreground: var(--foreground);
3647
- --abstract: oklch(0.691 0.148 221.723);
3648
- --info: oklch(0.722 0.128 207.084);
3649
- --tip: oklch(0.731 0.112 178.627);
3650
- --success: oklch(0.754 0.175 149.577);
3651
- --question: oklch(0.809 0.19 126.526);
3652
- --warning: oklch(0.74 0.175 62.778);
3653
- --failure: oklch(0.614 0.194 21.602);
3654
- --danger: oklch(0.577 0.245 16.439);
3655
- --bug: oklch(0.548 0.258 349.761);
3656
- --example: oklch(0.534 0.222 286.033);
3657
- --quote: oklch(0.65 0 0);
3658
- --sidebar: oklch(0.985 0 0);
3659
- --sidebar-foreground: oklch(0.145 0.014 285.823);
3660
- --sidebar-primary: oklch(0.205 0.014 285.823);
3661
- --sidebar-primary-foreground: oklch(0.985 0 0);
3662
- --sidebar-accent: oklch(0.965 0.001 286.375);
3663
- --sidebar-accent-foreground: oklch(0.205 0.014 285.823);
3664
- --sidebar-border: oklch(0.922 0.004 286.32);
3665
- --sidebar-ring: oklch(0.708 0.014 285.823);
3666
- --code-background: oklch(0.965 0.004 264);
3667
- --code-foreground: oklch(0.3 0.014 253.833);
3668
-
3669
- /* API reference — always-dark code panel */
3670
- --api-panel-bg: oklch(0.145 0.014 253.833);
3671
- --api-panel-fg: oklch(0.926 0.013 253.833);
3672
-
3673
- /* API semantic colors — HTTP methods, types, status codes */
3674
- /* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
3675
- --api-get: oklch(0.435 0.095 162);
3676
- --api-post: oklch(0.45 0.174 259);
3677
- --api-put: oklch(0.455 0.105 63);
3678
- --api-patch: oklch(0.455 0.13 45);
3679
- --api-delete: oklch(0.45 0.179 22);
3680
- --api-head: oklch(0.47 0.222 286);
3681
- --api-required: oklch(0.45 0.179 22);
3682
- --api-deprecated: oklch(0.455 0.105 63);
3683
- --api-type-string: oklch(0.435 0.095 162);
3684
- --api-type-number: oklch(0.45 0.174 259);
3685
- --api-type-boolean: oklch(0.455 0.105 63);
3686
- --api-type-object: oklch(0.47 0.222 286);
3687
- --api-type-array: oklch(0.455 0.13 45);
3688
- --api-status-2xx: oklch(0.435 0.095 162);
3689
- --api-status-3xx: oklch(0.45 0.174 259);
3690
- --api-status-4xx: oklch(0.455 0.105 63);
3691
- --api-status-5xx: oklch(0.45 0.179 22);
3692
-
3693
- --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
3694
- --font-heading: var(--font-sans);
3695
- --font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
3696
- }`,
3697
- dark: `.dark {
3698
- --background: oklch(0.145 0.014 285.823);
3699
- --foreground: oklch(0.985 0 0);
3700
- --card: oklch(0.145 0.014 285.823);
3701
- --card-foreground: oklch(0.985 0 0);
3702
- --popover: oklch(0.145 0.014 285.823);
3703
- --popover-foreground: oklch(0.985 0 0);
3704
- --primary: oklch(0.985 0 0);
3705
- --primary-foreground: oklch(0.205 0.014 285.823);
3706
- --secondary: oklch(0.269 0.007 286.033);
3707
- --secondary-foreground: oklch(0.985 0 0);
3708
- --muted: oklch(0.269 0.007 286.033);
3709
- --muted-foreground: oklch(0.708 0.014 285.823);
3710
- --accent: oklch(0.269 0.007 286.033);
3711
- --accent-foreground: oklch(0.985 0 0);
3712
- --destructive: oklch(0.704 0.191 22.216);
3713
- --destructive-foreground: oklch(0.145 0.014 285.823);
3714
- --border: oklch(0.269 0.007 286.033);
3715
- --input: oklch(0.269 0.007 286.033);
3716
- --ring: oklch(0.439 0.01 286.375);
3717
- --note-foreground: var(--foreground);
3718
- --sidebar: oklch(0.205 0.014 285.823);
3719
- --sidebar-foreground: oklch(0.985 0 0);
3720
- --sidebar-primary: oklch(0.488 0.243 264.376);
3721
- --sidebar-primary-foreground: oklch(0.985 0 0);
3722
- --sidebar-accent: oklch(0.269 0.007 286.033);
3723
- --sidebar-accent-foreground: oklch(0.985 0 0);
3724
- --sidebar-border: oklch(0.269 0.007 286.033);
3725
- --sidebar-ring: oklch(0.439 0.01 286.375);
3726
- --code-background: oklch(0.18 0.014 253.833);
3727
- --code-foreground: oklch(0.926 0.013 253.833);
3728
-
3729
- /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
3730
- --api-get: oklch(0.720 0.135 162);
3731
- --api-post: oklch(0.755 0.174 259);
3732
- --api-put: oklch(0.745 0.15 63);
3733
- --api-patch: oklch(0.725 0.18 45);
3734
- --api-delete: oklch(0.805 0.194 22);
3735
- --api-head: oklch(0.740 0.182 286);
3736
- --api-required: oklch(0.805 0.194 22);
3737
- --api-deprecated: oklch(0.745 0.15 63);
3738
- --api-type-string: oklch(0.690 0.135 162);
3739
- --api-type-number: oklch(0.725 0.174 259);
3740
- --api-type-boolean: oklch(0.720 0.15 63);
3741
- --api-type-object: oklch(0.740 0.182 286);
3742
- --api-type-array: oklch(0.725 0.18 45);
3743
- --api-status-2xx: oklch(0.720 0.135 162);
3744
- --api-status-3xx: oklch(0.755 0.174 259);
3745
- --api-status-4xx: oklch(0.770 0.15 63);
3746
- --api-status-5xx: oklch(0.805 0.194 22);
3747
- }`,
3748
- };
3749
- /**
3750
- * Mintlify-inspired theme — clean, neutral palette matching modern doc sites.
3751
- *
3752
- * Extracted from Claude Code docs (Mintlify):
3753
- * - White background, near-black text
3754
- * - Serif display font for H1, sans-serif body
3755
- * - Soft colored callout backgrounds (blue note, green tip, amber warning)
3756
- * - Rounded callout borders (16px radius)
3757
- * - Light gray code background, JetBrains Mono
3758
- */
3759
- const THEME_MINTLIFY = {
3760
- light: `:root {
3761
- --background: oklch(0.993 0.005 95);
3762
- --foreground: oklch(0.15 0.01 260);
3763
- --card: oklch(0.993 0.005 95);
3764
- --card-foreground: oklch(0.15 0.01 260);
3765
- --popover: oklch(0.993 0.005 95);
3766
- --popover-foreground: oklch(0.15 0.01 260);
3767
- --primary: oklch(0.15 0.01 260);
3768
- --primary-foreground: oklch(0.985 0 0);
3769
- --secondary: oklch(0.97 0.002 260);
3770
- --secondary-foreground: oklch(0.15 0.01 260);
3771
- --muted: oklch(0.97 0.002 260);
3772
- --muted-foreground: oklch(0.45 0.01 260);
3773
- --accent: oklch(0.97 0.002 260);
3774
- --accent-foreground: oklch(0.15 0.01 260);
3775
- --destructive: oklch(0.55 0.22 25);
3776
- --destructive-foreground: oklch(0.985 0 0);
3777
- --border: oklch(0.91 0.004 260);
3778
- --input: oklch(0.91 0.004 260);
3779
- --ring: oklch(0.7 0.01 260);
3780
- --radius: 1rem;
3781
- /* Callout colors — soft pastel backgrounds matching Mintlify */
3782
- --note: oklch(0.62 0.18 260);
3783
- --note-foreground: var(--foreground);
3784
- --abstract: oklch(0.65 0.14 220);
3785
- --info: oklch(0.65 0.14 230);
3786
- --tip: oklch(0.65 0.16 155);
3787
- --success: oklch(0.65 0.16 155);
3788
- --question: oklch(0.7 0.15 270);
3789
- --warning: oklch(0.7 0.15 75);
3790
- --failure: oklch(0.6 0.2 25);
3791
- --danger: oklch(0.55 0.22 25);
3792
- --bug: oklch(0.55 0.22 350);
3793
- --example: oklch(0.55 0.2 285);
3794
- --quote: oklch(0.55 0 0);
3795
- /* Sidebar */
3796
- --sidebar: oklch(0.993 0.005 95);
3797
- --sidebar-foreground: oklch(0.15 0.01 260);
3798
- --sidebar-primary: oklch(0.15 0.01 260);
3799
- --sidebar-primary-foreground: oklch(0.985 0 0);
3800
- --sidebar-accent: oklch(0.97 0.002 260);
3801
- --sidebar-accent-foreground: oklch(0.15 0.01 260);
3802
- --sidebar-border: oklch(0.93 0.004 260);
3803
- --sidebar-ring: oklch(0.7 0.01 260);
3804
- /* Code — light gray background, matching Mintlify's inline code */
3805
- --code-background: oklch(0.96 0.003 260);
3806
- --code-foreground: oklch(0.25 0.01 260);
3807
-
3808
- /* API reference — always-dark code panel */
3809
- --api-panel-bg: oklch(0.145 0.014 253.833);
3810
- --api-panel-fg: oklch(0.926 0.013 253.833);
3811
-
3812
- /* API semantic colors — HTTP methods, types, status codes */
3813
- /* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
3814
- --api-get: oklch(0.435 0.095 162);
3815
- --api-post: oklch(0.45 0.174 259);
3816
- --api-put: oklch(0.455 0.105 63);
3817
- --api-patch: oklch(0.455 0.13 45);
3818
- --api-delete: oklch(0.45 0.179 22);
3819
- --api-head: oklch(0.47 0.222 286);
3820
- --api-required: oklch(0.45 0.179 22);
3821
- --api-deprecated: oklch(0.455 0.105 63);
3822
- --api-type-string: oklch(0.435 0.095 162);
3823
- --api-type-number: oklch(0.45 0.174 259);
3824
- --api-type-boolean: oklch(0.455 0.105 63);
3825
- --api-type-object: oklch(0.47 0.222 286);
3826
- --api-type-array: oklch(0.455 0.13 45);
3827
- --api-status-2xx: oklch(0.435 0.095 162);
3828
- --api-status-3xx: oklch(0.45 0.174 259);
3829
- --api-status-4xx: oklch(0.455 0.105 63);
3830
- --api-status-5xx: oklch(0.45 0.179 22);
3831
-
3832
- /* Fonts — system sans for body, JetBrains Mono for code */
3833
- --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
3834
- --font-heading: var(--font-sans);
3835
- --font-code: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3836
- }`,
3837
- dark: `.dark {
3838
- --background: oklch(0.16 0.01 260);
3839
- --foreground: oklch(0.93 0.005 260);
3840
- --card: oklch(0.18 0.01 260);
3841
- --card-foreground: oklch(0.93 0.005 260);
3842
- --popover: oklch(0.18 0.01 260);
3843
- --popover-foreground: oklch(0.93 0.005 260);
3844
- --primary: oklch(0.93 0.005 260);
3845
- --primary-foreground: oklch(0.16 0.01 260);
3846
- --secondary: oklch(0.22 0.008 260);
3847
- --secondary-foreground: oklch(0.93 0.005 260);
3848
- --muted: oklch(0.22 0.008 260);
3849
- --muted-foreground: oklch(0.65 0.01 260);
3850
- --accent: oklch(0.22 0.008 260);
3851
- --accent-foreground: oklch(0.93 0.005 260);
3852
- --destructive: oklch(0.65 0.2 25);
3853
- --destructive-foreground: oklch(0.16 0.01 260);
3854
- --border: oklch(0.28 0.008 260);
3855
- --input: oklch(0.28 0.008 260);
3856
- --ring: oklch(0.45 0.01 260);
3857
- --note-foreground: var(--foreground);
3858
- --sidebar: oklch(0.16 0.01 260);
3859
- --sidebar-foreground: oklch(0.93 0.005 260);
3860
- --sidebar-primary: oklch(0.55 0.2 260);
3861
- --sidebar-primary-foreground: oklch(0.93 0.005 260);
3862
- --sidebar-accent: oklch(0.22 0.008 260);
3863
- --sidebar-accent-foreground: oklch(0.93 0.005 260);
3864
- --sidebar-border: oklch(0.28 0.008 260);
3865
- --sidebar-ring: oklch(0.45 0.01 260);
3866
- --code-background: oklch(0.2 0.01 260);
3867
- --code-foreground: oklch(0.9 0.01 260);
3868
-
3869
- /* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
3870
- --api-get: oklch(0.720 0.135 162);
3871
- --api-post: oklch(0.755 0.174 259);
3872
- --api-put: oklch(0.745 0.15 63);
3873
- --api-patch: oklch(0.725 0.18 45);
3874
- --api-delete: oklch(0.805 0.194 22);
3875
- --api-head: oklch(0.740 0.182 286);
3876
- --api-required: oklch(0.805 0.194 22);
3877
- --api-deprecated: oklch(0.745 0.15 63);
3878
- --api-type-string: oklch(0.690 0.135 162);
3879
- --api-type-number: oklch(0.725 0.174 259);
3880
- --api-type-boolean: oklch(0.720 0.15 63);
3881
- --api-type-object: oklch(0.740 0.182 286);
3882
- --api-type-array: oklch(0.725 0.18 45);
3883
- --api-status-2xx: oklch(0.720 0.135 162);
3884
- --api-status-3xx: oklch(0.755 0.174 259);
3885
- --api-status-4xx: oklch(0.770 0.15 63);
3886
- --api-status-5xx: oklch(0.805 0.194 22);
3887
- }`,
3888
- };
3889
3441
  function writeThemeFile(path, themeName) {
3890
- const tokens = themeName === "mintlify" ? THEME_MINTLIFY : THEME_DEFAULT;
3891
- writeFileSync(path, `@custom-variant dark (&:where(.dark, .dark *));
3892
-
3893
- @theme inline {
3894
- --color-background: var(--background);
3895
- --color-foreground: var(--foreground);
3896
- --color-card: var(--card);
3897
- --color-card-foreground: var(--card-foreground);
3898
- --color-popover: var(--popover);
3899
- --color-popover-foreground: var(--popover-foreground);
3900
- --color-primary: var(--primary);
3901
- --color-primary-foreground: var(--primary-foreground);
3902
- --color-secondary: var(--secondary);
3903
- --color-secondary-foreground: var(--secondary-foreground);
3904
- --color-muted: var(--muted);
3905
- --color-muted-foreground: var(--muted-foreground);
3906
- --color-accent: var(--accent);
3907
- --color-accent-foreground: var(--accent-foreground);
3908
- --color-destructive: var(--destructive);
3909
- --color-destructive-foreground: var(--destructive-foreground);
3910
- --color-border: var(--border);
3911
- --color-input: var(--input);
3912
- --color-ring: var(--ring);
3913
- --radius-sm: calc(var(--radius) - 4px);
3914
- --radius-md: calc(var(--radius) - 2px);
3915
- --radius-lg: var(--radius);
3916
- --radius-xl: calc(var(--radius) + 4px);
3917
-
3918
- /* Sidebar */
3919
- --color-sidebar: var(--sidebar);
3920
- --color-sidebar-foreground: var(--sidebar-foreground);
3921
- --color-sidebar-primary: var(--sidebar-primary);
3922
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
3923
- --color-sidebar-accent: var(--sidebar-accent);
3924
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
3925
- --color-sidebar-border: var(--sidebar-border);
3926
- --color-sidebar-ring: var(--sidebar-ring);
3927
-
3928
- /* Admonition colors */
3929
- --color-note: var(--note);
3930
- --color-note-foreground: var(--note-foreground);
3931
- --color-abstract: var(--abstract);
3932
- --color-info: var(--info);
3933
- --color-tip: var(--tip);
3934
- --color-success: var(--success);
3935
- --color-question: var(--question);
3936
- --color-warning: var(--warning);
3937
- --color-failure: var(--failure);
3938
- --color-danger: var(--danger);
3939
- --color-bug: var(--bug);
3940
- --color-example: var(--example);
3941
- --color-quote: var(--quote);
3942
-
3943
- /* Code */
3944
- --color-code-bg: var(--code-background);
3945
- --color-code-fg: var(--code-foreground);
3946
-
3947
- /* API reference — HTTP method, type, status, role colors */
3948
- --color-api-get: var(--api-get);
3949
- --color-api-post: var(--api-post);
3950
- --color-api-put: var(--api-put);
3951
- --color-api-patch: var(--api-patch);
3952
- --color-api-delete: var(--api-delete);
3953
- --color-api-head: var(--api-head);
3954
- --color-api-required: var(--api-required);
3955
- --color-api-deprecated: var(--api-deprecated);
3956
- --color-api-type-string: var(--api-type-string);
3957
- --color-api-type-number: var(--api-type-number);
3958
- --color-api-type-boolean: var(--api-type-boolean);
3959
- --color-api-type-object: var(--api-type-object);
3960
- --color-api-type-array: var(--api-type-array);
3961
- --color-api-status-2xx: var(--api-status-2xx);
3962
- --color-api-status-3xx: var(--api-status-3xx);
3963
- --color-api-status-4xx: var(--api-status-4xx);
3964
- --color-api-status-5xx: var(--api-status-5xx);
3965
-
3966
- /* Fonts */
3967
- --font-sans: var(--font-sans);
3968
- --font-mono: var(--font-code);
3969
- --font-family-code: var(--font-code);
3970
- }
3971
-
3972
- ${tokens.light}
3973
-
3974
- ${tokens.dark}
3975
-
3976
- @layer base {
3977
- * { @apply border-border outline-ring/50; }
3978
- body { @apply bg-background text-foreground font-sans; }
3979
- }
3980
-
3981
- /* Shiki dual-theme: light by default, dark when .dark class is active */
3982
- .shiki,
3983
- .shiki span,
3984
- .astro-code,
3985
- .astro-code span {
3986
- color: var(--shiki-light) !important;
3987
- }
3988
- .shiki,
3989
- .astro-code {
3990
- background-color: var(--shiki-light-bg) !important;
3991
- }
3992
- .dark .shiki,
3993
- .dark .shiki span,
3994
- .dark .astro-code,
3995
- .dark .astro-code span {
3996
- color: var(--shiki-dark) !important;
3997
- }
3998
- .dark .shiki,
3999
- .dark .astro-code {
4000
- background-color: var(--shiki-dark-bg) !important;
4001
- }
4002
- /* Fix comment color contrast for WCAG AA */
4003
- .shiki span[style*="--shiki-light:#6E7781"],
4004
- .astro-code span[style*="--shiki-light:#6E7781"] {
4005
- --shiki-light: #576069 !important;
4006
- }
4007
- `);
3442
+ writeFileSync(path, themeCss(themeName));
4008
3443
  }
4009
3444
  // ── Resolution helpers ──────────────────────────────────
4010
3445
  /**