@dogsbay/format-astro 0.2.0-beta.16 → 0.2.0-beta.161
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/api-markdown.d.ts +7 -0
- package/dist/api-markdown.js +49 -0
- package/dist/base-path.d.ts +93 -8
- package/dist/base-path.js +122 -9
- package/dist/blog.d.ts +133 -0
- package/dist/blog.js +325 -0
- package/dist/browser.d.ts +13 -0
- package/dist/browser.js +13 -0
- package/dist/canonical.d.ts +35 -0
- package/dist/canonical.js +110 -0
- package/dist/capabilities.d.ts +28 -0
- package/dist/capabilities.js +94 -0
- package/dist/cli.d.ts +0 -1
- package/dist/cli.js +3 -1
- package/dist/diff-decoration.d.ts +35 -0
- package/dist/diff-decoration.js +101 -0
- package/dist/granularity.d.ts +82 -0
- package/dist/granularity.js +246 -0
- package/dist/head-metadata.d.ts +25 -0
- package/dist/head-metadata.js +55 -0
- package/dist/index.d.ts +26 -6
- package/dist/index.js +34 -5
- package/dist/lead.d.ts +1 -39
- package/dist/lead.js +3 -38
- package/dist/llms-txt.d.ts +47 -11
- package/dist/llms-txt.js +83 -275
- package/dist/plugins.d.ts +0 -1
- package/dist/plugins.js +4 -3
- package/dist/project.d.ts +502 -13
- package/dist/project.js +2506 -697
- package/dist/redirects.d.ts +117 -0
- package/dist/redirects.js +223 -0
- package/dist/serialize.d.ts +14 -14
- package/dist/serialize.js +908 -270
- package/dist/site-chrome.d.ts +62 -0
- package/dist/site-chrome.js +56 -0
- package/dist/sitemap.d.ts +62 -4
- package/dist/sitemap.js +63 -56
- package/dist/taxonomy.d.ts +48 -2
- package/dist/taxonomy.js +50 -12
- package/dist/theme-css.d.ts +33 -0
- package/dist/theme-css.js +686 -0
- package/dist/tone-classes.d.ts +52 -0
- package/dist/tone-classes.js +84 -0
- package/dist/write-if-changed.d.ts +20 -0
- package/dist/write-if-changed.js +57 -0
- package/package.json +17 -7
- package/dist/base-path.d.ts.map +0 -1
- package/dist/base-path.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/lead.d.ts.map +0 -1
- package/dist/lead.js.map +0 -1
- package/dist/llms-txt.d.ts.map +0 -1
- package/dist/llms-txt.js.map +0 -1
- package/dist/plugins.d.ts.map +0 -1
- package/dist/plugins.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/serialize.d.ts.map +0 -1
- package/dist/serialize.js.map +0 -1
- package/dist/sitemap.d.ts.map +0 -1
- package/dist/sitemap.js.map +0 -1
- package/dist/taxonomy.d.ts.map +0 -1
- package/dist/taxonomy.js.map +0 -1
package/dist/project.js
CHANGED
|
@@ -4,15 +4,64 @@
|
|
|
4
4
|
* Takes ExportPage[] + NavItem[] and generates a complete Astro project
|
|
5
5
|
* with static .astro pages using real Dogsbay components.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, mkdirSync,
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, cpSync, readdirSync, rmSync, rmdirSync, openSync, readSync, closeSync, lstatSync } from "node:fs";
|
|
8
|
+
// Unchanged files are not rewritten, so `site dev` reloads only what changed
|
|
9
|
+
import { writeFileSync, copyFileSync } from "./write-if-changed.js";
|
|
8
10
|
import { join, dirname, relative, resolve } from "node:path";
|
|
9
11
|
import { fileURLToPath } from "node:url";
|
|
10
12
|
import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
|
|
11
|
-
import { treeToAstro
|
|
13
|
+
import { treeToAstro } from "./serialize.js";
|
|
14
|
+
import { apiPageLayout } from "@dogsbay/serialize-core";
|
|
15
|
+
import { missingTranslations, rewriteTreeLinks } from "@dogsbay/site-files";
|
|
16
|
+
import { sanitizeTree } from "@dogsbay/render-html/sanitize";
|
|
17
|
+
import { generateGlobalCss, themeCss } from "./theme-css.js";
|
|
18
|
+
/**
|
|
19
|
+
* A value as a JavaScript literal in a generated page. `<` becomes \u003c: Astro reads
|
|
20
|
+
* a page's frontmatter and template looking for markup, so a `<script>` inside a string
|
|
21
|
+
* (a page's own Markdown, a code block showing one) would be taken for a real script tag
|
|
22
|
+
* and its text compiled as JavaScript, which fails Vite's dependency scan.
|
|
23
|
+
*/
|
|
24
|
+
function pageLiteral(value, indent) {
|
|
25
|
+
return (JSON.stringify(value, null, indent) ?? "undefined").replace(/</g, "\\u003c");
|
|
26
|
+
}
|
|
27
|
+
import { emitPluginRuntime } from "./plugins.js";
|
|
12
28
|
import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
29
|
+
import { buildSitemapFiles, buildSitemapIndex, pageHref } from "./sitemap.js";
|
|
30
|
+
import { canonicalPlan } from "./canonical.js";
|
|
31
|
+
import { headMetadata } from "./head-metadata.js";
|
|
32
|
+
import { buildHeadersFile, buildRobotsTxt as buildRobotsTxtFile, pageFacets } from "@dogsbay/site-files";
|
|
33
|
+
import { mergeHeaders, mergeRedirects, productRootRedirects, siteFiles, siteRootTarget, switcher as productSwitcherRows, productSwitcher, isVersionMenuHref, versionMenuHref, toPagesScript, toScript, unitKeyString, unitManifest, unitOf, } from "@dogsbay/site-files";
|
|
34
|
+
import { assembleBook, estimateTreeBytes } from "./granularity.js";
|
|
35
|
+
/**
|
|
36
|
+
* The per-topic "Read as single page" affordance (granularity book view).
|
|
37
|
+
* Returns `[]` when the topic has no book (granularity off / not in a group).
|
|
38
|
+
* `href` is a build-time-known route (`<bookRoute>#<anchor>`).
|
|
39
|
+
*/
|
|
40
|
+
function singlePageLinkLines(href, indent) {
|
|
41
|
+
if (!href)
|
|
42
|
+
return [];
|
|
43
|
+
const pad = " ".repeat(indent);
|
|
44
|
+
return [`${pad}<a class="dsb-read-single-page not-prose" href="${href}">Read as single page →</a>`];
|
|
45
|
+
}
|
|
46
|
+
import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath, parseSiteUrl, combinePrefix, } from "./base-path.js";
|
|
47
|
+
import { buildBlogData, blogAdjacency } from "./blog.js";
|
|
48
|
+
/**
|
|
49
|
+
* Combined URL prefix = urlBase (Astro `base` from site.url path) +
|
|
50
|
+
* basePath (filesystem layout prefix). Every URL emitter (nav,
|
|
51
|
+
* sitemap, llms.txt, .md mirror, _headers, taxonomy) uses this for
|
|
52
|
+
* href output. Filesystem-layout consumers (mkdir, page output
|
|
53
|
+
* paths) keep using basePath alone — Astro's `base` config adds the
|
|
54
|
+
* urlBase prefix at route time.
|
|
55
|
+
*
|
|
56
|
+
* See plans/astro-base-from-site-url.md.
|
|
57
|
+
*/
|
|
58
|
+
function combinedPrefix(options) {
|
|
59
|
+
const { urlBase } = parseSiteUrl(options.siteUrl);
|
|
60
|
+
return combinePrefix(urlBase, normalizeBasePath(options.basePath));
|
|
61
|
+
}
|
|
62
|
+
import { detectLeadingNodes, deriveDescription } from "./lead.js";
|
|
63
|
+
import { chromeData, copyrightHtml } from "./site-chrome.js";
|
|
64
|
+
import { collectRedirects, renderMetaRefreshPage, renderRedirectsFile, stubPathForRule, } from "./redirects.js";
|
|
16
65
|
/**
|
|
17
66
|
* Recursively prefix all hrefs in a nav tree.
|
|
18
67
|
* Turns `<basePath>/foo` into `<basePath>/{section}/foo`. The
|
|
@@ -50,6 +99,39 @@ function prefixNavHrefs(items, section, basePath) {
|
|
|
50
99
|
function escapeRegex(s) {
|
|
51
100
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
52
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* Prefix the combined URL base onto root-absolute nav hrefs.
|
|
104
|
+
*
|
|
105
|
+
* Most importers emit nav hrefs already carrying the combined prefix —
|
|
106
|
+
* the dogsbay-md importer walks source paths through
|
|
107
|
+
* `fileToHref(file, hrefPrefix=combined)`, so OpenShift's `nav.yml`
|
|
108
|
+
* (`welcome/foo.md`) resolves straight to `/<base>/welcome/foo`. But an
|
|
109
|
+
* importer that produces a nav.json of *pre-resolved* hrefs at a
|
|
110
|
+
* different base — e.g. `dogsbay convert --from docusaurus --to
|
|
111
|
+
* dogsbay-md`, whose hrefs are root-absolute (`/about/foo`) — would
|
|
112
|
+
* otherwise emit unprefixed nav links that 404 under a site base, and
|
|
113
|
+
* break prev/next (pagination matches against the combined-prefixed
|
|
114
|
+
* `currentPath`).
|
|
115
|
+
*
|
|
116
|
+
* Running every nav href through `rewriteHref` here is the single
|
|
117
|
+
* chokepoint that makes nav base-correct for ALL importers. It's
|
|
118
|
+
* idempotent — hrefs already starting with the prefix (or external /
|
|
119
|
+
* anchor / protocol-relative) are left untouched — so importer-side
|
|
120
|
+
* prefixing keeps working unchanged, and an empty prefix is a no-op.
|
|
121
|
+
*/
|
|
122
|
+
function prefixNavBaseHrefs(items, prefix) {
|
|
123
|
+
if (!prefix)
|
|
124
|
+
return items;
|
|
125
|
+
return items.map((item) => {
|
|
126
|
+
const result = { ...item };
|
|
127
|
+
if (result.href)
|
|
128
|
+
result.href = rewriteHref(result.href, prefix);
|
|
129
|
+
if (result.children) {
|
|
130
|
+
result.children = prefixNavBaseHrefs(result.children, prefix);
|
|
131
|
+
}
|
|
132
|
+
return result;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
53
135
|
/**
|
|
54
136
|
* Rewrite internal hrefs in a page's tree nodes.
|
|
55
137
|
* Prepends `prefix` to root-relative links (e.g. `/workers/...` → `/docs/workers/...`).
|
|
@@ -61,8 +143,8 @@ function rewriteTreeHrefs(nodes, prefix) {
|
|
|
61
143
|
if (node.inline) {
|
|
62
144
|
rewriteInlineHrefs(node.inline, prefix);
|
|
63
145
|
}
|
|
64
|
-
// Rewrite hrefs in pre-rendered HTML
|
|
65
|
-
if (node.html) {
|
|
146
|
+
// Rewrite hrefs in pre-rendered HTML; a code block's `html` is its code, shown as written
|
|
147
|
+
if (node.html && node.type !== "code" && node.type !== "math-block") {
|
|
66
148
|
node.html = rewriteHtmlHrefs(node.html, prefix);
|
|
67
149
|
}
|
|
68
150
|
// Rewrite href in props (e.g. card, link-button)
|
|
@@ -98,6 +180,211 @@ function rewriteHref(href, prefix) {
|
|
|
98
180
|
return href;
|
|
99
181
|
return prefix + href;
|
|
100
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* Rewrite image srcs in inline nodes + raw HTML to include the
|
|
185
|
+
* combined URL prefix.
|
|
186
|
+
*
|
|
187
|
+
* Astro auto-prefixes `<a href>` and image imports going through
|
|
188
|
+
* `<AstroImage>`, but raw `<img src="...">` HTML in template
|
|
189
|
+
* output is left untouched. The serializer emits raw `<img>` for
|
|
190
|
+
* inline images and falls back to it for non-optimized block
|
|
191
|
+
* images, so we have to prefix manually before serialization to
|
|
192
|
+
* make `/_assets/...` paths resolve under subpath-mounted deploys
|
|
193
|
+
* (GH Pages project pages, multi-mount Cloudflare).
|
|
194
|
+
*
|
|
195
|
+
* Symmetric with rewriteTreeHrefs — same skip-rules (external,
|
|
196
|
+
* anchors, already-prefixed). Block images keep their prefix
|
|
197
|
+
* stripped back off for the `imageMap[...]` lookup key (see
|
|
198
|
+
* paragraphToAstro in serialize.ts) so Astro's image optimization
|
|
199
|
+
* still finds the source.
|
|
200
|
+
*/
|
|
201
|
+
function rewriteTreeImageSrcs(nodes, prefix) {
|
|
202
|
+
for (const node of nodes) {
|
|
203
|
+
if (node.inline) {
|
|
204
|
+
rewriteInlineImageSrcs(node.inline, prefix);
|
|
205
|
+
}
|
|
206
|
+
// a code block's `html` is its code, shown as written
|
|
207
|
+
if (node.html && node.type !== "code" && node.type !== "math-block") {
|
|
208
|
+
node.html = rewriteHtmlImageSrcs(node.html, prefix);
|
|
209
|
+
}
|
|
210
|
+
if (node.children) {
|
|
211
|
+
rewriteTreeImageSrcs(node.children, prefix);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function rewriteInlineImageSrcs(nodes, prefix) {
|
|
216
|
+
for (const node of nodes) {
|
|
217
|
+
if (node.type === "image" && typeof node.src === "string") {
|
|
218
|
+
node.src = rewriteHref(node.src, prefix);
|
|
219
|
+
}
|
|
220
|
+
else if (node.type === "link") {
|
|
221
|
+
// Links wrap inline children (which may include images) — same
|
|
222
|
+
// recursion shape as rewriteInlineHrefs.
|
|
223
|
+
rewriteInlineImageSrcs(node.children, prefix);
|
|
224
|
+
}
|
|
225
|
+
else if (node.type === "highlight" && node.children) {
|
|
226
|
+
rewriteInlineImageSrcs(node.children, prefix);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function rewriteHtmlImageSrcs(html, prefix) {
|
|
231
|
+
return html.replace(/(<img\b[^>]*\ssrc=")(\/[^"]+)"/g, (_match, before, src) => `${before}${rewriteHref(src, prefix)}"`);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Astro `outDir` for a Cloudflare Workers deploy mounted on a host subpath.
|
|
235
|
+
*
|
|
236
|
+
* Workers Static Assets resolves the FULL request pathname against the
|
|
237
|
+
* asset manifest and strips nothing. A site mounted at `dogsbay.ai/blog/*`
|
|
238
|
+
* is therefore asked for `/blog/index.html`, and unless the build output
|
|
239
|
+
* actually contains `blog/`, every request 404s. Astro's `base` does not
|
|
240
|
+
* do this: it prefixes generated URLs (including `/blog/_astro/...`) but
|
|
241
|
+
* leaves the emitted files at `dist/` root. Setting `outDir` to
|
|
242
|
+
* `./dist<urlBase>` is what makes the two agree.
|
|
243
|
+
*
|
|
244
|
+
* GitHub Pages needs the OPPOSITE and must not get this. There the
|
|
245
|
+
* uploaded artifact IS the site root, served at `https://<user>.github.io/
|
|
246
|
+
* <repo>/`, so the host supplies the prefix: URLs must carry it, files
|
|
247
|
+
* must not. Emitting `outDir` for a Pages build would produce
|
|
248
|
+
* `/<repo>/<repo>/`. Hence the deploy-target gate — the same `site.url`
|
|
249
|
+
* deliberately yields different layouts per target.
|
|
250
|
+
*
|
|
251
|
+
* Returns `undefined` when the target is not Workers or the site sits at
|
|
252
|
+
* the host root (no path in `site.url`), which is the common case.
|
|
253
|
+
*/
|
|
254
|
+
export function workersSubpathOutDir(deploy, urlBase) {
|
|
255
|
+
if (deploy !== "cloudflare-workers")
|
|
256
|
+
return undefined;
|
|
257
|
+
if (!urlBase)
|
|
258
|
+
return undefined;
|
|
259
|
+
return `./dist${urlBase}`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Delete generated content pages whose source markdown is gone.
|
|
263
|
+
*
|
|
264
|
+
* `emitAstroPages` writes a page per source file but never removed one,
|
|
265
|
+
* so deleting a post left its `.astro` and `.md.ts` behind: the page kept
|
|
266
|
+
* building, kept deploying, and kept being linked from tag archives.
|
|
267
|
+
* Measured on the blog — three deleted posts were still live.
|
|
268
|
+
*
|
|
269
|
+
* Deleting files needs a tighter contract than writing them, so this is
|
|
270
|
+
* bounded four ways:
|
|
271
|
+
*
|
|
272
|
+
* ROOT Only the subtree this invocation owns —
|
|
273
|
+
* `src/pages/<basePath>/<section>` — never all of `src/pages`.
|
|
274
|
+
* `--section` exists so several converts land in one project;
|
|
275
|
+
* walking the whole tree meant each run deleted the previous
|
|
276
|
+
* run's section.
|
|
277
|
+
* FLOOR Never prune when this run generated nothing. A mis-set
|
|
278
|
+
* `--section`, a moved source dir or a config typo yields zero
|
|
279
|
+
* pages, and an unguarded prune would delete the entire site
|
|
280
|
+
* and exit 0.
|
|
281
|
+
* WHOLE Never prune when a page failed to generate. The page loop
|
|
282
|
+
* warns and continues, so a failed page is absent from
|
|
283
|
+
* `generatedPaths` and would look exactly like a deleted one —
|
|
284
|
+
* turning a transient serializer throw into a deletion.
|
|
285
|
+
* BANNER Only files carrying the `dogsbay convert` banner. That marks
|
|
286
|
+
* content pages and their `.md` mirrors; taxonomy and blog
|
|
287
|
+
* routes carry a `dogsbay site build` banner and a writer's own
|
|
288
|
+
* page carries none. This runs BEFORE those emitters, when last
|
|
289
|
+
* build's routes are present and unclaimed, so the distinction
|
|
290
|
+
* is what keeps them alive.
|
|
291
|
+
*
|
|
292
|
+
* KNOWN GAP: missing-translation stubs are not covered. They carry
|
|
293
|
+
* `// AUTO-GENERATED missing-translation stub.`, not the convert banner,
|
|
294
|
+
* so deleting `en/foo.md` leaves `/fr/foo` as a live route redirecting to
|
|
295
|
+
* a now-404 `/en/foo` — the same symptom, one hop away. Fixing it needs
|
|
296
|
+
* the stubs tracked in `generatedPaths` first; giving them the convert
|
|
297
|
+
* banner alone would make every build prune the stub it just wrote.
|
|
298
|
+
*/
|
|
299
|
+
function pruneOrphanedPages(outputDir, root, generatedPaths) {
|
|
300
|
+
if (!existsSync(root))
|
|
301
|
+
return [];
|
|
302
|
+
const removed = [];
|
|
303
|
+
const walk = (dir) => {
|
|
304
|
+
let emptied = false;
|
|
305
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
306
|
+
const full = join(dir, entry.name);
|
|
307
|
+
if (entry.isDirectory()) {
|
|
308
|
+
walk(full);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
// Only our two output kinds can be orphans; skip everything else
|
|
312
|
+
// rather than reading it.
|
|
313
|
+
if (!entry.name.endsWith(".astro") && !entry.name.endsWith(".md.ts"))
|
|
314
|
+
continue;
|
|
315
|
+
if (generatedPaths.has(relative(outputDir, full)))
|
|
316
|
+
continue;
|
|
317
|
+
// The banner is always the first line, so read a prefix rather than
|
|
318
|
+
// the whole file — a `.md.ts` mirror embeds an entire page's
|
|
319
|
+
// markdown, and a large corpus is thousands of them per build.
|
|
320
|
+
let head;
|
|
321
|
+
try {
|
|
322
|
+
const fd = openSync(full, "r");
|
|
323
|
+
try {
|
|
324
|
+
const buf = Buffer.alloc(256);
|
|
325
|
+
const n = readSync(fd, buf, 0, 256, 0);
|
|
326
|
+
head = buf.subarray(0, n).toString("utf-8");
|
|
327
|
+
}
|
|
328
|
+
finally {
|
|
329
|
+
closeSync(fd);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (!head.includes("AUTO-GENERATED by `dogsbay convert`"))
|
|
336
|
+
continue;
|
|
337
|
+
rmSync(full, { force: true });
|
|
338
|
+
removed.push(relative(outputDir, full));
|
|
339
|
+
emptied = true;
|
|
340
|
+
}
|
|
341
|
+
// Only directories THIS pass emptied, and only via non-recursive
|
|
342
|
+
// rmdir: it fails atomically if anything landed in the meantime,
|
|
343
|
+
// which matters under `site dev` where the build re-runs while an
|
|
344
|
+
// editor and the dev server are live.
|
|
345
|
+
if (emptied && dir !== root) {
|
|
346
|
+
try {
|
|
347
|
+
rmdirSync(dir);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
/* not empty, or raced — correct to leave it */
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
walk(root);
|
|
355
|
+
return removed;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Build the `excludeFromRoutes` predicate.
|
|
359
|
+
*
|
|
360
|
+
* Exported because more than one emitter has to agree on which pages
|
|
361
|
+
* become routes. The blog listed excluded pages as posts, producing index
|
|
362
|
+
* cards and Newer/Older links to pages that were never emitted — a 404
|
|
363
|
+
* from the site's own front page.
|
|
364
|
+
*
|
|
365
|
+
* A single-segment pattern matches ANY segment (fragment dirs symlink to
|
|
366
|
+
* many depths in AsciiBinder corpora); a multi-segment pattern matches as
|
|
367
|
+
* a leading path prefix, so `welcome/_internal` excludes only that tree.
|
|
368
|
+
*/
|
|
369
|
+
export function makeSlugExcluder(excludeFromRoutes) {
|
|
370
|
+
const patterns = (excludeFromRoutes ?? []).map((p) => p.replace(/^\/+/, "").replace(/\/+$/, ""));
|
|
371
|
+
return (slug) => {
|
|
372
|
+
if (patterns.length === 0)
|
|
373
|
+
return false;
|
|
374
|
+
const segments = slug.split("/");
|
|
375
|
+
for (const pattern of patterns) {
|
|
376
|
+
if (pattern.includes("/")) {
|
|
377
|
+
if (slug === pattern || slug.startsWith(pattern + "/"))
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
if (segments.includes(pattern))
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
};
|
|
387
|
+
}
|
|
101
388
|
/**
|
|
102
389
|
* Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
|
|
103
390
|
*
|
|
@@ -148,18 +435,144 @@ function buildWranglerConfig(siteName, options) {
|
|
|
148
435
|
lines.push(`}`);
|
|
149
436
|
return lines.join("\n") + "\n";
|
|
150
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Build the GitHub Actions workflow YAML for `actions/deploy-pages`.
|
|
440
|
+
*
|
|
441
|
+
* The workflow:
|
|
442
|
+
* 1. Checks out the repo on every push to the default branch.
|
|
443
|
+
* 2. Installs node + pnpm at the Astro project directory, runs
|
|
444
|
+
* `dogsbay site build` (via `pnpm dlx` since Dogsbay is a
|
|
445
|
+
* global CLI, not a project dep), then `pnpm run build`
|
|
446
|
+
* (which runs `astro build && pagefind`).
|
|
447
|
+
* 3. Uploads `<astroDirRel>/dist` as a Pages artifact via
|
|
448
|
+
* `actions/upload-pages-artifact`.
|
|
449
|
+
* 4. Deploys via `actions/deploy-pages`.
|
|
450
|
+
*
|
|
451
|
+
* `astroDirRel` is the path of the Astro output relative to the
|
|
452
|
+
* repo root (typically "astro" — the default config has
|
|
453
|
+
* `output: ./astro`). Empty string is allowed when the project is
|
|
454
|
+
* flat (outputDir === projectDir); the workflow degrades naturally
|
|
455
|
+
* by omitting the `defaults: working-directory` block.
|
|
456
|
+
*
|
|
457
|
+
* Author edits — extra build steps, secrets, deploy gating — survive
|
|
458
|
+
* subsequent `dogsbay site build` runs because the file is written
|
|
459
|
+
* write-if-missing (see emitDeployArtifacts). To start over, delete
|
|
460
|
+
* the workflow file and rebuild.
|
|
461
|
+
*
|
|
462
|
+
* Note on basePath: GitHub Pages serves project sites at
|
|
463
|
+
* `https://<user>.github.io/<repo>/`. Authors who want their docs at
|
|
464
|
+
* the repo root should set `site.basePath: /<repo-name>` (or empty
|
|
465
|
+
* for user/org pages). The platform's basePath plumbing handles all
|
|
466
|
+
* URL rewriting; this workflow doesn't need to know about it.
|
|
467
|
+
*/
|
|
468
|
+
function buildGitHubPagesWorkflow(astroDirRel) {
|
|
469
|
+
// When the Astro output IS the project root, drop the working-
|
|
470
|
+
// directory block and reference cache + artifact paths without a
|
|
471
|
+
// prefix. This is the flat-layout case (rare for site-init flows;
|
|
472
|
+
// common for `dogsbay convert` outputs that get manually wired up).
|
|
473
|
+
const isFlat = astroDirRel === "" || astroDirRel === ".";
|
|
474
|
+
const workingDirBlock = isFlat
|
|
475
|
+
? ""
|
|
476
|
+
: `
|
|
477
|
+
defaults:
|
|
478
|
+
run:
|
|
479
|
+
working-directory: ${astroDirRel}`;
|
|
480
|
+
const cacheDep = isFlat
|
|
481
|
+
? "pnpm-lock.yaml"
|
|
482
|
+
: `${astroDirRel}/pnpm-lock.yaml`;
|
|
483
|
+
const artifactPath = isFlat ? "dist" : `${astroDirRel}/dist`;
|
|
484
|
+
return `# Deploy to GitHub Pages.
|
|
485
|
+
# Generated by \`dogsbay site init --deploy=github-pages\` (or by
|
|
486
|
+
# adding \`deploy: { target: github-pages }\` to dogsbay.config.yml
|
|
487
|
+
# and running \`dogsbay site build\`). Author edits survive every
|
|
488
|
+
# subsequent build — the file is never overwritten. To regenerate
|
|
489
|
+
# from template, delete the file and rebuild.
|
|
490
|
+
#
|
|
491
|
+
# Repo settings: Settings → Pages → Source = "GitHub Actions".
|
|
492
|
+
name: Deploy to GitHub Pages
|
|
493
|
+
|
|
494
|
+
on:
|
|
495
|
+
push:
|
|
496
|
+
branches: [main]
|
|
497
|
+
workflow_dispatch:
|
|
498
|
+
|
|
499
|
+
permissions:
|
|
500
|
+
contents: read
|
|
501
|
+
pages: write
|
|
502
|
+
id-token: write
|
|
503
|
+
|
|
504
|
+
# Allow only one concurrent deployment, skipping queued runs.
|
|
505
|
+
concurrency:
|
|
506
|
+
group: pages
|
|
507
|
+
cancel-in-progress: false
|
|
508
|
+
|
|
509
|
+
jobs:
|
|
510
|
+
build:
|
|
511
|
+
runs-on: ubuntu-latest${workingDirBlock}
|
|
512
|
+
steps:
|
|
513
|
+
- uses: actions/checkout@v4
|
|
514
|
+
|
|
515
|
+
- uses: pnpm/action-setup@v4
|
|
516
|
+
with:
|
|
517
|
+
version: 10
|
|
518
|
+
|
|
519
|
+
- uses: actions/setup-node@v4
|
|
520
|
+
with:
|
|
521
|
+
# Astro 6 requires Node ^20.19.5 || >=22.12.0; pin 22 for
|
|
522
|
+
# forward-compat (Node 20 LTS is fine for Astro 5 sites
|
|
523
|
+
# but the Dogsbay scaffold targets Astro 6).
|
|
524
|
+
node-version: 22
|
|
525
|
+
cache: pnpm
|
|
526
|
+
cache-dependency-path: ${cacheDep}
|
|
527
|
+
|
|
528
|
+
- name: Install dependencies
|
|
529
|
+
run: pnpm install --frozen-lockfile
|
|
530
|
+
|
|
531
|
+
# \`dogsbay\` is a global CLI, not a project dep — pnpm dlx
|
|
532
|
+
# fetches it on demand. To pin a version, replace with e.g.
|
|
533
|
+
# \`pnpm dlx dogsbay@0.2.0-beta.18 site build\`.
|
|
534
|
+
- name: Build with Dogsbay
|
|
535
|
+
run: pnpm dlx dogsbay@beta site build
|
|
536
|
+
|
|
537
|
+
- name: Build Astro site
|
|
538
|
+
run: pnpm run build
|
|
539
|
+
|
|
540
|
+
- name: Upload Pages artifact
|
|
541
|
+
uses: actions/upload-pages-artifact@v3
|
|
542
|
+
with:
|
|
543
|
+
path: ${artifactPath}
|
|
544
|
+
|
|
545
|
+
deploy:
|
|
546
|
+
needs: build
|
|
547
|
+
runs-on: ubuntu-latest
|
|
548
|
+
environment:
|
|
549
|
+
name: github-pages
|
|
550
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
551
|
+
steps:
|
|
552
|
+
- name: Deploy to GitHub Pages
|
|
553
|
+
id: deployment
|
|
554
|
+
uses: actions/deploy-pages@v4
|
|
555
|
+
`;
|
|
556
|
+
}
|
|
151
557
|
/**
|
|
152
558
|
* Construct the SiteConfig object that gets serialized to
|
|
153
559
|
* `src/data/site.json`. Backward-compatible: existing fields keep their
|
|
154
560
|
* empty-string defaults; new optional fields are omitted when undefined.
|
|
155
561
|
*/
|
|
156
|
-
function buildSiteConfig(siteName, options) {
|
|
562
|
+
function buildSiteConfig(siteName, options, outputDir) {
|
|
157
563
|
const cfg = {
|
|
158
564
|
siteName,
|
|
159
565
|
repoUrl: options.repoUrl || "",
|
|
160
|
-
editUri: options.editUri || "blob/main/docs/",
|
|
161
|
-
copyright: options.copyright || "",
|
|
162
566
|
};
|
|
567
|
+
// Favicon href, so GENERATED routes can emit <link rel="icon"> too.
|
|
568
|
+
// Content pages get it computed per page; the blog index and the
|
|
569
|
+
// taxonomy routes are emitted by other emitters that never received it,
|
|
570
|
+
// so the site's own front page had no icon while its posts did.
|
|
571
|
+
if (outputDir) {
|
|
572
|
+
const href = faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase);
|
|
573
|
+
if (href)
|
|
574
|
+
cfg.favicon = href;
|
|
575
|
+
}
|
|
163
576
|
if (options.siteUrl)
|
|
164
577
|
cfg.siteUrl = options.siteUrl;
|
|
165
578
|
if (options.description)
|
|
@@ -170,6 +583,28 @@ function buildSiteConfig(siteName, options) {
|
|
|
170
583
|
cfg.twitterHandle = options.twitterHandle;
|
|
171
584
|
if (options.themeColor)
|
|
172
585
|
cfg.themeColor = options.themeColor;
|
|
586
|
+
// editUri + copyright follow the same omit-on-empty pattern as the
|
|
587
|
+
// optional fields above; previously they were always written
|
|
588
|
+
// (editUri defaulted to "blob/main/docs/", copyright to ""), which
|
|
589
|
+
// left zombie config in src/data/site.json. Downstream guards already
|
|
590
|
+
// treat empty / undefined as "don't render" so this is purely a tidy.
|
|
591
|
+
if (options.editUri)
|
|
592
|
+
cfg.editUri = options.editUri;
|
|
593
|
+
if (options.copyright)
|
|
594
|
+
cfg.copyright = options.chrome ? copyrightHtml(options.copyright, combinedPrefix(options)) : options.copyright;
|
|
595
|
+
if (options.chrome && outputDir) {
|
|
596
|
+
cfg.chrome = chromeData(options.chrome, outputDir, {
|
|
597
|
+
combined: combinedPrefix(options),
|
|
598
|
+
urlBase: parseSiteUrl(options.siteUrl).urlBase,
|
|
599
|
+
logoFiles: options.chromeLogoFiles,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
if (options.tagline)
|
|
603
|
+
cfg.tagline = options.tagline;
|
|
604
|
+
// Written explicitly, both ways: the default is ON, so an ABSENT key in
|
|
605
|
+
// site.json would be indistinguishable from "not yet regenerated" and the
|
|
606
|
+
// page template would have to guess. `false` is what the opt-out looks like.
|
|
607
|
+
cfg.poweredBy = options.poweredBy !== false;
|
|
173
608
|
if (options.brandKeywords && options.brandKeywords.length > 0) {
|
|
174
609
|
cfg.brandKeywords = options.brandKeywords;
|
|
175
610
|
}
|
|
@@ -186,6 +621,14 @@ function buildSiteConfig(siteName, options) {
|
|
|
186
621
|
if (options.tagLabels && Object.keys(options.tagLabels).length > 0) {
|
|
187
622
|
cfg.tagLabels = options.tagLabels;
|
|
188
623
|
}
|
|
624
|
+
if (options.tagAutoColor) {
|
|
625
|
+
cfg.tagAutoColor = true;
|
|
626
|
+
}
|
|
627
|
+
// Link-icon glyphs — only emitted when configured, so sites without
|
|
628
|
+
// the feature keep byte-identical site.json.
|
|
629
|
+
if (options.linkIcons && (options.linkIcons.external || options.linkIcons.internal)) {
|
|
630
|
+
cfg.linkIcons = options.linkIcons;
|
|
631
|
+
}
|
|
189
632
|
if (options.taxonomyIndexPaths &&
|
|
190
633
|
Object.keys(options.taxonomyIndexPaths).length > 0) {
|
|
191
634
|
// Bake basePath into every emitted indexPath so consumers
|
|
@@ -196,15 +639,50 @@ function buildSiteConfig(siteName, options) {
|
|
|
196
639
|
// values (`/by-type`, `/tags`, etc.) — basePath threading is
|
|
197
640
|
// this emitter's responsibility, matching how `page.url` is
|
|
198
641
|
// already prefixed in the taxonomy data file.
|
|
199
|
-
|
|
642
|
+
// Taxonomy index paths are baked into site.json so components
|
|
643
|
+
// (TagList, TaxonomyIndex, TypeBadge) emit correct hrefs at
|
|
644
|
+
// runtime. Use combined so these resolve under the host's
|
|
645
|
+
// served subpath.
|
|
646
|
+
const taxoPrefix = combinedPrefix(options);
|
|
200
647
|
cfg.taxonomyIndexPaths = Object.fromEntries(Object.entries(options.taxonomyIndexPaths).map(([name, raw]) => [
|
|
201
648
|
name,
|
|
202
|
-
withBasePath(
|
|
649
|
+
withBasePath(taxoPrefix, raw),
|
|
203
650
|
]));
|
|
204
651
|
}
|
|
205
652
|
if (options.taxonomyDisplay &&
|
|
206
653
|
Object.keys(options.taxonomyDisplay).length > 0) {
|
|
207
|
-
|
|
654
|
+
// Flatten prefix labels into top-level entries so the
|
|
655
|
+
// search-facets resolver finds them after DocsLayout splits
|
|
656
|
+
// slash-nested tags into per-prefix Pagefind filter divs.
|
|
657
|
+
//
|
|
658
|
+
// Input:
|
|
659
|
+
// tags.prefixes = { difficulty: { label, color }, ... }
|
|
660
|
+
// tags.labels = { "difficulty/1": "Beginner", "difficulty/2": ... }
|
|
661
|
+
// Output additions (kept alongside the original `tags` entry):
|
|
662
|
+
// difficulty.labels = { "1": "Beginner", "2": ... }
|
|
663
|
+
//
|
|
664
|
+
// Resolver does `display[facetName].labels[value]` — facet name
|
|
665
|
+
// is now `difficulty`, value is `1`, → "Beginner". See
|
|
666
|
+
// plans/per-prefix-search-facets.md.
|
|
667
|
+
const flat = { ...options.taxonomyDisplay };
|
|
668
|
+
const tagsDisplay = options.taxonomyDisplay.tags;
|
|
669
|
+
if (tagsDisplay?.prefixes) {
|
|
670
|
+
for (const prefix of Object.keys(tagsDisplay.prefixes)) {
|
|
671
|
+
if (flat[prefix])
|
|
672
|
+
continue; // top-level entry wins
|
|
673
|
+
const leafLabels = {};
|
|
674
|
+
if (tagsDisplay.labels) {
|
|
675
|
+
const needle = `${prefix}/`;
|
|
676
|
+
for (const [slug, label] of Object.entries(tagsDisplay.labels)) {
|
|
677
|
+
if (slug.startsWith(needle)) {
|
|
678
|
+
leafLabels[slug.slice(needle.length)] = label;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
flat[prefix] = { labels: leafLabels };
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
cfg.taxonomyDisplay = flat;
|
|
208
686
|
}
|
|
209
687
|
return cfg;
|
|
210
688
|
}
|
|
@@ -233,6 +711,21 @@ export async function exportAstroProject(pages, nav, outputDir, options = {}) {
|
|
|
233
711
|
const scaffoldSkipped = emitSiteScaffold(outputDir, siteName, options, writeScaffold);
|
|
234
712
|
const { generated, outputNav } = await emitAstroPages(pages, nav, outputDir, options);
|
|
235
713
|
emitConfigDerivedFiles(outputDir, options);
|
|
714
|
+
// The generated layout unconditionally imports
|
|
715
|
+
// `@/data/switcherMap.json` and the plugin wrapper stacks
|
|
716
|
+
// (`@/components/wrappers/*Stack.astro`); the exporter must
|
|
717
|
+
// guarantee both exist (empty axes / passthrough slots when there
|
|
718
|
+
// is no version data and no plugins) — previously only
|
|
719
|
+
// `dogsbay site build` emitted them, so every direct
|
|
720
|
+
// exportAstroProject consumer produced an unbuildable site
|
|
721
|
+
// (root-caused 2026-07-13 on the first comparison-site build).
|
|
722
|
+
emitSwitcherMap(pages, outputDir, options);
|
|
723
|
+
emitPluginRuntime({
|
|
724
|
+
outputDir,
|
|
725
|
+
clientModules: [],
|
|
726
|
+
styles: [],
|
|
727
|
+
clientConfigs: [],
|
|
728
|
+
});
|
|
236
729
|
emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options);
|
|
237
730
|
console.log(`Generated ${generated} static .astro pages`);
|
|
238
731
|
if (alreadyScaffolded && !options.force && scaffoldSkipped > 0) {
|
|
@@ -269,7 +762,370 @@ function ensureDirectoryStructure(outputDir, basePath) {
|
|
|
269
762
|
*/
|
|
270
763
|
export function emitSiteConfig(outputDir, siteName, options) {
|
|
271
764
|
mkdirSync(join(outputDir, "src", "data"), { recursive: true });
|
|
272
|
-
writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
|
|
765
|
+
writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options, outputDir), null, 2));
|
|
766
|
+
// Auto-generated companion to astro.config.mjs. Carries the
|
|
767
|
+
// site/base values derived from dogsbay.config.yml's site.url so
|
|
768
|
+
// changes propagate without --force-rescaffolding the main
|
|
769
|
+
// astro.config.mjs (which is scaffold-once and may have author
|
|
770
|
+
// edits — custom integrations, build hooks, etc.). The main
|
|
771
|
+
// config imports `dogsbaySite` + `dogsbayBase` from here.
|
|
772
|
+
// See plans/astro-base-from-site-url.md.
|
|
773
|
+
const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
|
|
774
|
+
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
775
|
+
const dogsbaySiteJson = hasSiteUrl
|
|
776
|
+
? JSON.stringify(origin ?? options.siteUrl)
|
|
777
|
+
: "undefined";
|
|
778
|
+
const dogsbayBaseJson = astroBase ? JSON.stringify(astroBase) : "undefined";
|
|
779
|
+
// Workers subpath mounts need the build output to live under the mount
|
|
780
|
+
// path too — see workersSubpathOutDir. Undefined for every other target
|
|
781
|
+
// and for host-root sites, so nothing changes for existing deploys.
|
|
782
|
+
const dogsbayOutDir = workersSubpathOutDir(options.deploy, astroBase);
|
|
783
|
+
const dogsbayOutDirJson = dogsbayOutDir ? JSON.stringify(dogsbayOutDir) : "undefined";
|
|
784
|
+
// build.inlineStylesheets — defaults to "auto" (Astro's own
|
|
785
|
+
// default; matches our docs-first bias since theme.css is ~120KB
|
|
786
|
+
// and externalizing it lets the file cache cross-page). Authors
|
|
787
|
+
// wanting "always" / "never" set it via dogsbay.config.yml's
|
|
788
|
+
// build.inlineStylesheets. See docs/perf-tuning.md.
|
|
789
|
+
const dogsbayInline = options.inlineStylesheets ?? "auto";
|
|
790
|
+
writeFileSync(join(outputDir, "astro.config.dogsbay.mjs"), [
|
|
791
|
+
"// Auto-generated by `dogsbay site build` — DO NOT EDIT.",
|
|
792
|
+
"// Tracks site.url + derived Astro base + build behaviour from",
|
|
793
|
+
"// dogsbay.config.yml. Edit dogsbay.config.yml and rebuild;",
|
|
794
|
+
"// edits to this file will be overwritten on the next build.",
|
|
795
|
+
`export const dogsbaySite = ${dogsbaySiteJson};`,
|
|
796
|
+
`export const dogsbayBase = ${dogsbayBaseJson};`,
|
|
797
|
+
`export const dogsbayInlineStylesheets = ${JSON.stringify(dogsbayInline)};`,
|
|
798
|
+
`export const dogsbayOutDir = ${dogsbayOutDirJson};`,
|
|
799
|
+
"",
|
|
800
|
+
].join("\n"));
|
|
801
|
+
// dogsbay-mount.mjs — auto-generated, emitted only for subpath mounts.
|
|
802
|
+
//
|
|
803
|
+
// Two jobs that Astro cannot do itself once outDir moves to
|
|
804
|
+
// dist/<urlBase>/:
|
|
805
|
+
//
|
|
806
|
+
// clean `astro build` clears outDir, which is now the MOUNT dir,
|
|
807
|
+
// not dist/. Stale output therefore survives forever — a
|
|
808
|
+
// site migrating from the pre-mount layout keeps its old
|
|
809
|
+
// root-level index.html and _astro/, and changing site.url
|
|
810
|
+
// from /blog to /news strands the whole old tree. Both keep
|
|
811
|
+
// being uploaded, because `wrangler deploy` ships all of
|
|
812
|
+
// ./dist. dist/ is entirely derived, so clearing it is safe.
|
|
813
|
+
//
|
|
814
|
+
// NOTE robots.txt is deliberately NOT lifted. _headers is a CONFIG
|
|
815
|
+
// file that Workers reads from the assets root; robots.txt is a
|
|
816
|
+
// SERVED URL at /robots.txt, and on a subpath mount that path
|
|
817
|
+
// belongs to whichever worker owns the host root, not to this
|
|
818
|
+
// one. Lifting it would put a file at a path this worker's route
|
|
819
|
+
// never matches. The host-root worker's robots.txt is the
|
|
820
|
+
// authoritative one and must name this mount's sitemap — see
|
|
821
|
+
// plans/dogsbay-ai-site.md. The per-mount copy at
|
|
822
|
+
// <urlBase>/robots.txt is harmless but is NOT what crawlers read.
|
|
823
|
+
//
|
|
824
|
+
// finalize Workers Static Assets reads _headers AND _redirects only
|
|
825
|
+
// from the ROOT of assets.directory (./dist). Astro copies
|
|
826
|
+
// public/ into outDir, so both landed at dist/<urlBase>/,
|
|
827
|
+
// where they do nothing — the RFC 8288 Link header pointing
|
|
828
|
+
// agents at llms.txt silently stopped applying, and the raw
|
|
829
|
+
// files were served as assets. Move them up.
|
|
830
|
+
const mountHelperPath = join(outputDir, "dogsbay-mount.mjs");
|
|
831
|
+
if (!dogsbayOutDir && existsSync(mountHelperPath)) {
|
|
832
|
+
// No longer mounted (deploy target or site.url changed).
|
|
833
|
+
//
|
|
834
|
+
// Deleting it outright looks tidy and is wrong: package.json is
|
|
835
|
+
// scaffold-once, so the build script still runs
|
|
836
|
+
// `node ./dogsbay-mount.mjs clean && …` and the next `pnpm build` dies
|
|
837
|
+
// with ERR_MODULE_NOT_FOUND before Astro even starts. Downgrade it to
|
|
838
|
+
// a no-op instead, so the build keeps working while
|
|
839
|
+
// warnStaleMountBuildScript tells the author to update the script; only
|
|
840
|
+
// remove the file once nothing references it.
|
|
841
|
+
const pkgPath = join(outputDir, "package.json");
|
|
842
|
+
const stillReferenced = existsSync(pkgPath) && readFileSync(pkgPath, "utf-8").includes("dogsbay-mount.mjs");
|
|
843
|
+
if (stillReferenced) {
|
|
844
|
+
writeFileSync(mountHelperPath, [
|
|
845
|
+
"// AUTO-GENERATED by `dogsbay site build` — do not edit.",
|
|
846
|
+
"// This site is no longer mounted on a subpath, but package.json",
|
|
847
|
+
"// still calls this script, so it stays as a no-op rather than",
|
|
848
|
+
"// breaking the build. Update the build script and it goes away.",
|
|
849
|
+
"process.exit(0);",
|
|
850
|
+
"",
|
|
851
|
+
].join("\n"));
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
rmSync(mountHelperPath, { force: true });
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (dogsbayOutDir) {
|
|
858
|
+
const mountDir = dogsbayOutDir.replace(/^\.\//, "");
|
|
859
|
+
writeFileSync(mountHelperPath, [
|
|
860
|
+
"// AUTO-GENERATED by `dogsbay site build` — do not edit.",
|
|
861
|
+
"// Subpath-mount build steps. See project.ts (dogsbay-mount.mjs).",
|
|
862
|
+
'import { rmSync, existsSync, mkdirSync, renameSync } from "node:fs";',
|
|
863
|
+
'import { dirname, join, resolve } from "node:path";',
|
|
864
|
+
'import { fileURLToPath } from "node:url";',
|
|
865
|
+
"",
|
|
866
|
+
`const MOUNT_DIR = ${JSON.stringify(mountDir)};`,
|
|
867
|
+
"// Resolve against THIS FILE, never process.cwd(). `pnpm build` runs",
|
|
868
|
+
"// with cwd set to the project, but this is an ordinary script sitting",
|
|
869
|
+
"// in the project root, and the natural CI/debug invocation",
|
|
870
|
+
"// `node path/to/site/dogsbay-mount.mjs clean` would otherwise",
|
|
871
|
+
"// recursively delete the CALLER's dist/.",
|
|
872
|
+
'const ROOT = dirname(fileURLToPath(import.meta.url));',
|
|
873
|
+
'const DIST = join(ROOT, "dist");',
|
|
874
|
+
'const mode = process.argv[2];',
|
|
875
|
+
"",
|
|
876
|
+
'if (mode === "clean") {',
|
|
877
|
+
' // Derived output only — astro build + pagefind regenerate all of it.',
|
|
878
|
+
' rmSync(DIST, { recursive: true, force: true });',
|
|
879
|
+
'} else if (mode === "finalize") {',
|
|
880
|
+
" // Cloudflare reads BOTH of these only from the root of the assets",
|
|
881
|
+
" // directory, and Astro copies public/ into the mount dir — so on a",
|
|
882
|
+
" // subpath mount they land at dist/<mount>/ and apply to nothing.",
|
|
883
|
+
" for (const name of [\"_headers\", \"_redirects\"]) {",
|
|
884
|
+
" const from = resolve(ROOT, `${MOUNT_DIR}/${name}`);",
|
|
885
|
+
" if (!existsSync(from)) continue;",
|
|
886
|
+
" const to = join(DIST, name);",
|
|
887
|
+
" mkdirSync(dirname(to), { recursive: true });",
|
|
888
|
+
" renameSync(from, to);",
|
|
889
|
+
" }",
|
|
890
|
+
'} else {',
|
|
891
|
+
' console.error("dogsbay-mount.mjs: expected `clean` or `finalize`");',
|
|
892
|
+
" process.exit(1);",
|
|
893
|
+
"}",
|
|
894
|
+
"",
|
|
895
|
+
].join("\n"));
|
|
896
|
+
}
|
|
897
|
+
warnStaleMountBuildScript(outputDir, dogsbayOutDir, astroBase);
|
|
898
|
+
// Migration check: pre-beta.20 sites have an astro.config.mjs that
|
|
899
|
+
// doesn't import the companion. Without the import, the values
|
|
900
|
+
// emitted above are unused and Astro's `base` stays unset — the
|
|
901
|
+
// exact bug this work was meant to close. Warn loudly, with the
|
|
902
|
+
// patch the user needs to apply, until astro.config.mjs is
|
|
903
|
+
// updated. We don't auto-patch because the file may have author
|
|
904
|
+
// edits (custom integrations, build hooks).
|
|
905
|
+
const astroConfigPath = join(outputDir, "astro.config.mjs");
|
|
906
|
+
if (existsSync(astroConfigPath)) {
|
|
907
|
+
const astroConfigSrc = readFileSync(astroConfigPath, "utf-8");
|
|
908
|
+
if (!astroConfigSrc.includes("astro.config.dogsbay.mjs")) {
|
|
909
|
+
console.warn([
|
|
910
|
+
"",
|
|
911
|
+
" ⚠ astro.config.mjs is missing the dogsbay companion import.",
|
|
912
|
+
" Without it, Astro's `base` config stays unset and assets",
|
|
913
|
+
" served from a host subpath (GH Pages project pages,",
|
|
914
|
+
" multi-mount Cloudflare) will 404.",
|
|
915
|
+
"",
|
|
916
|
+
" Add these two lines to astro.config.mjs:",
|
|
917
|
+
"",
|
|
918
|
+
' import {',
|
|
919
|
+
' dogsbaySite,',
|
|
920
|
+
' dogsbayBase,',
|
|
921
|
+
' dogsbayInlineStylesheets,',
|
|
922
|
+
' dogsbayOutDir,',
|
|
923
|
+
' } from "./astro.config.dogsbay.mjs";',
|
|
924
|
+
"",
|
|
925
|
+
" export default defineConfig({",
|
|
926
|
+
" ...(dogsbaySite ? { site: dogsbaySite } : {}),",
|
|
927
|
+
" ...(dogsbayBase ? { base: dogsbayBase } : {}),",
|
|
928
|
+
" ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
|
|
929
|
+
" build: { inlineStylesheets: dogsbayInlineStylesheets },",
|
|
930
|
+
" // ...your existing config...",
|
|
931
|
+
" });",
|
|
932
|
+
"",
|
|
933
|
+
" OR regenerate from template (overwrites your edits):",
|
|
934
|
+
" dogsbay site init . --scaffold-only --force",
|
|
935
|
+
"",
|
|
936
|
+
].join("\n"));
|
|
937
|
+
}
|
|
938
|
+
else if (dogsbayOutDir && !astroConfigSrc.includes("dogsbayOutDir")) {
|
|
939
|
+
// The companion is imported, but this scaffold predates outDir
|
|
940
|
+
// support. astro.config.mjs is scaffold-once, so an existing site
|
|
941
|
+
// that moves to a Workers subpath mount keeps a config that emits
|
|
942
|
+
// to dist/ root while its wrangler route asks for dist<urlBase>/.
|
|
943
|
+
// Every request 404s and the HTML looks fine locally, so warn.
|
|
944
|
+
console.warn([
|
|
945
|
+
"",
|
|
946
|
+
" ⚠ astro.config.mjs does not apply `dogsbayOutDir`.",
|
|
947
|
+
` This site deploys to Cloudflare Workers under "${dogsbayOutDir.replace("./dist", "")}",`,
|
|
948
|
+
" so the build output must live under that path or every",
|
|
949
|
+
" request 404s — Workers matches the full request pathname",
|
|
950
|
+
" and strips no prefix.",
|
|
951
|
+
"",
|
|
952
|
+
" Add to the companion import in astro.config.mjs:",
|
|
953
|
+
"",
|
|
954
|
+
" dogsbayOutDir,",
|
|
955
|
+
"",
|
|
956
|
+
" and to defineConfig:",
|
|
957
|
+
"",
|
|
958
|
+
" ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
|
|
959
|
+
"",
|
|
960
|
+
].join("\n"));
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* The version spec a scaffolded site should pin `@dogsbay/*` at: this
|
|
966
|
+
* package's own version, since every `@dogsbay/*` package ships in lockstep
|
|
967
|
+
* (publish-beta.sh bumps them together).
|
|
968
|
+
*/
|
|
969
|
+
function readDogsbayPeerVersion() {
|
|
970
|
+
try {
|
|
971
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
972
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
973
|
+
// Caret on a stable version, exact pin on a prerelease.
|
|
974
|
+
//
|
|
975
|
+
// The reason given here used to be that "npm treats
|
|
976
|
+
// `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3`". That is FALSE.
|
|
977
|
+
// Checked against semver 7:
|
|
978
|
+
//
|
|
979
|
+
// ^0.2.0-beta.96 matches 0.2.0-beta.97 -> true
|
|
980
|
+
// ^0.2.0-beta.96 matches 0.2.0 -> true
|
|
981
|
+
// ^0.2.0-beta.96 matches 0.2.1-beta.1 -> false
|
|
982
|
+
//
|
|
983
|
+
// A caret DOES pick up later prereleases of the same
|
|
984
|
+
// major.minor.patch tuple. What it will not do is cross to a
|
|
985
|
+
// prerelease of a DIFFERENT tuple — probably what the original
|
|
986
|
+
// note was reaching for.
|
|
987
|
+
//
|
|
988
|
+
// The pin stays, for a reason that is actually true: a scaffolded
|
|
989
|
+
// site commits its generated `astro/` output, so an exact version
|
|
990
|
+
// guarantees the packages that produced the committed source are
|
|
991
|
+
// the ones CI installs. A floating range lets a lockfile refresh
|
|
992
|
+
// change a deployed site with nothing in the diff to show it.
|
|
993
|
+
//
|
|
994
|
+
// Our own site repos (dogsbay-ai-blog, dogsbay-ai-site) opt OUT
|
|
995
|
+
// of that and track the `beta` dist-tag instead, because we are
|
|
996
|
+
// the only consumers before release and the per-publish version
|
|
997
|
+
// bump is not worth the commit. Their .gitignore explains the
|
|
998
|
+
// trade and what to reverse at release.
|
|
999
|
+
return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
|
|
1000
|
+
}
|
|
1001
|
+
catch {
|
|
1002
|
+
return "^0.1.0"; // last-resort fallback
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Re-pin an existing site's `@dogsbay/*` dependencies to the building CLI's
|
|
1007
|
+
* version. Safe to call on every build.
|
|
1008
|
+
*
|
|
1009
|
+
* `package.json` is scaffold-once, and for a long time this ran only from
|
|
1010
|
+
* `emitSiteScaffold`, which `site build` never calls — so the guard against
|
|
1011
|
+
* version drift was unreachable from the one command where drift happens.
|
|
1012
|
+
* Measured on openshift-docs-markdown: a site rebuilt weekly for months kept
|
|
1013
|
+
* installing the `@dogsbay/docs-layout` written at its first scaffold, so no
|
|
1014
|
+
* change to a runtime component ever reached it. Its sibling escaped only
|
|
1015
|
+
* because that workflow passes `--force`, which re-scaffolds.
|
|
1016
|
+
*
|
|
1017
|
+
* Returns the changes made, so the caller can report them: a build that
|
|
1018
|
+
* silently edits a tracked file is worse than the drift it fixes.
|
|
1019
|
+
*/
|
|
1020
|
+
export function syncSiteDependencies(outputDir, options = {}) {
|
|
1021
|
+
const pkgPath = join(outputDir, "package.json");
|
|
1022
|
+
if (!existsSync(pkgPath))
|
|
1023
|
+
return [];
|
|
1024
|
+
// Versioned (published) style only — `workspace:*` and `file:` dev links
|
|
1025
|
+
// point at a checkout and must not be rewritten to a registry version.
|
|
1026
|
+
if (isInsideWorkspace(outputDir) || options.local)
|
|
1027
|
+
return [];
|
|
1028
|
+
return syncDogsbayDepVersions(pkgPath, readDogsbayPeerVersion());
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Re-pin every `@dogsbay/*` dependency in an existing package.json to
|
|
1032
|
+
* `version` (the building CLI's lockstep version), leaving non-Dogsbay deps
|
|
1033
|
+
* and any `workspace:` / `file:` dev links untouched. Returns the changes made.
|
|
1034
|
+
*
|
|
1035
|
+
* package.json is scaffold-once, so without this an upgraded CLI keeps building
|
|
1036
|
+
* against the lib versions written at first scaffold — the drift that strands a
|
|
1037
|
+
* site on a stale DocsLayout that can't render the new emitter's output.
|
|
1038
|
+
*/
|
|
1039
|
+
function syncDogsbayDepVersions(pkgPath, version) {
|
|
1040
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
1041
|
+
const changed = [];
|
|
1042
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
1043
|
+
const deps = pkg[field];
|
|
1044
|
+
if (!deps)
|
|
1045
|
+
continue;
|
|
1046
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
1047
|
+
if (!name.startsWith("@dogsbay/"))
|
|
1048
|
+
continue;
|
|
1049
|
+
if (spec.startsWith("workspace:") || spec.startsWith("file:"))
|
|
1050
|
+
continue;
|
|
1051
|
+
if (spec !== version) {
|
|
1052
|
+
deps[name] = version;
|
|
1053
|
+
changed.push(`${name}: ${spec} -> ${version}`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (changed.length > 0) {
|
|
1058
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
1059
|
+
}
|
|
1060
|
+
return changed;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Warn when the scaffold-once build script disagrees with the site's
|
|
1064
|
+
* current mount.
|
|
1065
|
+
*
|
|
1066
|
+
* Lives here, not in `emitSiteScaffold`, because that function only runs
|
|
1067
|
+
* on `site init` — a migration warning there would never fire for the
|
|
1068
|
+
* sites that need it. Called from `emitSiteConfig`, which runs on every
|
|
1069
|
+
* build.
|
|
1070
|
+
*
|
|
1071
|
+
* Both directions fail the same way: silently. Search stops working and
|
|
1072
|
+
* nothing else about the build looks wrong.
|
|
1073
|
+
*/
|
|
1074
|
+
function warnStaleMountBuildScript(outputDir, outDir, urlBase) {
|
|
1075
|
+
const pkgJsonPath = join(outputDir, "package.json");
|
|
1076
|
+
if (!existsSync(pkgJsonPath))
|
|
1077
|
+
return;
|
|
1078
|
+
const pkgSrc = readFileSync(pkgJsonPath, "utf-8");
|
|
1079
|
+
if (!outDir) {
|
|
1080
|
+
// Moved OFF a mount: the script still clears dist/ and writes the
|
|
1081
|
+
// Pagefind bundle to the old mount dir, while the page asks elsewhere.
|
|
1082
|
+
if (!pkgSrc.includes("dogsbay-mount.mjs"))
|
|
1083
|
+
return;
|
|
1084
|
+
console.warn([
|
|
1085
|
+
"",
|
|
1086
|
+
" ⚠ package.json's build script still runs the subpath-mount steps,",
|
|
1087
|
+
" but this site is no longer mounted on a subpath. dogsbay-mount.mjs",
|
|
1088
|
+
" has been reduced to a no-op so the build keeps working, and the",
|
|
1089
|
+
" Pagefind bundle is still being written to the old mount directory,",
|
|
1090
|
+
" so Cmd+K will 404 until the script is updated.",
|
|
1091
|
+
"",
|
|
1092
|
+
" Change the build script back to:",
|
|
1093
|
+
"",
|
|
1094
|
+
" astro build && pagefind --site dist",
|
|
1095
|
+
"",
|
|
1096
|
+
].join("\n"));
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
// Moved ONTO a mount, or BETWEEN mounts.
|
|
1100
|
+
//
|
|
1101
|
+
// Gating on the mere presence of "dogsbay-mount.mjs" missed the second
|
|
1102
|
+
// case: a site moving from /docs to /news regenerates MOUNT_DIR and
|
|
1103
|
+
// astro build writes dist/news, but the scaffold-once script still runs
|
|
1104
|
+
// `--output-path dist/docs/pagefind`. The bundle lands in a directory no
|
|
1105
|
+
// page references and Cmd+K silently dies — the exact failure this
|
|
1106
|
+
// warning exists for. Compare against the CURRENT mount instead.
|
|
1107
|
+
if (!pkgSrc.includes("pagefind --site dist"))
|
|
1108
|
+
return;
|
|
1109
|
+
const mountDir = outDir.replace(/^\.\//, "");
|
|
1110
|
+
if (pkgSrc.includes(`--output-path ${mountDir}/pagefind`))
|
|
1111
|
+
return;
|
|
1112
|
+
const wanted = `node ./dogsbay-mount.mjs clean && astro build && ` +
|
|
1113
|
+
`pagefind --site dist --output-path ${mountDir}/pagefind && ` +
|
|
1114
|
+
`node ./dogsbay-mount.mjs finalize`;
|
|
1115
|
+
console.warn([
|
|
1116
|
+
"",
|
|
1117
|
+
" ⚠ package.json's build script predates subpath-mount support.",
|
|
1118
|
+
` This site is mounted at "${urlBase}", which needs three things`,
|
|
1119
|
+
" the old script does not do: clear dist/ (astro build only clears",
|
|
1120
|
+
" the mount dir, so stale output ships forever), write the Pagefind",
|
|
1121
|
+
" bundle under the mount (or Cmd+K 404s), and lift _headers to the",
|
|
1122
|
+
" assets root (or the llms.txt Link header stops applying).",
|
|
1123
|
+
"",
|
|
1124
|
+
" Change the build script to:",
|
|
1125
|
+
"",
|
|
1126
|
+
` ${wanted}`,
|
|
1127
|
+
"",
|
|
1128
|
+
].join("\n"));
|
|
273
1129
|
}
|
|
274
1130
|
export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
275
1131
|
let scaffoldFilesSkipped = 0;
|
|
@@ -290,20 +1146,7 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
290
1146
|
// peer spec for `@dogsbay/docs-layout`, `@dogsbay/ui`, etc. in
|
|
291
1147
|
// the scaffolded astro/package.json. Fixes the "hardcoded
|
|
292
1148
|
// ^0.1.0" trap that caused npm install to fail post-publish.
|
|
293
|
-
const dogsbayPeerVersion = (
|
|
294
|
-
try {
|
|
295
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
296
|
-
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
297
|
-
// Caret on a stable version, exact pin on a prerelease (npm
|
|
298
|
-
// treats `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3` — the
|
|
299
|
-
// prerelease semantics force exact-or-explicit-range. Pinning
|
|
300
|
-
// prereleases avoids surprise resolves to incompatible betas.)
|
|
301
|
-
return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
|
|
302
|
-
}
|
|
303
|
-
catch {
|
|
304
|
-
return "^0.1.0"; // last-resort fallback
|
|
305
|
-
}
|
|
306
|
-
})();
|
|
1149
|
+
const dogsbayPeerVersion = readDogsbayPeerVersion();
|
|
307
1150
|
const dogsbayDep = (name) => {
|
|
308
1151
|
if (insideWs)
|
|
309
1152
|
return "workspace:*";
|
|
@@ -313,12 +1156,28 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
313
1156
|
};
|
|
314
1157
|
// Per-deploy-target additions to package.json
|
|
315
1158
|
const isCloudflare = options.deploy === "cloudflare-workers";
|
|
1159
|
+
const isGitHubPages = options.deploy === "github-pages";
|
|
316
1160
|
const deployScripts = isCloudflare
|
|
317
1161
|
? { deploy: "pnpm build && wrangler deploy" }
|
|
318
1162
|
: {};
|
|
319
1163
|
const deployDevDeps = isCloudflare
|
|
320
1164
|
? { wrangler: "^4.0.0" }
|
|
321
1165
|
: {};
|
|
1166
|
+
// `--local` outside a workspace also needs a hoisted node_modules.
|
|
1167
|
+
//
|
|
1168
|
+
// With pnpm's default isolated layout, `file:`-linked packages resolve their
|
|
1169
|
+
// own dependencies from their own store path, and Astro's transitives then
|
|
1170
|
+
// fail to resolve from the SITE (measured: "Rolldown failed to resolve import
|
|
1171
|
+
// @astrojs/internal-helpers/path"). Hoisting is what the linked-package case
|
|
1172
|
+
// wants and costs nothing here — the site is disposable derived output, not a
|
|
1173
|
+
// library whose dependency isolation anyone relies on.
|
|
1174
|
+
if (options.local && !insideWs && writeScaffold) {
|
|
1175
|
+
writeFileSync(join(outputDir, ".npmrc"), "node-linker=hoisted\n");
|
|
1176
|
+
}
|
|
1177
|
+
// Subpath mounts move the Astro output to dist/<urlBase>/, so Pagefind
|
|
1178
|
+
// must write its bundle there too — see the build script below.
|
|
1179
|
+
const scaffoldUrlBase = parseSiteUrl(options.siteUrl).urlBase;
|
|
1180
|
+
const scaffoldOutDir = workersSubpathOutDir(options.deploy, scaffoldUrlBase);
|
|
322
1181
|
// package.json — scaffold-once. Maintainers add their own deps; the
|
|
323
1182
|
// detection of "already scaffolded" actually keys off this file's
|
|
324
1183
|
// existence, so it's also our sentinel.
|
|
@@ -332,12 +1191,27 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
332
1191
|
dev: "astro dev",
|
|
333
1192
|
// Pagefind runs after astro build; indexes the static dist/ output and
|
|
334
1193
|
// writes `dist/pagefind/` for the search UI to load lazily on Cmd+K.
|
|
335
|
-
|
|
1194
|
+
//
|
|
1195
|
+
// Under a subpath mount the Astro output moves to dist/<urlBase>/,
|
|
1196
|
+
// but `--site` stays `dist` on purpose: Pagefind derives result
|
|
1197
|
+
// URLs from the indexed root, so indexing dist/ keeps them as
|
|
1198
|
+
// `/<urlBase>/page/`. Only the BUNDLE has to move, or the page
|
|
1199
|
+
// requests `/<urlBase>/pagefind/pagefind.js` (the pagefindUrl prop
|
|
1200
|
+
// is built from the combined prefix) and gets a 404 — a path that
|
|
1201
|
+
// is not even matched by the emitted `<host>/<urlBase>/*` route,
|
|
1202
|
+
// so Cmd+K would be unreachable by any URL.
|
|
1203
|
+
// Subpath mounts bracket the build with dogsbay-mount.mjs:
|
|
1204
|
+
// `clean` clears dist/ (astro build only clears the MOUNT dir,
|
|
1205
|
+
// so stale layouts would ship forever), and `finalize` lifts
|
|
1206
|
+
// _headers to the assets root, where Workers actually reads it.
|
|
1207
|
+
build: scaffoldOutDir
|
|
1208
|
+
? `node ./dogsbay-mount.mjs clean && astro build && pagefind --site dist --output-path ${scaffoldOutDir.replace(/^\.\//, "")}/pagefind && node ./dogsbay-mount.mjs finalize`
|
|
1209
|
+
: "astro build && pagefind --site dist",
|
|
336
1210
|
preview: "astro preview",
|
|
337
1211
|
...deployScripts,
|
|
338
1212
|
},
|
|
339
1213
|
dependencies: {
|
|
340
|
-
astro: "^
|
|
1214
|
+
astro: "^7.0.4",
|
|
341
1215
|
// Sitemap is emitted directly by Dogsbay into
|
|
342
1216
|
// public/<basePath>/sitemap-{index,0}.xml so multi-mount
|
|
343
1217
|
// deploys don't collide at the host root. We deliberately
|
|
@@ -348,8 +1222,14 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
348
1222
|
// include it; the produced search index is shipped statically and
|
|
349
1223
|
// doesn't load this dep at runtime.
|
|
350
1224
|
pagefind: "^1.4.0",
|
|
351
|
-
tailwindcss: "^4.
|
|
352
|
-
|
|
1225
|
+
tailwindcss: "^4.3.2",
|
|
1226
|
+
// 4.3.x required on Astro 7: 4.2.x fails against vite 7.3
|
|
1227
|
+
// ("rollupOptions.input should not be an html file when
|
|
1228
|
+
// building for SSR" — surfaced on a fresh comparison-site
|
|
1229
|
+
// build 2026-07-13). The OLD ~4.2.2 pin guarded an Astro 6
|
|
1230
|
+
// rolldown incompatibility that no longer applies; the
|
|
1231
|
+
// monorepo apps build on 4.3.2.
|
|
1232
|
+
"@tailwindcss/vite": "^4.3.2",
|
|
353
1233
|
"tailwind-variants": "^0.3.0",
|
|
354
1234
|
shiki: "^4.0.0",
|
|
355
1235
|
"@shikijs/transformers": "^4.0.0",
|
|
@@ -365,14 +1245,32 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
365
1245
|
"@dogsbay/primitives": dogsbayDep("primitives"),
|
|
366
1246
|
"@dogsbay/icons": dogsbayDep("icons"),
|
|
367
1247
|
"@dogsbay/elements": dogsbayDep("elements"),
|
|
1248
|
+
// Transitive of `@dogsbay/primitives` (via
|
|
1249
|
+
// `@floating-ui/dom`). Listed at the top level because
|
|
1250
|
+
// npm doesn't hoist the second-level transitive when
|
|
1251
|
+
// `@dogsbay/primitives` is linked via `file:` (the
|
|
1252
|
+
// `--local` monorepo mode + the canary publish flow on
|
|
1253
|
+
// GH Pages CI both hit this). Surfaced during the
|
|
1254
|
+
// FastAPI import: Rollup failed with "Cannot resolve
|
|
1255
|
+
// @floating-ui/core" at astro build time.
|
|
1256
|
+
"@floating-ui/core": "^1.7.0",
|
|
368
1257
|
},
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
1258
|
+
// `--local` outside a workspace needs these.
|
|
1259
|
+
//
|
|
1260
|
+
// A `file:` dependency is installed from the package's OWN manifest,
|
|
1261
|
+
// and every @dogsbay/* manifest declares its siblings as `workspace:*`
|
|
1262
|
+
// — a protocol that only resolves inside the workspace. So `file:`
|
|
1263
|
+
// alone fails at install with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND, naming
|
|
1264
|
+
// a transitive dependency the caller never wrote down. Overrides map
|
|
1265
|
+
// every sibling to the same checkout, which is what makes `--local`
|
|
1266
|
+
// installable rather than merely emitted.
|
|
1267
|
+
...(options.local && !insideWs ? { pnpm: { overrides: monorepoOverrides() } } : {}),
|
|
1268
|
+
// NOTE deliberately NO vite override: Astro 7 pairs with
|
|
1269
|
+
// vite 8. The old `overrides: { vite: "^7" }` (an Astro 6
|
|
1270
|
+
// era guard) FORCED vite 7 under astro 7.0.x and broke every
|
|
1271
|
+
// fresh scaffold build ("rollupOptions.input should not be
|
|
1272
|
+
// an html file when building for SSR") — root-caused
|
|
1273
|
+
// 2026-07-13 on the first standalone comparison-site build.
|
|
376
1274
|
...(Object.keys(deployDevDeps).length > 0
|
|
377
1275
|
? { devDependencies: deployDevDeps }
|
|
378
1276
|
: {}),
|
|
@@ -380,6 +1278,16 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
380
1278
|
}
|
|
381
1279
|
else {
|
|
382
1280
|
scaffoldFilesSkipped++;
|
|
1281
|
+
// package.json is scaffold-once, but the @dogsbay/* libraries ship in
|
|
1282
|
+
// lockstep with the CLI. Re-sync their pins to the building CLI's version
|
|
1283
|
+
// on every build so upgrading the CLI (e.g. `npm i -g dogsbay@beta`) can't
|
|
1284
|
+
// leave the astro project on stale libraries whose older components don't
|
|
1285
|
+
// render the new emitter's markup. Versioned (published) style only —
|
|
1286
|
+
// `workspace:*` / `file:` dev links are left untouched.
|
|
1287
|
+
const synced = syncSiteDependencies(outputDir, options);
|
|
1288
|
+
if (synced.length > 0) {
|
|
1289
|
+
console.log(`Synced ${synced.length} @dogsbay/* dependency pin(s) to ${dogsbayPeerVersion}`);
|
|
1290
|
+
}
|
|
383
1291
|
}
|
|
384
1292
|
// wrangler.jsonc — scaffold-once. Bindings, secrets, custom routes
|
|
385
1293
|
// belong here post-generation.
|
|
@@ -391,6 +1299,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
391
1299
|
scaffoldFilesSkipped++;
|
|
392
1300
|
}
|
|
393
1301
|
}
|
|
1302
|
+
// GitHub Pages deploy artifacts — workflow + .nojekyll. The actual
|
|
1303
|
+
// emission lives in `emitDeployArtifacts` so site-build can also
|
|
1304
|
+
// call it on existing sites without going through scaffold (a user
|
|
1305
|
+
// adds `deploy: github-pages` to dogsbay.config.yml and reruns
|
|
1306
|
+
// `site build` to get the workflow). At scaffold-time we pass
|
|
1307
|
+
// forceOverwrite=writeScaffold so `--force` regenerates from
|
|
1308
|
+
// template; on regular builds it stays write-if-missing.
|
|
1309
|
+
if (isGitHubPages) {
|
|
1310
|
+
emitDeployArtifacts(outputDir, options, {
|
|
1311
|
+
forceOverwrite: writeScaffold,
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
394
1314
|
// Generate astro.config.mjs
|
|
395
1315
|
// `preserveSymlinks: true` is used with --local to pin local file: deps to
|
|
396
1316
|
// their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
|
|
@@ -411,44 +1331,42 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
411
1331
|
// wire @astrojs/sitemap here; that integration hardcodes output
|
|
412
1332
|
// to dist/ root, breaking multi-mount deploys.
|
|
413
1333
|
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
catch {
|
|
433
|
-
originOnly = options.siteUrl;
|
|
434
|
-
}
|
|
435
|
-
siteField = `\n site: ${JSON.stringify(originOnly)},`;
|
|
436
|
-
}
|
|
437
|
-
// astro.config.mjs — scaffold-once. Maintainer adds custom integrations.
|
|
438
|
-
// The plugin-aliases import is for the Dogsbay plugin API: each
|
|
439
|
-
// build emits `astro.config.plugins.mjs` exporting `pluginAliases`,
|
|
440
|
-
// a Vite alias map for `virtual:dogsbay-plugin-config/<id>` modules.
|
|
441
|
-
// When no plugins use defineClientConfig the map is empty and the
|
|
442
|
-
// spread is a no-op. See plans/plugin-api.md.
|
|
1334
|
+
// site.url's path component (if any) becomes Astro's `base`. The
|
|
1335
|
+
// origin alone goes into `site`. This split lets dogsbay model
|
|
1336
|
+
// both axes independently:
|
|
1337
|
+
// - Astro's `base` (= urlBase) controls the URL prefix Astro
|
|
1338
|
+
// bakes into HTML asset references (`<basePath>/_astro/...`)
|
|
1339
|
+
// and the routes Astro generates from src/pages.
|
|
1340
|
+
// - dogsbay's basePath controls the filesystem layout
|
|
1341
|
+
// (`src/pages/<basePath>/...`).
|
|
1342
|
+
// The two compose at emit time — combining for nav hrefs,
|
|
1343
|
+
// sitemap, llms.txt, etc. See plans/astro-base-from-site-url.md.
|
|
1344
|
+
const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
|
|
1345
|
+
// astro.config.mjs — scaffold-once, but the site/base values flow
|
|
1346
|
+
// through a separate auto-generated file (`astro.config.dogsbay.mjs`,
|
|
1347
|
+
// emitted unconditionally below) so dogsbay-derived values stay in
|
|
1348
|
+
// sync with `dogsbay.config.yml` even on existing sites where the
|
|
1349
|
+
// main config is preserved. Same pattern as
|
|
1350
|
+
// `astro.config.plugins.mjs` — the import line is the load-bearing
|
|
1351
|
+
// bit; the auto-file is what changes.
|
|
443
1352
|
if (writeScaffold) {
|
|
444
1353
|
writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
|
|
445
1354
|
import tailwindcss from "@tailwindcss/vite";
|
|
446
1355
|
import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
|
|
1356
|
+
import {
|
|
1357
|
+
dogsbaySite,
|
|
1358
|
+
dogsbayBase,
|
|
1359
|
+
dogsbayInlineStylesheets,
|
|
1360
|
+
dogsbayOutDir,
|
|
1361
|
+
} from "./astro.config.dogsbay.mjs";
|
|
447
1362
|
|
|
448
|
-
export default defineConfig({
|
|
1363
|
+
export default defineConfig({
|
|
1364
|
+
...(dogsbaySite ? { site: dogsbaySite } : {}),
|
|
1365
|
+
...(dogsbayBase ? { base: dogsbayBase } : {}),
|
|
1366
|
+
...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),
|
|
449
1367
|
output: "static",
|
|
450
1368
|
build: {
|
|
451
|
-
inlineStylesheets:
|
|
1369
|
+
inlineStylesheets: dogsbayInlineStylesheets,
|
|
452
1370
|
},
|
|
453
1371
|
vite: {
|
|
454
1372
|
plugins: [tailwindcss()],
|
|
@@ -471,6 +1389,9 @@ export default defineConfig({${siteField}
|
|
|
471
1389
|
else {
|
|
472
1390
|
scaffoldFilesSkipped++;
|
|
473
1391
|
}
|
|
1392
|
+
// astro.config.dogsbay.mjs is emitted by emitSiteConfig (called
|
|
1393
|
+
// above and on every site build) so site/base values stay in
|
|
1394
|
+
// sync without a re-scaffold. See its definition for rationale.
|
|
474
1395
|
// Always seed an empty astro.config.plugins.mjs so the import in
|
|
475
1396
|
// astro.config.mjs resolves before the first plugin-emitting
|
|
476
1397
|
// build. Subsequent builds replace it via emitPluginRuntime.
|
|
@@ -518,15 +1439,12 @@ export default defineConfig({${siteField}
|
|
|
518
1439
|
else {
|
|
519
1440
|
scaffoldFilesSkipped += 2; // global.css + theme.css
|
|
520
1441
|
}
|
|
521
|
-
// UI components
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
else {
|
|
528
|
-
scaffoldFilesSkipped++;
|
|
529
|
-
}
|
|
1442
|
+
// UI components. Per shadcn pattern, copies are owned by the project and
|
|
1443
|
+
// meant to be edited, so an existing component folder is never touched.
|
|
1444
|
+
// Missing ones are added on every build: the serializer starts emitting a
|
|
1445
|
+
// new component (Kbd, FileTree) in a release, and a site scaffolded before
|
|
1446
|
+
// it would otherwise fail with "failed to resolve @ui/kbd/Kbd.astro".
|
|
1447
|
+
copyComponents(outputDir);
|
|
530
1448
|
return scaffoldFilesSkipped;
|
|
531
1449
|
}
|
|
532
1450
|
// ─── Tier 1: content ────────────────────────────────────────────────────
|
|
@@ -539,10 +1457,123 @@ export default defineConfig({${siteField}
|
|
|
539
1457
|
* redirect. Returns the count of pages emitted and the merged nav for
|
|
540
1458
|
* downstream consumers (llms.txt builder).
|
|
541
1459
|
*/
|
|
1460
|
+
/**
|
|
1461
|
+
* Emit `src/pages/404.astro`.
|
|
1462
|
+
*
|
|
1463
|
+
* The scaffold already sets `not_found_handling: "404-page"` in
|
|
1464
|
+
* wrangler.jsonc, but never emitted a page for it — so Workers had
|
|
1465
|
+
* nothing to serve and returned a bare 404 with a ZERO-BYTE body.
|
|
1466
|
+
* Correct status, no way to recover: an agent that mistypes a URL, or
|
|
1467
|
+
* follows a stale link, learns only that the page is gone.
|
|
1468
|
+
*
|
|
1469
|
+
* So this lists the routes worth trying next — the site index, the
|
|
1470
|
+
* sitemap, and llms.txt — which is what turns a dead end into a
|
|
1471
|
+
* redirectable one. The links are plain and visible rather than
|
|
1472
|
+
* decorative, because the reader here is as likely to be a crawler as
|
|
1473
|
+
* a person.
|
|
1474
|
+
*
|
|
1475
|
+
* Regenerated every build, so the links track the site. Skipped when
|
|
1476
|
+
* the author has removed the marker, matching every other emitted
|
|
1477
|
+
* page.
|
|
1478
|
+
*/
|
|
1479
|
+
function emitNotFoundPage(outputDir, options) {
|
|
1480
|
+
const path = join(outputDir, "src", "pages", "404.astro");
|
|
1481
|
+
if (existsSync(path)) {
|
|
1482
|
+
const existing = readFileSync(path, "utf-8");
|
|
1483
|
+
if (!existing.includes("AUTO-GENERATED by dogsbay site build"))
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
const combined = combinedPrefix(options);
|
|
1487
|
+
const home = combined || "/";
|
|
1488
|
+
const sitemap = withBasePath(combined, "/sitemap-index.xml");
|
|
1489
|
+
const llms = withBasePath(combined, "/llms.txt");
|
|
1490
|
+
const showLlms = options.llmsTxt !== false;
|
|
1491
|
+
// The product menu (only its menu: the page is in no product, so it reads "Product")
|
|
1492
|
+
const productMenu = options.products ? productMenuRows(options.products, combined) !== undefined : false;
|
|
1493
|
+
writeFileSync(path, `---
|
|
1494
|
+
// AUTO-GENERATED by dogsbay site build — safe to delete; will regenerate next build.
|
|
1495
|
+
// To customize this page, remove the marker line above (or modify the file
|
|
1496
|
+
// in any way that drops it) and dogsbay will leave your edits alone.
|
|
1497
|
+
import "@/styles/global.css";
|
|
1498
|
+
import DocsLayout from "@dogsbay/docs-layout/DocsLayout.astro";
|
|
1499
|
+
import type { SiteConfig } from "@dogsbay/types";
|
|
1500
|
+
import navData from "@/data/nav.json";
|
|
1501
|
+
import siteConfigData from "@/data/site.json";${productMenu ? `
|
|
1502
|
+
import switcherMapData from "@/data/switcherMap.json";` : ""}
|
|
1503
|
+
const siteConfig = siteConfigData as SiteConfig;
|
|
1504
|
+
const favicon = (siteConfigData as { favicon?: string }).favicon;
|
|
1505
|
+
---
|
|
1506
|
+
|
|
1507
|
+
<DocsLayout
|
|
1508
|
+
siteName={siteConfig.siteName}
|
|
1509
|
+
siteTagline={siteConfig.tagline}
|
|
1510
|
+
title="Page not found"
|
|
1511
|
+
description="That page does not exist. Here is where to look instead."
|
|
1512
|
+
nav={navData as never}
|
|
1513
|
+
siteUrl={siteConfig.siteUrl}
|
|
1514
|
+
favicon={favicon}
|
|
1515
|
+
noindex={true}
|
|
1516
|
+
excludeFromSearch={true}
|
|
1517
|
+
siteChrome={(siteConfigData as { chrome?: never }).chrome}
|
|
1518
|
+
copyright={(siteConfig as { chrome?: unknown }).chrome ? siteConfig.copyright || undefined : undefined}
|
|
1519
|
+
ogType="website"${productMenu ? `
|
|
1520
|
+
switcherMap={{ versions: [], byLogicalKey: {}, products: switcherMapData.products } as never}` : ""}
|
|
1521
|
+
>
|
|
1522
|
+
<article class="docs-prose">
|
|
1523
|
+
<h1>Page not found</h1>
|
|
1524
|
+
<p>There is no page at this address. It may have moved, or the link
|
|
1525
|
+
that brought you here may be out of date.</p>
|
|
1526
|
+
<p>Where to look instead:</p>
|
|
1527
|
+
<ul>
|
|
1528
|
+
<li><a href="${home}">Site index</a> — start here</li>
|
|
1529
|
+
<li><a href="${sitemap}">sitemap-index.xml</a> — every page on this site</li>${showLlms
|
|
1530
|
+
? `
|
|
1531
|
+
<li><a href="${llms}">llms.txt</a> — the same index, for agents</li>`
|
|
1532
|
+
: ""}
|
|
1533
|
+
</ul>
|
|
1534
|
+
</article>
|
|
1535
|
+
</DocsLayout>
|
|
1536
|
+
`);
|
|
1537
|
+
}
|
|
1538
|
+
/**
|
|
1539
|
+
* Nav entries come from a project's own TOC files (toctrees, `_toc.yml`,
|
|
1540
|
+
* `myst.yml`, nav.yml), i.e. from content. An href with a scheme other than
|
|
1541
|
+
* http(s)/mailto/tel — `javascript:` above all — is dropped (the entry keeps
|
|
1542
|
+
* its label and children), so it cannot reach the sidebar, nav.json or a
|
|
1543
|
+
* page's previous/next link.
|
|
1544
|
+
*/
|
|
1545
|
+
export function dropUnsafeNavHrefs(items) {
|
|
1546
|
+
return items.map((item) => {
|
|
1547
|
+
const out = { ...item };
|
|
1548
|
+
if (typeof out.href === "string") {
|
|
1549
|
+
// Browsers strip tabs, newlines and other C0 controls from a URL
|
|
1550
|
+
// (`java\tscript:` IS javascript:), so the scheme is tested without them.
|
|
1551
|
+
const probe = out.href.replace(/[\u0000-\u0020]/g, "");
|
|
1552
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(probe) && !/^(?:https?|mailto|tel):/i.test(probe))
|
|
1553
|
+
delete out.href;
|
|
1554
|
+
}
|
|
1555
|
+
if (out.children)
|
|
1556
|
+
out.children = dropUnsafeNavHrefs(out.children);
|
|
1557
|
+
return out;
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
542
1560
|
export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
1561
|
+
nav = dropUnsafeNavHrefs(nav);
|
|
543
1562
|
const siteName = options.siteName || "Documentation";
|
|
1563
|
+
// basePath = filesystem layout prefix (where pages live under
|
|
1564
|
+
// src/pages/...). combined = the URL prefix HTML hrefs need
|
|
1565
|
+
// (urlBase + basePath). The two diverge whenever site.url has a
|
|
1566
|
+
// path component (GH Pages project pages, multi-mount Cloudflare).
|
|
544
1567
|
const basePath = normalizeBasePath(options.basePath);
|
|
1568
|
+
const combined = combinedPrefix(options);
|
|
545
1569
|
const baseSegments = basePathSegments(basePath);
|
|
1570
|
+
// Base Pagefind prefixes RESULT urls with: the served path dist/'s root
|
|
1571
|
+
// maps to. Same rule as docs-layout's pagefindBaseUrl(); see the comment
|
|
1572
|
+
// on the pagefindBaseUrl prop below. Computed once, not per page.
|
|
1573
|
+
const pageUrlBase = parseSiteUrl(options.siteUrl).urlBase;
|
|
1574
|
+
const searchBaseUrl = workersSubpathOutDir(options.deploy, pageUrlBase)
|
|
1575
|
+
? "/"
|
|
1576
|
+
: `${pageUrlBase}/`;
|
|
546
1577
|
// Ensure dirs exist (callers may invoke us without going through the
|
|
547
1578
|
// full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
|
|
548
1579
|
mkdirSync(join(outputDir, "src", "pages", ...baseSegments), { recursive: true });
|
|
@@ -562,35 +1593,265 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
562
1593
|
// Remove existing entry for this section (full replace)
|
|
563
1594
|
existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
|
|
564
1595
|
&& item.label?.toLowerCase() !== section.toLowerCase());
|
|
565
|
-
|
|
1596
|
+
// Nav hrefs already carry the `combined` prefix (the importer
|
|
1597
|
+
// emits them via fileToHref(file, hrefPrefix=combined)).
|
|
1598
|
+
// prefixNavHrefs takes the existing prefix and weaves a section
|
|
1599
|
+
// segment into it.
|
|
1600
|
+
const prefixedNav = prefixNavHrefs(nav, section, combined);
|
|
566
1601
|
const sectionLabel = siteName
|
|
567
1602
|
|| section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
568
1603
|
existingNav.push({ label: sectionLabel, children: prefixedNav });
|
|
569
1604
|
outputNav = existingNav;
|
|
570
1605
|
}
|
|
1606
|
+
// Safety net: ensure every nav href carries the combined URL base,
|
|
1607
|
+
// regardless of which importer produced the nav. Idempotent — a nav
|
|
1608
|
+
// already prefixed at import time (dogsbay-md walking source paths) is
|
|
1609
|
+
// left untouched; a nav of pre-baked root-absolute hrefs (docusaurus
|
|
1610
|
+
// convert) gets the base it was missing. Keeps nav hrefs aligned with
|
|
1611
|
+
// the combined-prefixed `currentPath` so prev/next still matches.
|
|
1612
|
+
outputNav = prefixNavBaseHrefs(outputNav, combined);
|
|
571
1613
|
writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(outputNav, null, 2));
|
|
1614
|
+
// Also publish nav.json under public/_dogsbay/ so the
|
|
1615
|
+
// client-mode <DocsNavClient /> can fetch it at runtime
|
|
1616
|
+
// (Astro copies public/ to dist/ as-is at build). The src/data
|
|
1617
|
+
// copy stays for build-time imports (pagination, prev/next
|
|
1618
|
+
// calculation in each page) — the public copy is the on-the-wire
|
|
1619
|
+
// copy that the browser ever sees. Kept identical (same bytes,
|
|
1620
|
+
// same shape); the duplicate is cheap (one file) and keeps the
|
|
1621
|
+
// build-time and runtime worlds cleanly separated. See
|
|
1622
|
+
// plans/client-rendered-nav.md.
|
|
1623
|
+
// …UNDER the basePath, because that is where the client fetches it
|
|
1624
|
+
// from (`${basePath}/_dogsbay/nav.json`) and because two sites mounted
|
|
1625
|
+
// on one origin — e.g. two release-comparison pairs — would otherwise
|
|
1626
|
+
// both claim `/_dogsbay/nav.json` and collide. Found by the comparison
|
|
1627
|
+
// acceptance suite: the nav silently never hydrated (404), so no page
|
|
1628
|
+
// marks rendered at all.
|
|
1629
|
+
const publicNavDir = join(outputDir, "public", ...basePathSegments(normalizeBasePath(options.basePath)), "_dogsbay");
|
|
1630
|
+
mkdirSync(publicNavDir, { recursive: true });
|
|
1631
|
+
writeFileSync(join(publicNavDir, "nav.json"), JSON.stringify(outputNav));
|
|
572
1632
|
// Static assets (images etc.) — content-tier; always copy from the
|
|
573
1633
|
// user's source dir. If they removed an asset, we want it gone here
|
|
574
1634
|
// too. Skipped when sourceDir isn't supplied (programmatic callers
|
|
575
1635
|
// that want pure page emission).
|
|
576
|
-
if (options.
|
|
1636
|
+
if (options.assetSourceDirs && options.assetSourceDirs.length > 0) {
|
|
1637
|
+
// Multi-source: copy EACH source's assets under its version/locale
|
|
1638
|
+
// URL prefix (public/<prefix>/...), matching the per-source ref
|
|
1639
|
+
// rewrite. Replaces the single-sourceDir copy, which only handled
|
|
1640
|
+
// the first source and never version-namespaced.
|
|
1641
|
+
for (const { dir, urlPrefix } of options.assetSourceDirs) {
|
|
1642
|
+
copyAssets(dir, outputDir, options.imageOptimization, urlPrefix);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
else if (options.sourceDir) {
|
|
577
1646
|
copyAssets(options.sourceDir, outputDir, options.imageOptimization);
|
|
578
1647
|
}
|
|
1648
|
+
// External asset mounts (e.g. a Docusaurus `static/` dir) → public/_assets/,
|
|
1649
|
+
// preserving internal structure so the importer's `/_assets/...` image refs
|
|
1650
|
+
// resolve. Used by the convert --to astro path; the dogsbay-md → site build
|
|
1651
|
+
// path instead lands these under content/_assets and copyAssets picks them up.
|
|
1652
|
+
// A mount may target an `_assets` subdir (`into`) and restrict itself to
|
|
1653
|
+
// media files (`mediaOnly`) — used for docs-co-located images where the
|
|
1654
|
+
// mounted dir is the docs tree itself.
|
|
1655
|
+
if (options.assetMounts) {
|
|
1656
|
+
for (const mount of options.assetMounts) {
|
|
1657
|
+
if (!existsSync(mount.dir))
|
|
1658
|
+
continue;
|
|
1659
|
+
const dest = mount.into
|
|
1660
|
+
? join(outputDir, "public", mount.root ?? "_assets", mount.into)
|
|
1661
|
+
: join(outputDir, "public", mount.root ?? "_assets");
|
|
1662
|
+
const skipped = new Set((mount.skip ?? []).map((f) => join(mount.dir, f)));
|
|
1663
|
+
if (mount.mediaOnly)
|
|
1664
|
+
copyMediaTree(mount.dir, dest, skipped);
|
|
1665
|
+
// a link inside a mount is not followed or copied (it could name any file on the machine)
|
|
1666
|
+
else
|
|
1667
|
+
cpSync(mount.dir, dest, { recursive: true, filter: (src) => !skipped.has(src) && (src === mount.dir || !lstatSync(src).isSymbolicLink()) });
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
579
1670
|
let generated = 0;
|
|
1671
|
+
// Tracks whether a real root home page (`content/index.md` → slug
|
|
1672
|
+
// "index") was emitted. Drives the root-served-site redirect fallback
|
|
1673
|
+
// below: a corpus whose nav root is e.g. `welcome/index` (OpenShift)
|
|
1674
|
+
// has no `/` page, so without a redirect `/` 404s. See
|
|
1675
|
+
// plans/dir-index-slug-nav-drop.md.
|
|
1676
|
+
let rootIndexEmitted = false;
|
|
1677
|
+
const generatedPaths = new Set();
|
|
1678
|
+
let pageFailures = 0;
|
|
580
1679
|
const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
|
|
581
1680
|
const useImageOpt = options.imageOptimization ?? false;
|
|
582
|
-
// hrefPrefix is the
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
|
|
587
|
-
|
|
1681
|
+
// hrefPrefix is the COMBINED prefix (urlBase + basePath) — what
|
|
1682
|
+
// rendered HTML hrefs need so internal links resolve under the
|
|
1683
|
+
// host's served subpath AND under the dogsbay basePath. For
|
|
1684
|
+
// simple host-apex deploys with basePath, urlBase is empty so
|
|
1685
|
+
// combined === basePath (back-compat). For GH Pages project pages
|
|
1686
|
+
// and multi-mount Cloudflare, combined adds the urlBase layer.
|
|
1687
|
+
const hrefPrefix = combined;
|
|
1688
|
+
const mirrorMd = mirrorSerializer(options, pages);
|
|
1689
|
+
// Route-exclusion gate. Two signals, either is enough:
|
|
1690
|
+
// - frontmatter `_fragment: true` (loader-stamped — the importer
|
|
1691
|
+
// knew this .md was include-only, not a navigable page)
|
|
1692
|
+
// - excludeFromRoutes match against the slug (project-declared,
|
|
1693
|
+
// in dogsbay.config.yml — for content under conventional
|
|
1694
|
+
// fragment dirs like `modules/`, `_attributes/`, `snippets/`)
|
|
1695
|
+
// Excluded pages stay on disk under content/ (so includes resolve)
|
|
1696
|
+
// but don't produce .astro / .md.ts routes. See plans/build-at-scale.md.
|
|
1697
|
+
//
|
|
1698
|
+
// Match semantics:
|
|
1699
|
+
// - A single-segment pattern (no `/`) matches ANY occurrence of
|
|
1700
|
+
// that segment in the slug path. So `modules` excludes both
|
|
1701
|
+
// `modules/foo` AND `welcome/modules/foo` AND
|
|
1702
|
+
// `drupal-build/openshift-enterprise/ai/includes/x`. This is
|
|
1703
|
+
// what AsciiBinder corpora want — fragment dirs symlink into
|
|
1704
|
+
// section dirs so the same `includes/` etc. appears at many
|
|
1705
|
+
// depths.
|
|
1706
|
+
// - A multi-segment pattern (contains `/`) matches as a leading
|
|
1707
|
+
// prefix (exact path-prefix). `welcome/_internal` excludes
|
|
1708
|
+
// only that specific path tree, not arbitrary `_internal/`
|
|
1709
|
+
// elsewhere.
|
|
1710
|
+
const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
|
|
1711
|
+
// Granularity (additive book view): roll each top-level nav group into one
|
|
1712
|
+
// `_book` page emitted alongside the topics. Kept out of nav; quarantined via
|
|
1713
|
+
// noindex + excludeFromSearch frontmatter so robots/sitemap/pagefind exclude
|
|
1714
|
+
// it, and — being local to this emitter — it never reaches the llms.txt builder
|
|
1715
|
+
// either. The `_book` page renders through the same path as any other page.
|
|
1716
|
+
// See plans/granularity-views.md.
|
|
1717
|
+
const bookPages = [];
|
|
1718
|
+
// topic slug → "<bookRoute>#<anchor>" — the per-topic "Read as single page"
|
|
1719
|
+
// button target. Empty unless granularity.book is on.
|
|
1720
|
+
const singlePageBySlug = new Map();
|
|
1721
|
+
// Emit a single-page (book) per nav node whose EFFECTIVE granularity resolves
|
|
1722
|
+
// to "yes". The annotation lives on each nav node (`singlePage`):
|
|
1723
|
+
// - "yes" → roll up this node's subtree into one page.
|
|
1724
|
+
// - "children" → make each direct child default to "yes" (chapters).
|
|
1725
|
+
// - "no" → no page (the default).
|
|
1726
|
+
// `granularity.book: true` is the legacy all-on switch: it seeds the top level
|
|
1727
|
+
// as "yes", so every top-level group rolls up — a node's own annotation still
|
|
1728
|
+
// wins. (This comment previously said "children", which is the same OUTCOME
|
|
1729
|
+
// but the wrong value, and disagreed with the config schema's wording.) A rollup whose estimated source exceeds maxSinglePageMB is SKIPPED with
|
|
1730
|
+
// a warning (no silent multi-minute page). See plans/granularity-views.md.
|
|
1731
|
+
const maxBookBytes = Math.round((options.granularity?.maxSinglePageMB ?? 3) * 1024 * 1024);
|
|
1732
|
+
const rootInheritYes = options.granularity?.book === true;
|
|
1733
|
+
const emitBook = (nav, label) => {
|
|
1734
|
+
const book = assembleBook(nav, pages, { hrefPrefix: combined, title: label });
|
|
1735
|
+
if (!book)
|
|
1736
|
+
return;
|
|
1737
|
+
const bytes = estimateTreeBytes(book.page.tree);
|
|
1738
|
+
if (bytes > maxBookBytes) {
|
|
1739
|
+
console.warn(`[dogsbay] granularity: skipping single-page "${label}" — ~${(bytes / 1048576).toFixed(1)} MB ` +
|
|
1740
|
+
`exceeds maxSinglePageMB (${options.granularity?.maxSinglePageMB ?? 3}). ` +
|
|
1741
|
+
`Mark it "single-page: children" in the nav to split it into chapters.`);
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
book.page.frontmatter = { ...book.page.frontmatter, excludeFromSearch: true };
|
|
1745
|
+
bookPages.push(book.page);
|
|
1746
|
+
const bookRoute = combined ? `${combined}/${book.page.slug}` : `/${book.page.slug}`;
|
|
1747
|
+
for (const [slug, anchor] of book.topicAnchors) {
|
|
1748
|
+
singlePageBySlug.set(slug, `${bookRoute}#${anchor}`);
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
const walkBooks = (items, inheritYes) => {
|
|
1752
|
+
for (const item of items) {
|
|
1753
|
+
const effective = item.singlePage ?? (inheritYes ? "yes" : "no");
|
|
1754
|
+
if (effective === "yes" && item.children?.length) {
|
|
1755
|
+
emitBook(item.children, item.label);
|
|
1756
|
+
}
|
|
1757
|
+
if (item.children?.length)
|
|
1758
|
+
walkBooks(item.children, effective === "children");
|
|
1759
|
+
}
|
|
1760
|
+
};
|
|
1761
|
+
const hasAnnotation = (items) => items.some((i) => i.singlePage !== undefined || (i.children && hasAnnotation(i.children)));
|
|
1762
|
+
if (rootInheritYes || hasAnnotation(outputNav)) {
|
|
1763
|
+
walkBooks(outputNav, rootInheritYes);
|
|
1764
|
+
}
|
|
1765
|
+
const emitPages = bookPages.length ? [...pages, ...bookPages] : pages;
|
|
1766
|
+
// Blog mode: compute the post list ONCE, then read adjacency and
|
|
1767
|
+
// reading time per page out of it. Both derive from the same sorted
|
|
1768
|
+
// list, so a post's "next" and its neighbour's "prev" cannot disagree.
|
|
1769
|
+
// Book rollups are excluded — they are quarantined aggregates, not posts.
|
|
1770
|
+
const blogData = options.blog
|
|
1771
|
+
? buildBlogData(pages, {
|
|
1772
|
+
basePath,
|
|
1773
|
+
section: options.section,
|
|
1774
|
+
excludeSlug: isExcludedSlug,
|
|
1775
|
+
// Post links must carry the COMBINED prefix — the path the host
|
|
1776
|
+
// serves under. With basePath alone, a site mounted at /blog
|
|
1777
|
+
// emitted /second-post/, which is outside its own route.
|
|
1778
|
+
urlPrefix: combinedPrefix(options),
|
|
1779
|
+
config: options.blog,
|
|
1780
|
+
})
|
|
1781
|
+
: undefined;
|
|
1782
|
+
const blogAdj = blogData ? blogAdjacency(blogData) : undefined;
|
|
1783
|
+
const blogMinutes = new Map(blogData ? blogData.posts.map((post) => [post.slug, post.readingMinutes]) : []);
|
|
1784
|
+
// Older versions' pages name the default version's page as canonical (src/canonical.ts).
|
|
1785
|
+
const isFragmentPage = (p) => p.fragment === true || p.frontmatter?._fragment === true;
|
|
1786
|
+
// The version served at the root (`path: ""`) and the other versions'
|
|
1787
|
+
// segments: the nav filter keeps the root version's bucket by excluding them.
|
|
1788
|
+
const rootVersion = rootVersionOf(options);
|
|
1789
|
+
const otherVersionSegments = rootVersion
|
|
1790
|
+
? pageVersions(pages).filter((v) => v !== rootVersion)
|
|
1791
|
+
: [];
|
|
1792
|
+
const canonical = canonicalPlan(pages, {
|
|
1793
|
+
isEmitted: (p) => !isFragmentPage(p) && !isExcludedSlug(p.slug),
|
|
1794
|
+
siteUrl: options.siteUrl,
|
|
1795
|
+
basePath: combined,
|
|
1796
|
+
versions: options.versions,
|
|
1797
|
+
defaultVersion: options.defaultVersion,
|
|
1798
|
+
products: options.products,
|
|
1799
|
+
redirects: options.redirects,
|
|
1800
|
+
});
|
|
1801
|
+
// hreflang alternates, WebSite on home pages, BreadcrumbList from the nav (src/head-metadata.ts).
|
|
1802
|
+
const head = headMetadata(pages, outputNav, {
|
|
1803
|
+
siteUrl: options.siteUrl,
|
|
1804
|
+
basePath: combined,
|
|
1805
|
+
defaultLocale: options.defaultLocale,
|
|
1806
|
+
isEmitted: (p) => !isFragmentPage(p) && !isExcludedSlug(p.slug),
|
|
1807
|
+
indexable: (p) => {
|
|
1808
|
+
const c = canonical.get(p.slug);
|
|
1809
|
+
return !options.noindex && !c?.url && !c?.noindex && p.frontmatter?.noindex !== true;
|
|
1810
|
+
},
|
|
1811
|
+
});
|
|
1812
|
+
for (const page of emitPages) {
|
|
588
1813
|
try {
|
|
1814
|
+
// Skip excluded pages before any expensive work (tree rewrite,
|
|
1815
|
+
// serialize, IO).
|
|
1816
|
+
// BOTH signals. `ExportPage.fragment` is the typed field
|
|
1817
|
+
// (`@dogsbay/types` format.ts) that format-docusaurus sets in structural
|
|
1818
|
+
// mode; `frontmatter._fragment` is the loader-stamped one. Checking only
|
|
1819
|
+
// the second emitted every Docusaurus partial as its own routable,
|
|
1820
|
+
// empty-titled, orphan page — duplicating content already inlined into
|
|
1821
|
+
// the pages that included it, and landing in sitemap and search.
|
|
1822
|
+
const isFragment = page.fragment === true ||
|
|
1823
|
+
(page.frontmatter && page.frontmatter._fragment === true);
|
|
1824
|
+
if (isFragment || isExcludedSlug(page.slug)) {
|
|
1825
|
+
continue;
|
|
1826
|
+
}
|
|
1827
|
+
// A page that declares `redirect:` is not a page — it is the old URL of
|
|
1828
|
+
// one. Emitting it produced a live, empty, titled page at the slug the
|
|
1829
|
+
// author was trying to retire, which is the bug this whole feature
|
|
1830
|
+
// exists for (plans/redirects-and-aliases.md). The rule is emitted by
|
|
1831
|
+
// emitConfigDerivedFiles; here we simply do not write the page.
|
|
1832
|
+
if (typeof page.redirect === "string" && page.redirect.trim()) {
|
|
1833
|
+
continue;
|
|
1834
|
+
}
|
|
1835
|
+
// content.html: sanitize filters raw HTML and directive attributes, as dogsbay-ssg
|
|
1836
|
+
// does (script links are refused by the serializer either way: isSafeHref).
|
|
1837
|
+
if (options.html === "sanitize") {
|
|
1838
|
+
const removed = new Set();
|
|
1839
|
+
page.tree = sanitizeTree(page.tree, (_code, message) => removed.add(message));
|
|
1840
|
+
for (const m of removed)
|
|
1841
|
+
console.warn(`[html] ${page.slug || "/"}: ${m}`);
|
|
1842
|
+
}
|
|
589
1843
|
// Rewrite internal hrefs to match the output URL structure
|
|
590
1844
|
rewriteTreeHrefs(page.tree, hrefPrefix);
|
|
1845
|
+
// Same for raw image srcs — Astro doesn't auto-prefix
|
|
1846
|
+
// `<img src="/_assets/...">` so we do it here. Block images
|
|
1847
|
+
// strip the prefix back off for the `imageMap[...]` lookup
|
|
1848
|
+
// (see paragraphToAstro in serialize.ts).
|
|
1849
|
+
rewriteTreeImageSrcs(page.tree, hrefPrefix);
|
|
591
1850
|
const result = treeToAstro(page.tree, {
|
|
592
1851
|
imageOptimization: useImageOpt,
|
|
593
1852
|
codeBlockTitle: options.codeBlockTitle ?? true,
|
|
1853
|
+
imageCaptions: options.imageCaptions === "title",
|
|
1854
|
+
combinedPrefix: hrefPrefix,
|
|
594
1855
|
});
|
|
595
1856
|
const imageSetup = useImageOpt ? [
|
|
596
1857
|
'',
|
|
@@ -604,11 +1865,35 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
604
1865
|
' imageMap[publicPath] = mod.default;',
|
|
605
1866
|
'}',
|
|
606
1867
|
] : [];
|
|
607
|
-
// Per-page meta from frontmatter
|
|
1868
|
+
// Per-page meta from frontmatter. When no description is authored,
|
|
1869
|
+
// derive one from the first prose paragraph so every page emits a
|
|
1870
|
+
// <meta name="description"> (Lighthouse flags pages without one).
|
|
1871
|
+
// See plans/auto-meta-description.md.
|
|
608
1872
|
const fm = (page.frontmatter ?? {});
|
|
609
|
-
|
|
1873
|
+
// A writer-authored frontmatter description is meta-only (not in the body),
|
|
1874
|
+
// so it's safe to also render as a visible lede. A DERIVED description is
|
|
1875
|
+
// pulled FROM the body, so rendering it as a lede always duplicates content
|
|
1876
|
+
// (e.g. a page whose first paragraph sits under "## Big picture"). Keep the
|
|
1877
|
+
// two separate: derive for the <meta> tag, but only lede the authored one.
|
|
1878
|
+
const fmDescription = typeof fm.description === "string" && fm.description.trim().length > 0
|
|
1879
|
+
? fm.description
|
|
1880
|
+
: undefined;
|
|
1881
|
+
const pageDescription = fmDescription || deriveDescription(page.tree) || "";
|
|
610
1882
|
const pageOgImage = fm.ogImage ?? "";
|
|
611
|
-
|
|
1883
|
+
// Noindex / nofollow are independent meta directives. Site-level
|
|
1884
|
+
// forces both bits site-wide (staging / compliance lockdown);
|
|
1885
|
+
// page frontmatter can ESCALATE either bit independently but
|
|
1886
|
+
// cannot opt out of a site-level lockdown. `draft: true` keeps
|
|
1887
|
+
// its existing role as a noindex shorthand. See
|
|
1888
|
+
// plans/site-level-robots-meta.md.
|
|
1889
|
+
const pageCanonical = canonical.get(page.slug);
|
|
1890
|
+
const pageHead = head.get(page.slug);
|
|
1891
|
+
const pageNoindex = options.noindex === true ||
|
|
1892
|
+
fm.noindex === true ||
|
|
1893
|
+
fm.draft === true ||
|
|
1894
|
+
pageCanonical?.noindex === true;
|
|
1895
|
+
const pageNofollow = options.nofollow === true ||
|
|
1896
|
+
fm.nofollow === true;
|
|
612
1897
|
// Independent of noindex: pages can be excluded from in-site
|
|
613
1898
|
// Pagefind search even when external SEs should index them
|
|
614
1899
|
// (or vice versa). See DocsLayout's prop docs for the
|
|
@@ -627,6 +1912,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
627
1912
|
const pageCategory = Array.isArray(pageMeta?.category)
|
|
628
1913
|
? pageMeta.category
|
|
629
1914
|
: undefined;
|
|
1915
|
+
// "Last updated" date → DocsFooter. `updated` is the canonical PageMeta
|
|
1916
|
+
// field (parseMeta); also accept `lastUpdated` / `lastmod` for sitemap
|
|
1917
|
+
// parity. Plain string, rendered as-is.
|
|
1918
|
+
const rawLastUpdated = (typeof pageMeta?.updated === "string" && pageMeta.updated) ||
|
|
1919
|
+
(typeof fm.updated === "string" && fm.updated) ||
|
|
1920
|
+
(typeof fm.lastUpdated === "string" && fm.lastUpdated) ||
|
|
1921
|
+
(typeof fm.lastmod === "string" && fm.lastmod) ||
|
|
1922
|
+
null;
|
|
1923
|
+
// Canonical meta normalises to a full ISO datetime ("…T00:00:00.000Z").
|
|
1924
|
+
// Drop the time for display — these are dates, not timestamps. The footer
|
|
1925
|
+
// prettifies further; non-ISO strings pass through untouched.
|
|
1926
|
+
const pageLastUpdated = typeof rawLastUpdated === "string" && /^\d{4}-\d{2}-\d{2}T/.test(rawLastUpdated)
|
|
1927
|
+
? rawLastUpdated.slice(0, 10)
|
|
1928
|
+
: rawLastUpdated;
|
|
630
1929
|
// Custom-taxonomy values lifted from frontmatter into
|
|
631
1930
|
// `meta.taxonomies` by the importer (see `parseMeta` in
|
|
632
1931
|
// `@dogsbay/types`). Surfaced to DocsLayout so it can emit one
|
|
@@ -638,12 +1937,25 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
638
1937
|
const pageTaxonomies = pageMeta?.taxonomies && Object.keys(pageMeta.taxonomies).length > 0
|
|
639
1938
|
? pageMeta.taxonomies
|
|
640
1939
|
: undefined;
|
|
1940
|
+
// The page's search facets, by the mapping dogsbay-ssg's index uses
|
|
1941
|
+
// too; DocsLayout writes them as Pagefind filters. Computed here
|
|
1942
|
+
// because only the build knows which taxonomies are hierarchical.
|
|
1943
|
+
const pageFacetValues = pageFacets(pageMeta, {
|
|
1944
|
+
tagPrefixes: options.tagPrefixes,
|
|
1945
|
+
hierarchical: options.hierarchicalTaxonomies,
|
|
1946
|
+
unit: { product: page.multiSource?.namespace, version: page.multiSource?.version },
|
|
1947
|
+
});
|
|
641
1948
|
// `tagsIndexPath` flows to `<TagList>` for chip hrefs
|
|
642
1949
|
// (`${indexPath}/${tag}/`). Caller passes the raw config value
|
|
643
|
-
// (e.g. `/tags`); we bake
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
|
|
1950
|
+
// (e.g. `/tags`); we bake the COMBINED prefix (urlBase from
|
|
1951
|
+
// site.url's path + basePath) here so chips resolve under both
|
|
1952
|
+
// the host's served subpath AND the dogsbay basePath. With
|
|
1953
|
+
// basePath alone, chips 404 on GH Pages project deploys
|
|
1954
|
+
// (basePath="" + non-empty urlBase) — same shape as the
|
|
1955
|
+
// typeBadgeHref / statusBadgeHref composition in DocsLayout,
|
|
1956
|
+
// which already reads combined-prefixed values out of
|
|
1957
|
+
// siteConfig.taxonomyIndexPaths (baked in buildSiteConfig).
|
|
1958
|
+
const tagsIndexPath = withBasePath(combined, options.tagsIndexPath ?? "/tags");
|
|
647
1959
|
// Auto-lede detection. If the markdown body doesn't already
|
|
648
1960
|
// start with an H1 / leading paragraph, we ask DocsLayout to
|
|
649
1961
|
// render the frontmatter title / description at the top of
|
|
@@ -657,7 +1969,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
657
1969
|
page.title.length > 0;
|
|
658
1970
|
const autoLede = !isRedirect &&
|
|
659
1971
|
!bodyHasLede &&
|
|
660
|
-
|
|
1972
|
+
fmDescription !== undefined;
|
|
661
1973
|
// Per-page LLM action UI. Site-wide config in
|
|
662
1974
|
// `options.llmActions`; per-page opt-out via frontmatter
|
|
663
1975
|
// `llmActions: false`. Skipped on redirect pages (no real
|
|
@@ -675,12 +1987,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
675
1987
|
// available via other means. The "Open in" deep links work
|
|
676
1988
|
// regardless of mirror availability — agents that can't fetch
|
|
677
1989
|
// the page just see the URL in their chat.
|
|
1990
|
+
// pageHrefBase uses combined (urlBase + basePath) so the URL
|
|
1991
|
+
// resolves correctly when the host serves dist/ at a subpath
|
|
1992
|
+
// (GH Pages project page, multi-mount Cloudflare).
|
|
678
1993
|
const pageHrefBase = section
|
|
679
|
-
? (
|
|
680
|
-
: (
|
|
1994
|
+
? (combined ? `${combined}/${section}/${page.slug}` : `/${section}/${page.slug}`)
|
|
1995
|
+
: (combined ? `${combined}/${page.slug}` : `/${page.slug}`);
|
|
681
1996
|
const pageMdHref = `${pageHrefBase}.md`;
|
|
682
|
-
|
|
683
|
-
|
|
1997
|
+
// For absolute URLs (the "Copy as MD" deep link), use the
|
|
1998
|
+
// origin (no path) + the full combined path; siteUrl alone
|
|
1999
|
+
// would double-include the urlBase since pageHrefBase already
|
|
2000
|
+
// contains it.
|
|
2001
|
+
const { origin } = parseSiteUrl(options.siteUrl);
|
|
2002
|
+
const pageMdAbsoluteUrl = origin
|
|
2003
|
+
? origin + pageMdHref
|
|
684
2004
|
: pageMdHref;
|
|
685
2005
|
// Markdown body for the Copy button. Reuse the same serializer
|
|
686
2006
|
// that produces the .md mirror so what the user copies matches
|
|
@@ -696,7 +2016,13 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
696
2016
|
// These pages compose their own internal columns and need the
|
|
697
2017
|
// full inset width; the prose-readable cap and the right TOC
|
|
698
2018
|
// would just steal pixels. See plans/openapi-builtin.md.
|
|
699
|
-
const wideLayout = (page.tree
|
|
2019
|
+
const wideLayout = apiPageLayout(page.tree).wide;
|
|
2020
|
+
// TOC placement (DocsLayout `toc` prop). Default "top" — the
|
|
2021
|
+
// expandable "On this page" disclosure that frees the right rail
|
|
2022
|
+
// for plugin content (e.g. Ask AI). "rail" keeps the classic
|
|
2023
|
+
// right-hand TOC; in that mode the rail belongs to the TOC, so the
|
|
2024
|
+
// RightRail plugin region (slot + stack) is not emitted.
|
|
2025
|
+
const tocMode = options.toc ?? "top";
|
|
700
2026
|
const pageLines = [
|
|
701
2027
|
"---",
|
|
702
2028
|
"// AUTO-GENERATED by `dogsbay convert` — do not edit.",
|
|
@@ -716,13 +2042,22 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
716
2042
|
// Always exists; passthrough <slot /> when no plugin
|
|
717
2043
|
// contributes a wrapper for the MarkdownContent slot.
|
|
718
2044
|
'import MarkdownContentStack from "@/components/wrappers/MarkdownContentStack.astro";',
|
|
2045
|
+
// RightRailStack — hosts plugin content (e.g. an Ask AI panel) in
|
|
2046
|
+
// the freed right rail via DocsLayout's `right-rail` named slot.
|
|
2047
|
+
// Only imported/used when the rail isn't the classic TOC's.
|
|
2048
|
+
...(tocMode !== "rail"
|
|
2049
|
+
? ['import RightRailStack from "@/components/wrappers/RightRailStack.astro";']
|
|
2050
|
+
: []),
|
|
719
2051
|
'const siteConfig = siteConfigData as SiteConfig;',
|
|
720
2052
|
...result.imports,
|
|
721
2053
|
...imageSetup,
|
|
722
2054
|
"",
|
|
723
|
-
`const headings = ${
|
|
2055
|
+
`const headings = ${pageLiteral(page.headings || [])};`,
|
|
724
2056
|
`const nav = navData;`,
|
|
725
|
-
|
|
2057
|
+
// currentPath uses combined so it matches nav.json hrefs
|
|
2058
|
+
// (which are also combined-prefixed). getPagination compares
|
|
2059
|
+
// them as strings; mismatched prefixes break prev/next.
|
|
2060
|
+
`const currentPath = "${buildCurrentPath(combined, section, page.slug)}";`,
|
|
726
2061
|
// Filter nav to the current (locale, version) bucket
|
|
727
2062
|
// before computing prev/next — without this, pagination
|
|
728
2063
|
// walks the global nav and a "Next" link can leak from
|
|
@@ -731,24 +2066,51 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
731
2066
|
// this keeps prev/next aligned with what the reader
|
|
732
2067
|
// sees.
|
|
733
2068
|
`const _navForPagination = filterNavByAxis(nav as any[], {`,
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
2069
|
+
// Pass the REAL basePath ("" for a root-served site) — coercing ""
|
|
2070
|
+
// → "/docs" makes the filter look for `/docs/…` bucket hrefs that
|
|
2071
|
+
// don't exist, emptying the pagination nav so prev/next vanish on
|
|
2072
|
+
// every page of a root-served multi-source site.
|
|
2073
|
+
` basePath: ${pageLiteral(basePath ?? "")},`,
|
|
2074
|
+
` namespace: ${pageLiteral(page.multiSource?.namespace ?? null)} ?? undefined,`,
|
|
2075
|
+
` version: ${pageLiteral(page.multiSource?.version ?? null)} ?? undefined,`,
|
|
2076
|
+
` locale: ${pageLiteral(page.multiSource?.locale ?? null)} ?? undefined,`,
|
|
2077
|
+
...(rootVersion
|
|
2078
|
+
? [
|
|
2079
|
+
` rootVersion: ${pageLiteral(rootVersion)},`,
|
|
2080
|
+
` versionSegments: ${pageLiteral(otherVersionSegments)},`,
|
|
2081
|
+
]
|
|
2082
|
+
: []),
|
|
737
2083
|
`});`,
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
`const
|
|
750
|
-
`const
|
|
751
|
-
`const
|
|
2084
|
+
// Blog adjacency is CHRONOLOGICAL, not navigational. getPagination
|
|
2085
|
+
// walks NavItem[] — sidebar order — and a blog has no sidebar; its
|
|
2086
|
+
// neighbours are "the post before/after this one in time". Emitting
|
|
2087
|
+
// the pair as literals also means the page does no adjacency work at
|
|
2088
|
+
// runtime.
|
|
2089
|
+
...(blogAdj
|
|
2090
|
+
? [
|
|
2091
|
+
`const prev = ${pageLiteral(blogAdj.get(page.slug)?.prev ?? null)} ?? undefined;`,
|
|
2092
|
+
`const next = ${pageLiteral(blogAdj.get(page.slug)?.next ?? null)} ?? undefined;`,
|
|
2093
|
+
]
|
|
2094
|
+
: [`const { prev, next } = getPagination(currentPath, _navForPagination);`]),
|
|
2095
|
+
`const title = ${pageLiteral(page.title)};`,
|
|
2096
|
+
`const description = ${pageLiteral(pageDescription)} || undefined;`,
|
|
2097
|
+
`const ogImage = ${pageLiteral(pageOgImage)} || undefined;`,
|
|
2098
|
+
`const noindex = ${pageLiteral(pageNoindex)};`,
|
|
2099
|
+
`const canonicalUrl = ${pageLiteral(pageCanonical?.url ?? null)} ?? undefined;`,
|
|
2100
|
+
`const alternates = ${pageLiteral(pageHead?.alternates ?? [])};`,
|
|
2101
|
+
`const breadcrumbJsonLd = ${pageLiteral(pageHead?.breadcrumbJsonLd ?? null)} ?? undefined;`,
|
|
2102
|
+
`const nofollow = ${pageLiteral(pageNofollow)};`,
|
|
2103
|
+
`const excludeFromSearch = ${pageLiteral(pageExcludeFromSearch)};`,
|
|
2104
|
+
`const pageTags = ${pageLiteral(pageTags ?? null)};`,
|
|
2105
|
+
`const pageStatus = ${pageLiteral(pageStatus ?? null)};`,
|
|
2106
|
+
`const pageType = ${pageLiteral(pageTypeStr ?? null)};`,
|
|
2107
|
+
`const pageAudience = ${pageLiteral(pageAudience ?? null)};`,
|
|
2108
|
+
`const pageCategory = ${pageLiteral(pageCategory ?? null)};`,
|
|
2109
|
+
`const pageTaxonomies = ${pageLiteral(pageTaxonomies ?? null)};`,
|
|
2110
|
+
`const pageFacets = ${pageLiteral(pageFacetValues)};`,
|
|
2111
|
+
`const tagsIndexPath = ${pageLiteral(tagsIndexPath)};`,
|
|
2112
|
+
`const lastUpdated = ${pageLiteral(pageLastUpdated)} || undefined;`,
|
|
2113
|
+
`const llmActionsProps = ${pageLiteral(llmActionsEnabled
|
|
752
2114
|
? {
|
|
753
2115
|
enabled: true,
|
|
754
2116
|
providers: options.llmActions?.providers,
|
|
@@ -764,6 +2126,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
764
2126
|
"",
|
|
765
2127
|
`<DocsLayout`,
|
|
766
2128
|
` siteName={siteConfig.siteName}`,
|
|
2129
|
+
` siteTagline={siteConfig.tagline}`,
|
|
767
2130
|
` title={title}`,
|
|
768
2131
|
` nav={nav as any[]}`,
|
|
769
2132
|
` headings={headings}`,
|
|
@@ -777,6 +2140,11 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
777
2140
|
` twitterHandle={siteConfig.twitterHandle || undefined}`,
|
|
778
2141
|
` themeColor={siteConfig.themeColor || undefined}`,
|
|
779
2142
|
` noindex={noindex}`,
|
|
2143
|
+
` nofollow={nofollow}`,
|
|
2144
|
+
` canonicalUrl={canonicalUrl}`,
|
|
2145
|
+
` alternates={alternates}`,
|
|
2146
|
+
` breadcrumbJsonLd={breadcrumbJsonLd}`,
|
|
2147
|
+
...(pageHead?.home ? [` ogType="website"`] : []),
|
|
780
2148
|
` excludeFromSearch={excludeFromSearch}`,
|
|
781
2149
|
` plausibleDomain={siteConfig.plausible?.domain}`,
|
|
782
2150
|
` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
|
|
@@ -786,31 +2154,110 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
786
2154
|
` tagsIndexPath={tagsIndexPath}`,
|
|
787
2155
|
` tagPrefixes={siteConfig.tagPrefixes}`,
|
|
788
2156
|
` tagLabels={siteConfig.tagLabels}`,
|
|
2157
|
+
` tagAutoColor={siteConfig.tagAutoColor}`,
|
|
789
2158
|
` taxonomyIndexPaths={siteConfig.taxonomyIndexPaths}`,
|
|
790
2159
|
` taxonomyDisplay={siteConfig.taxonomyDisplay}`,
|
|
791
2160
|
` status={pageStatus ?? undefined}`,
|
|
792
2161
|
` pageType={pageType ?? undefined}`,
|
|
793
2162
|
` audience={pageAudience ?? undefined}`,
|
|
794
2163
|
` category={pageCategory ?? undefined}`,
|
|
2164
|
+
` lastUpdated={lastUpdated}`,
|
|
795
2165
|
` taxonomies={pageTaxonomies ?? undefined}`,
|
|
2166
|
+
` facets={pageFacets}`,
|
|
796
2167
|
` autoH1={${autoH1}}`,
|
|
797
2168
|
` autoLede={${autoLede}}`,
|
|
798
2169
|
` llmActions={llmActionsProps}`,
|
|
799
|
-
`
|
|
2170
|
+
` linkIcons={siteConfig.linkIcons}`,
|
|
2171
|
+
// Default ON: an absent key means yes, `false` means no.
|
|
2172
|
+
` poweredBy={siteConfig.poweredBy !== false}`,
|
|
2173
|
+
// site.copyright (inline Markdown, rendered into site.json): written there and never shown until 2026-10-30
|
|
2174
|
+
` copyright={(siteConfig as { chrome?: unknown }).chrome ? siteConfig.copyright || undefined : undefined}`,
|
|
2175
|
+
// The site's chrome (site-chrome.ts): logo, colour mode, announcement, header links, footer
|
|
2176
|
+
` siteChrome={(siteConfigData as { chrome?: never }).chrome}`,
|
|
2177
|
+
` multiSource={${pageLiteral(page.multiSource ?? null)} ?? undefined}`,
|
|
800
2178
|
` switcherMap={switcherMapData}`,
|
|
801
|
-
|
|
2179
|
+
// basePath here is the COMBINED URL prefix (urlBase from
|
|
2180
|
+
// site.url's path + dogsbay basePath). DocsLayout uses it
|
|
2181
|
+
// for switcher links, the footer llms.txt link, and the
|
|
2182
|
+
// <head> alternate link — all three need the full URL
|
|
2183
|
+
// prefix the host actually serves under. Empty string is
|
|
2184
|
+
// valid (root-served sites with no urlBase or basePath);
|
|
2185
|
+
// don't fall back to "/docs" — that would 404 for those.
|
|
2186
|
+
` basePath={${pageLiteral(combined)}}`,
|
|
2187
|
+
// navMode — controls whether DocsLayout server-renders the
|
|
2188
|
+
// full sidebar nav tree per page (`ssr-full`) or emits a
|
|
2189
|
+
// client-hydrated placeholder (`client`, default). The
|
|
2190
|
+
// client mode shrinks per-page HTML dramatically at scale.
|
|
2191
|
+
// See plans/client-rendered-nav.md.
|
|
2192
|
+
` navMode={${pageLiteral(options.navMode ?? "client")}}`,
|
|
2193
|
+
// Pagefind index URL — must include the combined prefix or
|
|
2194
|
+
// the loader 404s on subpath-mounted deploys. The pagefind
|
|
2195
|
+
// CLI writes to <astroOutput>/dist/pagefind/ which Astro
|
|
2196
|
+
// serves under its `base` (= urlBase); dogsbay's basePath
|
|
2197
|
+
// adds the second prefix layer. Empty combined → `/pagefind/`.
|
|
2198
|
+
` pagefindUrl={${pageLiteral(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
|
|
2199
|
+
// Base Pagefind prefixes RESULT urls with. The index stores URLs
|
|
2200
|
+
// relative to dist/, so the base is whatever served path dist/'s
|
|
2201
|
+
// root maps to. A Workers subpath mount builds pages INTO
|
|
2202
|
+
// dist/<mount>/, so its index already carries the mount → "/".
|
|
2203
|
+
// GitHub Pages builds to dist/ and Astro adds `base` at serve time
|
|
2204
|
+
// → "<urlBase>/". Pagefind's default (the folder above the bundle)
|
|
2205
|
+
// is right only for the second, and doubled the mount on the first:
|
|
2206
|
+
// /dogsbay-xml-docs/dogsbay-xml-docs/…, measured. Same rule as
|
|
2207
|
+
// docs-layout's pagefindBaseUrl() — keep the two in step.
|
|
2208
|
+
` pagefindBaseUrl={${pageLiteral(searchBaseUrl)}}`,
|
|
2209
|
+
// Favicon — composed with combined prefix so the
|
|
2210
|
+
// <link rel="icon"> resolves on subpath-mounted deploys.
|
|
2211
|
+
//
|
|
2212
|
+
// Emitted ONLY when the file actually exists in public/. The
|
|
2213
|
+
// scaffold ships no favicon, so an unconditional link meant every
|
|
2214
|
+
// generated site advertised an icon that 404s. Authors drop
|
|
2215
|
+
// `public/favicon.ico` (or `.svg`) in their Astro project and the
|
|
2216
|
+
// link appears on the next build; DocsLayout takes `false` to mean
|
|
2217
|
+
// "emit no <link rel=icon>".
|
|
2218
|
+
` favicon={${pageLiteral(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
|
|
802
2219
|
` wideLayout={${wideLayout}}`,
|
|
2220
|
+
// Blog chrome — sidebar dropped, byline on. Every other prop above
|
|
2221
|
+
// is unchanged, which is what keeps the blog visually continuous
|
|
2222
|
+
// with the docs rather than a lookalike.
|
|
2223
|
+
...(options.blog
|
|
2224
|
+
? [
|
|
2225
|
+
` chrome="blog"`,
|
|
2226
|
+
` author={${pageLiteral(page.meta?.author ?? null)} ?? undefined}`,
|
|
2227
|
+
` publishedDate={${pageLiteral(page.meta?.[options.blog.dateField] ?? null)} ?? undefined}`,
|
|
2228
|
+
` updatedDate={${pageLiteral(page.meta?.updated ?? null)} ?? undefined}`,
|
|
2229
|
+
` readingMinutes={${blogMinutes.get(page.slug) ?? 1}}`,
|
|
2230
|
+
// Series position, derived in buildBlogData from publication
|
|
2231
|
+
// order — never hand-typed in prose, which goes stale the
|
|
2232
|
+
// moment another part lands.
|
|
2233
|
+
// String-guarded like buildBlogData's firstString: a
|
|
2234
|
+
// `heroImage: {src, alt}` object would otherwise reach
|
|
2235
|
+
// DocsLayout's `heroImage?: string` prop and render as
|
|
2236
|
+
// src="[object Object]".
|
|
2237
|
+
` heroImage={${pageLiteral(typeof (page.frontmatter ?? {}).heroImage === "string"
|
|
2238
|
+
? (page.frontmatter ?? {}).heroImage
|
|
2239
|
+
: null)} ?? undefined}`,
|
|
2240
|
+
]
|
|
2241
|
+
: []),
|
|
2242
|
+
` toc={${pageLiteral(tocMode)}}`,
|
|
803
2243
|
`>`,
|
|
2244
|
+
// RightRail region — assigned to DocsLayout's `right-rail` named
|
|
2245
|
+
// slot. Inert (empty passthrough) until a plugin contributes a
|
|
2246
|
+
// RightRail wrapper. Omitted in "rail" mode (TOC owns the rail).
|
|
2247
|
+
...(tocMode !== "rail"
|
|
2248
|
+
? [` <RightRailStack slot="right-rail" />`]
|
|
2249
|
+
: []),
|
|
804
2250
|
` <MarkdownContentStack>`,
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
2251
|
+
// Every page body gets the docs-prose typography wrapper — wide
|
|
2252
|
+
// (endpoint) pages included. Mixed prose+endpoint pages (MDX
|
|
2253
|
+
// imports) previously lost ALL typography because the wrapper
|
|
2254
|
+
// was skipped; API-region internals opt out via the `dba-api`
|
|
2255
|
+
// marker on ApiLayout + the `:not(.dba-api *)` guards in the
|
|
2256
|
+
// generated .docs-prose rules.
|
|
2257
|
+
` <article class="docs-prose">`,
|
|
2258
|
+
...singlePageLinkLines(singlePageBySlug.get(page.slug), 6),
|
|
2259
|
+
result.body.split("\n").map((l) => ` ${l}`).join("\n"),
|
|
2260
|
+
` </article>`,
|
|
814
2261
|
` </MarkdownContentStack>`,
|
|
815
2262
|
`</DocsLayout>`,
|
|
816
2263
|
];
|
|
@@ -835,6 +2282,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
835
2282
|
mkdirSync(dirname(pagePath), { recursive: true });
|
|
836
2283
|
writeFileSync(pagePath, pageLines.join("\n") + "\n");
|
|
837
2284
|
generated++;
|
|
2285
|
+
if (!section && page.slug === "index")
|
|
2286
|
+
rootIndexEmitted = true;
|
|
2287
|
+
generatedPaths.add(relative(outputDir, pagePath));
|
|
838
2288
|
// Companion .md endpoint for content negotiation. Prerendered, so
|
|
839
2289
|
// it's served as a static asset at runtime — no Worker overhead.
|
|
840
2290
|
//
|
|
@@ -848,8 +2298,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
848
2298
|
if (options.mdMirror !== false) {
|
|
849
2299
|
const mdEndpointPath = join(pagesDir, section ? section : "", `${page.slug}.md.ts`);
|
|
850
2300
|
mkdirSync(dirname(mdEndpointPath), { recursive: true });
|
|
851
|
-
const endpointBody = buildMdEndpoint(page, sourceRel);
|
|
2301
|
+
const endpointBody = buildMdEndpoint(page, sourceRel, mirrorMd);
|
|
852
2302
|
writeFileSync(mdEndpointPath, endpointBody);
|
|
2303
|
+
generatedPaths.add(relative(outputDir, mdEndpointPath));
|
|
853
2304
|
// Sibling-level mirror for the index page under a non-empty
|
|
854
2305
|
// basePath. baseSegments is empty when basePath is empty
|
|
855
2306
|
// (root-served sites); in that case the index slug is just
|
|
@@ -862,22 +2313,144 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
862
2313
|
const siblingPath = join(outputDir, "src", "pages", ...parentSegments, `${lastSeg}.md.ts`);
|
|
863
2314
|
mkdirSync(dirname(siblingPath), { recursive: true });
|
|
864
2315
|
writeFileSync(siblingPath, endpointBody);
|
|
2316
|
+
generatedPaths.add(relative(outputDir, siblingPath));
|
|
865
2317
|
}
|
|
866
2318
|
}
|
|
867
2319
|
}
|
|
868
2320
|
catch (err) {
|
|
869
2321
|
console.warn(`Warning: failed to generate ${page.slug}: ${err.message}`);
|
|
2322
|
+
// A failed page is absent from generatedPaths, which is
|
|
2323
|
+
// indistinguishable from a deleted one. Pruning is suppressed for
|
|
2324
|
+
// the whole run rather than deleting last build's still-good copy.
|
|
2325
|
+
pageFailures++;
|
|
870
2326
|
}
|
|
871
2327
|
}
|
|
872
2328
|
// Generate index redirect at src/pages/index.astro — sends `/` to the
|
|
873
|
-
// first nav href.
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
2329
|
+
// first nav href. Two cases need it:
|
|
2330
|
+
// - basePath set: `/` (host apex) isn't a content page, so redirect
|
|
2331
|
+
// into the basePath subtree.
|
|
2332
|
+
// - basePath empty BUT no root home page emitted (no
|
|
2333
|
+
// `content/index.md`): the corpus's entry point is a nested page
|
|
2334
|
+
// (e.g. OpenShift's `welcome/index`), so `/` would 404 without a
|
|
2335
|
+
// redirect. Only emit here for non-section builds and never when a
|
|
2336
|
+
// real root index page exists (writing a redirect would clobber it).
|
|
2337
|
+
// Use the base-prefixed nav (the same hrefs written to nav.json), not the
|
|
2338
|
+
// raw input `nav` — otherwise importers whose nav starts root-relative (e.g.
|
|
2339
|
+
// the docusaurus convert path) emit a redirect that drops the URL base and
|
|
2340
|
+
// sends `/` to the host root instead of into the site's subpath.
|
|
2341
|
+
// ─── Per-version subtree index redirects ─────────────────────────
|
|
2342
|
+
// A version subtree (`/3.29/…`, `/latest/…`) owns no page of its
|
|
2343
|
+
// own, so `/latest/` 404s while `/latest/about` resolves — and
|
|
2344
|
+
// `/latest/` is exactly what a reader types, what a switcher link
|
|
2345
|
+
// truncates to, and what a "latest" alias is FOR. Give each bucket
|
|
2346
|
+
// the same contract the site root gets: redirect to its first nav
|
|
2347
|
+
// entry.
|
|
2348
|
+
const subtreeRoots = axisSubtreeRoots(pages);
|
|
2349
|
+
// A subtree's entries leave out the deeper subtrees nested in it: with the
|
|
2350
|
+
// default version at the root (`path: ""`) and a locale axis, `/en/` is
|
|
2351
|
+
// that version's subtree and `/en/v3/` another version's.
|
|
2352
|
+
const nestedIn = (slugPrefix) => [...subtreeRoots.keys()]
|
|
2353
|
+
.filter((q) => slugPrefix === "" ? q !== "" : q.startsWith(`${slugPrefix}/`))
|
|
2354
|
+
.map((q) => `${combined}/${q}`);
|
|
2355
|
+
for (const slugPrefix of subtreeRoots.keys()) {
|
|
2356
|
+
const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`], nestedIn(slugPrefix));
|
|
2357
|
+
if (!target)
|
|
2358
|
+
continue;
|
|
2359
|
+
const subtreeIndexPath = join(pagesDir, section ? section : "", slugPrefix, "index.astro");
|
|
2360
|
+
const subtreeIndexRel = relative(outputDir, subtreeIndexPath);
|
|
2361
|
+
// A real page at this route wins — never clobber content.
|
|
2362
|
+
if (generatedPaths.has(subtreeIndexRel))
|
|
2363
|
+
continue;
|
|
2364
|
+
mkdirSync(dirname(subtreeIndexPath), { recursive: true });
|
|
2365
|
+
writeFileSync(subtreeIndexPath,
|
|
2366
|
+
// Marked as ours so a later emitter (the blog index claims this
|
|
2367
|
+
// exact route) can tell a generated stub from a page a writer
|
|
2368
|
+
// wrote. Without the marker the stub looked hand-authored and
|
|
2369
|
+
// silently blocked the blog index from ever being emitted.
|
|
2370
|
+
`---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${target}");\n---\n`);
|
|
2371
|
+
generatedPaths.add(subtreeIndexRel);
|
|
879
2372
|
}
|
|
880
|
-
|
|
2373
|
+
// The site root targets the DEFAULT version's first entry, not the
|
|
2374
|
+
// first entry overall. Nav is assembled oldest-version-first, so
|
|
2375
|
+
// `findFirstNavHref` alone sends every reader arriving at `/` into
|
|
2376
|
+
// the OLDEST docs — the opposite of what `defaultVersion` declares.
|
|
2377
|
+
const defaultVersionPrefixes = options.defaultVersion
|
|
2378
|
+
? [...subtreeRoots]
|
|
2379
|
+
.filter(([, version]) => version === options.defaultVersion)
|
|
2380
|
+
.map(([slugPrefix]) => `${combined}/${slugPrefix}`)
|
|
2381
|
+
: [];
|
|
2382
|
+
// With the default version at the root (`path: ""`) and no locale or
|
|
2383
|
+
// namespace, it has no subtree: its entries are the ones outside every
|
|
2384
|
+
// other version's subtree.
|
|
2385
|
+
// With products and no unnamed source, `/` goes to the first product's default home (site-files siteRootTarget).
|
|
2386
|
+
const productRoot = options.products ? siteRootTarget(options.products) : undefined;
|
|
2387
|
+
const firstHref = productRoot !== undefined ? `${combined}${productRoot}` :
|
|
2388
|
+
(defaultVersionPrefixes.length > 0
|
|
2389
|
+
? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes, [...subtreeRoots]
|
|
2390
|
+
.filter(([q]) => defaultVersionPrefixes.some((d) => `${combined}/${q}`.startsWith(`${d}/`)))
|
|
2391
|
+
.map(([q]) => `${combined}/${q}`))
|
|
2392
|
+
: rootVersion && subtreeRoots.size > 0
|
|
2393
|
+
? findFirstNavHrefUnder(outputNav, [combined], nestedIn(""))
|
|
2394
|
+
: undefined) ?? findFirstNavHref(outputNav, basePath);
|
|
2395
|
+
const needsRootRedirect = basePath !== "" ||
|
|
2396
|
+
(!section && !rootIndexEmitted && firstHref !== "");
|
|
2397
|
+
if (needsRootRedirect) {
|
|
2398
|
+
const indexPath = join(outputDir, "src", "pages", "index.astro");
|
|
2399
|
+
writeFileSync(indexPath, `---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${firstHref}");\n---\n`);
|
|
2400
|
+
generatedPaths.add(relative(outputDir, indexPath));
|
|
2401
|
+
}
|
|
2402
|
+
// See pruneOrphanedPages for why each of these guards exists. They are
|
|
2403
|
+
// cheap; the failure they prevent is deleting a site.
|
|
2404
|
+
if (generatedPaths.size === 0) {
|
|
2405
|
+
// Zero pages is a broken input, not an empty site.
|
|
2406
|
+
}
|
|
2407
|
+
else if (pageFailures > 0) {
|
|
2408
|
+
console.warn(` Skipped pruning: ${pageFailures} page(s) failed to generate, so an ` +
|
|
2409
|
+
`orphan cannot be told from a failure.`);
|
|
2410
|
+
}
|
|
2411
|
+
else {
|
|
2412
|
+
const pruned = pruneOrphanedPages(outputDir, join(pagesDir, section ?? ""), generatedPaths);
|
|
2413
|
+
if (pruned.length > 0) {
|
|
2414
|
+
console.log(` Pruned ${pruned.length} page(s) whose source was deleted: ` +
|
|
2415
|
+
`${pruned.slice(0, 3).join(", ")}${pruned.length > 3 ? ", …" : ""}`);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
// After the prune, so it is never mistaken for an orphan, and after
|
|
2419
|
+
// outputNav exists so its links match the site that was just built.
|
|
2420
|
+
emitNotFoundPage(outputDir, options);
|
|
2421
|
+
return { generated, outputNav, generatedPaths };
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* Copy each passthrough `.astro` source to its computed output path.
|
|
2425
|
+
* Aborts with a clear error if the destination is already in
|
|
2426
|
+
* `generatedPaths` (a generated page from `emitAstroPages` would
|
|
2427
|
+
* silently overwrite the hand-authored file otherwise).
|
|
2428
|
+
*/
|
|
2429
|
+
export function emitPassthroughAstroPages(copies, outputDir, generatedPaths) {
|
|
2430
|
+
if (copies.length === 0)
|
|
2431
|
+
return { copied: 0 };
|
|
2432
|
+
// Collision detection — a generated page and a passthrough page
|
|
2433
|
+
// would write to the same file. Refuse to overwrite; tell the
|
|
2434
|
+
// author exactly which two files conflict.
|
|
2435
|
+
const collisions = [];
|
|
2436
|
+
for (const copy of copies) {
|
|
2437
|
+
if (generatedPaths.has(copy.outputRelPath)) {
|
|
2438
|
+
collisions.push(copy.outputRelPath);
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
if (collisions.length > 0) {
|
|
2442
|
+
throw new Error(`Passthrough Astro page collides with a generated page:\n` +
|
|
2443
|
+
collisions.map((c) => ` - ${c}`).join("\n") + "\n" +
|
|
2444
|
+
`Rename the .astro source or remove the colliding entry from nav.yml.`);
|
|
2445
|
+
}
|
|
2446
|
+
let copied = 0;
|
|
2447
|
+
for (const copy of copies) {
|
|
2448
|
+
const dest = join(outputDir, copy.outputRelPath);
|
|
2449
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
2450
|
+
copyFileSync(copy.sourceAbs, dest);
|
|
2451
|
+
copied++;
|
|
2452
|
+
}
|
|
2453
|
+
return { copied };
|
|
881
2454
|
}
|
|
882
2455
|
// ─── Tier 1: config-derived ─────────────────────────────────────────────
|
|
883
2456
|
// Files driven entirely by config + flags. Always regenerated; site
|
|
@@ -888,10 +2461,135 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
888
2461
|
* from `options`. Maintainers wanting custom Disallow rules layer
|
|
889
2462
|
* them at the CDN level.
|
|
890
2463
|
*/
|
|
891
|
-
export function emitConfigDerivedFiles(outputDir, options) {
|
|
2464
|
+
export function emitConfigDerivedFiles(outputDir, options, pages = []) {
|
|
892
2465
|
mkdirSync(join(outputDir, "public"), { recursive: true });
|
|
893
2466
|
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
894
2467
|
writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
|
|
2468
|
+
emitRedirects(outputDir, options, pages);
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Redirects, per deploy target.
|
|
2472
|
+
*
|
|
2473
|
+
* Emitted from the CONFIG-derived tier, not the agent-readiness tier, on
|
|
2474
|
+
* purpose: `_headers` lives in the latter behind `options.llmsTxt !== false`,
|
|
2475
|
+
* so turning off llms.txt silently drops the Link header with it. Redirects
|
|
2476
|
+
* must not inherit that coupling — a site with no llms.txt still needs its
|
|
2477
|
+
* old URLs to resolve.
|
|
2478
|
+
*
|
|
2479
|
+
* | deploy target | mechanism | status |
|
|
2480
|
+
* |----------------------|------------------------------|--------|
|
|
2481
|
+
* | `cloudflare-workers` | `_redirects` | real |
|
|
2482
|
+
* | anything else | meta-refresh stub pages | 200 |
|
|
2483
|
+
*
|
|
2484
|
+
* The stub is a real downgrade and says so at build time rather than being
|
|
2485
|
+
* presented as equivalent.
|
|
2486
|
+
*/
|
|
2487
|
+
function emitRedirects(outputDir, options, pages) {
|
|
2488
|
+
const prefix = combinedPrefix(options);
|
|
2489
|
+
// A product's root goes straight to where its default version's home forwards (one hop, no chain to report)
|
|
2490
|
+
const forwarded = new Map(options.homeForwards ?? []);
|
|
2491
|
+
const { rules, errors, warnings } = collectRedirects({
|
|
2492
|
+
pages: pages.map((p) => ({
|
|
2493
|
+
slug: p.slug,
|
|
2494
|
+
redirect: p.redirect,
|
|
2495
|
+
frontmatter: p.frontmatter,
|
|
2496
|
+
// Front matter paths are relative to the page's language and version (#096): collectRedirects needs its original slug.
|
|
2497
|
+
multiSource: p.multiSource,
|
|
2498
|
+
})),
|
|
2499
|
+
// A product's root forwards to its default version's home, a unit with no home page to its
|
|
2500
|
+
// first page (homeForwards); a redirect the config gives for either wins.
|
|
2501
|
+
config: options.products || options.homeForwards?.length
|
|
2502
|
+
? { ...Object.fromEntries((options.products ? productRootRedirects(options.products) : []).map(([from, to]) => [from, forwarded.get(to) ?? to])), ...Object.fromEntries(forwarded), ...options.redirects }
|
|
2503
|
+
: options.redirects,
|
|
2504
|
+
prefix,
|
|
2505
|
+
lenient: options.allowBrokenRedirects === true,
|
|
2506
|
+
});
|
|
2507
|
+
if (errors.length > 0) {
|
|
2508
|
+
throw new Error(`redirects: ${errors.length} problem(s) — refusing to build.\n - ${errors.join("\n - ")}`);
|
|
2509
|
+
}
|
|
2510
|
+
for (const w of warnings)
|
|
2511
|
+
console.warn(` Warning: ${w}`);
|
|
2512
|
+
const publicDir = join(outputDir, "public");
|
|
2513
|
+
const own = options.hostFiles?.redirects;
|
|
2514
|
+
if (own !== undefined) {
|
|
2515
|
+
// The project's _redirects first, ours after: written whatever the deploy target, since their host reads it
|
|
2516
|
+
const merged = mergeRedirects(own, rules.length ? renderRedirectsFile(rules) : undefined, options.deploy === "cloudflare-workers");
|
|
2517
|
+
mkdirSync(publicDir, { recursive: true });
|
|
2518
|
+
writeFileSync(join(publicDir, "_redirects"), merged.text);
|
|
2519
|
+
for (const n of merged.notes)
|
|
2520
|
+
console.warn(` Warning: ${n}`);
|
|
2521
|
+
console.log(` Redirects: the project's _redirects, then the generated rules it does not already decide → public/_redirects`);
|
|
2522
|
+
if (options.deploy === "cloudflare-workers")
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
if (rules.length === 0)
|
|
2526
|
+
return;
|
|
2527
|
+
if (options.deploy === "cloudflare-workers") {
|
|
2528
|
+
// Workers Static Assets reads _redirects from the ROOT of the assets
|
|
2529
|
+
// directory. On a subpath mount `public/` lands at dist/<mount>/, so the
|
|
2530
|
+
// file has to be lifted afterwards — dogsbay-mount.mjs finalize does it,
|
|
2531
|
+
// the same way it already lifts _headers.
|
|
2532
|
+
writeFileSync(join(publicDir, "_redirects"), renderRedirectsFile(rules));
|
|
2533
|
+
console.log(` Redirects: ${rules.length} rule(s) → public/_redirects`);
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
for (const rule of rules) {
|
|
2537
|
+
const rel = stubPathForRule(rule, prefix);
|
|
2538
|
+
const dest = join(publicDir, ...rel.split("/"));
|
|
2539
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
2540
|
+
writeFileSync(dest, renderMetaRefreshPage(rule, options.siteUrl));
|
|
2541
|
+
}
|
|
2542
|
+
console.warn(` Redirects: ${rules.length} rule(s) emitted as meta-refresh stub pages. ` +
|
|
2543
|
+
`These answer 200, not 301 — ${options.deploy === "github-pages" ? "GitHub Pages" : "this deploy target"} ` +
|
|
2544
|
+
`has no redirect mechanism. Set deploy.target: cloudflare-workers for real status codes.`);
|
|
2545
|
+
}
|
|
2546
|
+
/**
|
|
2547
|
+
* Per-deploy-target artifact emission.
|
|
2548
|
+
*
|
|
2549
|
+
* Called from `emitSiteScaffold` (with `forceOverwrite=writeScaffold`
|
|
2550
|
+
* so `--force` regenerates from template) and from `dogsbay site
|
|
2551
|
+
* build` (with `forceOverwrite=false` so an existing site can adopt
|
|
2552
|
+
* a deploy target by editing config and rebuilding — the missing
|
|
2553
|
+
* artifact gets created on the next build).
|
|
2554
|
+
*
|
|
2555
|
+
* Emit policy is the union: write when forced OR when the file is
|
|
2556
|
+
* missing. Author edits to e.g. the workflow YAML survive every
|
|
2557
|
+
* regular build.
|
|
2558
|
+
*
|
|
2559
|
+
* Currently handles `github-pages` (workflow + .nojekyll). The
|
|
2560
|
+
* existing `cloudflare-workers` artifacts (wrangler.jsonc + package
|
|
2561
|
+
* scripts) stay in the scaffold-only path because they overlap with
|
|
2562
|
+
* scaffold-only files (package.json scripts, devDependencies). A
|
|
2563
|
+
* future refactor could fold them in here too.
|
|
2564
|
+
*/
|
|
2565
|
+
export function emitDeployArtifacts(outputDir, options, opts = { forceOverwrite: false }) {
|
|
2566
|
+
if (options.deploy === "github-pages") {
|
|
2567
|
+
// GitHub reads workflows from <repo-root>/.github/workflows/, NOT
|
|
2568
|
+
// from inside subdirectories. Use projectDir (the repo root) for
|
|
2569
|
+
// the workflow file; fall back to outputDir when unset (flat
|
|
2570
|
+
// `dogsbay convert` flows where the Astro project IS the repo).
|
|
2571
|
+
const projectDir = options.projectDir ?? outputDir;
|
|
2572
|
+
// Path of the Astro output relative to the project root. Used by
|
|
2573
|
+
// the workflow's working-directory + cache-dependency-path so
|
|
2574
|
+
// pnpm install / pnpm run build target the right place. Empty
|
|
2575
|
+
// string when outputDir === projectDir (flat layout).
|
|
2576
|
+
const astroDirRel = relative(projectDir, outputDir).replace(/\\/g, "/");
|
|
2577
|
+
const workflowPath = join(projectDir, ".github", "workflows", "deploy.yml");
|
|
2578
|
+
if (opts.forceOverwrite || !existsSync(workflowPath)) {
|
|
2579
|
+
mkdirSync(dirname(workflowPath), { recursive: true });
|
|
2580
|
+
writeFileSync(workflowPath, buildGitHubPagesWorkflow(astroDirRel));
|
|
2581
|
+
}
|
|
2582
|
+
// .nojekyll — must exist in the deployed artifact root so GH
|
|
2583
|
+
// Pages skips Jekyll's `_underscored-paths` filter (Astro's
|
|
2584
|
+
// `_astro/` chunk dir gets eaten otherwise). Lives inside the
|
|
2585
|
+
// Astro project's `public/` so it's copied into `dist/` at
|
|
2586
|
+
// build time.
|
|
2587
|
+
const nojekyllPath = join(outputDir, "public", ".nojekyll");
|
|
2588
|
+
mkdirSync(dirname(nojekyllPath), { recursive: true });
|
|
2589
|
+
if (opts.forceOverwrite || !existsSync(nojekyllPath)) {
|
|
2590
|
+
writeFileSync(nojekyllPath, "");
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
895
2593
|
}
|
|
896
2594
|
/**
|
|
897
2595
|
* Emit `src/data/switcherMap.json` describing per-page
|
|
@@ -909,7 +2607,10 @@ export function emitConfigDerivedFiles(outputDir, options) {
|
|
|
909
2607
|
* baseline page in a multi-version site).
|
|
910
2608
|
*/
|
|
911
2609
|
export function emitSwitcherMap(pages, outputDir, options) {
|
|
912
|
-
|
|
2610
|
+
// Switcher URLs use combined so the link the dropdown emits
|
|
2611
|
+
// resolves under the host's served subpath (GH Pages project
|
|
2612
|
+
// pages, multi-mount Cloudflare).
|
|
2613
|
+
const combined = combinedPrefix(options);
|
|
913
2614
|
const dataDir = join(outputDir, "src", "data");
|
|
914
2615
|
const outPath = join(dataDir, "switcherMap.json");
|
|
915
2616
|
// Detect axis activation by inspecting the data the loader
|
|
@@ -927,13 +2628,14 @@ export function emitSwitcherMap(pages, outputDir, options) {
|
|
|
927
2628
|
}
|
|
928
2629
|
const versionAxisActive = versionsSeen.size >= 2;
|
|
929
2630
|
const localeAxisActive = localesSeen.size >= 2;
|
|
930
|
-
if (!versionAxisActive && !localeAxisActive) {
|
|
2631
|
+
if (!versionAxisActive && !localeAxisActive && !options.products) {
|
|
931
2632
|
mkdirSync(dataDir, { recursive: true });
|
|
932
2633
|
writeFileSync(outPath, JSON.stringify({ versions: [], locales: [], byLogicalKey: {} }, null, 2));
|
|
933
2634
|
return;
|
|
934
2635
|
}
|
|
2636
|
+
const rootVersion = rootVersionOf(options);
|
|
935
2637
|
const versions = composeAxisHeader(options.versions ?? [], versionsSeen, options.defaultVersion,
|
|
936
|
-
/* allowEol */ true);
|
|
2638
|
+
/* allowEol */ true).map((v) => (v.id === rootVersion ? { ...v, root: true } : v));
|
|
937
2639
|
const locales = composeAxisHeader(options.locales ?? [], localesSeen, options.defaultLocale,
|
|
938
2640
|
/* allowEol */ false);
|
|
939
2641
|
// Per-logical-key list of variants — every (locale, version)
|
|
@@ -948,16 +2650,180 @@ export function emitSwitcherMap(pages, outputDir, options) {
|
|
|
948
2650
|
const variant = {
|
|
949
2651
|
...(ms.locale !== undefined ? { locale: ms.locale } : {}),
|
|
950
2652
|
...(ms.version !== undefined ? { version: ms.version } : {}),
|
|
951
|
-
|
|
2653
|
+
// As the site serves the page (a folder, with the slash): a raw slug cost each menu link a redirect
|
|
2654
|
+
url: pageHref(page.slug, combined),
|
|
952
2655
|
};
|
|
953
2656
|
if (!byLogicalKey[key])
|
|
954
2657
|
byLogicalKey[key] = [];
|
|
955
2658
|
byLogicalKey[key].push(variant);
|
|
956
2659
|
}
|
|
957
2660
|
const data = { versions, locales, byLogicalKey };
|
|
2661
|
+
const banners = versionBanners(options, combined, pages);
|
|
2662
|
+
if (banners)
|
|
2663
|
+
data.banners = banners;
|
|
2664
|
+
const menus = versionMenus(options, combined);
|
|
2665
|
+
if (menus)
|
|
2666
|
+
data.versionMenu = menus;
|
|
2667
|
+
if (options.products) {
|
|
2668
|
+
data.productVersions = productVersionRows(options.products, combined);
|
|
2669
|
+
const productMenu = productMenuRows(options.products, combined);
|
|
2670
|
+
if (productMenu)
|
|
2671
|
+
data.products = productMenu;
|
|
2672
|
+
emitProductDataFiles(pages, outputDir, options, options.products);
|
|
2673
|
+
}
|
|
958
2674
|
mkdirSync(dataDir, { recursive: true });
|
|
959
2675
|
writeFileSync(outPath, JSON.stringify(data, null, 2));
|
|
960
2676
|
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Each unit's version banner, as dogsbay-ssg shows it: with a `products:` block the shared model's
|
|
2679
|
+
* (site-files `unitOf`), none on a hidden version (the number a `latest` alias serves); site-wide,
|
|
2680
|
+
* an older version's, pointing at the default version's unit with the same source and language
|
|
2681
|
+
* (its home read from that unit's pages), and none when the default version has no such unit.
|
|
2682
|
+
*/
|
|
2683
|
+
function versionBanners(options, combined, pages) {
|
|
2684
|
+
const out = {};
|
|
2685
|
+
if (options.products) {
|
|
2686
|
+
const model = options.products;
|
|
2687
|
+
for (const u of model.units) {
|
|
2688
|
+
if (u.version === undefined)
|
|
2689
|
+
continue;
|
|
2690
|
+
const product = model.products.find((p) => p.name === u.product);
|
|
2691
|
+
if (product?.versions.find((v) => v.id === u.version)?.hidden)
|
|
2692
|
+
continue;
|
|
2693
|
+
const b = unitOf(model, u).banner;
|
|
2694
|
+
if (b)
|
|
2695
|
+
out[unitKeyString(u)] = b.latest ? { ...b, latest: { ...b.latest, home: `${combined}${b.latest.home}` } } : b;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
else if (options.defaultVersion) {
|
|
2699
|
+
const def = options.versions?.find((v) => v.id === options.defaultVersion);
|
|
2700
|
+
if (!def)
|
|
2701
|
+
return undefined;
|
|
2702
|
+
const hidden = new Set((options.versions ?? []).filter((v) => v.hidden).map((v) => v.id));
|
|
2703
|
+
// Each unit (source, language, version) and the URL prefix its pages are served under
|
|
2704
|
+
const home = new Map();
|
|
2705
|
+
const units = new Map();
|
|
2706
|
+
const bare = (x) => x.replace(/^\/+|\/+$/g, "").replace(/(^|\/)index$/, "");
|
|
2707
|
+
for (const page of pages) {
|
|
2708
|
+
const ms = page.multiSource;
|
|
2709
|
+
if (!ms?.version || page.redirect)
|
|
2710
|
+
continue;
|
|
2711
|
+
const u = { ...(ms.namespace ? { product: ms.namespace } : {}), ...(ms.locale ? { locale: ms.locale } : {}), version: ms.version };
|
|
2712
|
+
const key = unitKeyString(u);
|
|
2713
|
+
units.set(key, u);
|
|
2714
|
+
if (home.has(key))
|
|
2715
|
+
continue;
|
|
2716
|
+
const slug = bare(page.slug);
|
|
2717
|
+
const own = bare(ms.originalSlug);
|
|
2718
|
+
const prefix = own === "" ? slug : slug.endsWith(`/${own}`) ? slug.slice(0, -own.length - 1) : slug === own ? "" : undefined;
|
|
2719
|
+
if (prefix !== undefined)
|
|
2720
|
+
home.set(key, `${combined}/${prefix ? `${prefix}/` : ""}`);
|
|
2721
|
+
}
|
|
2722
|
+
for (const [key, u] of units) {
|
|
2723
|
+
if (u.version === def.id || hidden.has(u.version))
|
|
2724
|
+
continue;
|
|
2725
|
+
const target = home.get(unitKeyString({ ...u, version: def.id }));
|
|
2726
|
+
if (target)
|
|
2727
|
+
out[key] = { kind: "old", latest: { id: def.id, label: def.label ?? def.id, home: target } };
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
return Object.keys(out).length ? out : undefined;
|
|
2731
|
+
}
|
|
2732
|
+
/** The product menu per locale (`<locale|_>`), or nothing with fewer than two products. */
|
|
2733
|
+
function productMenuRows(model, combined) {
|
|
2734
|
+
const locales = model.localeAxis ? [...new Set(model.units.map((u) => u.locale))] : [undefined];
|
|
2735
|
+
const out = {};
|
|
2736
|
+
for (const l of locales) {
|
|
2737
|
+
const rows = productSwitcher(model, l);
|
|
2738
|
+
if (rows.length < 2)
|
|
2739
|
+
continue;
|
|
2740
|
+
out[l ?? "_"] = rows.map((r) => ({ name: r.name, label: r.label, home: `${combined}${r.home}` }));
|
|
2741
|
+
}
|
|
2742
|
+
return Object.keys(out).length ? out : undefined;
|
|
2743
|
+
}
|
|
2744
|
+
/** Each product's switcher rows per locale (`<product|_>/<locale|_>`), with full home URLs. */
|
|
2745
|
+
/** Each version menu's links and limit, per product (`_`: site-wide), hrefs as the site serves them. */
|
|
2746
|
+
function versionMenus(options, combined) {
|
|
2747
|
+
const out = {};
|
|
2748
|
+
// A site path may be written with the base path (as redirects take it): dropped, then the served prefix added
|
|
2749
|
+
const base = normalizeBasePath(options.basePath).replace(/\/+$/, "");
|
|
2750
|
+
const unbased = (href) => (base && (href === base || href.startsWith(`${base}/`)) ? href.slice(base.length) || "/" : href);
|
|
2751
|
+
const add = (key, m) => {
|
|
2752
|
+
if (!m)
|
|
2753
|
+
return;
|
|
2754
|
+
// Checked at config load; options built in code are checked again, so a script URL never reaches the page
|
|
2755
|
+
const links = m.links.filter((l) => typeof l.label === "string" && l.label.trim() && isVersionMenuHref(l.href));
|
|
2756
|
+
if (!links.length && m.limit === undefined)
|
|
2757
|
+
return;
|
|
2758
|
+
out[key] = { links: links.map((l) => ({ label: l.label, href: versionMenuHref(unbased(l.href), combined) })), ...(m.limit !== undefined ? { limit: m.limit } : {}) };
|
|
2759
|
+
};
|
|
2760
|
+
if (options.products)
|
|
2761
|
+
for (const p of options.products.products)
|
|
2762
|
+
add(p.name ?? "_", p.versionMenu);
|
|
2763
|
+
else
|
|
2764
|
+
add("_", options.versionMenu);
|
|
2765
|
+
return Object.keys(out).length ? out : undefined;
|
|
2766
|
+
}
|
|
2767
|
+
function productVersionRows(model, combined) {
|
|
2768
|
+
const out = {};
|
|
2769
|
+
const locales = model.localeAxis ? [...new Set(model.units.map((u) => u.locale))] : [undefined];
|
|
2770
|
+
for (const p of model.products) {
|
|
2771
|
+
if (!p.versions.length)
|
|
2772
|
+
continue;
|
|
2773
|
+
for (const l of locales) {
|
|
2774
|
+
const rows = productSwitcherRows(model, p.name, l);
|
|
2775
|
+
if (!rows.length)
|
|
2776
|
+
continue;
|
|
2777
|
+
out[`${p.name ?? "_"}/${l ?? "_"}`] = rows.map((r) => ({
|
|
2778
|
+
id: r.id,
|
|
2779
|
+
label: r.label,
|
|
2780
|
+
...(r.eol ? { eol: true } : {}),
|
|
2781
|
+
...(r.prerelease ? { prerelease: true } : {}),
|
|
2782
|
+
...(r.isDefault ? { default: true } : {}),
|
|
2783
|
+
...(r.badge ? { badge: true } : {}),
|
|
2784
|
+
home: `${combined}${r.home}`,
|
|
2785
|
+
}));
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
return out;
|
|
2789
|
+
}
|
|
2790
|
+
/**
|
|
2791
|
+
* The data files dogsbay-ssg writes too (@dogsbay/site-files products.ts):
|
|
2792
|
+
* `_dogsbay/versions.js` (+ `.json`), the switchers and product switcher every
|
|
2793
|
+
* page can load, and each unit's page list in its own folder,
|
|
2794
|
+
* `<home>_dogsbay/pages.js`. Scripts, so they also work from `file://`.
|
|
2795
|
+
*/
|
|
2796
|
+
function emitProductDataFiles(pages, outputDir, options, model) {
|
|
2797
|
+
const publicDir = join(outputDir, "public", ...basePathSegments(normalizeBasePath(options.basePath)));
|
|
2798
|
+
const byUnit = new Map();
|
|
2799
|
+
for (const page of pages) {
|
|
2800
|
+
const ms = page.multiSource;
|
|
2801
|
+
if (!ms || page.redirect || page.fragment === true)
|
|
2802
|
+
continue;
|
|
2803
|
+
const key = unitKeyString({ ...(ms.namespace ? { product: ms.namespace } : {}), ...(ms.locale ? { locale: ms.locale } : {}), ...(ms.version ? { version: ms.version } : {}) });
|
|
2804
|
+
const list = byUnit.get(key) ?? [];
|
|
2805
|
+
list.push(ms.originalSlug.replace(/^\/+|\/+$/g, "").replace(/(^|\/)index$/, ""));
|
|
2806
|
+
byUnit.set(key, list);
|
|
2807
|
+
}
|
|
2808
|
+
const built = new Set(model.units.map(unitKeyString));
|
|
2809
|
+
const manifests = [...byUnit.entries()]
|
|
2810
|
+
.filter(([key]) => built.has(key))
|
|
2811
|
+
.map(([key, list]) => {
|
|
2812
|
+
const [product, locale, version] = key.split("/").map((x) => (x === "_" ? undefined : x));
|
|
2813
|
+
return unitManifest({ ...(product ? { product } : {}), ...(locale ? { locale } : {}), ...(version ? { version } : {}) }, { pages: [...new Set(list)].sort(), sitemap: [], redirects: [] });
|
|
2814
|
+
});
|
|
2815
|
+
const files = siteFiles(model, manifests);
|
|
2816
|
+
const dataDir = join(publicDir, "_dogsbay");
|
|
2817
|
+
mkdirSync(dataDir, { recursive: true });
|
|
2818
|
+
writeFileSync(join(dataDir, "versions.js"), toScript("__DOGSBAY_VERSIONS__", files.versions));
|
|
2819
|
+
writeFileSync(join(dataDir, "versions.json"), JSON.stringify(files.versions));
|
|
2820
|
+
for (const m of manifests) {
|
|
2821
|
+
const unit = unitOf(model, { ...(m.product ? { product: m.product } : {}), ...(m.locale ? { locale: m.locale } : {}), ...(m.version ? { version: m.version } : {}) });
|
|
2822
|
+
const dest = join(publicDir, ...unit.segments, "_dogsbay", "pages.js");
|
|
2823
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
2824
|
+
writeFileSync(dest, toPagesScript(unit.home, m.pages));
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
961
2827
|
/**
|
|
962
2828
|
* Compose a switcherMap axis header from declared entries +
|
|
963
2829
|
* the set of ids actually seen in pages. Honours declared
|
|
@@ -969,11 +2835,23 @@ function composeAxisHeader(declared, seen, defaultId, allowEol) {
|
|
|
969
2835
|
const out = [];
|
|
970
2836
|
const seenInDeclared = new Set();
|
|
971
2837
|
for (const d of declared) {
|
|
972
|
-
|
|
2838
|
+
// A `hidden` version is the NUMBER an alias stands in for — the
|
|
2839
|
+
// Docusaurus `lastVersion` + `path: "latest"` pair. Its pages are
|
|
2840
|
+
// served under the alias, so no page ever carries its id and `seen`
|
|
2841
|
+
// will never contain it. Admit it anyway: it is label-only metadata
|
|
2842
|
+
// (the switcher filters hidden rows out of the dropdown), and it is
|
|
2843
|
+
// the sole source of the "3.32 (latest)" label. Gating it on `seen`
|
|
2844
|
+
// silently degrades that row to a bare "latest".
|
|
2845
|
+
const aliasedNumber = allowEol && d.hidden === true;
|
|
2846
|
+
if (seen.has(d.id) || aliasedNumber) {
|
|
973
2847
|
out.push({
|
|
974
2848
|
id: d.id,
|
|
975
2849
|
...(d.label !== undefined ? { label: d.label } : {}),
|
|
2850
|
+
// eol + prerelease + hidden are version-only marks (allowEol gates them).
|
|
976
2851
|
...(allowEol && d.eol === true ? { eol: true } : {}),
|
|
2852
|
+
...(allowEol && d.prerelease === true ? { prerelease: true } : {}),
|
|
2853
|
+
...(allowEol && d.hidden === true ? { hidden: true } : {}),
|
|
2854
|
+
...(allowEol && d.badge === true ? { badge: true } : {}),
|
|
977
2855
|
...(defaultId === d.id ? { default: true } : {}),
|
|
978
2856
|
});
|
|
979
2857
|
seenInDeclared.add(d.id);
|
|
@@ -1014,61 +2892,50 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
|
|
|
1014
2892
|
const defaultLocale = options.defaultLocale;
|
|
1015
2893
|
if (!defaultLocale)
|
|
1016
2894
|
return;
|
|
1017
|
-
const knownLocales = new Set();
|
|
1018
|
-
for (const p of pages) {
|
|
1019
|
-
const l = p.multiSource?.locale;
|
|
1020
|
-
if (l)
|
|
1021
|
-
knownLocales.add(l);
|
|
1022
|
-
}
|
|
1023
|
-
if (knownLocales.size < 2)
|
|
1024
|
-
return;
|
|
1025
|
-
if (!knownLocales.has(defaultLocale))
|
|
1026
|
-
return;
|
|
1027
2895
|
const basePath = normalizeBasePath(options.basePath);
|
|
1028
2896
|
const baseSegments = basePathSegments(basePath);
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
//
|
|
1032
|
-
|
|
1033
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
//
|
|
1036
|
-
//
|
|
1037
|
-
//
|
|
1038
|
-
|
|
2897
|
+
// combined drives the redirect URL (the user-facing path they
|
|
2898
|
+
// get bounced to); basePath stays the filesystem path under
|
|
2899
|
+
// src/pages/ where the stub lives.
|
|
2900
|
+
const combined = combinedPrefix(options);
|
|
2901
|
+
// Which pages need a stub is @dogsbay/site-files' missingTranslations,
|
|
2902
|
+
// shared with dogsbay-ssg. A page's key is its slug after the locale
|
|
2903
|
+
// segment, inside its version. Slugs on a namespaced site start with the
|
|
2904
|
+
// namespace, not the locale, so such a site gets no stubs. Only pages that
|
|
2905
|
+
// are routed get one: not include-only fragments, `excludeFromRoutes`
|
|
2906
|
+
// folders or `redirect:` pages. In another language, a `redirect:` page
|
|
2907
|
+
// holds the URL, so it counts as present.
|
|
2908
|
+
const units = new Map();
|
|
2909
|
+
const defaultPages = new Map();
|
|
2910
|
+
const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
|
|
2911
|
+
const groupOf = (ms) => `${ms.namespace ?? ""}\0${ms.version ?? ""}`;
|
|
1039
2912
|
for (const p of pages) {
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
let emitted = 0;
|
|
1043
|
-
for (const defaultPage of pages) {
|
|
1044
|
-
const ms = defaultPage.multiSource;
|
|
1045
|
-
if (!ms || ms.locale !== defaultLocale)
|
|
2913
|
+
const ms = p.multiSource;
|
|
2914
|
+
if (!ms?.locale || !p.slug.startsWith(`${ms.locale}/`))
|
|
1046
2915
|
continue;
|
|
1047
|
-
|
|
1048
|
-
// the locale-neutral tail. Defensive: if for some reason the
|
|
1049
|
-
// slug doesn't start with the locale (e.g. unprefixed
|
|
1050
|
-
// baseline page in a half-active site), skip.
|
|
1051
|
-
const localePrefix = `${defaultLocale}/`;
|
|
1052
|
-
if (!defaultPage.slug.startsWith(localePrefix))
|
|
2916
|
+
if (p.fragment === true || p.frontmatter?._fragment === true || isExcludedSlug(p.slug))
|
|
1053
2917
|
continue;
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
2918
|
+
if (typeof p.redirect === "string" && p.redirect.trim() && ms.locale === defaultLocale)
|
|
2919
|
+
continue;
|
|
2920
|
+
const tail = p.slug.slice(ms.locale.length + 1);
|
|
2921
|
+
const group = groupOf(ms);
|
|
2922
|
+
const k = `${group}\0${ms.locale}`;
|
|
2923
|
+
if (!units.has(k))
|
|
2924
|
+
units.set(k, { locale: ms.locale, version: group, pages: [] });
|
|
2925
|
+
units.get(k).pages.push(tail);
|
|
2926
|
+
if (ms.locale === defaultLocale)
|
|
2927
|
+
defaultPages.set(`${group}\0${tail}`, p);
|
|
2928
|
+
}
|
|
2929
|
+
let emitted = 0;
|
|
2930
|
+
for (const m of missingTranslations([...units.values()], defaultLocale)) {
|
|
2931
|
+
const defaultPage = defaultPages.get(`${m.version}\0${m.page}`);
|
|
2932
|
+
const targetSlug = `${m.locale}/${m.page}`;
|
|
2933
|
+
// The page's URL: an `index` slug is its folder (`/en/v3/index` is not served).
|
|
2934
|
+
const defaultUrl = `${combined}/${defaultPage.slug.replace(/(^|\/)index$/, "")}`;
|
|
2935
|
+
const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
|
|
2936
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
2937
|
+
writeFileSync(`${filePath}.astro`, buildMissingTranslationStub(defaultUrl, m.locale, defaultLocale));
|
|
2938
|
+
emitted++;
|
|
1072
2939
|
}
|
|
1073
2940
|
if (emitted > 0) {
|
|
1074
2941
|
console.log(`Emitted ${emitted} missing-translation stub(s) → redirect to default locale.`);
|
|
@@ -1098,6 +2965,15 @@ function buildMissingTranslationStub(defaultUrl, fromLocale, toLocale) {
|
|
|
1098
2965
|
* `emitAstroPages` (they share the page-loop's serialization step).
|
|
1099
2966
|
*/
|
|
1100
2967
|
export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options) {
|
|
2968
|
+
// The project's own _headers first, the generated Link header after (when llms.txt is on)
|
|
2969
|
+
const ownHeaders = options.hostFiles?.headers;
|
|
2970
|
+
if (ownHeaders !== undefined && options.llmsTxt === false) {
|
|
2971
|
+
mkdirSync(join(outputDir, "public"), { recursive: true });
|
|
2972
|
+
const merged = mergeHeaders(ownHeaders, undefined, options.deploy === "cloudflare-workers");
|
|
2973
|
+
writeFileSync(join(outputDir, "public", "_headers"), merged.text);
|
|
2974
|
+
for (const n of merged.notes)
|
|
2975
|
+
console.warn(` Warning: ${n}`);
|
|
2976
|
+
}
|
|
1101
2977
|
if (options.llmsTxt !== false) {
|
|
1102
2978
|
emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
|
|
1103
2979
|
// public/_headers — Cloudflare Workers / Pages convention. Adds an
|
|
@@ -1105,7 +2981,18 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1105
2981
|
// (basePath-prefixed) without parsing HTML. Emitted alongside
|
|
1106
2982
|
// llms.txt so the two files travel together.
|
|
1107
2983
|
mkdirSync(join(outputDir, "public"), { recursive: true });
|
|
1108
|
-
|
|
2984
|
+
// _headers Link header points at the per-mount llms.txt at
|
|
2985
|
+
// <combined>/llms.txt — the URL agents would actually fetch.
|
|
2986
|
+
const generated = buildHeadersFile(withBasePath(combinedPrefix(options), "/llms.txt"));
|
|
2987
|
+
if (ownHeaders === undefined) {
|
|
2988
|
+
writeFileSync(join(outputDir, "public", "_headers"), generated);
|
|
2989
|
+
}
|
|
2990
|
+
else {
|
|
2991
|
+
const merged = mergeHeaders(ownHeaders, generated, options.deploy === "cloudflare-workers");
|
|
2992
|
+
writeFileSync(join(outputDir, "public", "_headers"), merged.text);
|
|
2993
|
+
for (const n of merged.notes)
|
|
2994
|
+
console.warn(` Warning: ${n}`);
|
|
2995
|
+
}
|
|
1109
2996
|
}
|
|
1110
2997
|
// Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
|
|
1111
2998
|
// public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
|
|
@@ -1124,12 +3011,45 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1124
3011
|
// OR the version axis has a defaultVersion set with ≥2
|
|
1125
3012
|
// declared versions. When neither applies, no middleware is
|
|
1126
3013
|
// emitted (keeps single-feature sites' src/ tidy).
|
|
1127
|
-
|
|
3014
|
+
// Middleware only does something on an SSR deploy. In Astro's static
|
|
3015
|
+
// output — which is what `deploy: cloudflare-workers` produces, and
|
|
3016
|
+
// what every Dogsbay site ships today — there is no runtime to invoke
|
|
3017
|
+
// it: every route is prerendered, and the guard inside the generated
|
|
3018
|
+
// middleware short-circuits at BUILD time because there is no client
|
|
3019
|
+
// whose Accept header could be honoured.
|
|
3020
|
+
//
|
|
3021
|
+
// It used to be emitted anyway. That is worse than not emitting it:
|
|
3022
|
+
// an external agent audit reported markdown content negotiation as
|
|
3023
|
+
// failing, and the file sitting in src/ implied the feature existed
|
|
3024
|
+
// and was broken, rather than being inapplicable to the target. Dead
|
|
3025
|
+
// code that claims a capability costs more than a missing feature.
|
|
3026
|
+
//
|
|
3027
|
+
// The `.md` mirrors are unaffected — they are real files, and they
|
|
3028
|
+
// are how agents actually fetch markdown here. What is lost on a
|
|
3029
|
+
// static deploy is only `Accept: text/markdown` negotiation on the
|
|
3030
|
+
// HTML URL, which needs a Worker running before asset serving.
|
|
3031
|
+
// See docs-dev/markdown-negotiation.md.
|
|
3032
|
+
const staticDeploy = options.deploy === "cloudflare-workers";
|
|
3033
|
+
const mdMirrorOn = options.mdMirror !== false && !staticDeploy;
|
|
1128
3034
|
const knownVersions = pageVersions(pages);
|
|
1129
3035
|
const knownLocales = pageLocales(pages);
|
|
1130
|
-
|
|
3036
|
+
// With the default version at the root (`path: ""`), a versionless URL IS
|
|
3037
|
+
// one of its pages: there is nothing to redirect.
|
|
3038
|
+
const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2 && !rootVersionOf(options);
|
|
1131
3039
|
const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
|
|
1132
3040
|
const axisRedirectOn = versionRedirectOn || localeRedirectOn;
|
|
3041
|
+
const middlewarePath = join(outputDir, "src", "middleware.ts");
|
|
3042
|
+
if (!mdMirrorOn && !axisRedirectOn && existsSync(middlewarePath)) {
|
|
3043
|
+
// Remove a middleware emitted by an EARLIER build, from before this
|
|
3044
|
+
// stopped emitting for static targets. Left in place it keeps
|
|
3045
|
+
// implying a capability the deploy cannot provide, and a site that
|
|
3046
|
+
// commits its generated output would carry it indefinitely. Only
|
|
3047
|
+
// ours — an author-written middleware is left alone.
|
|
3048
|
+
const existing = readFileSync(middlewarePath, "utf-8");
|
|
3049
|
+
if (existing.includes("AUTO-GENERATED by `dogsbay site build`")) {
|
|
3050
|
+
rmSync(middlewarePath);
|
|
3051
|
+
}
|
|
3052
|
+
}
|
|
1133
3053
|
if (mdMirrorOn || axisRedirectOn) {
|
|
1134
3054
|
// Taxonomy index paths share a single global namespace across
|
|
1135
3055
|
// locales / versions (one `/tags/` for the whole site, not one
|
|
@@ -1148,9 +3068,16 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1148
3068
|
mkdirSync(join(outputDir, "src"), { recursive: true });
|
|
1149
3069
|
writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
|
|
1150
3070
|
mdMirror: mdMirrorOn,
|
|
3071
|
+
// Same combined prefix the axis redirect uses — the middleware
|
|
3072
|
+
// compares against the request URL, which carries the served
|
|
3073
|
+
// subpath.
|
|
3074
|
+
mdMirrorBasePath: combinedPrefix(options),
|
|
1151
3075
|
axisRedirect: axisRedirectOn
|
|
1152
3076
|
? {
|
|
1153
|
-
|
|
3077
|
+
// Middleware compares paths against the request URL,
|
|
3078
|
+
// which carries the host's served subpath — so use the
|
|
3079
|
+
// combined prefix here.
|
|
3080
|
+
basePath: combinedPrefix(options),
|
|
1154
3081
|
...(versionRedirectOn
|
|
1155
3082
|
? {
|
|
1156
3083
|
defaultVersion: options.defaultVersion,
|
|
@@ -1204,10 +3131,66 @@ const LLMS_TXT_EXTERNAL_RE = /^(?:https?:\/\/|mailto:|tel:|#)/i;
|
|
|
1204
3131
|
* frontmatter included, body normalized. Used for both the .md
|
|
1205
3132
|
* mirror endpoints and the body summary in llms-full.txt.
|
|
1206
3133
|
*/
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
3134
|
+
/**
|
|
3135
|
+
* The URL of a page's Markdown copy. The home page's is `<prefix>.md` beside a `site.basePath`
|
|
3136
|
+
* mount (a sibling endpoint is written there) and `<prefix>/index.md` otherwise: under a base from
|
|
3137
|
+
* `site.url`'s path Astro serves nothing outside the base, and llms.txt named `/repo.md`, a 404.
|
|
3138
|
+
*/
|
|
3139
|
+
function mirrorHref(slug, options) {
|
|
3140
|
+
const prefix = combinedPrefix(options).replace(/\/$/, "");
|
|
3141
|
+
if (slug === "" || slug === "index")
|
|
3142
|
+
return basePathSegments(normalizeBasePath(options.basePath)).length ? `${prefix}.md` : `${prefix}/index.md`;
|
|
3143
|
+
return `${prefix}/${slug}.md`;
|
|
3144
|
+
}
|
|
3145
|
+
/** A page with a route (and a `.md` copy): not a fragment, not excluded from routes, not a redirect. */
|
|
3146
|
+
function hasRoute(p, options) {
|
|
3147
|
+
const fragment = p.fragment === true || p.frontmatter?._fragment === true;
|
|
3148
|
+
return !fragment && !makeSlugExcluder(options.excludeFromRoutes)(p.slug) && !(typeof p.redirect === "string" && p.redirect.trim());
|
|
3149
|
+
}
|
|
3150
|
+
/**
|
|
3151
|
+
* Writes a page's Markdown copy (its `.md`, its part of llms-full.txt) in the site's `style:`, its
|
|
3152
|
+
* links as the site serves them: the tree already carries the base path (emitAstroPages), so
|
|
3153
|
+
* here a link becomes absolute with `siteUrl`, and with `mdMirrorLinks: md` a page link names that
|
|
3154
|
+
* page's copy. In llms-full.txt (`where: "full"`) a same-page `#x` names its page. Shared walker:
|
|
3155
|
+
* `rewriteTreeLinks` (@dogsbay/site-files), as dogsbay-ssg's copies.
|
|
3156
|
+
*/
|
|
3157
|
+
function mirrorSerializer(options, pages) {
|
|
3158
|
+
const prefix = combinedPrefix(options).replace(/\/$/, "");
|
|
3159
|
+
const site = options.siteUrl && /^https?:\/\//i.test(options.siteUrl) ? parseSiteUrl(options.siteUrl).origin : undefined;
|
|
3160
|
+
const htmlPath = (slug) => (slug === "" || slug === "index" ? `${prefix}/` : `${prefix}/${slug}`);
|
|
3161
|
+
const key = (path) => path.replace(/\/index$/, "").replace(/\/+$/, "") || "/";
|
|
3162
|
+
// Only pages with a route of their own have a copy: not a fragment, a slug excludeFromRoutes
|
|
3163
|
+
// names, or a page that is a `redirect:` (review: `.md` links to them were 404s)
|
|
3164
|
+
const bySite = new Map();
|
|
3165
|
+
for (const p of pages)
|
|
3166
|
+
if (hasRoute(p, options))
|
|
3167
|
+
bySite.set(key(htmlPath(p.slug)), p.slug);
|
|
3168
|
+
const abs = (path) => (site && path.startsWith("/") && !path.startsWith("//") ? `${site}${path}` : path);
|
|
3169
|
+
return (page, where) => {
|
|
3170
|
+
const link = (href) => {
|
|
3171
|
+
// A same-page `#x` names its page in llms-full.txt, and in a copy too when links are absolute
|
|
3172
|
+
// (dogsbay-ssg writes it so: a copy pasted elsewhere still points at the page)
|
|
3173
|
+
if (href.startsWith("#"))
|
|
3174
|
+
return where === "full" || site ? abs(htmlPath(page.slug)) + href : href;
|
|
3175
|
+
if (!href.startsWith("/") || href.startsWith("//"))
|
|
3176
|
+
return href;
|
|
3177
|
+
const at = href.search(/[?#]/);
|
|
3178
|
+
const path = at === -1 ? href : href.slice(0, at);
|
|
3179
|
+
const rest = at === -1 ? "" : href.slice(at);
|
|
3180
|
+
const slug = options.mdMirrorLinks === "md" ? bySite.get(key(path)) : undefined;
|
|
3181
|
+
return abs(slug !== undefined ? mirrorHref(slug, options) : path) + rest;
|
|
3182
|
+
};
|
|
3183
|
+
const restore = rewriteTreeLinks(page.tree, { link, asset: (src) => abs(src) });
|
|
3184
|
+
try {
|
|
3185
|
+
return treeToDogsbayMd(page.tree, {
|
|
3186
|
+
frontmatter: page.frontmatter,
|
|
3187
|
+
...(options.markdownStyle ?? {}),
|
|
3188
|
+
});
|
|
3189
|
+
}
|
|
3190
|
+
finally {
|
|
3191
|
+
restore();
|
|
3192
|
+
}
|
|
3193
|
+
};
|
|
1211
3194
|
}
|
|
1212
3195
|
/**
|
|
1213
3196
|
* Build the contents of `public/robots.txt`.
|
|
@@ -1220,19 +3203,25 @@ function serializePageMd(page) {
|
|
|
1220
3203
|
* opt-in.
|
|
1221
3204
|
*/
|
|
1222
3205
|
function buildRobotsTxt(options, hasSiteUrl) {
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
//
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
: ""
|
|
1235
|
-
|
|
3206
|
+
// Per-mount URLs: each Dogsbay site emits its sitemap index and
|
|
3207
|
+
// llms.txt under <basePath>/, so robots.txt points there. The origin
|
|
3208
|
+
// is taken without its path (site.url may carry one when hosting on
|
|
3209
|
+
// a subpath) and the combined prefix (urlBase + basePath) added, so
|
|
3210
|
+
// the path is not counted twice.
|
|
3211
|
+
const { origin } = parseSiteUrl(options.siteUrl);
|
|
3212
|
+
const combined = combinedPrefix(options);
|
|
3213
|
+
const absolute = hasSiteUrl && origin;
|
|
3214
|
+
return buildRobotsTxtFile({
|
|
3215
|
+
signals: { search: options.aiSearch, aiInput: options.aiInput, aiTrain: options.aiTrain },
|
|
3216
|
+
preamble: options.contentSignalPreamble === true,
|
|
3217
|
+
sitemapUrl: absolute ? `${origin}${withBasePath(combined, "/sitemap-index.xml")}` : undefined,
|
|
3218
|
+
// `Llms-Txt:` is opt-in (`agent.llmsTxtDirective`): Lighthouse reports
|
|
3219
|
+
// it as an unknown directive and fails "robots.txt is valid"
|
|
3220
|
+
// (measured: SEO 100 -> 92 on every page), and nothing consumes it.
|
|
3221
|
+
llmsTxtUrl: absolute && options.llmsTxtDirective === true && options.llmsTxt !== false
|
|
3222
|
+
? `${origin}${withBasePath(combined, "/llms.txt")}`
|
|
3223
|
+
: undefined,
|
|
3224
|
+
});
|
|
1236
3225
|
}
|
|
1237
3226
|
/**
|
|
1238
3227
|
* Build the contents of `public/_headers` (Cloudflare Pages / Workers
|
|
@@ -1244,13 +3233,36 @@ function buildRobotsTxt(options, hasSiteUrl) {
|
|
|
1244
3233
|
* `/docs` mount) — matches where the platform actually emits
|
|
1245
3234
|
* llms.txt under the per-mount layout.
|
|
1246
3235
|
*/
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
3236
|
+
/**
|
|
3237
|
+
* URL for `<link rel="icon">`, or `false` when the project ships no
|
|
3238
|
+
* favicon.
|
|
3239
|
+
*
|
|
3240
|
+
* The scaffold deliberately ships none — authors drop their own into
|
|
3241
|
+
* `public/`. Emitting the link unconditionally therefore advertised a
|
|
3242
|
+
* 404 on every generated site. Checks `.ico` then `.svg`, matching the
|
|
3243
|
+
* two the scaffold documents.
|
|
3244
|
+
*/
|
|
3245
|
+
export function faviconHref(outputDir, urlBase) {
|
|
3246
|
+
// urlBase, NOT the combined prefix. `public/` is copied to the ROOT of
|
|
3247
|
+
// outDir, so a favicon serves from <urlBase>/favicon.ico on all three
|
|
3248
|
+
// layouts — mounted (outDir = dist/<urlBase>), GitHub Pages (host
|
|
3249
|
+
// supplies <urlBase>), and host-root. Composing with basePath as well
|
|
3250
|
+
// produced /blog/docs/favicon.ico for a file that serves from
|
|
3251
|
+
// /blog/favicon.ico: a 404 with a passing existence check in front of
|
|
3252
|
+
// it. Per-mount files (sitemap, llms.txt, nav.json) DO live under
|
|
3253
|
+
// public/<basePath>/ and legitimately use the combined prefix; the
|
|
3254
|
+
// root-level ones (favicon, robots.txt, _headers) do not.
|
|
3255
|
+
//
|
|
3256
|
+
// .png is in the list because it is the most common form — both
|
|
3257
|
+
// packages/cli/templates and apps/material-docs hardcode a .png — and
|
|
3258
|
+
// omitting it would silently emit no icon for authors who did the right
|
|
3259
|
+
// thing.
|
|
3260
|
+
for (const name of ["favicon.ico", "favicon.svg", "favicon.png"]) {
|
|
3261
|
+
if (existsSync(join(outputDir, "public", name))) {
|
|
3262
|
+
return urlBase ? `${urlBase}/${name}` : `/${name}`;
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
return false;
|
|
1254
3266
|
}
|
|
1255
3267
|
function buildMiddlewareSource(config) {
|
|
1256
3268
|
const lines = [
|
|
@@ -1299,7 +3311,7 @@ function buildMiddlewareSource(config) {
|
|
|
1299
3311
|
lines.push(" if (context.isPrerendered) return next();");
|
|
1300
3312
|
lines.push(" const url = new URL(context.request.url);");
|
|
1301
3313
|
if (config.mdMirror) {
|
|
1302
|
-
lines.push(' const accept = context.request.headers.get("accept");',
|
|
3314
|
+
lines.push(' const accept = context.request.headers.get("accept");', ` const mdTarget = shouldRewriteToMarkdown(accept, url.pathname, ${JSON.stringify(config.mdMirrorBasePath ?? "")});`, " if (mdTarget) return context.rewrite(mdTarget);");
|
|
1303
3315
|
}
|
|
1304
3316
|
if (config.axisRedirect) {
|
|
1305
3317
|
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);", " }");
|
|
@@ -1312,8 +3324,8 @@ function buildMiddlewareSource(config) {
|
|
|
1312
3324
|
* markdown body is embedded as a string literal at convert time;
|
|
1313
3325
|
* Astro prerenders the endpoint into a static `.md` file under `dist/`.
|
|
1314
3326
|
*/
|
|
1315
|
-
function buildMdEndpoint(page, sourceRel) {
|
|
1316
|
-
const body =
|
|
3327
|
+
function buildMdEndpoint(page, sourceRel, serialize) {
|
|
3328
|
+
const body = serialize(page, "copy");
|
|
1317
3329
|
return [
|
|
1318
3330
|
"// AUTO-GENERATED by `dogsbay convert` — do not edit.",
|
|
1319
3331
|
`// Source: ${sourceRel}`,
|
|
@@ -1351,32 +3363,80 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
|
|
|
1351
3363
|
description: options.description,
|
|
1352
3364
|
siteUrl: options.siteUrl,
|
|
1353
3365
|
};
|
|
1354
|
-
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
//
|
|
1358
|
-
|
|
3366
|
+
// hrefPrefix is the COMBINED prefix — used for the URL paths that
|
|
3367
|
+
// appear inside the llms.txt body (so agents fetch the correct
|
|
3368
|
+
// host-relative URLs). Filesystem layout uses basePath alone:
|
|
3369
|
+
// `public/<basePath>/llms.txt` matches the existing per-mount
|
|
3370
|
+
// delivery shape.
|
|
3371
|
+
const hrefPrefix = combinedPrefix(options);
|
|
3372
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
3373
|
+
const baseSegments = basePathSegments(basePath);
|
|
1359
3374
|
const mountDir = join(outputDir, "public", ...baseSegments);
|
|
1360
3375
|
mkdirSync(mountDir, { recursive: true });
|
|
1361
|
-
writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, {
|
|
1362
|
-
writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
|
|
1363
|
-
summary: "body",
|
|
1364
|
-
serializePage: serializePageMd,
|
|
3376
|
+
writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, {
|
|
1365
3377
|
hrefPrefix,
|
|
3378
|
+
indexMirrorHref: mirrorHref("index", options),
|
|
3379
|
+
aggregates: options.aggregates,
|
|
3380
|
+
whenToUse: options.llmsWhenToUse,
|
|
1366
3381
|
}));
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
|
|
1370
|
-
|
|
3382
|
+
// llms-full.txt is every page's copy in one file: off with `llmsFull: false`, and with
|
|
3383
|
+
// `mdMirror: false` (no copies), as in dogsbay-ssg
|
|
3384
|
+
const full = join(mountDir, "llms-full.txt");
|
|
3385
|
+
if (options.llmsFull !== false && options.mdMirror !== false) {
|
|
3386
|
+
const serialize = mirrorSerializer(options, pages);
|
|
3387
|
+
writeFileSync(full,
|
|
3388
|
+
// only pages with a route: the others' links never got the site's prefix, and they have no
|
|
3389
|
+
// page for an agent to cite (review)
|
|
3390
|
+
buildLlmsFullTxt(siteConfig, nav, pages.filter((p) => hasRoute(p, options)), {
|
|
3391
|
+
summary: "body",
|
|
3392
|
+
serializePage: (page) => serialize(page, "full"),
|
|
3393
|
+
hrefPrefix,
|
|
3394
|
+
indexMirrorHref: mirrorHref("index", options),
|
|
3395
|
+
}));
|
|
3396
|
+
}
|
|
3397
|
+
else if (existsSync(full))
|
|
3398
|
+
rmSync(full);
|
|
3399
|
+
// Per-section files. `deriveSectionDir` returns a host-absolute
|
|
3400
|
+
// path derived from nav hrefs, which since the combined-prefix
|
|
3401
|
+
// refactor (commit 132891e) include urlBase + basePath — NOT just
|
|
3402
|
+
// basePath. So joining its return onto public/ directly would
|
|
3403
|
+
// double-prefix into `public/<urlBase>/<basePath>/<section>/llms.txt`,
|
|
3404
|
+
// which then serves at `<urlBase>/<urlBase>/<basePath>/<section>/...`
|
|
3405
|
+
// once Astro's base prefix is applied at request time.
|
|
3406
|
+
//
|
|
3407
|
+
// Strip the combined prefix off the section dir to get just the
|
|
3408
|
+
// section tail, then re-prepend basePath via mountDir. Result:
|
|
3409
|
+
// `public/<basePath>/<section>/llms.txt`, served under the deploy's
|
|
3410
|
+
// base mount as `<urlBase>/<basePath>/<section>/llms.txt`.
|
|
3411
|
+
const combinedSegs = hrefPrefix.replace(/^\//, "");
|
|
1371
3412
|
for (const group of nav) {
|
|
1372
3413
|
if (!group.children || group.children.length === 0)
|
|
1373
3414
|
continue;
|
|
1374
3415
|
const dir = deriveSectionDir(group);
|
|
1375
3416
|
if (!dir)
|
|
1376
3417
|
continue;
|
|
1377
|
-
|
|
3418
|
+
let relDir;
|
|
3419
|
+
if (combinedSegs && dir === combinedSegs) {
|
|
3420
|
+
// The section's pages share no directory below the mount — they
|
|
3421
|
+
// are spread across several, so the common prefix collapsed to
|
|
3422
|
+
// the mount itself. There is no directory that belongs to this
|
|
3423
|
+
// section, and writing here would land on `<mount>/llms.txt`:
|
|
3424
|
+
// the site index, which was written just above. Skip it. The
|
|
3425
|
+
// index already lists the section, so nothing is lost.
|
|
3426
|
+
continue;
|
|
3427
|
+
}
|
|
3428
|
+
else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
|
|
3429
|
+
relDir = dir.slice(combinedSegs.length + 1);
|
|
3430
|
+
}
|
|
3431
|
+
else {
|
|
3432
|
+
// Defensive: if for some reason the dir doesn't carry the
|
|
3433
|
+
// combined prefix (older importer, manual nav.yml, etc.), fall
|
|
3434
|
+
// back to the raw value rather than rooting at /.
|
|
3435
|
+
relDir = dir;
|
|
3436
|
+
}
|
|
3437
|
+
const sectionPath = join(mountDir, relDir, "llms.txt");
|
|
1378
3438
|
mkdirSync(dirname(sectionPath), { recursive: true });
|
|
1379
|
-
writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
|
|
3439
|
+
writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix, indexMirrorHref: mirrorHref("index", options) }));
|
|
1380
3440
|
}
|
|
1381
3441
|
}
|
|
1382
3442
|
/**
|
|
@@ -1392,12 +3452,44 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
|
|
|
1392
3452
|
* file. Skip emission rather than write a broken sitemap.
|
|
1393
3453
|
*/
|
|
1394
3454
|
function emitSitemapFiles(outputDir, options, pages) {
|
|
3455
|
+
// Filesystem path uses basePath (sitemap files live in
|
|
3456
|
+
// public/<basePath>/sitemap-*.xml). The URL prefix encoded into
|
|
3457
|
+
// each <loc> uses combined so the absolute URLs resolve under the
|
|
3458
|
+
// host's served subpath. buildSitemapFiles strips path off siteUrl
|
|
3459
|
+
// internally, so passing siteUrl + combined as basePath gives
|
|
3460
|
+
// origin + combined as the final URL.
|
|
1395
3461
|
const basePath = normalizeBasePath(options.basePath);
|
|
3462
|
+
const combined = combinedPrefix(options);
|
|
1396
3463
|
const baseSegments = basePathSegments(basePath);
|
|
1397
3464
|
const mountDir = join(outputDir, "public", ...baseSegments);
|
|
1398
3465
|
mkdirSync(mountDir, { recursive: true });
|
|
1399
|
-
|
|
1400
|
-
|
|
3466
|
+
// An older version's page whose canonical is the default version's, or a noindex version's page, is left out.
|
|
3467
|
+
const excluded = makeSlugExcluder(options.excludeFromRoutes);
|
|
3468
|
+
const plan = canonicalPlan(pages, {
|
|
3469
|
+
isEmitted: (p) => !(p.fragment === true || p.frontmatter?._fragment === true) && !excluded(p.slug),
|
|
3470
|
+
siteUrl: options.siteUrl,
|
|
3471
|
+
basePath: combined,
|
|
3472
|
+
versions: options.versions,
|
|
3473
|
+
defaultVersion: options.defaultVersion,
|
|
3474
|
+
products: options.products,
|
|
3475
|
+
redirects: options.redirects,
|
|
3476
|
+
});
|
|
3477
|
+
// More than one file past 50,000 URLs, the protocol's limit per file.
|
|
3478
|
+
const files = buildSitemapFiles(pages.filter((p) => plan.get(p.slug)?.sitemap !== false), {
|
|
3479
|
+
siteUrl: options.siteUrl,
|
|
3480
|
+
basePath: combined,
|
|
3481
|
+
siteNoindex: options.noindex === true,
|
|
3482
|
+
});
|
|
3483
|
+
for (const [name, xml] of files)
|
|
3484
|
+
writeFileSync(join(mountDir, name), xml);
|
|
3485
|
+
writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({
|
|
3486
|
+
siteUrl: options.siteUrl,
|
|
3487
|
+
basePath: combined,
|
|
3488
|
+
sitemapCount: files.length,
|
|
3489
|
+
extraSitemaps: (options.aggregates ?? [])
|
|
3490
|
+
.map((a) => a.sitemap)
|
|
3491
|
+
.filter((u) => !!u),
|
|
3492
|
+
}));
|
|
1401
3493
|
}
|
|
1402
3494
|
/**
|
|
1403
3495
|
* Pick a directory under `public/` for a top-level nav group. Prefers
|
|
@@ -1451,10 +3543,80 @@ function findFirstNavHref(items, fallback) {
|
|
|
1451
3543
|
}
|
|
1452
3544
|
return fallback;
|
|
1453
3545
|
}
|
|
1454
|
-
|
|
3546
|
+
/**
|
|
3547
|
+
* URL subtree root of every multi-source axis bucket, mapped to the
|
|
3548
|
+
* version that bucket carries. Keys are slug prefixes (no leading or
|
|
3549
|
+
* trailing slash), e.g. `3.29` or `calico/en/3.29`.
|
|
3550
|
+
*
|
|
3551
|
+
* `multiSource.originalSlug` is the slug as the importer produced it,
|
|
3552
|
+
* BEFORE the loader applied the axis prefix — so stripping it off the
|
|
3553
|
+
* emitted slug yields that bucket's prefix exactly. Deriving it that
|
|
3554
|
+
* way rather than by segment index matters because how many segments
|
|
3555
|
+
* precede the version depends on which axes are active
|
|
3556
|
+
* (`/<namespace>/<locale>/<version>/<slug>`), and a longest-common-
|
|
3557
|
+
* prefix guess collapses to nothing when two products share a version
|
|
3558
|
+
* id.
|
|
3559
|
+
*/
|
|
3560
|
+
function axisSubtreeRoots(pages) {
|
|
3561
|
+
const roots = new Map();
|
|
3562
|
+
for (const page of pages) {
|
|
3563
|
+
const meta = page.multiSource;
|
|
3564
|
+
if (!meta?.version || meta.originalSlug === undefined)
|
|
3565
|
+
continue;
|
|
3566
|
+
if (!page.slug.endsWith(meta.originalSlug))
|
|
3567
|
+
continue;
|
|
3568
|
+
const prefix = page.slug
|
|
3569
|
+
.slice(0, page.slug.length - meta.originalSlug.length)
|
|
3570
|
+
.replace(/^\/+|\/+$/g, "");
|
|
3571
|
+
if (prefix)
|
|
3572
|
+
roots.set(prefix, meta.version);
|
|
3573
|
+
}
|
|
3574
|
+
return roots;
|
|
3575
|
+
}
|
|
3576
|
+
/**
|
|
3577
|
+
* The version served at the site root: the `defaultVersion` when its
|
|
3578
|
+
* `versions` entry has `path: ""` (the CLI's config load allows it on no
|
|
3579
|
+
* other version). Its pages have no version segment.
|
|
3580
|
+
*/
|
|
3581
|
+
export function rootVersionOf(options) {
|
|
3582
|
+
const def = options.defaultVersion;
|
|
3583
|
+
if (!def)
|
|
3584
|
+
return undefined;
|
|
3585
|
+
return options.versions?.find((v) => v.id === def)?.path === "" ? def : undefined;
|
|
3586
|
+
}
|
|
3587
|
+
/**
|
|
3588
|
+
* First nav href (in nav order) living under any of `prefixes`, which
|
|
3589
|
+
* are URL paths without a trailing slash. Used to pick a redirect
|
|
3590
|
+
* target for a subtree root that has no page of its own.
|
|
3591
|
+
*/
|
|
3592
|
+
function findFirstNavHrefUnder(items, prefixes,
|
|
3593
|
+
/** Deeper subtrees to skip (another version's, nested in the root version's). */
|
|
3594
|
+
exclude = []) {
|
|
3595
|
+
for (const item of items) {
|
|
3596
|
+
const href = item.href;
|
|
3597
|
+
if (href &&
|
|
3598
|
+
prefixes.some((prefix) => href.startsWith(`${prefix}/`)) &&
|
|
3599
|
+
!exclude.some((x) => href === x || href.startsWith(`${x}/`))) {
|
|
3600
|
+
return href;
|
|
3601
|
+
}
|
|
3602
|
+
if (item.children) {
|
|
3603
|
+
const found = findFirstNavHrefUnder(item.children, prefixes, exclude);
|
|
3604
|
+
if (found)
|
|
3605
|
+
return found;
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
return undefined;
|
|
3609
|
+
}
|
|
3610
|
+
/**
|
|
3611
|
+
* Copy the UI component folders the serializer can emit into the site's
|
|
3612
|
+
* `src/components/ui/`, skipping any that exist (project-owned, maybe
|
|
3613
|
+
* edited). Returns the names added. `site build` calls it so a component
|
|
3614
|
+
* introduced in a release (Kbd, FileTree) reaches sites scaffolded earlier.
|
|
3615
|
+
*/
|
|
3616
|
+
export function copyComponents(outputDir) {
|
|
1455
3617
|
const componentsSource = resolveComponentsSource();
|
|
1456
3618
|
if (!componentsSource)
|
|
1457
|
-
return;
|
|
3619
|
+
return [];
|
|
1458
3620
|
// Components copied into the generated project. These are the ones
|
|
1459
3621
|
// format-astro's serializer emits imports for, plus a few used by the
|
|
1460
3622
|
// project shell (DocsLayout, pagination, theme-toggle).
|
|
@@ -1481,32 +3643,93 @@ function copyComponents(outputDir) {
|
|
|
1481
3643
|
// inline `:icon[name]` directive. Without this entry every page
|
|
1482
3644
|
// emitting the icon import 500s with "module not found".
|
|
1483
3645
|
"icon",
|
|
3646
|
+
// Inline :kbd and the :::files tree.
|
|
3647
|
+
"kbd", "files",
|
|
3648
|
+
// :::details{sheet} / DITA db-details sheet
|
|
3649
|
+
"details-sheet",
|
|
1484
3650
|
];
|
|
3651
|
+
const added = [];
|
|
1485
3652
|
for (const name of needed) {
|
|
1486
3653
|
const src = join(componentsSource, name);
|
|
1487
3654
|
const dest = join(outputDir, "src", "components", "ui", name);
|
|
1488
3655
|
if (existsSync(src) && !existsSync(dest)) {
|
|
1489
3656
|
cpSync(src, dest, { recursive: true });
|
|
3657
|
+
added.push(name);
|
|
1490
3658
|
}
|
|
1491
3659
|
}
|
|
3660
|
+
return added;
|
|
1492
3661
|
}
|
|
1493
|
-
|
|
3662
|
+
/** Media files worth carrying from a `mediaOnly` asset mount. */
|
|
3663
|
+
const MOUNT_MEDIA_EXTS = new Set([
|
|
3664
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
|
|
3665
|
+
".svg", ".ico", ".pdf", ".mp4", ".webm",
|
|
3666
|
+
]);
|
|
3667
|
+
/** Recursively copy only media files, preserving relative structure. */
|
|
3668
|
+
function copyMediaTree(srcDir, destDir, skip) {
|
|
3669
|
+
for (const entry of readdirSync(srcDir)) {
|
|
3670
|
+
const full = join(srcDir, entry);
|
|
3671
|
+
if (skip?.has(full))
|
|
3672
|
+
continue;
|
|
3673
|
+
// A link is never followed: an imported repo's `public/x -> /home/user` would copy the
|
|
3674
|
+
// user's files into the site, and `public/loop -> .` would recurse forever (a review)
|
|
3675
|
+
const stat = lstatSync(full);
|
|
3676
|
+
if (stat.isSymbolicLink())
|
|
3677
|
+
continue;
|
|
3678
|
+
if (stat.isDirectory()) {
|
|
3679
|
+
copyMediaTree(full, join(destDir, entry), skip);
|
|
3680
|
+
continue;
|
|
3681
|
+
}
|
|
3682
|
+
const dot = entry.lastIndexOf(".");
|
|
3683
|
+
if (dot === -1 || !MOUNT_MEDIA_EXTS.has(entry.slice(dot).toLowerCase()))
|
|
3684
|
+
continue;
|
|
3685
|
+
mkdirSync(destDir, { recursive: true });
|
|
3686
|
+
cpSync(full, join(destDir, entry));
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
function copyAssets(sourceDir, outputDir, imageOptimization, urlPrefix) {
|
|
1494
3690
|
// sourceDir is already the docs dir (e.g. .../fastapi/docs/en/docs)
|
|
1495
3691
|
const searchDir = sourceDir;
|
|
3692
|
+
// A version/locale/namespace URL segment (e.g. "8.8") the content's
|
|
3693
|
+
// asset refs were rewritten with — so each source's assets land under
|
|
3694
|
+
// public/<prefix>/... and same-path images across versions never
|
|
3695
|
+
// collide. Empty for single-source builds (today's flat behaviour).
|
|
3696
|
+
const prefixParts = (urlPrefix ?? "")
|
|
3697
|
+
.split("/")
|
|
3698
|
+
.map((p) => p.trim())
|
|
3699
|
+
.filter(Boolean);
|
|
3700
|
+
const withPrefix = (rel) => prefixParts.length > 0 ? join(...prefixParts, rel) : rel;
|
|
1496
3701
|
// Raster images benefit from Astro optimization (WebP, dimensions)
|
|
1497
3702
|
const optimizableExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
1498
3703
|
// SVGs, icons, PDFs always go to public/ (no optimization needed)
|
|
1499
3704
|
const passthroughExts = new Set([".svg", ".ico", ".pdf"]);
|
|
1500
3705
|
function walk(dir) {
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
3706
|
+
// withFileTypes gives LSTAT semantics, so a symlink reports
|
|
3707
|
+
// isSymbolicLink() and never isDirectory(). That matters more than it
|
|
3708
|
+
// looks: AsciiBinder corpora symlink every section back to a shared
|
|
3709
|
+
// images/ (331 of them in openshift-docs), and statSync FOLLOWED those
|
|
3710
|
+
// links — copying the whole 64 MB image tree once per link. Measured:
|
|
3711
|
+
// kebab.png landed in public/ 77 times, public/ reached 4.6 GB from a
|
|
3712
|
+
// 273 MB checkout, full expansion ~21 GB. On CI the runner filled its
|
|
3713
|
+
// disk and died so hard it could not write its own log.
|
|
3714
|
+
//
|
|
3715
|
+
// Dormant only because those links pointed at content/images/ while
|
|
3716
|
+
// images lived in content/_assets/images/, so they dangled and the walk
|
|
3717
|
+
// skipped them. Moving images to content/images/ made all 331 resolve
|
|
3718
|
+
// and the latent bug fired.
|
|
3719
|
+
//
|
|
3720
|
+
// A symlinked directory is a second path to files this walk already
|
|
3721
|
+
// reaches by their real one, so skipping links copies each file once.
|
|
3722
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
3723
|
+
if (entry.isSymbolicLink())
|
|
3724
|
+
continue;
|
|
3725
|
+
const full = join(dir, entry.name);
|
|
3726
|
+
if (entry.isDirectory()) {
|
|
1504
3727
|
walk(full);
|
|
1505
3728
|
}
|
|
1506
3729
|
else {
|
|
1507
|
-
const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
|
|
3730
|
+
const ext = entry.name.substring(entry.name.lastIndexOf(".")).toLowerCase();
|
|
1508
3731
|
if (optimizableExts.has(ext)) {
|
|
1509
|
-
const rel = relative(searchDir, full);
|
|
3732
|
+
const rel = withPrefix(relative(searchDir, full));
|
|
1510
3733
|
// Always copy to public/ so inline <img src="/..."> works
|
|
1511
3734
|
const pubDest = join(outputDir, "public", rel);
|
|
1512
3735
|
mkdirSync(dirname(pubDest), { recursive: true });
|
|
@@ -1521,7 +3744,7 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
|
|
|
1521
3744
|
}
|
|
1522
3745
|
else if (passthroughExts.has(ext)) {
|
|
1523
3746
|
// SVGs, icons, PDFs always go to public/
|
|
1524
|
-
const rel = relative(searchDir, full);
|
|
3747
|
+
const rel = withPrefix(relative(searchDir, full));
|
|
1525
3748
|
const dest = join(outputDir, "public", rel);
|
|
1526
3749
|
mkdirSync(dirname(dest), { recursive: true });
|
|
1527
3750
|
cpSync(full, dest);
|
|
@@ -1534,452 +3757,8 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
|
|
|
1534
3757
|
}
|
|
1535
3758
|
catch { /* source may not exist */ }
|
|
1536
3759
|
}
|
|
1537
|
-
// ── CSS generation (ported from import-mkdocs.ts) ───────
|
|
1538
|
-
/**
|
|
1539
|
-
* Build the `@source inline("...")` directive that pins the
|
|
1540
|
-
* grid-tone palette into the generated stylesheet.
|
|
1541
|
-
*
|
|
1542
|
-
* Why we need it: tone classes like `bg-primary/10` only appear in
|
|
1543
|
-
* `.astro` pages emitted by Dogsbay's grid-item serializer. When
|
|
1544
|
-
* Tailwind's content scanner doesn't pick them up — because the
|
|
1545
|
-
* page lives outside the default scan globs, or because a class
|
|
1546
|
-
* is composed at the boundary of an interpolation — they get
|
|
1547
|
-
* purged. Result observed in dogsbay-docs-markdown audit: half
|
|
1548
|
-
* the grid demo cells render with no background. Pinning forces
|
|
1549
|
-
* generation regardless of scanner reach.
|
|
1550
|
-
*
|
|
1551
|
-
* Single source of truth: derived from TONE_CLASSES so any new
|
|
1552
|
-
* tone added to the palette is automatically safelisted.
|
|
1553
|
-
*/
|
|
1554
|
-
function buildToneSafelist() {
|
|
1555
|
-
const seen = new Set();
|
|
1556
|
-
for (const classes of Object.values(TONE_CLASSES)) {
|
|
1557
|
-
for (const cls of classes.split(/\s+/)) {
|
|
1558
|
-
if (cls)
|
|
1559
|
-
seen.add(cls);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
return [...seen].sort().join(" ");
|
|
1563
|
-
}
|
|
1564
|
-
function generateGlobalCss() {
|
|
1565
|
-
return `@import "tailwindcss";
|
|
1566
|
-
@import "./theme.css";
|
|
1567
|
-
|
|
1568
|
-
/* Scan @dogsbay packages for Tailwind classes */
|
|
1569
|
-
@source "../../node_modules/@dogsbay/ui/src";
|
|
1570
|
-
@source "../../node_modules/@dogsbay/docs-layout/src";
|
|
1571
|
-
|
|
1572
|
-
/* Pin the grid-tone palette. These classes are emitted into
|
|
1573
|
-
markdown-generated .astro pages by the grid-item serializer
|
|
1574
|
-
(TONE_CLASSES in @dogsbay/format-astro). Without inlining,
|
|
1575
|
-
opacity-modified utilities like bg-primary/10 get purged when
|
|
1576
|
-
Tailwind doesn't see them in the scanned globs, leaving grid
|
|
1577
|
-
demo cells with no visible background. */
|
|
1578
|
-
@source inline("${buildToneSafelist()}");
|
|
1579
|
-
|
|
1580
|
-
/* Prose typography for rendered content */
|
|
1581
|
-
.docs-prose {
|
|
1582
|
-
line-height: 1.7;
|
|
1583
|
-
|
|
1584
|
-
& 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; }
|
|
1585
|
-
& 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; }
|
|
1586
|
-
& h3 { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
|
|
1587
|
-
& h4 { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
|
1588
|
-
|
|
1589
|
-
& p { margin-top: 0.75rem; margin-bottom: 0.75rem; }
|
|
1590
|
-
|
|
1591
|
-
& a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
1592
|
-
& a:hover { opacity: 0.8; }
|
|
1593
|
-
|
|
1594
|
-
& strong { font-weight: 600; }
|
|
1595
|
-
& 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); }
|
|
1596
|
-
& pre code { padding: 0; background: none; font-size: 1em; }
|
|
1597
|
-
|
|
1598
|
-
/* Spacing between consecutive block elements (code blocks, alerts, etc.) */
|
|
1599
|
-
& > * + * { margin-top: 0.75rem; }
|
|
1600
|
-
& li > * + * { margin-top: 0.75rem; }
|
|
1601
|
-
|
|
1602
|
-
& ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
1603
|
-
& ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
1604
|
-
& li { margin: 0.25rem 0; }
|
|
1605
|
-
& li > ul, & li > ol { margin: 0.25rem 0; }
|
|
1606
|
-
|
|
1607
|
-
& blockquote { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
|
|
1608
|
-
|
|
1609
|
-
& hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
|
1610
|
-
|
|
1611
|
-
& table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
|
|
1612
|
-
& th { text-align: left; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
|
|
1613
|
-
& td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
|
|
1614
|
-
|
|
1615
|
-
& img { max-width: 100%; border-radius: 0.5rem; }
|
|
1616
|
-
|
|
1617
|
-
& .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
|
|
1618
|
-
& h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
|
|
1619
|
-
|
|
1620
|
-
& details { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
|
|
1621
|
-
& summary { cursor: pointer; font-weight: 600; }
|
|
1622
|
-
|
|
1623
|
-
& dl { margin: 1rem 0; }
|
|
1624
|
-
& dt { font-weight: 600; margin-top: 0.75rem; }
|
|
1625
|
-
& dd { margin-left: 1.5rem; color: var(--muted-foreground); }
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
|
-
@utility scrollbar-none {
|
|
1629
|
-
-ms-overflow-style: none;
|
|
1630
|
-
scrollbar-width: none;
|
|
1631
|
-
&::-webkit-scrollbar {
|
|
1632
|
-
display: none;
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
`;
|
|
1636
|
-
}
|
|
1637
|
-
// ── Theme presets ───────────────────────────────────────
|
|
1638
|
-
const THEME_DEFAULT = {
|
|
1639
|
-
light: `:root {
|
|
1640
|
-
--background: oklch(1 0 0);
|
|
1641
|
-
--foreground: oklch(0.145 0.014 285.823);
|
|
1642
|
-
--card: oklch(1 0 0);
|
|
1643
|
-
--card-foreground: oklch(0.145 0.014 285.823);
|
|
1644
|
-
--popover: oklch(1 0 0);
|
|
1645
|
-
--popover-foreground: oklch(0.145 0.014 285.823);
|
|
1646
|
-
--primary: oklch(0.205 0.014 285.823);
|
|
1647
|
-
--primary-foreground: oklch(0.985 0 0);
|
|
1648
|
-
--secondary: oklch(0.965 0.001 286.375);
|
|
1649
|
-
--secondary-foreground: oklch(0.205 0.014 285.823);
|
|
1650
|
-
--muted: oklch(0.965 0.001 286.375);
|
|
1651
|
-
--muted-foreground: oklch(0.45 0.014 285.938);
|
|
1652
|
-
--accent: oklch(0.965 0.001 286.375);
|
|
1653
|
-
--accent-foreground: oklch(0.205 0.014 285.823);
|
|
1654
|
-
--destructive: oklch(0.45 0.245 27.325);
|
|
1655
|
-
--destructive-foreground: oklch(0.985 0 0);
|
|
1656
|
-
--border: oklch(0.922 0.004 286.32);
|
|
1657
|
-
--input: oklch(0.922 0.004 286.32);
|
|
1658
|
-
--ring: oklch(0.708 0.014 285.823);
|
|
1659
|
-
--radius: 0.625rem;
|
|
1660
|
-
--note: oklch(0.637 0.174 259.126);
|
|
1661
|
-
--note-foreground: var(--foreground);
|
|
1662
|
-
--abstract: oklch(0.691 0.148 221.723);
|
|
1663
|
-
--info: oklch(0.722 0.128 207.084);
|
|
1664
|
-
--tip: oklch(0.731 0.112 178.627);
|
|
1665
|
-
--success: oklch(0.754 0.175 149.577);
|
|
1666
|
-
--question: oklch(0.809 0.19 126.526);
|
|
1667
|
-
--warning: oklch(0.74 0.175 62.778);
|
|
1668
|
-
--failure: oklch(0.614 0.194 21.602);
|
|
1669
|
-
--danger: oklch(0.577 0.245 16.439);
|
|
1670
|
-
--bug: oklch(0.548 0.258 349.761);
|
|
1671
|
-
--example: oklch(0.534 0.222 286.033);
|
|
1672
|
-
--quote: oklch(0.65 0 0);
|
|
1673
|
-
--sidebar: oklch(0.985 0 0);
|
|
1674
|
-
--sidebar-foreground: oklch(0.145 0.014 285.823);
|
|
1675
|
-
--sidebar-primary: oklch(0.205 0.014 285.823);
|
|
1676
|
-
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
1677
|
-
--sidebar-accent: oklch(0.965 0.001 286.375);
|
|
1678
|
-
--sidebar-accent-foreground: oklch(0.205 0.014 285.823);
|
|
1679
|
-
--sidebar-border: oklch(0.922 0.004 286.32);
|
|
1680
|
-
--sidebar-ring: oklch(0.708 0.014 285.823);
|
|
1681
|
-
--code-background: oklch(0.965 0.004 264);
|
|
1682
|
-
--code-foreground: oklch(0.3 0.014 253.833);
|
|
1683
|
-
|
|
1684
|
-
/* API reference — always-dark code panel */
|
|
1685
|
-
--api-panel-bg: oklch(0.145 0.014 253.833);
|
|
1686
|
-
--api-panel-fg: oklch(0.926 0.013 253.833);
|
|
1687
|
-
|
|
1688
|
-
/* API semantic colors — HTTP methods, types, status codes */
|
|
1689
|
-
--api-get: oklch(0.723 0.15 162);
|
|
1690
|
-
--api-post: oklch(0.637 0.174 259);
|
|
1691
|
-
--api-put: oklch(0.74 0.175 63);
|
|
1692
|
-
--api-patch: oklch(0.7 0.18 45);
|
|
1693
|
-
--api-delete: oklch(0.614 0.194 22);
|
|
1694
|
-
--api-head: oklch(0.534 0.222 286);
|
|
1695
|
-
--api-required: oklch(0.614 0.194 22);
|
|
1696
|
-
--api-deprecated: oklch(0.74 0.175 63);
|
|
1697
|
-
--api-type-string: oklch(0.723 0.15 162);
|
|
1698
|
-
--api-type-number: oklch(0.637 0.174 259);
|
|
1699
|
-
--api-type-boolean: oklch(0.74 0.175 63);
|
|
1700
|
-
--api-type-object: oklch(0.534 0.222 286);
|
|
1701
|
-
--api-type-array: oklch(0.7 0.18 45);
|
|
1702
|
-
--api-status-2xx: oklch(0.723 0.15 162);
|
|
1703
|
-
--api-status-3xx: oklch(0.637 0.174 259);
|
|
1704
|
-
--api-status-4xx: oklch(0.74 0.175 63);
|
|
1705
|
-
--api-status-5xx: oklch(0.614 0.194 22);
|
|
1706
|
-
|
|
1707
|
-
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
1708
|
-
--font-heading: var(--font-sans);
|
|
1709
|
-
--font-code: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
|
1710
|
-
}`,
|
|
1711
|
-
dark: `.dark {
|
|
1712
|
-
--background: oklch(0.145 0.014 285.823);
|
|
1713
|
-
--foreground: oklch(0.985 0 0);
|
|
1714
|
-
--card: oklch(0.145 0.014 285.823);
|
|
1715
|
-
--card-foreground: oklch(0.985 0 0);
|
|
1716
|
-
--popover: oklch(0.145 0.014 285.823);
|
|
1717
|
-
--popover-foreground: oklch(0.985 0 0);
|
|
1718
|
-
--primary: oklch(0.985 0 0);
|
|
1719
|
-
--primary-foreground: oklch(0.205 0.014 285.823);
|
|
1720
|
-
--secondary: oklch(0.269 0.007 286.033);
|
|
1721
|
-
--secondary-foreground: oklch(0.985 0 0);
|
|
1722
|
-
--muted: oklch(0.269 0.007 286.033);
|
|
1723
|
-
--muted-foreground: oklch(0.708 0.014 285.823);
|
|
1724
|
-
--accent: oklch(0.269 0.007 286.033);
|
|
1725
|
-
--accent-foreground: oklch(0.985 0 0);
|
|
1726
|
-
--destructive: oklch(0.704 0.191 22.216);
|
|
1727
|
-
--destructive-foreground: oklch(0.145 0.014 285.823);
|
|
1728
|
-
--border: oklch(0.269 0.007 286.033);
|
|
1729
|
-
--input: oklch(0.269 0.007 286.033);
|
|
1730
|
-
--ring: oklch(0.439 0.01 286.375);
|
|
1731
|
-
--note-foreground: var(--foreground);
|
|
1732
|
-
--sidebar: oklch(0.205 0.014 285.823);
|
|
1733
|
-
--sidebar-foreground: oklch(0.985 0 0);
|
|
1734
|
-
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
1735
|
-
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
1736
|
-
--sidebar-accent: oklch(0.269 0.007 286.033);
|
|
1737
|
-
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
1738
|
-
--sidebar-border: oklch(0.269 0.007 286.033);
|
|
1739
|
-
--sidebar-ring: oklch(0.439 0.01 286.375);
|
|
1740
|
-
--code-background: oklch(0.18 0.014 253.833);
|
|
1741
|
-
--code-foreground: oklch(0.926 0.013 253.833);
|
|
1742
|
-
}`,
|
|
1743
|
-
};
|
|
1744
|
-
/**
|
|
1745
|
-
* Mintlify-inspired theme — clean, neutral palette matching modern doc sites.
|
|
1746
|
-
*
|
|
1747
|
-
* Extracted from Claude Code docs (Mintlify):
|
|
1748
|
-
* - White background, near-black text
|
|
1749
|
-
* - Serif display font for H1, sans-serif body
|
|
1750
|
-
* - Soft colored callout backgrounds (blue note, green tip, amber warning)
|
|
1751
|
-
* - Rounded callout borders (16px radius)
|
|
1752
|
-
* - Light gray code background, JetBrains Mono
|
|
1753
|
-
*/
|
|
1754
|
-
const THEME_MINTLIFY = {
|
|
1755
|
-
light: `:root {
|
|
1756
|
-
--background: oklch(0.993 0.005 95);
|
|
1757
|
-
--foreground: oklch(0.15 0.01 260);
|
|
1758
|
-
--card: oklch(0.993 0.005 95);
|
|
1759
|
-
--card-foreground: oklch(0.15 0.01 260);
|
|
1760
|
-
--popover: oklch(0.993 0.005 95);
|
|
1761
|
-
--popover-foreground: oklch(0.15 0.01 260);
|
|
1762
|
-
--primary: oklch(0.15 0.01 260);
|
|
1763
|
-
--primary-foreground: oklch(0.985 0 0);
|
|
1764
|
-
--secondary: oklch(0.97 0.002 260);
|
|
1765
|
-
--secondary-foreground: oklch(0.15 0.01 260);
|
|
1766
|
-
--muted: oklch(0.97 0.002 260);
|
|
1767
|
-
--muted-foreground: oklch(0.45 0.01 260);
|
|
1768
|
-
--accent: oklch(0.97 0.002 260);
|
|
1769
|
-
--accent-foreground: oklch(0.15 0.01 260);
|
|
1770
|
-
--destructive: oklch(0.55 0.22 25);
|
|
1771
|
-
--destructive-foreground: oklch(0.985 0 0);
|
|
1772
|
-
--border: oklch(0.91 0.004 260);
|
|
1773
|
-
--input: oklch(0.91 0.004 260);
|
|
1774
|
-
--ring: oklch(0.7 0.01 260);
|
|
1775
|
-
--radius: 1rem;
|
|
1776
|
-
/* Callout colors — soft pastel backgrounds matching Mintlify */
|
|
1777
|
-
--note: oklch(0.62 0.18 260);
|
|
1778
|
-
--note-foreground: var(--foreground);
|
|
1779
|
-
--abstract: oklch(0.65 0.14 220);
|
|
1780
|
-
--info: oklch(0.65 0.14 230);
|
|
1781
|
-
--tip: oklch(0.65 0.16 155);
|
|
1782
|
-
--success: oklch(0.65 0.16 155);
|
|
1783
|
-
--question: oklch(0.7 0.15 270);
|
|
1784
|
-
--warning: oklch(0.7 0.15 75);
|
|
1785
|
-
--failure: oklch(0.6 0.2 25);
|
|
1786
|
-
--danger: oklch(0.55 0.22 25);
|
|
1787
|
-
--bug: oklch(0.55 0.22 350);
|
|
1788
|
-
--example: oklch(0.55 0.2 285);
|
|
1789
|
-
--quote: oklch(0.55 0 0);
|
|
1790
|
-
/* Sidebar */
|
|
1791
|
-
--sidebar: oklch(0.993 0.005 95);
|
|
1792
|
-
--sidebar-foreground: oklch(0.15 0.01 260);
|
|
1793
|
-
--sidebar-primary: oklch(0.15 0.01 260);
|
|
1794
|
-
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
1795
|
-
--sidebar-accent: oklch(0.97 0.002 260);
|
|
1796
|
-
--sidebar-accent-foreground: oklch(0.15 0.01 260);
|
|
1797
|
-
--sidebar-border: oklch(0.93 0.004 260);
|
|
1798
|
-
--sidebar-ring: oklch(0.7 0.01 260);
|
|
1799
|
-
/* Code — light gray background, matching Mintlify's inline code */
|
|
1800
|
-
--code-background: oklch(0.96 0.003 260);
|
|
1801
|
-
--code-foreground: oklch(0.25 0.01 260);
|
|
1802
|
-
|
|
1803
|
-
/* API reference — always-dark code panel */
|
|
1804
|
-
--api-panel-bg: oklch(0.145 0.014 253.833);
|
|
1805
|
-
--api-panel-fg: oklch(0.926 0.013 253.833);
|
|
1806
|
-
|
|
1807
|
-
/* API semantic colors — HTTP methods, types, status codes */
|
|
1808
|
-
--api-get: oklch(0.723 0.15 162);
|
|
1809
|
-
--api-post: oklch(0.637 0.174 259);
|
|
1810
|
-
--api-put: oklch(0.74 0.175 63);
|
|
1811
|
-
--api-patch: oklch(0.7 0.18 45);
|
|
1812
|
-
--api-delete: oklch(0.614 0.194 22);
|
|
1813
|
-
--api-head: oklch(0.534 0.222 286);
|
|
1814
|
-
--api-required: oklch(0.614 0.194 22);
|
|
1815
|
-
--api-deprecated: oklch(0.74 0.175 63);
|
|
1816
|
-
--api-type-string: oklch(0.723 0.15 162);
|
|
1817
|
-
--api-type-number: oklch(0.637 0.174 259);
|
|
1818
|
-
--api-type-boolean: oklch(0.74 0.175 63);
|
|
1819
|
-
--api-type-object: oklch(0.534 0.222 286);
|
|
1820
|
-
--api-type-array: oklch(0.7 0.18 45);
|
|
1821
|
-
--api-status-2xx: oklch(0.723 0.15 162);
|
|
1822
|
-
--api-status-3xx: oklch(0.637 0.174 259);
|
|
1823
|
-
--api-status-4xx: oklch(0.74 0.175 63);
|
|
1824
|
-
--api-status-5xx: oklch(0.614 0.194 22);
|
|
1825
|
-
|
|
1826
|
-
/* Fonts — system sans for body, JetBrains Mono for code */
|
|
1827
|
-
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
1828
|
-
--font-heading: var(--font-sans);
|
|
1829
|
-
--font-code: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
1830
|
-
}`,
|
|
1831
|
-
dark: `.dark {
|
|
1832
|
-
--background: oklch(0.16 0.01 260);
|
|
1833
|
-
--foreground: oklch(0.93 0.005 260);
|
|
1834
|
-
--card: oklch(0.18 0.01 260);
|
|
1835
|
-
--card-foreground: oklch(0.93 0.005 260);
|
|
1836
|
-
--popover: oklch(0.18 0.01 260);
|
|
1837
|
-
--popover-foreground: oklch(0.93 0.005 260);
|
|
1838
|
-
--primary: oklch(0.93 0.005 260);
|
|
1839
|
-
--primary-foreground: oklch(0.16 0.01 260);
|
|
1840
|
-
--secondary: oklch(0.22 0.008 260);
|
|
1841
|
-
--secondary-foreground: oklch(0.93 0.005 260);
|
|
1842
|
-
--muted: oklch(0.22 0.008 260);
|
|
1843
|
-
--muted-foreground: oklch(0.65 0.01 260);
|
|
1844
|
-
--accent: oklch(0.22 0.008 260);
|
|
1845
|
-
--accent-foreground: oklch(0.93 0.005 260);
|
|
1846
|
-
--destructive: oklch(0.65 0.2 25);
|
|
1847
|
-
--destructive-foreground: oklch(0.16 0.01 260);
|
|
1848
|
-
--border: oklch(0.28 0.008 260);
|
|
1849
|
-
--input: oklch(0.28 0.008 260);
|
|
1850
|
-
--ring: oklch(0.45 0.01 260);
|
|
1851
|
-
--note-foreground: var(--foreground);
|
|
1852
|
-
--sidebar: oklch(0.16 0.01 260);
|
|
1853
|
-
--sidebar-foreground: oklch(0.93 0.005 260);
|
|
1854
|
-
--sidebar-primary: oklch(0.55 0.2 260);
|
|
1855
|
-
--sidebar-primary-foreground: oklch(0.93 0.005 260);
|
|
1856
|
-
--sidebar-accent: oklch(0.22 0.008 260);
|
|
1857
|
-
--sidebar-accent-foreground: oklch(0.93 0.005 260);
|
|
1858
|
-
--sidebar-border: oklch(0.28 0.008 260);
|
|
1859
|
-
--sidebar-ring: oklch(0.45 0.01 260);
|
|
1860
|
-
--code-background: oklch(0.2 0.01 260);
|
|
1861
|
-
--code-foreground: oklch(0.9 0.01 260);
|
|
1862
|
-
}`,
|
|
1863
|
-
};
|
|
1864
3760
|
function writeThemeFile(path, themeName) {
|
|
1865
|
-
|
|
1866
|
-
writeFileSync(path, `@custom-variant dark (&:where(.dark, .dark *));
|
|
1867
|
-
|
|
1868
|
-
@theme inline {
|
|
1869
|
-
--color-background: var(--background);
|
|
1870
|
-
--color-foreground: var(--foreground);
|
|
1871
|
-
--color-card: var(--card);
|
|
1872
|
-
--color-card-foreground: var(--card-foreground);
|
|
1873
|
-
--color-popover: var(--popover);
|
|
1874
|
-
--color-popover-foreground: var(--popover-foreground);
|
|
1875
|
-
--color-primary: var(--primary);
|
|
1876
|
-
--color-primary-foreground: var(--primary-foreground);
|
|
1877
|
-
--color-secondary: var(--secondary);
|
|
1878
|
-
--color-secondary-foreground: var(--secondary-foreground);
|
|
1879
|
-
--color-muted: var(--muted);
|
|
1880
|
-
--color-muted-foreground: var(--muted-foreground);
|
|
1881
|
-
--color-accent: var(--accent);
|
|
1882
|
-
--color-accent-foreground: var(--accent-foreground);
|
|
1883
|
-
--color-destructive: var(--destructive);
|
|
1884
|
-
--color-destructive-foreground: var(--destructive-foreground);
|
|
1885
|
-
--color-border: var(--border);
|
|
1886
|
-
--color-input: var(--input);
|
|
1887
|
-
--color-ring: var(--ring);
|
|
1888
|
-
--radius-sm: calc(var(--radius) - 4px);
|
|
1889
|
-
--radius-md: calc(var(--radius) - 2px);
|
|
1890
|
-
--radius-lg: var(--radius);
|
|
1891
|
-
--radius-xl: calc(var(--radius) + 4px);
|
|
1892
|
-
|
|
1893
|
-
/* Sidebar */
|
|
1894
|
-
--color-sidebar: var(--sidebar);
|
|
1895
|
-
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
1896
|
-
--color-sidebar-primary: var(--sidebar-primary);
|
|
1897
|
-
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
1898
|
-
--color-sidebar-accent: var(--sidebar-accent);
|
|
1899
|
-
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
1900
|
-
--color-sidebar-border: var(--sidebar-border);
|
|
1901
|
-
--color-sidebar-ring: var(--sidebar-ring);
|
|
1902
|
-
|
|
1903
|
-
/* Admonition colors */
|
|
1904
|
-
--color-note: var(--note);
|
|
1905
|
-
--color-note-foreground: var(--note-foreground);
|
|
1906
|
-
--color-abstract: var(--abstract);
|
|
1907
|
-
--color-info: var(--info);
|
|
1908
|
-
--color-tip: var(--tip);
|
|
1909
|
-
--color-success: var(--success);
|
|
1910
|
-
--color-question: var(--question);
|
|
1911
|
-
--color-warning: var(--warning);
|
|
1912
|
-
--color-failure: var(--failure);
|
|
1913
|
-
--color-danger: var(--danger);
|
|
1914
|
-
--color-bug: var(--bug);
|
|
1915
|
-
--color-example: var(--example);
|
|
1916
|
-
--color-quote: var(--quote);
|
|
1917
|
-
|
|
1918
|
-
/* Code */
|
|
1919
|
-
--color-code-bg: var(--code-background);
|
|
1920
|
-
--color-code-fg: var(--code-foreground);
|
|
1921
|
-
|
|
1922
|
-
/* API reference — HTTP method, type, status, role colors */
|
|
1923
|
-
--color-api-get: var(--api-get);
|
|
1924
|
-
--color-api-post: var(--api-post);
|
|
1925
|
-
--color-api-put: var(--api-put);
|
|
1926
|
-
--color-api-patch: var(--api-patch);
|
|
1927
|
-
--color-api-delete: var(--api-delete);
|
|
1928
|
-
--color-api-head: var(--api-head);
|
|
1929
|
-
--color-api-required: var(--api-required);
|
|
1930
|
-
--color-api-deprecated: var(--api-deprecated);
|
|
1931
|
-
--color-api-type-string: var(--api-type-string);
|
|
1932
|
-
--color-api-type-number: var(--api-type-number);
|
|
1933
|
-
--color-api-type-boolean: var(--api-type-boolean);
|
|
1934
|
-
--color-api-type-object: var(--api-type-object);
|
|
1935
|
-
--color-api-type-array: var(--api-type-array);
|
|
1936
|
-
--color-api-status-2xx: var(--api-status-2xx);
|
|
1937
|
-
--color-api-status-3xx: var(--api-status-3xx);
|
|
1938
|
-
--color-api-status-4xx: var(--api-status-4xx);
|
|
1939
|
-
--color-api-status-5xx: var(--api-status-5xx);
|
|
1940
|
-
|
|
1941
|
-
/* Fonts */
|
|
1942
|
-
--font-sans: var(--font-sans);
|
|
1943
|
-
--font-mono: var(--font-code);
|
|
1944
|
-
--font-family-code: var(--font-code);
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
${tokens.light}
|
|
1948
|
-
|
|
1949
|
-
${tokens.dark}
|
|
1950
|
-
|
|
1951
|
-
@layer base {
|
|
1952
|
-
* { @apply border-border outline-ring/50; }
|
|
1953
|
-
body { @apply bg-background text-foreground font-sans; }
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
/* Shiki dual-theme: light by default, dark when .dark class is active */
|
|
1957
|
-
.shiki,
|
|
1958
|
-
.shiki span,
|
|
1959
|
-
.astro-code,
|
|
1960
|
-
.astro-code span {
|
|
1961
|
-
color: var(--shiki-light) !important;
|
|
1962
|
-
}
|
|
1963
|
-
.shiki,
|
|
1964
|
-
.astro-code {
|
|
1965
|
-
background-color: var(--shiki-light-bg) !important;
|
|
1966
|
-
}
|
|
1967
|
-
.dark .shiki,
|
|
1968
|
-
.dark .shiki span,
|
|
1969
|
-
.dark .astro-code,
|
|
1970
|
-
.dark .astro-code span {
|
|
1971
|
-
color: var(--shiki-dark) !important;
|
|
1972
|
-
}
|
|
1973
|
-
.dark .shiki,
|
|
1974
|
-
.dark .astro-code {
|
|
1975
|
-
background-color: var(--shiki-dark-bg) !important;
|
|
1976
|
-
}
|
|
1977
|
-
/* Fix comment color contrast for WCAG AA */
|
|
1978
|
-
.shiki span[style*="--shiki-light:#6E7781"],
|
|
1979
|
-
.astro-code span[style*="--shiki-light:#6E7781"] {
|
|
1980
|
-
--shiki-light: #576069 !important;
|
|
1981
|
-
}
|
|
1982
|
-
`);
|
|
3761
|
+
writeFileSync(path, themeCss(themeName));
|
|
1983
3762
|
}
|
|
1984
3763
|
// ── Resolution helpers ──────────────────────────────────
|
|
1985
3764
|
/**
|
|
@@ -2009,6 +3788,37 @@ function isInsideWorkspace(outputDir) {
|
|
|
2009
3788
|
}
|
|
2010
3789
|
return false;
|
|
2011
3790
|
}
|
|
3791
|
+
/**
|
|
3792
|
+
* Every @dogsbay/* package in this checkout, mapped to its `file:` path.
|
|
3793
|
+
*
|
|
3794
|
+
* Enumerated from disk rather than hard-coded: the set of packages changes, and
|
|
3795
|
+
* a stale list fails as a confusing install error naming a package nobody
|
|
3796
|
+
* mentioned.
|
|
3797
|
+
*/
|
|
3798
|
+
function monorepoOverrides() {
|
|
3799
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
3800
|
+
const overrides = {};
|
|
3801
|
+
if (!existsSync(root))
|
|
3802
|
+
return overrides;
|
|
3803
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
3804
|
+
if (!entry.isDirectory())
|
|
3805
|
+
continue;
|
|
3806
|
+
const manifest = join(root, entry.name, "package.json");
|
|
3807
|
+
if (!existsSync(manifest))
|
|
3808
|
+
continue;
|
|
3809
|
+
try {
|
|
3810
|
+
const { name } = JSON.parse(readFileSync(manifest, "utf-8"));
|
|
3811
|
+
if (name?.startsWith("@dogsbay/"))
|
|
3812
|
+
overrides[name] = `file:${join(root, entry.name)}`;
|
|
3813
|
+
}
|
|
3814
|
+
catch {
|
|
3815
|
+
// A malformed manifest in the checkout is not this function's problem;
|
|
3816
|
+
// skipping it degrades to the previous behaviour for that one package
|
|
3817
|
+
// rather than failing the whole export.
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
return overrides;
|
|
3821
|
+
}
|
|
2012
3822
|
function resolveMonorepoPkg(name) {
|
|
2013
3823
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
2014
3824
|
// From packages/format-astro/src/ → ../../{name}
|
|
@@ -2042,4 +3852,3 @@ function resolveComponentsSource() {
|
|
|
2042
3852
|
return demo;
|
|
2043
3853
|
return null;
|
|
2044
3854
|
}
|
|
2045
|
-
//# sourceMappingURL=project.js.map
|