@dogsbay/format-astro 0.2.0-beta.0

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.
@@ -0,0 +1,1858 @@
1
+ /**
2
+ * Astro project exporter.
3
+ *
4
+ * Takes ExportPage[] + NavItem[] and generates a complete Astro project
5
+ * with static .astro pages using real Dogsbay components.
6
+ */
7
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, cpSync, readdirSync, statSync, } from "node:fs";
8
+ import { join, dirname, relative, resolve } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
11
+ import { treeToAstro } from "./serialize.js";
12
+ import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
13
+ import { normalizeBasePath, basePathSegments, buildCurrentPath } from "./base-path.js";
14
+ import { detectLeadingNodes } from "./lead.js";
15
+ /**
16
+ * Recursively prefix all hrefs in a nav tree.
17
+ * Turns `<basePath>/foo` into `<basePath>/{section}/foo`. The
18
+ * `basePath` is the configured prefix (default `/docs`); empty
19
+ * `basePath` collapses to `/{section}/...`.
20
+ */
21
+ function prefixNavHrefs(items, section, basePath) {
22
+ const sectionedPrefix = basePath ? `${basePath}/${section}` : `/${section}`;
23
+ // The href shape we look for in the input — what emitAstroPages
24
+ // produced for the unsectioned case. Empty basePath means raw "/".
25
+ const baseHref = basePath || "/";
26
+ return items.map((item) => {
27
+ const result = { ...item };
28
+ if (result.href) {
29
+ if (basePath) {
30
+ // <basePath>/foo → <basePath>/{section}/foo, <basePath> → <basePath>/{section}
31
+ result.href = result.href === basePath
32
+ ? sectionedPrefix
33
+ : result.href.replace(new RegExp(`^${escapeRegex(basePath)}\/`), `${sectionedPrefix}/`);
34
+ }
35
+ else {
36
+ // Empty basePath: nav hrefs look like "/foo"; rewrite to
37
+ // "/{section}/foo". Lone "/" becomes "/{section}".
38
+ result.href = result.href === baseHref
39
+ ? sectionedPrefix
40
+ : result.href.replace(/^\//, `${sectionedPrefix}/`);
41
+ }
42
+ }
43
+ if (result.children) {
44
+ result.children = prefixNavHrefs(result.children, section, basePath);
45
+ }
46
+ return result;
47
+ });
48
+ }
49
+ function escapeRegex(s) {
50
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ }
52
+ /**
53
+ * Rewrite internal hrefs in a page's tree nodes.
54
+ * Prepends `prefix` to root-relative links (e.g. `/workers/...` → `/docs/workers/...`).
55
+ * Skips external URLs, anchors, and links already starting with the prefix.
56
+ */
57
+ function rewriteTreeHrefs(nodes, prefix) {
58
+ for (const node of nodes) {
59
+ // Rewrite hrefs in inline nodes (InlineLink)
60
+ if (node.inline) {
61
+ rewriteInlineHrefs(node.inline, prefix);
62
+ }
63
+ // Rewrite hrefs in pre-rendered HTML
64
+ if (node.html) {
65
+ node.html = rewriteHtmlHrefs(node.html, prefix);
66
+ }
67
+ // Rewrite href in props (e.g. card, link-button)
68
+ if (node.props?.href && typeof node.props.href === "string") {
69
+ node.props.href = rewriteHref(node.props.href, prefix);
70
+ }
71
+ // Recurse into children
72
+ if (node.children) {
73
+ rewriteTreeHrefs(node.children, prefix);
74
+ }
75
+ }
76
+ }
77
+ function rewriteInlineHrefs(nodes, prefix) {
78
+ for (const node of nodes) {
79
+ if (node.type === "link") {
80
+ node.href = rewriteHref(node.href, prefix);
81
+ if (node.children) {
82
+ rewriteInlineHrefs(node.children, prefix);
83
+ }
84
+ }
85
+ }
86
+ }
87
+ function rewriteHtmlHrefs(html, prefix) {
88
+ return html.replace(/href="(\/[^"]+)"/g, (_match, href) => {
89
+ return `href="${rewriteHref(href, prefix)}"`;
90
+ });
91
+ }
92
+ function rewriteHref(href, prefix) {
93
+ // Skip external, anchors, already-prefixed
94
+ if (!href.startsWith("/") || href.startsWith("//"))
95
+ return href;
96
+ if (href.startsWith(prefix + "/") || href === prefix)
97
+ return href;
98
+ return prefix + href;
99
+ }
100
+ /**
101
+ * Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
102
+ *
103
+ * The generated Worker serves the Astro build output (`dist/`) as
104
+ * edge-cached static assets. When `siteUrl` is an absolute URL pointing
105
+ * at a real Cloudflare-managed zone, a `routes` block is emitted that
106
+ * binds the Worker to the corresponding hostname + path prefix; without
107
+ * a real siteUrl the routes block is omitted, the Worker still deploys
108
+ * to a *.workers.dev URL.
109
+ *
110
+ * Hand-edited wrangler customizations (D1 bindings, KV, secrets, AI,
111
+ * etc.) belong in this file post-generation. The Stainless-style file-
112
+ * protection mechanism (deferred follow-up) will preserve them across
113
+ * re-converts.
114
+ */
115
+ function buildWranglerConfig(siteName, options) {
116
+ const lines = [];
117
+ lines.push(`{`);
118
+ lines.push(` "$schema": "node_modules/wrangler/config-schema.json",`);
119
+ lines.push(` "name": ${JSON.stringify(slugify(siteName))},`);
120
+ lines.push(` "compatibility_date": "2026-04-01",`);
121
+ lines.push(``);
122
+ lines.push(` "assets": {`);
123
+ lines.push(` "directory": "./dist",`);
124
+ lines.push(` "not_found_handling": "404-page",`);
125
+ lines.push(` "html_handling": "auto-trailing-slash"`);
126
+ lines.push(` }`);
127
+ // Routes (only emitted when siteUrl is absolute + parseable as a real host)
128
+ if (options.siteUrl && /^https?:\/\//.test(options.siteUrl)) {
129
+ try {
130
+ const url = new URL(options.siteUrl);
131
+ const path = url.pathname.replace(/\/$/, "");
132
+ const pattern = `${url.hostname}${path || ""}/*`;
133
+ // zone_name is the apex domain — strip subdomains to a best guess.
134
+ // For dogsbay.ai or md-docs.dogsbay.ai we want "dogsbay.ai".
135
+ const parts = url.hostname.split(".");
136
+ const zone = parts.length >= 2 ? parts.slice(-2).join(".") : url.hostname;
137
+ lines[lines.length - 1] = ` },`; // close assets block with trailing comma
138
+ lines.push(``);
139
+ lines.push(` "routes": [`);
140
+ lines.push(` { "pattern": ${JSON.stringify(pattern)}, "zone_name": ${JSON.stringify(zone)} }`);
141
+ lines.push(` ]`);
142
+ }
143
+ catch {
144
+ // siteUrl wasn't a valid URL — skip routes block entirely
145
+ }
146
+ }
147
+ lines.push(`}`);
148
+ return lines.join("\n") + "\n";
149
+ }
150
+ /**
151
+ * Construct the SiteConfig object that gets serialized to
152
+ * `src/data/site.json`. Backward-compatible: existing fields keep their
153
+ * empty-string defaults; new optional fields are omitted when undefined.
154
+ */
155
+ function buildSiteConfig(siteName, options) {
156
+ const cfg = {
157
+ siteName,
158
+ repoUrl: options.repoUrl || "",
159
+ editUri: options.editUri || "blob/main/docs/",
160
+ copyright: options.copyright || "",
161
+ };
162
+ if (options.siteUrl)
163
+ cfg.siteUrl = options.siteUrl;
164
+ if (options.description)
165
+ cfg.description = options.description;
166
+ if (options.ogImage)
167
+ cfg.ogImage = options.ogImage;
168
+ if (options.twitterHandle)
169
+ cfg.twitterHandle = options.twitterHandle;
170
+ if (options.themeColor)
171
+ cfg.themeColor = options.themeColor;
172
+ if (options.brandKeywords && options.brandKeywords.length > 0) {
173
+ cfg.brandKeywords = options.brandKeywords;
174
+ }
175
+ if (options.plausibleDomain) {
176
+ cfg.plausible = options.plausibleScriptUrl
177
+ ? { domain: options.plausibleDomain, scriptUrl: options.plausibleScriptUrl }
178
+ : { domain: options.plausibleDomain };
179
+ }
180
+ // Tag display config — only emitted when set, so existing sites'
181
+ // site.json stays byte-identical.
182
+ if (options.tagPrefixes && Object.keys(options.tagPrefixes).length > 0) {
183
+ cfg.tagPrefixes = options.tagPrefixes;
184
+ }
185
+ if (options.tagLabels && Object.keys(options.tagLabels).length > 0) {
186
+ cfg.tagLabels = options.tagLabels;
187
+ }
188
+ if (options.taxonomyIndexPaths &&
189
+ Object.keys(options.taxonomyIndexPaths).length > 0) {
190
+ cfg.taxonomyIndexPaths = options.taxonomyIndexPaths;
191
+ }
192
+ if (options.taxonomyDisplay &&
193
+ Object.keys(options.taxonomyDisplay).length > 0) {
194
+ cfg.taxonomyDisplay = options.taxonomyDisplay;
195
+ }
196
+ return cfg;
197
+ }
198
+ /**
199
+ * Export pages + nav to a complete Astro project directory.
200
+ *
201
+ * Thin orchestrator. The heavy lifting lives in four focused emitters
202
+ * (one per emission tier) that this function calls in sequence:
203
+ *
204
+ * 1. emitSiteScaffold — Tier 2 (scaffold-once): package.json,
205
+ * theme, astro.config.mjs, components, etc.
206
+ * 2. emitAstroPages — Tier 1 (content): nav.json, .astro pages,
207
+ * .md mirror endpoints (when enabled)
208
+ * 3. emitConfigDerivedFiles — Tier 1 (config-derived): robots.txt
209
+ * 4. emitAgentReadinessFiles — Tier 1 (agent-tier): llms.txt, _headers,
210
+ * middleware.ts (when enabled)
211
+ *
212
+ * Each emitter is also callable directly — site init / site build (in
213
+ * progress) call only the subset they need.
214
+ */
215
+ export async function exportAstroProject(pages, nav, outputDir, options = {}) {
216
+ const siteName = options.siteName || "Documentation";
217
+ const alreadyScaffolded = existsSync(join(outputDir, "package.json"));
218
+ const writeScaffold = options.force === true || !alreadyScaffolded;
219
+ ensureDirectoryStructure(outputDir, normalizeBasePath(options.basePath));
220
+ const scaffoldSkipped = emitSiteScaffold(outputDir, siteName, options, writeScaffold);
221
+ const { generated, outputNav } = await emitAstroPages(pages, nav, outputDir, options);
222
+ emitConfigDerivedFiles(outputDir, options);
223
+ emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options);
224
+ console.log(`Generated ${generated} static .astro pages`);
225
+ if (alreadyScaffolded && !options.force && scaffoldSkipped > 0) {
226
+ console.log(`Preserved ${scaffoldSkipped} maintainer-customizable file(s) ` +
227
+ `(theme, config, package.json, etc.). Pass --force to overwrite.`);
228
+ }
229
+ }
230
+ /** Create the standard directory structure for a Dogsbay site. */
231
+ function ensureDirectoryStructure(outputDir, basePath) {
232
+ mkdirSync(join(outputDir, "src", "content", "docs"), { recursive: true });
233
+ mkdirSync(join(outputDir, "src", "styles"), { recursive: true });
234
+ mkdirSync(join(outputDir, "src", "pages", ...basePathSegments(basePath)), { recursive: true });
235
+ mkdirSync(join(outputDir, "src", "components", "ui"), { recursive: true });
236
+ mkdirSync(join(outputDir, "src", "data"), { recursive: true });
237
+ }
238
+ // ─── Tier 2: scaffold-once ──────────────────────────────────────────────
239
+ // Files that init writes once and `convert` won't clobber on re-run unless
240
+ // `--force` is passed. Maintainers may freely edit these.
241
+ /**
242
+ * Emit the project scaffold: package.json, astro.config.mjs, tsconfig,
243
+ * theme/global CSS, site.json, copied UI components, deploy config.
244
+ *
245
+ * @returns the count of files skipped because the project is already
246
+ * scaffolded (used by the orchestrator's "preserved N files" log).
247
+ */
248
+ /**
249
+ * Emit `src/data/site.json` from the resolved options. Pure
250
+ * config-derived (Tier 1) — overwrites unconditionally so config
251
+ * edits in `dogsbay.config.yml` propagate to the rendered site on
252
+ * every `dogsbay site build` without needing `--force`.
253
+ *
254
+ * Standalone helper so `site build` can refresh it without going
255
+ * through the full scaffold path. `emitSiteScaffold` also calls it.
256
+ */
257
+ export function emitSiteConfig(outputDir, siteName, options) {
258
+ mkdirSync(join(outputDir, "src", "data"), { recursive: true });
259
+ writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
260
+ }
261
+ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
262
+ let scaffoldFilesSkipped = 0;
263
+ // Ensure dirs exist when called standalone (not via the orchestrator).
264
+ ensureDirectoryStructure(outputDir, normalizeBasePath(options.basePath));
265
+ // site.json: always emit. It's purely config-derived (every field
266
+ // comes from options) so unconditional overwrite is safe and keeps
267
+ // config edits in `dogsbay.config.yml` reaching the rendered site.
268
+ emitSiteConfig(outputDir, siteName, options);
269
+ // Generate package.json — pick dep style based on destination:
270
+ // - Inside a pnpm/npm workspace: use "workspace:*" links
271
+ // - --local outside workspace: use file: paths pointing at monorepo packages
272
+ // - Otherwise: versioned specs (expects registry-published packages)
273
+ const insideWs = isInsideWorkspace(outputDir);
274
+ const dogsbayDep = (name) => {
275
+ if (insideWs)
276
+ return "workspace:*";
277
+ if (options.local)
278
+ return `file:${resolveMonorepoPkg(name)}`;
279
+ return "^0.1.0";
280
+ };
281
+ // Per-deploy-target additions to package.json
282
+ const isCloudflare = options.deploy === "cloudflare-workers";
283
+ const deployScripts = isCloudflare
284
+ ? { deploy: "pnpm build && wrangler deploy" }
285
+ : {};
286
+ const deployDevDeps = isCloudflare
287
+ ? { wrangler: "^4.0.0" }
288
+ : {};
289
+ // package.json — scaffold-once. Maintainers add their own deps; the
290
+ // detection of "already scaffolded" actually keys off this file's
291
+ // existence, so it's also our sentinel.
292
+ if (writeScaffold) {
293
+ writeFileSync(join(outputDir, "package.json"), JSON.stringify({
294
+ name: slugify(siteName),
295
+ type: "module",
296
+ version: "0.1.0",
297
+ private: true,
298
+ scripts: {
299
+ dev: "astro dev",
300
+ // Pagefind runs after astro build; indexes the static dist/ output and
301
+ // writes `dist/pagefind/` for the search UI to load lazily on Cmd+K.
302
+ build: "astro build && pagefind --site dist",
303
+ preview: "astro preview",
304
+ ...deployScripts,
305
+ },
306
+ dependencies: {
307
+ astro: "^6.0.0",
308
+ "@astrojs/sitemap": "^3.0.0",
309
+ // Pagefind is invoked from the build script (see scripts.build above).
310
+ // Lives in dependencies (not devDependencies) so production builds
311
+ // include it; the produced search index is shipped statically and
312
+ // doesn't load this dep at runtime.
313
+ pagefind: "^1.4.0",
314
+ tailwindcss: "^4.0.0",
315
+ "@tailwindcss/vite": "^4.0.0",
316
+ "tailwind-variants": "^0.3.0",
317
+ shiki: "^4.0.0",
318
+ "@shikijs/transformers": "^4.0.0",
319
+ katex: "^0.16.44",
320
+ // Used by MarkdownExample component for plain-markdown fallback rendering.
321
+ // Tree-shaken out if no :::example blocks are present.
322
+ "markdown-it": "^14.1.0",
323
+ "markdown-it-attrs": "^4.3.1",
324
+ "markdown-it-deflist": "^3.0.0",
325
+ "@dogsbay/docs-layout": dogsbayDep("docs-layout"),
326
+ "@dogsbay/ui": dogsbayDep("ui"),
327
+ "@dogsbay/types": dogsbayDep("types"),
328
+ "@dogsbay/primitives": dogsbayDep("primitives"),
329
+ "@dogsbay/icons": dogsbayDep("icons"),
330
+ "@dogsbay/elements": dogsbayDep("elements"),
331
+ },
332
+ ...(Object.keys(deployDevDeps).length > 0
333
+ ? { devDependencies: deployDevDeps }
334
+ : {}),
335
+ }, null, 2) + "\n");
336
+ }
337
+ else {
338
+ scaffoldFilesSkipped++;
339
+ }
340
+ // wrangler.jsonc — scaffold-once. Bindings, secrets, custom routes
341
+ // belong here post-generation.
342
+ if (isCloudflare) {
343
+ if (writeScaffold) {
344
+ writeFileSync(join(outputDir, "wrangler.jsonc"), buildWranglerConfig(siteName, options));
345
+ }
346
+ else {
347
+ scaffoldFilesSkipped++;
348
+ }
349
+ }
350
+ // Generate astro.config.mjs
351
+ // `preserveSymlinks: true` is used with --local to pin local file: deps to
352
+ // their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
353
+ // module resolution (e.g. `@astrojs/internal-helpers/path`), so we only
354
+ // emit it when not building inside an existing workspace.
355
+ const resolveBlock = options.local && !insideWs
356
+ ? `
357
+ resolve: {
358
+ preserveSymlinks: true,
359
+ },`
360
+ : "";
361
+ // Sitemap integration is conditional: requires an absolute site URL so
362
+ // <loc> entries can be properly absolute. Without siteUrl, the sitemap
363
+ // step is skipped (the import + integration call are simply omitted from
364
+ // the generated config). Sitemap also filters out frontmatter-noindex pages.
365
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
366
+ const sitemapImport = hasSiteUrl ? `import sitemap from "@astrojs/sitemap";\n` : "";
367
+ // Strip any path component from site.url before emitting. The
368
+ // config validator already rejects `site.url` containing a path
369
+ // when `basePath` is non-empty (canonical URLs would double-count
370
+ // the prefix); this is a defensive normalisation for the case
371
+ // where the validator is bypassed or basePath is empty.
372
+ //
373
+ // Note: we deliberately do NOT emit Astro's `base:` field. With
374
+ // the current file emission (pages live under
375
+ // `src/pages/<basePath>/...`), adding `base` would cause Astro
376
+ // to doubly-prefix every route. Switching to `base`-driven
377
+ // routing is a separate refactor — see plans/configurable-base-path.md.
378
+ let siteField = "";
379
+ if (hasSiteUrl) {
380
+ let originOnly;
381
+ try {
382
+ const u = new URL(options.siteUrl);
383
+ originOnly = `${u.protocol}//${u.host}`;
384
+ }
385
+ catch {
386
+ originOnly = options.siteUrl;
387
+ }
388
+ siteField = `\n site: ${JSON.stringify(originOnly)},`;
389
+ }
390
+ const integrationsField = hasSiteUrl ? `\n integrations: [sitemap()],` : "";
391
+ // astro.config.mjs — scaffold-once. Maintainer adds custom integrations.
392
+ // The plugin-aliases import is for the Dogsbay plugin API: each
393
+ // build emits `astro.config.plugins.mjs` exporting `pluginAliases`,
394
+ // a Vite alias map for `virtual:dogsbay-plugin-config/<id>` modules.
395
+ // When no plugins use defineClientConfig the map is empty and the
396
+ // spread is a no-op. See plans/plugin-api.md.
397
+ if (writeScaffold) {
398
+ writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
399
+ import tailwindcss from "@tailwindcss/vite";
400
+ ${sitemapImport}import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
401
+
402
+ export default defineConfig({${siteField}
403
+ output: "static",
404
+ build: {
405
+ inlineStylesheets: "always",
406
+ },${integrationsField}
407
+ vite: {
408
+ plugins: [tailwindcss()],
409
+ resolve: {
410
+ alias: { ...pluginAliases },${options.local && !insideWs ? "\n preserveSymlinks: true," : ""}
411
+ },
412
+ server: {
413
+ fs: {
414
+ // Allow Vite to serve plugin client modules / styles
415
+ // shipped from outside the project root (workspace deps,
416
+ // monorepo siblings). Empty when no plugins use absolute
417
+ // paths.
418
+ allow: ["..", ...pluginFsAllow],
419
+ },
420
+ },
421
+ },
422
+ });
423
+ `);
424
+ }
425
+ else {
426
+ scaffoldFilesSkipped++;
427
+ }
428
+ // Always seed an empty astro.config.plugins.mjs so the import in
429
+ // astro.config.mjs resolves before the first plugin-emitting
430
+ // build. Subsequent builds replace it via emitPluginRuntime.
431
+ if (!existsSync(join(outputDir, "astro.config.plugins.mjs"))) {
432
+ writeFileSync(join(outputDir, "astro.config.plugins.mjs"), [
433
+ "// Auto-generated by dogsbay site build (initial empty seed).",
434
+ "export const pluginAliases = {};",
435
+ "export const pluginFsAllow = [];",
436
+ "",
437
+ ].join("\n"));
438
+ }
439
+ // tsconfig.json — scaffold-once.
440
+ if (writeScaffold) {
441
+ writeFileSync(join(outputDir, "tsconfig.json"), JSON.stringify({
442
+ extends: "astro/tsconfigs/strict",
443
+ compilerOptions: {
444
+ baseUrl: ".",
445
+ paths: {
446
+ "@/*": ["./src/*"],
447
+ "@ui/*": ["./src/components/ui/*"],
448
+ },
449
+ },
450
+ }, null, 2) + "\n");
451
+ }
452
+ else {
453
+ scaffoldFilesSkipped++;
454
+ }
455
+ // global.css + theme.css + extra_css copies — all scaffold-once.
456
+ if (writeScaffold) {
457
+ let globalCss = generateGlobalCss();
458
+ if (options.extraCss && options.extraCss.length > 0 && options.sourceDir) {
459
+ mkdirSync(join(outputDir, "src", "styles", "custom"), { recursive: true });
460
+ for (const cssPath of options.extraCss) {
461
+ const fullPath = join(options.sourceDir, cssPath);
462
+ if (existsSync(fullPath)) {
463
+ const fileName = cssPath.split("/").pop();
464
+ cpSync(fullPath, join(outputDir, "src", "styles", "custom", fileName));
465
+ globalCss += `@import "./custom/${fileName}";\n`;
466
+ }
467
+ }
468
+ }
469
+ writeFileSync(join(outputDir, "src", "styles", "global.css"), globalCss);
470
+ writeThemeFile(join(outputDir, "src", "styles", "theme.css"), options.theme);
471
+ }
472
+ else {
473
+ scaffoldFilesSkipped += 2; // global.css + theme.css
474
+ }
475
+ // UI components — scaffold-once. Per shadcn pattern, copies are owned
476
+ // by the project and meant to be edited. Re-running convert leaves the
477
+ // existing tree alone.
478
+ if (writeScaffold) {
479
+ copyComponents(outputDir);
480
+ }
481
+ else {
482
+ scaffoldFilesSkipped++;
483
+ }
484
+ return scaffoldFilesSkipped;
485
+ }
486
+ // ─── Tier 1: content ────────────────────────────────────────────────────
487
+ // Per-page output: nav.json, .astro pages, .md mirror endpoints, the
488
+ // site root index.astro redirect. Always regenerated; convert + site
489
+ // build both call this directly.
490
+ /**
491
+ * Emit content-tier files: nav.json, every page's .astro, the per-page
492
+ * .md mirror endpoint (when `mdMirror` is enabled), and the index
493
+ * redirect. Returns the count of pages emitted and the merged nav for
494
+ * downstream consumers (llms.txt builder).
495
+ */
496
+ export async function emitAstroPages(pages, nav, outputDir, options) {
497
+ const siteName = options.siteName || "Documentation";
498
+ const basePath = normalizeBasePath(options.basePath);
499
+ const baseSegments = basePathSegments(basePath);
500
+ // Ensure dirs exist (callers may invoke us without going through the
501
+ // full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
502
+ mkdirSync(join(outputDir, "src", "pages", ...baseSegments), { recursive: true });
503
+ mkdirSync(join(outputDir, "src", "data"), { recursive: true });
504
+ // Generate nav.json — merge with existing on disk when --section is set
505
+ const section = options.section;
506
+ let outputNav = nav;
507
+ if (section) {
508
+ const navPath = join(outputDir, "src", "data", "nav.json");
509
+ let existingNav = [];
510
+ try {
511
+ existingNav = JSON.parse(readFileSync(navPath, "utf-8"));
512
+ }
513
+ catch {
514
+ // No existing nav — start fresh
515
+ }
516
+ // Remove existing entry for this section (full replace)
517
+ existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
518
+ && item.label?.toLowerCase() !== section.toLowerCase());
519
+ const prefixedNav = prefixNavHrefs(nav, section, basePath);
520
+ const sectionLabel = siteName
521
+ || section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
522
+ existingNav.push({ label: sectionLabel, children: prefixedNav });
523
+ outputNav = existingNav;
524
+ }
525
+ writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(outputNav, null, 2));
526
+ // Static assets (images etc.) — content-tier; always copy from the
527
+ // user's source dir. If they removed an asset, we want it gone here
528
+ // too. Skipped when sourceDir isn't supplied (programmatic callers
529
+ // that want pure page emission).
530
+ if (options.sourceDir) {
531
+ copyAssets(options.sourceDir, outputDir, options.imageOptimization);
532
+ }
533
+ let generated = 0;
534
+ const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
535
+ const useImageOpt = options.imageOptimization ?? false;
536
+ // hrefPrefix is the same string as basePath. rewriteHref handles the
537
+ // empty-basePath case correctly: any link starting with "/" matches
538
+ // the early-return guard, so root-relative links pass through
539
+ // unrewritten when the site is served at host root.
540
+ const hrefPrefix = basePath;
541
+ for (const page of pages) {
542
+ try {
543
+ // Rewrite internal hrefs to match the output URL structure
544
+ rewriteTreeHrefs(page.tree, hrefPrefix);
545
+ const result = treeToAstro(page.tree, {
546
+ imageOptimization: useImageOpt,
547
+ codeBlockTitle: options.codeBlockTitle ?? true,
548
+ });
549
+ const imageSetup = useImageOpt ? [
550
+ '',
551
+ 'const imageModules = import.meta.glob<{ default: ImageMetadata }>(',
552
+ ' "/src/assets/**/*.{png,jpg,jpeg,gif,webp}",',
553
+ ' { eager: true }',
554
+ ');',
555
+ 'const imageMap: Record<string, ImageMetadata> = {};',
556
+ 'for (const [path, mod] of Object.entries(imageModules)) {',
557
+ ' const publicPath = path.replace("/src/assets/", "/");',
558
+ ' imageMap[publicPath] = mod.default;',
559
+ '}',
560
+ ] : [];
561
+ // Per-page meta from frontmatter
562
+ const fm = (page.frontmatter ?? {});
563
+ const pageDescription = fm.description ?? "";
564
+ const pageOgImage = fm.ogImage ?? "";
565
+ const pageNoindex = fm.noindex === true || fm.draft === true;
566
+ // Independent of noindex: pages can be excluded from in-site
567
+ // Pagefind search even when external SEs should index them
568
+ // (or vice versa). See DocsLayout's prop docs for the
569
+ // separation rationale.
570
+ const pageExcludeFromSearch = fm.excludeFromSearch === true || fm.pagefindExclude === true;
571
+ // Typed PageMeta — populated by importers via parseMeta. Drives
572
+ // the in-page meta strip (tags, status, type), the taxonomy
573
+ // term page links, and Pagefind facet filter meta tags.
574
+ const pageMeta = page.meta;
575
+ const pageTags = Array.isArray(pageMeta?.tags) ? pageMeta.tags : undefined;
576
+ const pageStatus = pageMeta?.status;
577
+ const pageTypeStr = pageMeta?.type;
578
+ const pageAudience = Array.isArray(pageMeta?.audience)
579
+ ? pageMeta.audience
580
+ : undefined;
581
+ const pageCategory = Array.isArray(pageMeta?.category)
582
+ ? pageMeta.category
583
+ : undefined;
584
+ const tagsIndexPath = options.tagsIndexPath ?? "/tags";
585
+ // Auto-lede detection. If the markdown body doesn't already
586
+ // start with an H1 / leading paragraph, we ask DocsLayout to
587
+ // render the frontmatter title / description at the top of
588
+ // <main>. Skipped when the page is a redirect (no body
589
+ // content rendered anyway). See plans/auto-lede.md.
590
+ const isRedirect = typeof page.redirect === "string" && page.redirect.length > 0;
591
+ const { hasH1: bodyHasH1, hasLede: bodyHasLede } = detectLeadingNodes(page.tree);
592
+ const autoH1 = !isRedirect &&
593
+ !bodyHasH1 &&
594
+ typeof page.title === "string" &&
595
+ page.title.length > 0;
596
+ const autoLede = !isRedirect &&
597
+ !bodyHasLede &&
598
+ pageDescription.length > 0;
599
+ // Per-page LLM action UI. Site-wide config in
600
+ // `options.llmActions`; per-page opt-out via frontmatter
601
+ // `llmActions: false`. Skipped on redirect pages (no real
602
+ // content to copy / open). See plans/llm-page-actions.md.
603
+ const pageLlmActionsOptOut = fm.llmActions === false;
604
+ const llmActionsEnabled = !isRedirect &&
605
+ !pageLlmActionsOptOut &&
606
+ !!options.llmActions &&
607
+ options.llmActions.enabled !== false;
608
+ // Compute the absolute (or root-relative when no siteUrl) URL
609
+ // of the .md mirror for this page. The mirror endpoint is
610
+ // emitted below when `mdMirror !== false`; if mdMirror is
611
+ // disabled we still pass the would-be URL since static hosts
612
+ // serving raw .md files (e.g. via CF rules) may have made it
613
+ // available via other means. The "Open in" deep links work
614
+ // regardless of mirror availability — agents that can't fetch
615
+ // the page just see the URL in their chat.
616
+ const pageHrefBase = section
617
+ ? (basePath ? `${basePath}/${section}/${page.slug}` : `/${section}/${page.slug}`)
618
+ : (basePath ? `${basePath}/${page.slug}` : `/${page.slug}`);
619
+ const pageMdHref = `${pageHrefBase}.md`;
620
+ const pageMdAbsoluteUrl = options.siteUrl
621
+ ? options.siteUrl.replace(/\/$/, "") + pageMdHref
622
+ : pageMdHref;
623
+ // Markdown body for the Copy button. Reuse the same serializer
624
+ // that produces the .md mirror so what the user copies matches
625
+ // what the .md endpoint serves byte-for-byte.
626
+ const pageMarkdownBody = llmActionsEnabled && options.llmActions?.copyButton !== false
627
+ ? treeToDogsbayMd(page.tree)
628
+ : "";
629
+ // Source-file relative path used in the banner. The slug already
630
+ // matches the .md path under apps/<content-dir>/.
631
+ const sourceRel = `${page.slug}.md`;
632
+ // Detect "wide-layout" pages — currently any page whose top-level
633
+ // tree contains an `endpoint` TreeNode (OpenAPI operation pages).
634
+ // These pages compose their own internal columns and need the
635
+ // full inset width; the prose-readable cap and the right TOC
636
+ // would just steal pixels. See plans/openapi-builtin.md.
637
+ const wideLayout = (page.tree ?? []).some((n) => n && n.type === "endpoint");
638
+ const pageLines = [
639
+ "---",
640
+ "// AUTO-GENERATED by `dogsbay convert` — do not edit.",
641
+ `// Source: ${sourceRel}`,
642
+ "// Edit the source markdown and re-run `dogsbay convert` to regenerate.",
643
+ "",
644
+ 'import "@/styles/global.css";',
645
+ 'import "katex/dist/katex.min.css";',
646
+ 'import DocsLayout from "@dogsbay/docs-layout/DocsLayout.astro";',
647
+ 'import { getPagination } from "@dogsbay/docs-layout/pagination";',
648
+ 'import { filterNavByAxis } from "@dogsbay/docs-layout/nav-filter";',
649
+ 'import type { SiteConfig } from "@dogsbay/types";',
650
+ 'import navData from "@/data/nav.json";',
651
+ 'import siteConfigData from "@/data/site.json";',
652
+ 'import switcherMapData from "@/data/switcherMap.json";',
653
+ // MarkdownContentStack — generated by emitPluginRuntime.
654
+ // Always exists; passthrough <slot /> when no plugin
655
+ // contributes a wrapper for the MarkdownContent slot.
656
+ 'import MarkdownContentStack from "@/components/wrappers/MarkdownContentStack.astro";',
657
+ 'const siteConfig = siteConfigData as SiteConfig;',
658
+ ...result.imports,
659
+ ...imageSetup,
660
+ "",
661
+ `const headings = ${JSON.stringify(page.headings || [])};`,
662
+ `const nav = navData;`,
663
+ `const currentPath = "${buildCurrentPath(basePath, section, page.slug)}";`,
664
+ // Filter nav to the current (locale, version) bucket
665
+ // before computing prev/next — without this, pagination
666
+ // walks the global nav and a "Next" link can leak from
667
+ // /fr/latest/... to /en/archive/... or similar. The
668
+ // sidebar already does the same filter at render time;
669
+ // this keeps prev/next aligned with what the reader
670
+ // sees.
671
+ `const _navForPagination = filterNavByAxis(nav as any[], {`,
672
+ ` basePath: ${JSON.stringify(basePath || "/docs")},`,
673
+ ` version: ${JSON.stringify(page.multiSource?.version ?? null)} ?? undefined,`,
674
+ ` locale: ${JSON.stringify(page.multiSource?.locale ?? null)} ?? undefined,`,
675
+ `});`,
676
+ `const { prev, next } = getPagination(currentPath, _navForPagination);`,
677
+ `const title = ${JSON.stringify(page.title)};`,
678
+ `const description = ${JSON.stringify(pageDescription)} || undefined;`,
679
+ `const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
680
+ `const noindex = ${JSON.stringify(pageNoindex)};`,
681
+ `const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
682
+ `const pageTags = ${JSON.stringify(pageTags ?? null)};`,
683
+ `const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
684
+ `const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
685
+ `const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
686
+ `const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
687
+ `const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
688
+ `const llmActionsProps = ${JSON.stringify(llmActionsEnabled
689
+ ? {
690
+ enabled: true,
691
+ providers: options.llmActions?.providers,
692
+ placement: options.llmActions?.placement,
693
+ copyButton: options.llmActions?.copyButton,
694
+ promptTemplate: options.llmActions?.promptTemplate,
695
+ footerLink: options.llmActions?.footerLink,
696
+ markdownBody: pageMarkdownBody,
697
+ mdUrl: pageMdAbsoluteUrl,
698
+ }
699
+ : null)} ?? undefined;`,
700
+ "---",
701
+ "",
702
+ `<DocsLayout`,
703
+ ` siteName={siteConfig.siteName}`,
704
+ ` title={title}`,
705
+ ` nav={nav as any[]}`,
706
+ ` headings={headings}`,
707
+ ` prev={prev}`,
708
+ ` next={next}`,
709
+ ` description={description}`,
710
+ ` siteDescription={siteConfig.description || undefined}`,
711
+ ` ogImage={ogImage}`,
712
+ ` defaultOgImage={siteConfig.ogImage || undefined}`,
713
+ ` siteUrl={siteConfig.siteUrl || "/"}`,
714
+ ` twitterHandle={siteConfig.twitterHandle || undefined}`,
715
+ ` themeColor={siteConfig.themeColor || undefined}`,
716
+ ` noindex={noindex}`,
717
+ ` excludeFromSearch={excludeFromSearch}`,
718
+ ` plausibleDomain={siteConfig.plausible?.domain}`,
719
+ ` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
720
+ ` repoUrl={siteConfig.repoUrl || undefined}`,
721
+ ` mdMirror={${options.mdMirror !== false}}`,
722
+ ` tags={pageTags ?? undefined}`,
723
+ ` tagsIndexPath={tagsIndexPath}`,
724
+ ` tagPrefixes={siteConfig.tagPrefixes}`,
725
+ ` tagLabels={siteConfig.tagLabels}`,
726
+ ` taxonomyIndexPaths={siteConfig.taxonomyIndexPaths}`,
727
+ ` taxonomyDisplay={siteConfig.taxonomyDisplay}`,
728
+ ` status={pageStatus ?? undefined}`,
729
+ ` pageType={pageType ?? undefined}`,
730
+ ` audience={pageAudience ?? undefined}`,
731
+ ` category={pageCategory ?? undefined}`,
732
+ ` autoH1={${autoH1}}`,
733
+ ` autoLede={${autoLede}}`,
734
+ ` llmActions={llmActionsProps}`,
735
+ ` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
736
+ ` switcherMap={switcherMapData}`,
737
+ ` basePath={${JSON.stringify(basePath || "/docs")}}`,
738
+ ` wideLayout={${wideLayout}}`,
739
+ `>`,
740
+ ` <MarkdownContentStack>`,
741
+ wideLayout
742
+ ? result.body.split("\n").map((l) => ` ${l}`).join("\n")
743
+ : ` <article class="docs-prose">`,
744
+ ...(wideLayout
745
+ ? []
746
+ : [
747
+ result.body.split("\n").map((l) => ` ${l}`).join("\n"),
748
+ ` </article>`,
749
+ ]),
750
+ ` </MarkdownContentStack>`,
751
+ `</DocsLayout>`,
752
+ ];
753
+ if (result.scripts.length > 0) {
754
+ pageLines.push("");
755
+ for (const script of result.scripts) {
756
+ pageLines.push(`<script>`);
757
+ pageLines.push(script);
758
+ pageLines.push(`</script>`);
759
+ }
760
+ }
761
+ // Plugin runtime entry — every Dogsbay-built page imports the
762
+ // generated runtime so plugin clientModules participate in the
763
+ // page bundle. The file is emitted by `emitPluginRuntime` and
764
+ // always exists (even with no plugins) per plans/plugin-api.md.
765
+ pageLines.push("");
766
+ pageLines.push(`<script>`);
767
+ pageLines.push(` // @ts-ignore — generated by dogsbay site build`);
768
+ pageLines.push(` import "/src/lib/plugin-runtime.ts";`);
769
+ pageLines.push(`</script>`);
770
+ const pagePath = join(pagesDir, section ? section : "", `${page.slug}.astro`);
771
+ mkdirSync(dirname(pagePath), { recursive: true });
772
+ writeFileSync(pagePath, pageLines.join("\n") + "\n");
773
+ generated++;
774
+ // Companion .md endpoint for content negotiation. Prerendered, so
775
+ // it's served as a static asset at runtime — no Worker overhead.
776
+ //
777
+ // For an index page (slug "index") under a non-empty basePath, we
778
+ // ALSO emit a sibling `<basePath>.md.ts` at the level above
779
+ // `<basePath>/index.md.ts`. This makes the `.md` mirror reachable
780
+ // at `/docs.md` — the URL the `<link rel="alternate">` and
781
+ // middleware rewrite both target. Without this sibling, those
782
+ // refs point at a 404 (the actual file is at /docs/index.md, but
783
+ // both the link href and the middleware emit `/docs.md`).
784
+ if (options.mdMirror !== false) {
785
+ const mdEndpointPath = join(pagesDir, section ? section : "", `${page.slug}.md.ts`);
786
+ mkdirSync(dirname(mdEndpointPath), { recursive: true });
787
+ const endpointBody = buildMdEndpoint(page, sourceRel);
788
+ writeFileSync(mdEndpointPath, endpointBody);
789
+ // Sibling-level mirror for the index page under a non-empty
790
+ // basePath. baseSegments is empty when basePath is empty
791
+ // (root-served sites); in that case the index slug is just
792
+ // "index" and the existing `/index.md` route is the
793
+ // canonical mirror — no sibling needed.
794
+ const isIndex = page.slug === "index" && !section;
795
+ if (isIndex && baseSegments.length > 0) {
796
+ const lastSeg = baseSegments[baseSegments.length - 1];
797
+ const parentSegments = baseSegments.slice(0, -1);
798
+ const siblingPath = join(outputDir, "src", "pages", ...parentSegments, `${lastSeg}.md.ts`);
799
+ mkdirSync(dirname(siblingPath), { recursive: true });
800
+ writeFileSync(siblingPath, endpointBody);
801
+ }
802
+ }
803
+ }
804
+ catch (err) {
805
+ console.warn(`Warning: failed to generate ${page.slug}: ${err.message}`);
806
+ }
807
+ }
808
+ // Generate index redirect at src/pages/index.astro — sends `/` to the
809
+ // first nav href. Skipped when basePath is empty: with root-served
810
+ // sites, src/pages/index.astro is the actual home page (not a
811
+ // redirect target), and writing a redirect would clobber it.
812
+ if (basePath !== "") {
813
+ const firstHref = findFirstNavHref(nav, basePath);
814
+ writeFileSync(join(outputDir, "src", "pages", "index.astro"), `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
815
+ }
816
+ return { generated, outputNav };
817
+ }
818
+ // ─── Tier 1: config-derived ─────────────────────────────────────────────
819
+ // Files driven entirely by config + flags. Always regenerated; site
820
+ // build calls this directly so flag changes take effect on every run.
821
+ /**
822
+ * Emit `public/robots.txt` with `Content-Signal` directives + sitemap
823
+ * link. Always overwrites — the file's contents are pure derivation
824
+ * from `options`. Maintainers wanting custom Disallow rules layer
825
+ * them at the CDN level.
826
+ */
827
+ export function emitConfigDerivedFiles(outputDir, options) {
828
+ mkdirSync(join(outputDir, "public"), { recursive: true });
829
+ const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
830
+ writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
831
+ }
832
+ /**
833
+ * Emit `src/data/switcherMap.json` describing per-page
834
+ * version + locale equivalents. Always writes the file —
835
+ * even when no axis is active — with empty `versions: []`,
836
+ * `locales: []`, `byLogicalKey: {}`. Keeps the per-page
837
+ * static import working in every site, with switchers
838
+ * rendering nothing when their axis has < 2 declared entries.
839
+ *
840
+ * Pages without `multiSource` metadata are skipped — they
841
+ * aren't part of any axis, so they don't appear in any
842
+ * switcher. Pages with multiSource (any axis active) DO
843
+ * appear, even when this particular page sits in the
844
+ * default-bucket of the relevant axis (e.g. an unversioned
845
+ * baseline page in a multi-version site).
846
+ */
847
+ export function emitSwitcherMap(pages, outputDir, options) {
848
+ const basePath = normalizeBasePath(options.basePath);
849
+ const dataDir = join(outputDir, "src", "data");
850
+ const outPath = join(dataDir, "switcherMap.json");
851
+ // Detect axis activation by inspecting the data the loader
852
+ // already stamped, not by re-reading config — keeps emitter
853
+ // independent of the schema layer.
854
+ const versionsSeen = new Set();
855
+ const localesSeen = new Set();
856
+ for (const page of pages) {
857
+ const v = page.multiSource?.version;
858
+ if (v)
859
+ versionsSeen.add(v);
860
+ const l = page.multiSource?.locale;
861
+ if (l)
862
+ localesSeen.add(l);
863
+ }
864
+ const versionAxisActive = versionsSeen.size >= 2;
865
+ const localeAxisActive = localesSeen.size >= 2;
866
+ if (!versionAxisActive && !localeAxisActive) {
867
+ mkdirSync(dataDir, { recursive: true });
868
+ writeFileSync(outPath, JSON.stringify({ versions: [], locales: [], byLogicalKey: {} }, null, 2));
869
+ return;
870
+ }
871
+ const versions = composeAxisHeader(options.versions ?? [], versionsSeen, options.defaultVersion,
872
+ /* allowEol */ true);
873
+ const locales = composeAxisHeader(options.locales ?? [], localesSeen, options.defaultLocale,
874
+ /* allowEol */ false);
875
+ // Per-logical-key list of variants — every (locale, version)
876
+ // combo that this logical page exists in.
877
+ const byLogicalKey = {};
878
+ for (const page of pages) {
879
+ const ms = page.multiSource;
880
+ if (!ms || (!ms.version && !ms.locale))
881
+ continue;
882
+ const ns = ms.namespace ?? "docs";
883
+ const key = `${ns}/${ms.originalSlug}`;
884
+ const variant = {
885
+ ...(ms.locale !== undefined ? { locale: ms.locale } : {}),
886
+ ...(ms.version !== undefined ? { version: ms.version } : {}),
887
+ url: `${basePath}/${page.slug}`,
888
+ };
889
+ if (!byLogicalKey[key])
890
+ byLogicalKey[key] = [];
891
+ byLogicalKey[key].push(variant);
892
+ }
893
+ const data = { versions, locales, byLogicalKey };
894
+ mkdirSync(dataDir, { recursive: true });
895
+ writeFileSync(outPath, JSON.stringify(data, null, 2));
896
+ }
897
+ /**
898
+ * Compose a switcherMap axis header from declared entries +
899
+ * the set of ids actually seen in pages. Honours declared
900
+ * order + labels + EOL marks; falls back to discovered ids for
901
+ * anything not declared. Filters out declared-but-unseen ids
902
+ * (no point listing a version no page exists in).
903
+ */
904
+ function composeAxisHeader(declared, seen, defaultId, allowEol) {
905
+ const out = [];
906
+ const seenInDeclared = new Set();
907
+ for (const d of declared) {
908
+ if (seen.has(d.id)) {
909
+ out.push({
910
+ id: d.id,
911
+ ...(d.label !== undefined ? { label: d.label } : {}),
912
+ ...(allowEol && d.eol === true ? { eol: true } : {}),
913
+ ...(defaultId === d.id ? { default: true } : {}),
914
+ });
915
+ seenInDeclared.add(d.id);
916
+ }
917
+ }
918
+ for (const id of seen) {
919
+ if (!seenInDeclared.has(id)) {
920
+ out.push({
921
+ id,
922
+ ...(defaultId === id ? { default: true } : {}),
923
+ });
924
+ }
925
+ }
926
+ return out;
927
+ }
928
+ // ─── Tier 1: missing-translation stubs ──────────────────────────────────
929
+ // For multi-locale sites, emit redirect-stub Astro pages for every
930
+ // (locale × namespace × version × originalSlug) combo where a page
931
+ // exists in the default locale but NOT in another configured locale.
932
+ // The stub redirects to the default-locale equivalent so untranslated
933
+ // readers see useful content rather than a 404.
934
+ //
935
+ // Driven by ExportPage[].multiSource. No-op when the locale axis isn't
936
+ // active or no defaultLocale is configured.
937
+ /**
938
+ * Emit redirect stub pages for missing translations. For each
939
+ * page that exists in the default locale, ensure there's a
940
+ * corresponding URL for every other declared locale: if no
941
+ * page already exists at that URL, write a stub Astro page
942
+ * that 302-redirects to the default-locale equivalent.
943
+ *
944
+ * Stubs aren't emitted FROM other-locale TO default-locale —
945
+ * only the other way. The default locale is the canonical
946
+ * baseline; if a writer authored content only in (say) `fr`,
947
+ * we don't manufacture an `en` URL with a fake redirect.
948
+ */
949
+ export function emitMissingTranslationStubs(pages, outputDir, options) {
950
+ const defaultLocale = options.defaultLocale;
951
+ if (!defaultLocale)
952
+ return;
953
+ const knownLocales = new Set();
954
+ for (const p of pages) {
955
+ const l = p.multiSource?.locale;
956
+ if (l)
957
+ knownLocales.add(l);
958
+ }
959
+ if (knownLocales.size < 2)
960
+ return;
961
+ if (!knownLocales.has(defaultLocale))
962
+ return;
963
+ const basePath = normalizeBasePath(options.basePath);
964
+ const baseSegments = basePathSegments(basePath);
965
+ // Index existing pages by (slug after locale segment) so we
966
+ // can detect missing translations cheaply. Key shape:
967
+ // `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
968
+ // is the version + namespace segments (when those axes are
969
+ // active for the page).
970
+ //
971
+ // For each default-locale page, we strip the leading `<defaultLocale>/`
972
+ // off the slug to get the "neutral" tail, then for every other
973
+ // locale, the expected URL is `<basePath>/<otherLocale>/<tail>`.
974
+ const existingByUrl = new Set();
975
+ for (const p of pages) {
976
+ existingByUrl.add(`${basePath}/${p.slug}`);
977
+ }
978
+ let emitted = 0;
979
+ for (const defaultPage of pages) {
980
+ const ms = defaultPage.multiSource;
981
+ if (!ms || ms.locale !== defaultLocale)
982
+ continue;
983
+ // Strip the leading `<defaultLocale>/` from the slug to get
984
+ // the locale-neutral tail. Defensive: if for some reason the
985
+ // slug doesn't start with the locale (e.g. unprefixed
986
+ // baseline page in a half-active site), skip.
987
+ const localePrefix = `${defaultLocale}/`;
988
+ if (!defaultPage.slug.startsWith(localePrefix))
989
+ continue;
990
+ const tail = defaultPage.slug.slice(localePrefix.length);
991
+ for (const otherLocale of knownLocales) {
992
+ if (otherLocale === defaultLocale)
993
+ continue;
994
+ const targetSlug = `${otherLocale}/${tail}`;
995
+ const targetUrl = `${basePath}/${targetSlug}`;
996
+ if (existingByUrl.has(targetUrl))
997
+ continue; // already translated
998
+ const defaultUrl = `${basePath}/${defaultPage.slug}`;
999
+ const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
1000
+ // Ensure parent dir exists; write a redirect-stub Astro
1001
+ // file. Adding `.astro` to the leaf turns it into a
1002
+ // routable page.
1003
+ const dir = filePath.substring(0, filePath.lastIndexOf("/"));
1004
+ mkdirSync(dir, { recursive: true });
1005
+ writeFileSync(`${filePath}.astro`, buildMissingTranslationStub(defaultUrl, otherLocale, defaultLocale));
1006
+ emitted++;
1007
+ }
1008
+ }
1009
+ if (emitted > 0) {
1010
+ console.log(`Emitted ${emitted} missing-translation stub(s) → redirect to default locale.`);
1011
+ }
1012
+ }
1013
+ function buildMissingTranslationStub(defaultUrl, fromLocale, toLocale) {
1014
+ return [
1015
+ "---",
1016
+ "// AUTO-GENERATED missing-translation stub.",
1017
+ `// This page is not translated to "${fromLocale}"; redirects`,
1018
+ `// to the canonical version in "${toLocale}".`,
1019
+ `return Astro.redirect(${JSON.stringify(defaultUrl)}, 302);`,
1020
+ "---",
1021
+ "",
1022
+ ].join("\n");
1023
+ }
1024
+ // ─── Tier 1: agent-readiness ────────────────────────────────────────────
1025
+ // llms.txt index files, _headers, middleware.ts. Driven by both content
1026
+ // (page list, nav) and config (`llmsTxt`, `mdMirror`).
1027
+ /**
1028
+ * Emit agent-readiness files: `public/llms.txt` + `llms-full.txt` +
1029
+ * per-section indexes when `llmsTxt` is enabled; `public/_headers`
1030
+ * (Cloudflare Link header) alongside; and `src/middleware.ts` when
1031
+ * `mdMirror` is enabled.
1032
+ *
1033
+ * `.md` mirror endpoints themselves are emitted per-page inside
1034
+ * `emitAstroPages` (they share the page-loop's serialization step).
1035
+ */
1036
+ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options) {
1037
+ if (options.llmsTxt !== false) {
1038
+ emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
1039
+ // public/_headers — Cloudflare Workers / Pages convention. Adds an
1040
+ // RFC 8288 Link header pointing agents at /llms.txt without parsing
1041
+ // HTML. Emitted alongside llms.txt so the two files travel together.
1042
+ mkdirSync(join(outputDir, "public"), { recursive: true });
1043
+ writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile());
1044
+ }
1045
+ // src/middleware.ts — Tier 1 (always update). Drives both the
1046
+ // `Accept: text/markdown` content-negotiation rewrite (via
1047
+ // `@dogsbay/docs-layout/markdown-negotiation`) AND the
1048
+ // default-version redirect (via
1049
+ // `@dogsbay/docs-layout/version-redirect`).
1050
+ //
1051
+ // Emitted whenever EITHER feature is needed: md-mirror is on
1052
+ // OR the version axis has a defaultVersion set with ≥2
1053
+ // declared versions. When neither applies, no middleware is
1054
+ // emitted (keeps single-feature sites' src/ tidy).
1055
+ const mdMirrorOn = options.mdMirror !== false;
1056
+ const knownVersions = pageVersions(pages);
1057
+ const knownLocales = pageLocales(pages);
1058
+ const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2;
1059
+ const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
1060
+ const axisRedirectOn = versionRedirectOn || localeRedirectOn;
1061
+ if (mdMirrorOn || axisRedirectOn) {
1062
+ mkdirSync(join(outputDir, "src"), { recursive: true });
1063
+ writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
1064
+ mdMirror: mdMirrorOn,
1065
+ axisRedirect: axisRedirectOn
1066
+ ? {
1067
+ basePath: normalizeBasePath(options.basePath),
1068
+ ...(versionRedirectOn
1069
+ ? {
1070
+ defaultVersion: options.defaultVersion,
1071
+ knownVersions,
1072
+ }
1073
+ : {}),
1074
+ ...(localeRedirectOn
1075
+ ? {
1076
+ defaultLocale: options.defaultLocale,
1077
+ knownLocales,
1078
+ }
1079
+ : {}),
1080
+ }
1081
+ : undefined,
1082
+ }));
1083
+ }
1084
+ }
1085
+ /**
1086
+ * Distinct effective versions across the page set — derived from
1087
+ * the `multiSource.version` stamps on emitted pages. Used to
1088
+ * decide whether to emit the version-redirect middleware and
1089
+ * what version ids to inline in it.
1090
+ */
1091
+ function pageVersions(pages) {
1092
+ const seen = new Set();
1093
+ for (const page of pages) {
1094
+ const v = page.multiSource?.version;
1095
+ if (v)
1096
+ seen.add(v);
1097
+ }
1098
+ return [...seen];
1099
+ }
1100
+ /**
1101
+ * Distinct effective locales across the page set — same role as
1102
+ * pageVersions for the i18n axis.
1103
+ */
1104
+ function pageLocales(pages) {
1105
+ const seen = new Set();
1106
+ for (const page of pages) {
1107
+ const l = page.multiSource?.locale;
1108
+ if (l)
1109
+ seen.add(l);
1110
+ }
1111
+ return [...seen];
1112
+ }
1113
+ // ── Helpers ─────────────────────────────────────────────
1114
+ const LLMS_TXT_EXTERNAL_RE = /^(?:https?:\/\/|mailto:|tel:|#)/i;
1115
+ /**
1116
+ * Serialize a page to its canonical Dogsbay-MD representation —
1117
+ * frontmatter included, body normalized. Used for both the .md
1118
+ * mirror endpoints and the body summary in llms-full.txt.
1119
+ */
1120
+ function serializePageMd(page) {
1121
+ return treeToDogsbayMd(page.tree, {
1122
+ frontmatter: page.frontmatter,
1123
+ });
1124
+ }
1125
+ /**
1126
+ * Build the contents of `public/robots.txt`.
1127
+ *
1128
+ * In addition to the standard `User-agent` / `Allow` / `Sitemap`
1129
+ * lines, emits a `Content-Signal` directive declaring the site's
1130
+ * AI-permission posture (cloudflare.com/agent-readiness). Defaults
1131
+ * are `search=yes, ai-input=yes, ai-train=no` — agents may read
1132
+ * docs to answer user questions, but training corpus inclusion is
1133
+ * opt-in.
1134
+ */
1135
+ function buildRobotsTxt(options, hasSiteUrl) {
1136
+ const search = options.aiSearch ?? "yes";
1137
+ const aiInput = options.aiInput ?? "yes";
1138
+ const aiTrain = options.aiTrain ?? "no";
1139
+ const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
1140
+ const sitemap = hasSiteUrl
1141
+ ? `Sitemap: ${options.siteUrl.replace(/\/$/, "")}/sitemap-index.xml\n`
1142
+ : "";
1143
+ return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}`;
1144
+ }
1145
+ /**
1146
+ * Build the contents of `public/_headers` (Cloudflare Pages / Workers
1147
+ * Static Assets convention). Emits a global RFC 8288 Link header
1148
+ * pointing at the site's llms.txt index, so agents don't need to
1149
+ * parse HTML to discover the LLM-friendly content listing.
1150
+ */
1151
+ function buildHeadersFile() {
1152
+ return [
1153
+ "/*",
1154
+ ' Link: </llms.txt>; rel="describedby"; type="text/plain"',
1155
+ "",
1156
+ ].join("\n");
1157
+ }
1158
+ function buildMiddlewareSource(config) {
1159
+ const lines = [
1160
+ "// AUTO-GENERATED by `dogsbay site build` — do not edit.",
1161
+ "// Composes the docs-layout middleware helpers.",
1162
+ "//",
1163
+ "// Markdown content negotiation:",
1164
+ "// This middleware fires on every request, but in Astro's static",
1165
+ "// prerender mode (output: \"static\") request headers are NOT",
1166
+ "// forwarded — Astro warns about \"Astro.request.headers was used",
1167
+ "// when rendering...\" and serves a prerendered HTML response.",
1168
+ "// That means `Accept: text/markdown` negotiation only kicks in",
1169
+ "// under SSR (output: \"server\") or via an edge function on the",
1170
+ "// deployment layer (Cloudflare Worker, Netlify Edge, etc.).",
1171
+ "// For pure-static deploys, agents should follow the page's",
1172
+ "// <link rel=\"alternate\" type=\"text/markdown\"> href to fetch",
1173
+ "// the .md mirror directly (e.g. /docs.md).",
1174
+ "//",
1175
+ "// The Cloudflare-Worker-driven full fix is tracked in",
1176
+ "// plans/cloudflare-deploy-content-negotiation.md.",
1177
+ 'import { defineMiddleware } from "astro:middleware";',
1178
+ ];
1179
+ if (config.mdMirror) {
1180
+ lines.push('import { shouldRewriteToMarkdown } from "@dogsbay/docs-layout/markdown-negotiation";');
1181
+ }
1182
+ if (config.axisRedirect) {
1183
+ lines.push('import { shouldRedirectToDefaultVersion } from "@dogsbay/docs-layout/version-redirect";');
1184
+ }
1185
+ lines.push("");
1186
+ if (config.axisRedirect) {
1187
+ lines.push(`const AXIS_REDIRECT_CONFIG = ${JSON.stringify(config.axisRedirect, null, 2)};`, "");
1188
+ }
1189
+ lines.push("export const onRequest = defineMiddleware((context, next) => {");
1190
+ lines.push(" const url = new URL(context.request.url);");
1191
+ if (config.mdMirror) {
1192
+ lines.push(' const accept = context.request.headers.get("accept");', " const mdTarget = shouldRewriteToMarkdown(accept, url.pathname);", " if (mdTarget) return context.rewrite(mdTarget);");
1193
+ }
1194
+ if (config.axisRedirect) {
1195
+ lines.push(" const axisTarget = shouldRedirectToDefaultVersion(", " url.pathname,", " AXIS_REDIRECT_CONFIG,", " );", " if (axisTarget) {", " // 302 (not 301) — the version + locale switchers let readers", " // navigate away from defaults, so we don't want browsers", " // permanently caching the unprefixed URL as default content.", " return Response.redirect(new URL(axisTarget, url.origin), 302);", " }");
1196
+ }
1197
+ lines.push(" return next();", "});", "");
1198
+ return lines.join("\n");
1199
+ }
1200
+ /**
1201
+ * Build the contents of a `.md.ts` Astro endpoint for one page. The
1202
+ * markdown body is embedded as a string literal at convert time;
1203
+ * Astro prerenders the endpoint into a static `.md` file under `dist/`.
1204
+ */
1205
+ function buildMdEndpoint(page, sourceRel) {
1206
+ const body = serializePageMd(page);
1207
+ return [
1208
+ "// AUTO-GENERATED by `dogsbay convert` — do not edit.",
1209
+ `// Source: ${sourceRel}`,
1210
+ "// Edit the source markdown and re-run `dogsbay convert` to regenerate.",
1211
+ "export const prerender = true;",
1212
+ `const body = ${JSON.stringify(body)};`,
1213
+ "export const GET = () =>",
1214
+ " new Response(body, {",
1215
+ ' headers: { "Content-Type": "text/markdown; charset=utf-8" },',
1216
+ " });",
1217
+ "",
1218
+ ].join("\n");
1219
+ }
1220
+ /**
1221
+ * Emit `public/llms.txt`, `public/llms-full.txt`, and per-section
1222
+ * `public/<dir>/llms.txt` files for the site.
1223
+ *
1224
+ * Per-section files are written for every top-level nav group that
1225
+ * resolves to a site directory (either via `group.href` or via the
1226
+ * common prefix of its descendants' hrefs).
1227
+ */
1228
+ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
1229
+ const siteConfig = {
1230
+ siteName,
1231
+ description: options.description,
1232
+ siteUrl: options.siteUrl,
1233
+ };
1234
+ const publicDir = join(outputDir, "public");
1235
+ mkdirSync(publicDir, { recursive: true });
1236
+ const hrefPrefix = normalizeBasePath(options.basePath);
1237
+ writeFileSync(join(publicDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
1238
+ writeFileSync(join(publicDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
1239
+ summary: "body",
1240
+ serializePage: serializePageMd,
1241
+ hrefPrefix,
1242
+ }));
1243
+ for (const group of nav) {
1244
+ if (!group.children || group.children.length === 0)
1245
+ continue;
1246
+ const dir = deriveSectionDir(group);
1247
+ if (!dir)
1248
+ continue;
1249
+ const sectionPath = join(publicDir, dir, "llms.txt");
1250
+ mkdirSync(dirname(sectionPath), { recursive: true });
1251
+ writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
1252
+ }
1253
+ }
1254
+ /**
1255
+ * Pick a directory under `public/` for a top-level nav group. Prefers
1256
+ * the group's own href (already a `/docs/x/y` path); otherwise falls
1257
+ * back to the longest `/`-bounded common prefix of descendant hrefs.
1258
+ */
1259
+ function deriveSectionDir(group) {
1260
+ if (group.href && !LLMS_TXT_EXTERNAL_RE.test(group.href)) {
1261
+ return group.href.replace(/^\//, "").replace(/\/$/, "");
1262
+ }
1263
+ const hrefs = [];
1264
+ const collect = (items) => {
1265
+ if (!items)
1266
+ return;
1267
+ for (const it of items) {
1268
+ if (it.href && !LLMS_TXT_EXTERNAL_RE.test(it.href))
1269
+ hrefs.push(it.href);
1270
+ collect(it.children);
1271
+ }
1272
+ };
1273
+ collect(group.children);
1274
+ if (hrefs.length === 0)
1275
+ return null;
1276
+ const split = hrefs.map((h) => h.split("/").filter(Boolean));
1277
+ const common = [];
1278
+ const minLen = Math.min(...split.map((s) => s.length));
1279
+ for (let i = 0; i < minLen; i++) {
1280
+ const seg = split[0][i];
1281
+ if (split.every((s) => s[i] === seg))
1282
+ common.push(seg);
1283
+ else
1284
+ break;
1285
+ }
1286
+ // Single child means common == its full path; the directory is one up.
1287
+ if (hrefs.length === 1 && common.length > 0)
1288
+ common.pop();
1289
+ return common.length > 0 ? common.join("/") : null;
1290
+ }
1291
+ function slugify(name) {
1292
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1293
+ }
1294
+ function findFirstNavHref(items, fallback) {
1295
+ for (const item of items) {
1296
+ if (item.href)
1297
+ return item.href;
1298
+ if (item.children) {
1299
+ const found = findFirstNavHref(item.children, fallback);
1300
+ if (found !== fallback)
1301
+ return found;
1302
+ }
1303
+ }
1304
+ return fallback;
1305
+ }
1306
+ function copyComponents(outputDir) {
1307
+ const componentsSource = resolveComponentsSource();
1308
+ if (!componentsSource)
1309
+ return;
1310
+ // Components copied into the generated project. These are the ones
1311
+ // format-astro's serializer emits imports for, plus a few used by the
1312
+ // project shell (DocsLayout, pagination, theme-toggle).
1313
+ //
1314
+ // ContentRenderer is intentionally NOT in this list — format-astro is the
1315
+ // canonical TreeNode → Astro path and produces explicit component
1316
+ // imports per page, so the runtime ContentRenderer is unused dead weight
1317
+ // in static-mode exports. See packages/ui/src/content-renderer/
1318
+ // ContentRenderer.astro for the deprecation rationale.
1319
+ const needed = [
1320
+ "alert", "code-block", "code-rich", "tabs", "collapsible",
1321
+ "separator", "badge", "button", "pagination", "docs-footer",
1322
+ "theme-toggle", "table", "scroll-area", "youtube", "footnote",
1323
+ "image", "grid", "diagram", "steps", "card",
1324
+ "api-params", "api-symbol", "api-doc", "api-source",
1325
+ // OpenAPI rendering (used by the `endpoint` TreeNode case)
1326
+ "api-layout", "api-url-bar", "endpoint-card", "method-badge",
1327
+ "parameter-table", "property-item", "property-list",
1328
+ "response-tabs", "schema-viewer", "code-samples", "copy-button",
1329
+ "markdown-example",
1330
+ "accordion", "link-card", "avatar", "math",
1331
+ ];
1332
+ for (const name of needed) {
1333
+ const src = join(componentsSource, name);
1334
+ const dest = join(outputDir, "src", "components", "ui", name);
1335
+ if (existsSync(src) && !existsSync(dest)) {
1336
+ cpSync(src, dest, { recursive: true });
1337
+ }
1338
+ }
1339
+ }
1340
+ function copyAssets(sourceDir, outputDir, imageOptimization) {
1341
+ // sourceDir is already the docs dir (e.g. .../fastapi/docs/en/docs)
1342
+ const searchDir = sourceDir;
1343
+ // Raster images benefit from Astro optimization (WebP, dimensions)
1344
+ const optimizableExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
1345
+ // SVGs, icons, PDFs always go to public/ (no optimization needed)
1346
+ const passthroughExts = new Set([".svg", ".ico", ".pdf"]);
1347
+ function walk(dir) {
1348
+ for (const entry of readdirSync(dir)) {
1349
+ const full = join(dir, entry);
1350
+ if (statSync(full).isDirectory()) {
1351
+ walk(full);
1352
+ }
1353
+ else {
1354
+ const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
1355
+ if (optimizableExts.has(ext)) {
1356
+ const rel = relative(searchDir, full);
1357
+ // Always copy to public/ so inline <img src="/..."> works
1358
+ const pubDest = join(outputDir, "public", rel);
1359
+ mkdirSync(dirname(pubDest), { recursive: true });
1360
+ cpSync(full, pubDest);
1361
+ // Also copy to src/assets/ when optimization is enabled
1362
+ // so BlockImage can use Astro's <Image> component
1363
+ if (imageOptimization) {
1364
+ const assetDest = join(outputDir, "src", "assets", rel);
1365
+ mkdirSync(dirname(assetDest), { recursive: true });
1366
+ cpSync(full, assetDest);
1367
+ }
1368
+ }
1369
+ else if (passthroughExts.has(ext)) {
1370
+ // SVGs, icons, PDFs always go to public/
1371
+ const rel = relative(searchDir, full);
1372
+ const dest = join(outputDir, "public", rel);
1373
+ mkdirSync(dirname(dest), { recursive: true });
1374
+ cpSync(full, dest);
1375
+ }
1376
+ }
1377
+ }
1378
+ }
1379
+ try {
1380
+ walk(searchDir);
1381
+ }
1382
+ catch { /* source may not exist */ }
1383
+ }
1384
+ // ── CSS generation (ported from import-mkdocs.ts) ───────
1385
+ function generateGlobalCss() {
1386
+ return `@import "tailwindcss";
1387
+ @import "./theme.css";
1388
+
1389
+ /* Scan @dogsbay packages for Tailwind classes */
1390
+ @source "../../node_modules/@dogsbay/ui/src";
1391
+ @source "../../node_modules/@dogsbay/docs-layout/src";
1392
+
1393
+ /* Prose typography for rendered content */
1394
+ .docs-prose {
1395
+ line-height: 1.7;
1396
+
1397
+ & h1 { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; line-height: 1.2; letter-spacing: -0.025em; }
1398
+ & h2 { font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 0.75rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; letter-spacing: -0.015em; }
1399
+ & h3 { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
1400
+ & h4 { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
1401
+
1402
+ & p { margin-top: 0.75rem; margin-bottom: 0.75rem; }
1403
+
1404
+ & a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
1405
+ & a:hover { opacity: 0.8; }
1406
+
1407
+ & strong { font-weight: 600; }
1408
+ & code { font-size: 0.875em; padding: 0.15em 0.35em; border-radius: 0.25rem; background: var(--muted); font-family: var(--font-code, ui-monospace, monospace); }
1409
+ & pre code { padding: 0; background: none; font-size: 1em; }
1410
+
1411
+ /* Spacing between consecutive block elements (code blocks, alerts, etc.) */
1412
+ & > * + * { margin-top: 0.75rem; }
1413
+ & li > * + * { margin-top: 0.75rem; }
1414
+
1415
+ & ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
1416
+ & ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
1417
+ & li { margin: 0.25rem 0; }
1418
+ & li > ul, & li > ol { margin: 0.25rem 0; }
1419
+
1420
+ & blockquote { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
1421
+
1422
+ & hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
1423
+
1424
+ & table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
1425
+ & th { text-align: left; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
1426
+ & td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
1427
+
1428
+ & img { max-width: 100%; border-radius: 0.5rem; }
1429
+
1430
+ & .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
1431
+ & h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
1432
+
1433
+ & details { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
1434
+ & summary { cursor: pointer; font-weight: 600; }
1435
+
1436
+ & dl { margin: 1rem 0; }
1437
+ & dt { font-weight: 600; margin-top: 0.75rem; }
1438
+ & dd { margin-left: 1.5rem; color: var(--muted-foreground); }
1439
+ }
1440
+
1441
+ @utility scrollbar-none {
1442
+ -ms-overflow-style: none;
1443
+ scrollbar-width: none;
1444
+ &::-webkit-scrollbar {
1445
+ display: none;
1446
+ }
1447
+ }
1448
+ `;
1449
+ }
1450
+ // ── Theme presets ───────────────────────────────────────
1451
+ const THEME_DEFAULT = {
1452
+ light: `:root {
1453
+ --background: oklch(1 0 0);
1454
+ --foreground: oklch(0.145 0.014 285.823);
1455
+ --card: oklch(1 0 0);
1456
+ --card-foreground: oklch(0.145 0.014 285.823);
1457
+ --popover: oklch(1 0 0);
1458
+ --popover-foreground: oklch(0.145 0.014 285.823);
1459
+ --primary: oklch(0.205 0.014 285.823);
1460
+ --primary-foreground: oklch(0.985 0 0);
1461
+ --secondary: oklch(0.965 0.001 286.375);
1462
+ --secondary-foreground: oklch(0.205 0.014 285.823);
1463
+ --muted: oklch(0.965 0.001 286.375);
1464
+ --muted-foreground: oklch(0.45 0.014 285.938);
1465
+ --accent: oklch(0.965 0.001 286.375);
1466
+ --accent-foreground: oklch(0.205 0.014 285.823);
1467
+ --destructive: oklch(0.45 0.245 27.325);
1468
+ --destructive-foreground: oklch(0.985 0 0);
1469
+ --border: oklch(0.922 0.004 286.32);
1470
+ --input: oklch(0.922 0.004 286.32);
1471
+ --ring: oklch(0.708 0.014 285.823);
1472
+ --radius: 0.625rem;
1473
+ --note: oklch(0.637 0.174 259.126);
1474
+ --note-foreground: var(--foreground);
1475
+ --abstract: oklch(0.691 0.148 221.723);
1476
+ --info: oklch(0.722 0.128 207.084);
1477
+ --tip: oklch(0.731 0.112 178.627);
1478
+ --success: oklch(0.754 0.175 149.577);
1479
+ --question: oklch(0.809 0.19 126.526);
1480
+ --warning: oklch(0.74 0.175 62.778);
1481
+ --failure: oklch(0.614 0.194 21.602);
1482
+ --danger: oklch(0.577 0.245 16.439);
1483
+ --bug: oklch(0.548 0.258 349.761);
1484
+ --example: oklch(0.534 0.222 286.033);
1485
+ --quote: oklch(0.65 0 0);
1486
+ --sidebar: oklch(0.985 0 0);
1487
+ --sidebar-foreground: oklch(0.145 0.014 285.823);
1488
+ --sidebar-primary: oklch(0.205 0.014 285.823);
1489
+ --sidebar-primary-foreground: oklch(0.985 0 0);
1490
+ --sidebar-accent: oklch(0.965 0.001 286.375);
1491
+ --sidebar-accent-foreground: oklch(0.205 0.014 285.823);
1492
+ --sidebar-border: oklch(0.922 0.004 286.32);
1493
+ --sidebar-ring: oklch(0.708 0.014 285.823);
1494
+ --code-background: oklch(0.965 0.004 264);
1495
+ --code-foreground: oklch(0.3 0.014 253.833);
1496
+
1497
+ /* API reference — always-dark code panel */
1498
+ --api-panel-bg: oklch(0.145 0.014 253.833);
1499
+ --api-panel-fg: oklch(0.926 0.013 253.833);
1500
+
1501
+ /* API semantic colors — HTTP methods, types, status codes */
1502
+ --api-get: oklch(0.723 0.15 162);
1503
+ --api-post: oklch(0.637 0.174 259);
1504
+ --api-put: oklch(0.74 0.175 63);
1505
+ --api-patch: oklch(0.7 0.18 45);
1506
+ --api-delete: oklch(0.614 0.194 22);
1507
+ --api-head: oklch(0.534 0.222 286);
1508
+ --api-required: oklch(0.614 0.194 22);
1509
+ --api-deprecated: oklch(0.74 0.175 63);
1510
+ --api-type-string: oklch(0.723 0.15 162);
1511
+ --api-type-number: oklch(0.637 0.174 259);
1512
+ --api-type-boolean: oklch(0.74 0.175 63);
1513
+ --api-type-object: oklch(0.534 0.222 286);
1514
+ --api-type-array: oklch(0.7 0.18 45);
1515
+ --api-status-2xx: oklch(0.723 0.15 162);
1516
+ --api-status-3xx: oklch(0.637 0.174 259);
1517
+ --api-status-4xx: oklch(0.74 0.175 63);
1518
+ --api-status-5xx: oklch(0.614 0.194 22);
1519
+
1520
+ --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1521
+ --font-heading: var(--font-sans);
1522
+ --font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
1523
+ }`,
1524
+ dark: `.dark {
1525
+ --background: oklch(0.145 0.014 285.823);
1526
+ --foreground: oklch(0.985 0 0);
1527
+ --card: oklch(0.145 0.014 285.823);
1528
+ --card-foreground: oklch(0.985 0 0);
1529
+ --popover: oklch(0.145 0.014 285.823);
1530
+ --popover-foreground: oklch(0.985 0 0);
1531
+ --primary: oklch(0.985 0 0);
1532
+ --primary-foreground: oklch(0.205 0.014 285.823);
1533
+ --secondary: oklch(0.269 0.007 286.033);
1534
+ --secondary-foreground: oklch(0.985 0 0);
1535
+ --muted: oklch(0.269 0.007 286.033);
1536
+ --muted-foreground: oklch(0.708 0.014 285.823);
1537
+ --accent: oklch(0.269 0.007 286.033);
1538
+ --accent-foreground: oklch(0.985 0 0);
1539
+ --destructive: oklch(0.704 0.191 22.216);
1540
+ --destructive-foreground: oklch(0.145 0.014 285.823);
1541
+ --border: oklch(0.269 0.007 286.033);
1542
+ --input: oklch(0.269 0.007 286.033);
1543
+ --ring: oklch(0.439 0.01 286.375);
1544
+ --note-foreground: var(--foreground);
1545
+ --sidebar: oklch(0.205 0.014 285.823);
1546
+ --sidebar-foreground: oklch(0.985 0 0);
1547
+ --sidebar-primary: oklch(0.488 0.243 264.376);
1548
+ --sidebar-primary-foreground: oklch(0.985 0 0);
1549
+ --sidebar-accent: oklch(0.269 0.007 286.033);
1550
+ --sidebar-accent-foreground: oklch(0.985 0 0);
1551
+ --sidebar-border: oklch(0.269 0.007 286.033);
1552
+ --sidebar-ring: oklch(0.439 0.01 286.375);
1553
+ --code-background: oklch(0.18 0.014 253.833);
1554
+ --code-foreground: oklch(0.926 0.013 253.833);
1555
+ }`,
1556
+ };
1557
+ /**
1558
+ * Mintlify-inspired theme — clean, neutral palette matching modern doc sites.
1559
+ *
1560
+ * Extracted from Claude Code docs (Mintlify):
1561
+ * - White background, near-black text
1562
+ * - Serif display font for H1, sans-serif body
1563
+ * - Soft colored callout backgrounds (blue note, green tip, amber warning)
1564
+ * - Rounded callout borders (16px radius)
1565
+ * - Light gray code background, JetBrains Mono
1566
+ */
1567
+ const THEME_MINTLIFY = {
1568
+ light: `:root {
1569
+ --background: oklch(0.993 0.005 95);
1570
+ --foreground: oklch(0.15 0.01 260);
1571
+ --card: oklch(0.993 0.005 95);
1572
+ --card-foreground: oklch(0.15 0.01 260);
1573
+ --popover: oklch(0.993 0.005 95);
1574
+ --popover-foreground: oklch(0.15 0.01 260);
1575
+ --primary: oklch(0.15 0.01 260);
1576
+ --primary-foreground: oklch(0.985 0 0);
1577
+ --secondary: oklch(0.97 0.002 260);
1578
+ --secondary-foreground: oklch(0.15 0.01 260);
1579
+ --muted: oklch(0.97 0.002 260);
1580
+ --muted-foreground: oklch(0.45 0.01 260);
1581
+ --accent: oklch(0.97 0.002 260);
1582
+ --accent-foreground: oklch(0.15 0.01 260);
1583
+ --destructive: oklch(0.55 0.22 25);
1584
+ --destructive-foreground: oklch(0.985 0 0);
1585
+ --border: oklch(0.91 0.004 260);
1586
+ --input: oklch(0.91 0.004 260);
1587
+ --ring: oklch(0.7 0.01 260);
1588
+ --radius: 1rem;
1589
+ /* Callout colors — soft pastel backgrounds matching Mintlify */
1590
+ --note: oklch(0.62 0.18 260);
1591
+ --note-foreground: var(--foreground);
1592
+ --abstract: oklch(0.65 0.14 220);
1593
+ --info: oklch(0.65 0.14 230);
1594
+ --tip: oklch(0.65 0.16 155);
1595
+ --success: oklch(0.65 0.16 155);
1596
+ --question: oklch(0.7 0.15 270);
1597
+ --warning: oklch(0.7 0.15 75);
1598
+ --failure: oklch(0.6 0.2 25);
1599
+ --danger: oklch(0.55 0.22 25);
1600
+ --bug: oklch(0.55 0.22 350);
1601
+ --example: oklch(0.55 0.2 285);
1602
+ --quote: oklch(0.55 0 0);
1603
+ /* Sidebar */
1604
+ --sidebar: oklch(0.993 0.005 95);
1605
+ --sidebar-foreground: oklch(0.15 0.01 260);
1606
+ --sidebar-primary: oklch(0.15 0.01 260);
1607
+ --sidebar-primary-foreground: oklch(0.985 0 0);
1608
+ --sidebar-accent: oklch(0.97 0.002 260);
1609
+ --sidebar-accent-foreground: oklch(0.15 0.01 260);
1610
+ --sidebar-border: oklch(0.93 0.004 260);
1611
+ --sidebar-ring: oklch(0.7 0.01 260);
1612
+ /* Code — light gray background, matching Mintlify's inline code */
1613
+ --code-background: oklch(0.96 0.003 260);
1614
+ --code-foreground: oklch(0.25 0.01 260);
1615
+
1616
+ /* API reference — always-dark code panel */
1617
+ --api-panel-bg: oklch(0.145 0.014 253.833);
1618
+ --api-panel-fg: oklch(0.926 0.013 253.833);
1619
+
1620
+ /* API semantic colors — HTTP methods, types, status codes */
1621
+ --api-get: oklch(0.723 0.15 162);
1622
+ --api-post: oklch(0.637 0.174 259);
1623
+ --api-put: oklch(0.74 0.175 63);
1624
+ --api-patch: oklch(0.7 0.18 45);
1625
+ --api-delete: oklch(0.614 0.194 22);
1626
+ --api-head: oklch(0.534 0.222 286);
1627
+ --api-required: oklch(0.614 0.194 22);
1628
+ --api-deprecated: oklch(0.74 0.175 63);
1629
+ --api-type-string: oklch(0.723 0.15 162);
1630
+ --api-type-number: oklch(0.637 0.174 259);
1631
+ --api-type-boolean: oklch(0.74 0.175 63);
1632
+ --api-type-object: oklch(0.534 0.222 286);
1633
+ --api-type-array: oklch(0.7 0.18 45);
1634
+ --api-status-2xx: oklch(0.723 0.15 162);
1635
+ --api-status-3xx: oklch(0.637 0.174 259);
1636
+ --api-status-4xx: oklch(0.74 0.175 63);
1637
+ --api-status-5xx: oklch(0.614 0.194 22);
1638
+
1639
+ /* Fonts — system sans for body, JetBrains Mono for code */
1640
+ --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
1641
+ --font-heading: var(--font-sans);
1642
+ --font-code: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1643
+ }`,
1644
+ dark: `.dark {
1645
+ --background: oklch(0.16 0.01 260);
1646
+ --foreground: oklch(0.93 0.005 260);
1647
+ --card: oklch(0.18 0.01 260);
1648
+ --card-foreground: oklch(0.93 0.005 260);
1649
+ --popover: oklch(0.18 0.01 260);
1650
+ --popover-foreground: oklch(0.93 0.005 260);
1651
+ --primary: oklch(0.93 0.005 260);
1652
+ --primary-foreground: oklch(0.16 0.01 260);
1653
+ --secondary: oklch(0.22 0.008 260);
1654
+ --secondary-foreground: oklch(0.93 0.005 260);
1655
+ --muted: oklch(0.22 0.008 260);
1656
+ --muted-foreground: oklch(0.65 0.01 260);
1657
+ --accent: oklch(0.22 0.008 260);
1658
+ --accent-foreground: oklch(0.93 0.005 260);
1659
+ --destructive: oklch(0.65 0.2 25);
1660
+ --destructive-foreground: oklch(0.16 0.01 260);
1661
+ --border: oklch(0.28 0.008 260);
1662
+ --input: oklch(0.28 0.008 260);
1663
+ --ring: oklch(0.45 0.01 260);
1664
+ --note-foreground: var(--foreground);
1665
+ --sidebar: oklch(0.16 0.01 260);
1666
+ --sidebar-foreground: oklch(0.93 0.005 260);
1667
+ --sidebar-primary: oklch(0.55 0.2 260);
1668
+ --sidebar-primary-foreground: oklch(0.93 0.005 260);
1669
+ --sidebar-accent: oklch(0.22 0.008 260);
1670
+ --sidebar-accent-foreground: oklch(0.93 0.005 260);
1671
+ --sidebar-border: oklch(0.28 0.008 260);
1672
+ --sidebar-ring: oklch(0.45 0.01 260);
1673
+ --code-background: oklch(0.2 0.01 260);
1674
+ --code-foreground: oklch(0.9 0.01 260);
1675
+ }`,
1676
+ };
1677
+ function writeThemeFile(path, themeName) {
1678
+ const tokens = themeName === "mintlify" ? THEME_MINTLIFY : THEME_DEFAULT;
1679
+ writeFileSync(path, `@custom-variant dark (&:where(.dark, .dark *));
1680
+
1681
+ @theme inline {
1682
+ --color-background: var(--background);
1683
+ --color-foreground: var(--foreground);
1684
+ --color-card: var(--card);
1685
+ --color-card-foreground: var(--card-foreground);
1686
+ --color-popover: var(--popover);
1687
+ --color-popover-foreground: var(--popover-foreground);
1688
+ --color-primary: var(--primary);
1689
+ --color-primary-foreground: var(--primary-foreground);
1690
+ --color-secondary: var(--secondary);
1691
+ --color-secondary-foreground: var(--secondary-foreground);
1692
+ --color-muted: var(--muted);
1693
+ --color-muted-foreground: var(--muted-foreground);
1694
+ --color-accent: var(--accent);
1695
+ --color-accent-foreground: var(--accent-foreground);
1696
+ --color-destructive: var(--destructive);
1697
+ --color-destructive-foreground: var(--destructive-foreground);
1698
+ --color-border: var(--border);
1699
+ --color-input: var(--input);
1700
+ --color-ring: var(--ring);
1701
+ --radius-sm: calc(var(--radius) - 4px);
1702
+ --radius-md: calc(var(--radius) - 2px);
1703
+ --radius-lg: var(--radius);
1704
+ --radius-xl: calc(var(--radius) + 4px);
1705
+
1706
+ /* Sidebar */
1707
+ --color-sidebar: var(--sidebar);
1708
+ --color-sidebar-foreground: var(--sidebar-foreground);
1709
+ --color-sidebar-primary: var(--sidebar-primary);
1710
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
1711
+ --color-sidebar-accent: var(--sidebar-accent);
1712
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
1713
+ --color-sidebar-border: var(--sidebar-border);
1714
+ --color-sidebar-ring: var(--sidebar-ring);
1715
+
1716
+ /* Admonition colors */
1717
+ --color-note: var(--note);
1718
+ --color-note-foreground: var(--note-foreground);
1719
+ --color-abstract: var(--abstract);
1720
+ --color-info: var(--info);
1721
+ --color-tip: var(--tip);
1722
+ --color-success: var(--success);
1723
+ --color-question: var(--question);
1724
+ --color-warning: var(--warning);
1725
+ --color-failure: var(--failure);
1726
+ --color-danger: var(--danger);
1727
+ --color-bug: var(--bug);
1728
+ --color-example: var(--example);
1729
+ --color-quote: var(--quote);
1730
+
1731
+ /* Code */
1732
+ --color-code-bg: var(--code-background);
1733
+ --color-code-fg: var(--code-foreground);
1734
+
1735
+ /* API reference — HTTP method, type, status, role colors */
1736
+ --color-api-get: var(--api-get);
1737
+ --color-api-post: var(--api-post);
1738
+ --color-api-put: var(--api-put);
1739
+ --color-api-patch: var(--api-patch);
1740
+ --color-api-delete: var(--api-delete);
1741
+ --color-api-head: var(--api-head);
1742
+ --color-api-required: var(--api-required);
1743
+ --color-api-deprecated: var(--api-deprecated);
1744
+ --color-api-type-string: var(--api-type-string);
1745
+ --color-api-type-number: var(--api-type-number);
1746
+ --color-api-type-boolean: var(--api-type-boolean);
1747
+ --color-api-type-object: var(--api-type-object);
1748
+ --color-api-type-array: var(--api-type-array);
1749
+ --color-api-status-2xx: var(--api-status-2xx);
1750
+ --color-api-status-3xx: var(--api-status-3xx);
1751
+ --color-api-status-4xx: var(--api-status-4xx);
1752
+ --color-api-status-5xx: var(--api-status-5xx);
1753
+
1754
+ /* Fonts */
1755
+ --font-sans: var(--font-sans);
1756
+ --font-mono: var(--font-code);
1757
+ --font-family-code: var(--font-code);
1758
+ }
1759
+
1760
+ ${tokens.light}
1761
+
1762
+ ${tokens.dark}
1763
+
1764
+ @layer base {
1765
+ * { @apply border-border outline-ring/50; }
1766
+ body { @apply bg-background text-foreground font-sans; }
1767
+ }
1768
+
1769
+ /* Shiki dual-theme: light by default, dark when .dark class is active */
1770
+ .shiki,
1771
+ .shiki span,
1772
+ .astro-code,
1773
+ .astro-code span {
1774
+ color: var(--shiki-light) !important;
1775
+ }
1776
+ .shiki,
1777
+ .astro-code {
1778
+ background-color: var(--shiki-light-bg) !important;
1779
+ }
1780
+ .dark .shiki,
1781
+ .dark .shiki span,
1782
+ .dark .astro-code,
1783
+ .dark .astro-code span {
1784
+ color: var(--shiki-dark) !important;
1785
+ }
1786
+ .dark .shiki,
1787
+ .dark .astro-code {
1788
+ background-color: var(--shiki-dark-bg) !important;
1789
+ }
1790
+ /* Fix comment color contrast for WCAG AA */
1791
+ .shiki span[style*="--shiki-light:#6E7781"],
1792
+ .astro-code span[style*="--shiki-light:#6E7781"] {
1793
+ --shiki-light: #576069 !important;
1794
+ }
1795
+ `);
1796
+ }
1797
+ // ── Resolution helpers ──────────────────────────────────
1798
+ /**
1799
+ * Detect whether outputDir is inside a pnpm/npm workspace.
1800
+ * Walks up looking for a pnpm-workspace.yaml or a package.json with "workspaces".
1801
+ */
1802
+ function isInsideWorkspace(outputDir) {
1803
+ let dir = resolve(outputDir);
1804
+ for (let depth = 0; depth < 10; depth++) {
1805
+ if (existsSync(join(dir, "pnpm-workspace.yaml")))
1806
+ return true;
1807
+ const pkgPath = join(dir, "package.json");
1808
+ if (existsSync(pkgPath)) {
1809
+ try {
1810
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1811
+ if (pkg.workspaces)
1812
+ return true;
1813
+ }
1814
+ catch {
1815
+ // ignore invalid JSON
1816
+ }
1817
+ }
1818
+ const parent = dirname(dir);
1819
+ if (parent === dir)
1820
+ break;
1821
+ dir = parent;
1822
+ }
1823
+ return false;
1824
+ }
1825
+ function resolveMonorepoPkg(name) {
1826
+ const thisDir = dirname(fileURLToPath(import.meta.url));
1827
+ // From packages/format-astro/src/ → ../../{name}
1828
+ // From packages/format-astro/dist/ → ../../{name}
1829
+ const sibling = resolve(thisDir, "..", "..", name);
1830
+ if (existsSync(join(sibling, "package.json")))
1831
+ return sibling;
1832
+ // Env override
1833
+ if (process.env.DOGSBAY_PACKAGES_DIR) {
1834
+ const envDir = join(process.env.DOGSBAY_PACKAGES_DIR, name);
1835
+ if (existsSync(join(envDir, "package.json")))
1836
+ return envDir;
1837
+ }
1838
+ // Fallback: relative path (may not work outside monorepo)
1839
+ return resolve(thisDir, "..", "..", name);
1840
+ }
1841
+ function resolveComponentsSource() {
1842
+ if (process.env.DOGSBAY_COMPONENTS_DIR) {
1843
+ const envDir = process.env.DOGSBAY_COMPONENTS_DIR;
1844
+ if (existsSync(envDir))
1845
+ return envDir;
1846
+ }
1847
+ const thisDir = dirname(fileURLToPath(import.meta.url));
1848
+ // Monorepo: packages/format-astro/src/ or dist/ → packages/ui/src/
1849
+ const uiPkg = resolve(thisDir, "..", "..", "ui", "src");
1850
+ if (existsSync(uiPkg))
1851
+ return uiPkg;
1852
+ // Fallback: demo app
1853
+ const demo = resolve(thisDir, "..", "..", "..", "apps", "demo", "src", "components", "ui");
1854
+ if (existsSync(demo))
1855
+ return demo;
1856
+ return null;
1857
+ }
1858
+ //# sourceMappingURL=project.js.map