@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.
- package/dist/base-path.d.ts +83 -0
- package/dist/base-path.d.ts.map +1 -0
- package/dist/base-path.js +110 -0
- package/dist/base-path.js.map +1 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +53 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/lead.d.ts +39 -0
- package/dist/lead.d.ts.map +1 -0
- package/dist/lead.js +38 -0
- package/dist/lead.js.map +1 -0
- package/dist/llms-txt.d.ts +81 -0
- package/dist/llms-txt.d.ts.map +1 -0
- package/dist/llms-txt.js +288 -0
- package/dist/llms-txt.js.map +1 -0
- package/dist/plugins.d.ts +40 -0
- package/dist/plugins.d.ts.map +1 -0
- package/dist/plugins.js +339 -0
- package/dist/plugins.js.map +1 -0
- package/dist/project.d.ts +320 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +1858 -0
- package/dist/project.js.map +1 -0
- package/dist/serialize.d.ts +30 -0
- package/dist/serialize.d.ts.map +1 -0
- package/dist/serialize.js +1197 -0
- package/dist/serialize.js.map +1 -0
- package/dist/taxonomy.d.ts +87 -0
- package/dist/taxonomy.d.ts.map +1 -0
- package/dist/taxonomy.js +467 -0
- package/dist/taxonomy.js.map +1 -0
- package/package.json +47 -0
package/dist/project.js
ADDED
|
@@ -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
|