@dogsbay/format-astro 0.2.0-beta.11 → 0.2.0-beta.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/base-path.d.ts +93 -7
- package/dist/base-path.d.ts.map +1 -1
- package/dist/base-path.js +122 -8
- package/dist/base-path.js.map +1 -1
- package/dist/blog.d.ts +134 -0
- package/dist/blog.d.ts.map +1 -0
- package/dist/blog.js +319 -0
- package/dist/blog.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1 -0
- package/dist/cli.js.map +1 -1
- package/dist/diff-decoration.d.ts +79 -0
- package/dist/diff-decoration.d.ts.map +1 -0
- package/dist/diff-decoration.js +541 -0
- package/dist/diff-decoration.js.map +1 -0
- package/dist/granularity.d.ts +83 -0
- package/dist/granularity.d.ts.map +1 -0
- package/dist/granularity.js +247 -0
- package/dist/granularity.js.map +1 -0
- package/dist/index.d.ts +22 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -3
- package/dist/index.js.map +1 -1
- package/dist/lead.d.ts +19 -0
- package/dist/lead.d.ts.map +1 -1
- package/dist/lead.js +101 -6
- package/dist/lead.js.map +1 -1
- package/dist/llms-txt.d.ts +48 -2
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +131 -14
- package/dist/llms-txt.js.map +1 -1
- package/dist/plugins.js +1 -1
- package/dist/plugins.js.map +1 -1
- package/dist/project.d.ts +311 -13
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +2261 -211
- package/dist/project.js.map +1 -1
- package/dist/serialize.d.ts +23 -0
- package/dist/serialize.d.ts.map +1 -1
- package/dist/serialize.js +359 -138
- package/dist/serialize.js.map +1 -1
- package/dist/sitemap.d.ts +81 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +200 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/taxonomy.d.ts +48 -1
- package/dist/taxonomy.d.ts.map +1 -1
- package/dist/taxonomy.js +61 -23
- package/dist/taxonomy.js.map +1 -1
- package/package.json +8 -7
package/dist/project.js
CHANGED
|
@@ -4,14 +4,43 @@
|
|
|
4
4
|
* Takes ExportPage[] + NavItem[] and generates a complete Astro project
|
|
5
5
|
* with static .astro pages using real Dogsbay components.
|
|
6
6
|
*/
|
|
7
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, cpSync, readdirSync, statSync, } from "node:fs";
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, cpSync, readdirSync, statSync, rmSync, rmdirSync, openSync, readSync, closeSync, } from "node:fs";
|
|
8
8
|
import { join, dirname, relative, resolve } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
import { treeToDogsbayMd } from "@dogsbay/format-dogsbay-md";
|
|
11
|
-
import { treeToAstro } from "./serialize.js";
|
|
11
|
+
import { treeToAstro, TONE_CLASSES } from "./serialize.js";
|
|
12
|
+
import { emitPluginRuntime } from "./plugins.js";
|
|
12
13
|
import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import { buildSitemap, buildSitemapIndex } from "./sitemap.js";
|
|
15
|
+
import { assembleBook, estimateTreeBytes } from "./granularity.js";
|
|
16
|
+
/**
|
|
17
|
+
* The per-topic "Read as single page" affordance (granularity book view).
|
|
18
|
+
* Returns `[]` when the topic has no book (granularity off / not in a group).
|
|
19
|
+
* `href` is a build-time-known route (`<bookRoute>#<anchor>`).
|
|
20
|
+
*/
|
|
21
|
+
function singlePageLinkLines(href, indent) {
|
|
22
|
+
if (!href)
|
|
23
|
+
return [];
|
|
24
|
+
const pad = " ".repeat(indent);
|
|
25
|
+
return [`${pad}<a class="dsb-read-single-page" href="${href}">Read as single page →</a>`];
|
|
26
|
+
}
|
|
27
|
+
import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath, parseSiteUrl, combinePrefix, } from "./base-path.js";
|
|
28
|
+
import { buildBlogData, blogAdjacency } from "./blog.js";
|
|
29
|
+
/**
|
|
30
|
+
* Combined URL prefix = urlBase (Astro `base` from site.url path) +
|
|
31
|
+
* basePath (filesystem layout prefix). Every URL emitter (nav,
|
|
32
|
+
* sitemap, llms.txt, .md mirror, _headers, taxonomy) uses this for
|
|
33
|
+
* href output. Filesystem-layout consumers (mkdir, page output
|
|
34
|
+
* paths) keep using basePath alone — Astro's `base` config adds the
|
|
35
|
+
* urlBase prefix at route time.
|
|
36
|
+
*
|
|
37
|
+
* See plans/astro-base-from-site-url.md.
|
|
38
|
+
*/
|
|
39
|
+
function combinedPrefix(options) {
|
|
40
|
+
const { urlBase } = parseSiteUrl(options.siteUrl);
|
|
41
|
+
return combinePrefix(urlBase, normalizeBasePath(options.basePath));
|
|
42
|
+
}
|
|
43
|
+
import { detectLeadingNodes, deriveDescription } from "./lead.js";
|
|
15
44
|
/**
|
|
16
45
|
* Recursively prefix all hrefs in a nav tree.
|
|
17
46
|
* Turns `<basePath>/foo` into `<basePath>/{section}/foo`. The
|
|
@@ -49,6 +78,39 @@ function prefixNavHrefs(items, section, basePath) {
|
|
|
49
78
|
function escapeRegex(s) {
|
|
50
79
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Prefix the combined URL base onto root-absolute nav hrefs.
|
|
83
|
+
*
|
|
84
|
+
* Most importers emit nav hrefs already carrying the combined prefix —
|
|
85
|
+
* the dogsbay-md importer walks source paths through
|
|
86
|
+
* `fileToHref(file, hrefPrefix=combined)`, so OpenShift's `nav.yml`
|
|
87
|
+
* (`welcome/foo.md`) resolves straight to `/<base>/welcome/foo`. But an
|
|
88
|
+
* importer that produces a nav.json of *pre-resolved* hrefs at a
|
|
89
|
+
* different base — e.g. `dogsbay convert --from docusaurus --to
|
|
90
|
+
* dogsbay-md`, whose hrefs are root-absolute (`/about/foo`) — would
|
|
91
|
+
* otherwise emit unprefixed nav links that 404 under a site base, and
|
|
92
|
+
* break prev/next (pagination matches against the combined-prefixed
|
|
93
|
+
* `currentPath`).
|
|
94
|
+
*
|
|
95
|
+
* Running every nav href through `rewriteHref` here is the single
|
|
96
|
+
* chokepoint that makes nav base-correct for ALL importers. It's
|
|
97
|
+
* idempotent — hrefs already starting with the prefix (or external /
|
|
98
|
+
* anchor / protocol-relative) are left untouched — so importer-side
|
|
99
|
+
* prefixing keeps working unchanged, and an empty prefix is a no-op.
|
|
100
|
+
*/
|
|
101
|
+
function prefixNavBaseHrefs(items, prefix) {
|
|
102
|
+
if (!prefix)
|
|
103
|
+
return items;
|
|
104
|
+
return items.map((item) => {
|
|
105
|
+
const result = { ...item };
|
|
106
|
+
if (result.href)
|
|
107
|
+
result.href = rewriteHref(result.href, prefix);
|
|
108
|
+
if (result.children) {
|
|
109
|
+
result.children = prefixNavBaseHrefs(result.children, prefix);
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
52
114
|
/**
|
|
53
115
|
* Rewrite internal hrefs in a page's tree nodes.
|
|
54
116
|
* Prepends `prefix` to root-relative links (e.g. `/workers/...` → `/docs/workers/...`).
|
|
@@ -97,6 +159,210 @@ function rewriteHref(href, prefix) {
|
|
|
97
159
|
return href;
|
|
98
160
|
return prefix + href;
|
|
99
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Rewrite image srcs in inline nodes + raw HTML to include the
|
|
164
|
+
* combined URL prefix.
|
|
165
|
+
*
|
|
166
|
+
* Astro auto-prefixes `<a href>` and image imports going through
|
|
167
|
+
* `<AstroImage>`, but raw `<img src="...">` HTML in template
|
|
168
|
+
* output is left untouched. The serializer emits raw `<img>` for
|
|
169
|
+
* inline images and falls back to it for non-optimized block
|
|
170
|
+
* images, so we have to prefix manually before serialization to
|
|
171
|
+
* make `/_assets/...` paths resolve under subpath-mounted deploys
|
|
172
|
+
* (GH Pages project pages, multi-mount Cloudflare).
|
|
173
|
+
*
|
|
174
|
+
* Symmetric with rewriteTreeHrefs — same skip-rules (external,
|
|
175
|
+
* anchors, already-prefixed). Block images keep their prefix
|
|
176
|
+
* stripped back off for the `imageMap[...]` lookup key (see
|
|
177
|
+
* paragraphToAstro in serialize.ts) so Astro's image optimization
|
|
178
|
+
* still finds the source.
|
|
179
|
+
*/
|
|
180
|
+
function rewriteTreeImageSrcs(nodes, prefix) {
|
|
181
|
+
for (const node of nodes) {
|
|
182
|
+
if (node.inline) {
|
|
183
|
+
rewriteInlineImageSrcs(node.inline, prefix);
|
|
184
|
+
}
|
|
185
|
+
if (node.html) {
|
|
186
|
+
node.html = rewriteHtmlImageSrcs(node.html, prefix);
|
|
187
|
+
}
|
|
188
|
+
if (node.children) {
|
|
189
|
+
rewriteTreeImageSrcs(node.children, prefix);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function rewriteInlineImageSrcs(nodes, prefix) {
|
|
194
|
+
for (const node of nodes) {
|
|
195
|
+
if (node.type === "image" && typeof node.src === "string") {
|
|
196
|
+
node.src = rewriteHref(node.src, prefix);
|
|
197
|
+
}
|
|
198
|
+
else if (node.type === "link") {
|
|
199
|
+
// Links wrap inline children (which may include images) — same
|
|
200
|
+
// recursion shape as rewriteInlineHrefs.
|
|
201
|
+
rewriteInlineImageSrcs(node.children, prefix);
|
|
202
|
+
}
|
|
203
|
+
else if (node.type === "highlight" && node.children) {
|
|
204
|
+
rewriteInlineImageSrcs(node.children, prefix);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function rewriteHtmlImageSrcs(html, prefix) {
|
|
209
|
+
return html.replace(/(<img\b[^>]*\ssrc=")(\/[^"]+)"/g, (_match, before, src) => `${before}${rewriteHref(src, prefix)}"`);
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Astro `outDir` for a Cloudflare Workers deploy mounted on a host subpath.
|
|
213
|
+
*
|
|
214
|
+
* Workers Static Assets resolves the FULL request pathname against the
|
|
215
|
+
* asset manifest and strips nothing. A site mounted at `dogsbay.ai/blog/*`
|
|
216
|
+
* is therefore asked for `/blog/index.html`, and unless the build output
|
|
217
|
+
* actually contains `blog/`, every request 404s. Astro's `base` does not
|
|
218
|
+
* do this: it prefixes generated URLs (including `/blog/_astro/...`) but
|
|
219
|
+
* leaves the emitted files at `dist/` root. Setting `outDir` to
|
|
220
|
+
* `./dist<urlBase>` is what makes the two agree.
|
|
221
|
+
*
|
|
222
|
+
* GitHub Pages needs the OPPOSITE and must not get this. There the
|
|
223
|
+
* uploaded artifact IS the site root, served at `https://<user>.github.io/
|
|
224
|
+
* <repo>/`, so the host supplies the prefix: URLs must carry it, files
|
|
225
|
+
* must not. Emitting `outDir` for a Pages build would produce
|
|
226
|
+
* `/<repo>/<repo>/`. Hence the deploy-target gate — the same `site.url`
|
|
227
|
+
* deliberately yields different layouts per target.
|
|
228
|
+
*
|
|
229
|
+
* Returns `undefined` when the target is not Workers or the site sits at
|
|
230
|
+
* the host root (no path in `site.url`), which is the common case.
|
|
231
|
+
*/
|
|
232
|
+
export function workersSubpathOutDir(deploy, urlBase) {
|
|
233
|
+
if (deploy !== "cloudflare-workers")
|
|
234
|
+
return undefined;
|
|
235
|
+
if (!urlBase)
|
|
236
|
+
return undefined;
|
|
237
|
+
return `./dist${urlBase}`;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Delete generated content pages whose source markdown is gone.
|
|
241
|
+
*
|
|
242
|
+
* `emitAstroPages` writes a page per source file but never removed one,
|
|
243
|
+
* so deleting a post left its `.astro` and `.md.ts` behind: the page kept
|
|
244
|
+
* building, kept deploying, and kept being linked from tag archives.
|
|
245
|
+
* Measured on the blog — three deleted posts were still live.
|
|
246
|
+
*
|
|
247
|
+
* Deleting files needs a tighter contract than writing them, so this is
|
|
248
|
+
* bounded four ways:
|
|
249
|
+
*
|
|
250
|
+
* ROOT Only the subtree this invocation owns —
|
|
251
|
+
* `src/pages/<basePath>/<section>` — never all of `src/pages`.
|
|
252
|
+
* `--section` exists so several converts land in one project;
|
|
253
|
+
* walking the whole tree meant each run deleted the previous
|
|
254
|
+
* run's section.
|
|
255
|
+
* FLOOR Never prune when this run generated nothing. A mis-set
|
|
256
|
+
* `--section`, a moved source dir or a config typo yields zero
|
|
257
|
+
* pages, and an unguarded prune would delete the entire site
|
|
258
|
+
* and exit 0.
|
|
259
|
+
* WHOLE Never prune when a page failed to generate. The page loop
|
|
260
|
+
* warns and continues, so a failed page is absent from
|
|
261
|
+
* `generatedPaths` and would look exactly like a deleted one —
|
|
262
|
+
* turning a transient serializer throw into a deletion.
|
|
263
|
+
* BANNER Only files carrying the `dogsbay convert` banner. That marks
|
|
264
|
+
* content pages and their `.md` mirrors; taxonomy and blog
|
|
265
|
+
* routes carry a `dogsbay site build` banner and a writer's own
|
|
266
|
+
* page carries none. This runs BEFORE those emitters, when last
|
|
267
|
+
* build's routes are present and unclaimed, so the distinction
|
|
268
|
+
* is what keeps them alive.
|
|
269
|
+
*
|
|
270
|
+
* KNOWN GAP: missing-translation stubs are not covered. They carry
|
|
271
|
+
* `// AUTO-GENERATED missing-translation stub.`, not the convert banner,
|
|
272
|
+
* so deleting `en/foo.md` leaves `/fr/foo` as a live route redirecting to
|
|
273
|
+
* a now-404 `/en/foo` — the same symptom, one hop away. Fixing it needs
|
|
274
|
+
* the stubs tracked in `generatedPaths` first; giving them the convert
|
|
275
|
+
* banner alone would make every build prune the stub it just wrote.
|
|
276
|
+
*/
|
|
277
|
+
function pruneOrphanedPages(outputDir, root, generatedPaths) {
|
|
278
|
+
if (!existsSync(root))
|
|
279
|
+
return [];
|
|
280
|
+
const removed = [];
|
|
281
|
+
const walk = (dir) => {
|
|
282
|
+
let emptied = false;
|
|
283
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
284
|
+
const full = join(dir, entry.name);
|
|
285
|
+
if (entry.isDirectory()) {
|
|
286
|
+
walk(full);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
// Only our two output kinds can be orphans; skip everything else
|
|
290
|
+
// rather than reading it.
|
|
291
|
+
if (!entry.name.endsWith(".astro") && !entry.name.endsWith(".md.ts"))
|
|
292
|
+
continue;
|
|
293
|
+
if (generatedPaths.has(relative(outputDir, full)))
|
|
294
|
+
continue;
|
|
295
|
+
// The banner is always the first line, so read a prefix rather than
|
|
296
|
+
// the whole file — a `.md.ts` mirror embeds an entire page's
|
|
297
|
+
// markdown, and a large corpus is thousands of them per build.
|
|
298
|
+
let head;
|
|
299
|
+
try {
|
|
300
|
+
const fd = openSync(full, "r");
|
|
301
|
+
try {
|
|
302
|
+
const buf = Buffer.alloc(256);
|
|
303
|
+
const n = readSync(fd, buf, 0, 256, 0);
|
|
304
|
+
head = buf.subarray(0, n).toString("utf-8");
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
closeSync(fd);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!head.includes("AUTO-GENERATED by `dogsbay convert`"))
|
|
314
|
+
continue;
|
|
315
|
+
rmSync(full, { force: true });
|
|
316
|
+
removed.push(relative(outputDir, full));
|
|
317
|
+
emptied = true;
|
|
318
|
+
}
|
|
319
|
+
// Only directories THIS pass emptied, and only via non-recursive
|
|
320
|
+
// rmdir: it fails atomically if anything landed in the meantime,
|
|
321
|
+
// which matters under `site dev` where the build re-runs while an
|
|
322
|
+
// editor and the dev server are live.
|
|
323
|
+
if (emptied && dir !== root) {
|
|
324
|
+
try {
|
|
325
|
+
rmdirSync(dir);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
/* not empty, or raced — correct to leave it */
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
walk(root);
|
|
333
|
+
return removed;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Build the `excludeFromRoutes` predicate.
|
|
337
|
+
*
|
|
338
|
+
* Exported because more than one emitter has to agree on which pages
|
|
339
|
+
* become routes. The blog listed excluded pages as posts, producing index
|
|
340
|
+
* cards and Newer/Older links to pages that were never emitted — a 404
|
|
341
|
+
* from the site's own front page.
|
|
342
|
+
*
|
|
343
|
+
* A single-segment pattern matches ANY segment (fragment dirs symlink to
|
|
344
|
+
* many depths in AsciiBinder corpora); a multi-segment pattern matches as
|
|
345
|
+
* a leading path prefix, so `welcome/_internal` excludes only that tree.
|
|
346
|
+
*/
|
|
347
|
+
export function makeSlugExcluder(excludeFromRoutes) {
|
|
348
|
+
const patterns = (excludeFromRoutes ?? []).map((p) => p.replace(/^\/+/, "").replace(/\/+$/, ""));
|
|
349
|
+
return (slug) => {
|
|
350
|
+
if (patterns.length === 0)
|
|
351
|
+
return false;
|
|
352
|
+
const segments = slug.split("/");
|
|
353
|
+
for (const pattern of patterns) {
|
|
354
|
+
if (pattern.includes("/")) {
|
|
355
|
+
if (slug === pattern || slug.startsWith(pattern + "/"))
|
|
356
|
+
return true;
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
if (segments.includes(pattern))
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return false;
|
|
364
|
+
};
|
|
365
|
+
}
|
|
100
366
|
/**
|
|
101
367
|
* Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
|
|
102
368
|
*
|
|
@@ -147,18 +413,144 @@ function buildWranglerConfig(siteName, options) {
|
|
|
147
413
|
lines.push(`}`);
|
|
148
414
|
return lines.join("\n") + "\n";
|
|
149
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* Build the GitHub Actions workflow YAML for `actions/deploy-pages`.
|
|
418
|
+
*
|
|
419
|
+
* The workflow:
|
|
420
|
+
* 1. Checks out the repo on every push to the default branch.
|
|
421
|
+
* 2. Installs node + pnpm at the Astro project directory, runs
|
|
422
|
+
* `dogsbay site build` (via `pnpm dlx` since Dogsbay is a
|
|
423
|
+
* global CLI, not a project dep), then `pnpm run build`
|
|
424
|
+
* (which runs `astro build && pagefind`).
|
|
425
|
+
* 3. Uploads `<astroDirRel>/dist` as a Pages artifact via
|
|
426
|
+
* `actions/upload-pages-artifact`.
|
|
427
|
+
* 4. Deploys via `actions/deploy-pages`.
|
|
428
|
+
*
|
|
429
|
+
* `astroDirRel` is the path of the Astro output relative to the
|
|
430
|
+
* repo root (typically "astro" — the default config has
|
|
431
|
+
* `output: ./astro`). Empty string is allowed when the project is
|
|
432
|
+
* flat (outputDir === projectDir); the workflow degrades naturally
|
|
433
|
+
* by omitting the `defaults: working-directory` block.
|
|
434
|
+
*
|
|
435
|
+
* Author edits — extra build steps, secrets, deploy gating — survive
|
|
436
|
+
* subsequent `dogsbay site build` runs because the file is written
|
|
437
|
+
* write-if-missing (see emitDeployArtifacts). To start over, delete
|
|
438
|
+
* the workflow file and rebuild.
|
|
439
|
+
*
|
|
440
|
+
* Note on basePath: GitHub Pages serves project sites at
|
|
441
|
+
* `https://<user>.github.io/<repo>/`. Authors who want their docs at
|
|
442
|
+
* the repo root should set `site.basePath: /<repo-name>` (or empty
|
|
443
|
+
* for user/org pages). The platform's basePath plumbing handles all
|
|
444
|
+
* URL rewriting; this workflow doesn't need to know about it.
|
|
445
|
+
*/
|
|
446
|
+
function buildGitHubPagesWorkflow(astroDirRel) {
|
|
447
|
+
// When the Astro output IS the project root, drop the working-
|
|
448
|
+
// directory block and reference cache + artifact paths without a
|
|
449
|
+
// prefix. This is the flat-layout case (rare for site-init flows;
|
|
450
|
+
// common for `dogsbay convert` outputs that get manually wired up).
|
|
451
|
+
const isFlat = astroDirRel === "" || astroDirRel === ".";
|
|
452
|
+
const workingDirBlock = isFlat
|
|
453
|
+
? ""
|
|
454
|
+
: `
|
|
455
|
+
defaults:
|
|
456
|
+
run:
|
|
457
|
+
working-directory: ${astroDirRel}`;
|
|
458
|
+
const cacheDep = isFlat
|
|
459
|
+
? "pnpm-lock.yaml"
|
|
460
|
+
: `${astroDirRel}/pnpm-lock.yaml`;
|
|
461
|
+
const artifactPath = isFlat ? "dist" : `${astroDirRel}/dist`;
|
|
462
|
+
return `# Deploy to GitHub Pages.
|
|
463
|
+
# Generated by \`dogsbay site init --deploy=github-pages\` (or by
|
|
464
|
+
# adding \`deploy: { target: github-pages }\` to dogsbay.config.yml
|
|
465
|
+
# and running \`dogsbay site build\`). Author edits survive every
|
|
466
|
+
# subsequent build — the file is never overwritten. To regenerate
|
|
467
|
+
# from template, delete the file and rebuild.
|
|
468
|
+
#
|
|
469
|
+
# Repo settings: Settings → Pages → Source = "GitHub Actions".
|
|
470
|
+
name: Deploy to GitHub Pages
|
|
471
|
+
|
|
472
|
+
on:
|
|
473
|
+
push:
|
|
474
|
+
branches: [main]
|
|
475
|
+
workflow_dispatch:
|
|
476
|
+
|
|
477
|
+
permissions:
|
|
478
|
+
contents: read
|
|
479
|
+
pages: write
|
|
480
|
+
id-token: write
|
|
481
|
+
|
|
482
|
+
# Allow only one concurrent deployment, skipping queued runs.
|
|
483
|
+
concurrency:
|
|
484
|
+
group: pages
|
|
485
|
+
cancel-in-progress: false
|
|
486
|
+
|
|
487
|
+
jobs:
|
|
488
|
+
build:
|
|
489
|
+
runs-on: ubuntu-latest${workingDirBlock}
|
|
490
|
+
steps:
|
|
491
|
+
- uses: actions/checkout@v4
|
|
492
|
+
|
|
493
|
+
- uses: pnpm/action-setup@v4
|
|
494
|
+
with:
|
|
495
|
+
version: 10
|
|
496
|
+
|
|
497
|
+
- uses: actions/setup-node@v4
|
|
498
|
+
with:
|
|
499
|
+
# Astro 6 requires Node ^20.19.5 || >=22.12.0; pin 22 for
|
|
500
|
+
# forward-compat (Node 20 LTS is fine for Astro 5 sites
|
|
501
|
+
# but the Dogsbay scaffold targets Astro 6).
|
|
502
|
+
node-version: 22
|
|
503
|
+
cache: pnpm
|
|
504
|
+
cache-dependency-path: ${cacheDep}
|
|
505
|
+
|
|
506
|
+
- name: Install dependencies
|
|
507
|
+
run: pnpm install --frozen-lockfile
|
|
508
|
+
|
|
509
|
+
# \`dogsbay\` is a global CLI, not a project dep — pnpm dlx
|
|
510
|
+
# fetches it on demand. To pin a version, replace with e.g.
|
|
511
|
+
# \`pnpm dlx dogsbay@0.2.0-beta.18 site build\`.
|
|
512
|
+
- name: Build with Dogsbay
|
|
513
|
+
run: pnpm dlx dogsbay@beta site build
|
|
514
|
+
|
|
515
|
+
- name: Build Astro site
|
|
516
|
+
run: pnpm run build
|
|
517
|
+
|
|
518
|
+
- name: Upload Pages artifact
|
|
519
|
+
uses: actions/upload-pages-artifact@v3
|
|
520
|
+
with:
|
|
521
|
+
path: ${artifactPath}
|
|
522
|
+
|
|
523
|
+
deploy:
|
|
524
|
+
needs: build
|
|
525
|
+
runs-on: ubuntu-latest
|
|
526
|
+
environment:
|
|
527
|
+
name: github-pages
|
|
528
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
529
|
+
steps:
|
|
530
|
+
- name: Deploy to GitHub Pages
|
|
531
|
+
id: deployment
|
|
532
|
+
uses: actions/deploy-pages@v4
|
|
533
|
+
`;
|
|
534
|
+
}
|
|
150
535
|
/**
|
|
151
536
|
* Construct the SiteConfig object that gets serialized to
|
|
152
537
|
* `src/data/site.json`. Backward-compatible: existing fields keep their
|
|
153
538
|
* empty-string defaults; new optional fields are omitted when undefined.
|
|
154
539
|
*/
|
|
155
|
-
function buildSiteConfig(siteName, options) {
|
|
540
|
+
function buildSiteConfig(siteName, options, outputDir) {
|
|
156
541
|
const cfg = {
|
|
157
542
|
siteName,
|
|
158
543
|
repoUrl: options.repoUrl || "",
|
|
159
|
-
editUri: options.editUri || "blob/main/docs/",
|
|
160
|
-
copyright: options.copyright || "",
|
|
161
544
|
};
|
|
545
|
+
// Favicon href, so GENERATED routes can emit <link rel="icon"> too.
|
|
546
|
+
// Content pages get it computed per page; the blog index and the
|
|
547
|
+
// taxonomy routes are emitted by other emitters that never received it,
|
|
548
|
+
// so the site's own front page had no icon while its posts did.
|
|
549
|
+
if (outputDir) {
|
|
550
|
+
const href = faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase);
|
|
551
|
+
if (href)
|
|
552
|
+
cfg.favicon = href;
|
|
553
|
+
}
|
|
162
554
|
if (options.siteUrl)
|
|
163
555
|
cfg.siteUrl = options.siteUrl;
|
|
164
556
|
if (options.description)
|
|
@@ -169,6 +561,15 @@ function buildSiteConfig(siteName, options) {
|
|
|
169
561
|
cfg.twitterHandle = options.twitterHandle;
|
|
170
562
|
if (options.themeColor)
|
|
171
563
|
cfg.themeColor = options.themeColor;
|
|
564
|
+
// editUri + copyright follow the same omit-on-empty pattern as the
|
|
565
|
+
// optional fields above; previously they were always written
|
|
566
|
+
// (editUri defaulted to "blob/main/docs/", copyright to ""), which
|
|
567
|
+
// left zombie config in src/data/site.json. Downstream guards already
|
|
568
|
+
// treat empty / undefined as "don't render" so this is purely a tidy.
|
|
569
|
+
if (options.editUri)
|
|
570
|
+
cfg.editUri = options.editUri;
|
|
571
|
+
if (options.copyright)
|
|
572
|
+
cfg.copyright = options.copyright;
|
|
172
573
|
if (options.brandKeywords && options.brandKeywords.length > 0) {
|
|
173
574
|
cfg.brandKeywords = options.brandKeywords;
|
|
174
575
|
}
|
|
@@ -185,6 +586,11 @@ function buildSiteConfig(siteName, options) {
|
|
|
185
586
|
if (options.tagLabels && Object.keys(options.tagLabels).length > 0) {
|
|
186
587
|
cfg.tagLabels = options.tagLabels;
|
|
187
588
|
}
|
|
589
|
+
// Link-icon glyphs — only emitted when configured, so sites without
|
|
590
|
+
// the feature keep byte-identical site.json.
|
|
591
|
+
if (options.linkIcons && (options.linkIcons.external || options.linkIcons.internal)) {
|
|
592
|
+
cfg.linkIcons = options.linkIcons;
|
|
593
|
+
}
|
|
188
594
|
if (options.taxonomyIndexPaths &&
|
|
189
595
|
Object.keys(options.taxonomyIndexPaths).length > 0) {
|
|
190
596
|
// Bake basePath into every emitted indexPath so consumers
|
|
@@ -195,15 +601,50 @@ function buildSiteConfig(siteName, options) {
|
|
|
195
601
|
// values (`/by-type`, `/tags`, etc.) — basePath threading is
|
|
196
602
|
// this emitter's responsibility, matching how `page.url` is
|
|
197
603
|
// already prefixed in the taxonomy data file.
|
|
198
|
-
|
|
604
|
+
// Taxonomy index paths are baked into site.json so components
|
|
605
|
+
// (TagList, TaxonomyIndex, TypeBadge) emit correct hrefs at
|
|
606
|
+
// runtime. Use combined so these resolve under the host's
|
|
607
|
+
// served subpath.
|
|
608
|
+
const taxoPrefix = combinedPrefix(options);
|
|
199
609
|
cfg.taxonomyIndexPaths = Object.fromEntries(Object.entries(options.taxonomyIndexPaths).map(([name, raw]) => [
|
|
200
610
|
name,
|
|
201
|
-
withBasePath(
|
|
611
|
+
withBasePath(taxoPrefix, raw),
|
|
202
612
|
]));
|
|
203
613
|
}
|
|
204
614
|
if (options.taxonomyDisplay &&
|
|
205
615
|
Object.keys(options.taxonomyDisplay).length > 0) {
|
|
206
|
-
|
|
616
|
+
// Flatten prefix labels into top-level entries so the
|
|
617
|
+
// search-facets resolver finds them after DocsLayout splits
|
|
618
|
+
// slash-nested tags into per-prefix Pagefind filter divs.
|
|
619
|
+
//
|
|
620
|
+
// Input:
|
|
621
|
+
// tags.prefixes = { difficulty: { label, color }, ... }
|
|
622
|
+
// tags.labels = { "difficulty/1": "Beginner", "difficulty/2": ... }
|
|
623
|
+
// Output additions (kept alongside the original `tags` entry):
|
|
624
|
+
// difficulty.labels = { "1": "Beginner", "2": ... }
|
|
625
|
+
//
|
|
626
|
+
// Resolver does `display[facetName].labels[value]` — facet name
|
|
627
|
+
// is now `difficulty`, value is `1`, → "Beginner". See
|
|
628
|
+
// plans/per-prefix-search-facets.md.
|
|
629
|
+
const flat = { ...options.taxonomyDisplay };
|
|
630
|
+
const tagsDisplay = options.taxonomyDisplay.tags;
|
|
631
|
+
if (tagsDisplay?.prefixes) {
|
|
632
|
+
for (const prefix of Object.keys(tagsDisplay.prefixes)) {
|
|
633
|
+
if (flat[prefix])
|
|
634
|
+
continue; // top-level entry wins
|
|
635
|
+
const leafLabels = {};
|
|
636
|
+
if (tagsDisplay.labels) {
|
|
637
|
+
const needle = `${prefix}/`;
|
|
638
|
+
for (const [slug, label] of Object.entries(tagsDisplay.labels)) {
|
|
639
|
+
if (slug.startsWith(needle)) {
|
|
640
|
+
leafLabels[slug.slice(needle.length)] = label;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
flat[prefix] = { labels: leafLabels };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
cfg.taxonomyDisplay = flat;
|
|
207
648
|
}
|
|
208
649
|
return cfg;
|
|
209
650
|
}
|
|
@@ -232,6 +673,21 @@ export async function exportAstroProject(pages, nav, outputDir, options = {}) {
|
|
|
232
673
|
const scaffoldSkipped = emitSiteScaffold(outputDir, siteName, options, writeScaffold);
|
|
233
674
|
const { generated, outputNav } = await emitAstroPages(pages, nav, outputDir, options);
|
|
234
675
|
emitConfigDerivedFiles(outputDir, options);
|
|
676
|
+
// The generated layout unconditionally imports
|
|
677
|
+
// `@/data/switcherMap.json` and the plugin wrapper stacks
|
|
678
|
+
// (`@/components/wrappers/*Stack.astro`); the exporter must
|
|
679
|
+
// guarantee both exist (empty axes / passthrough slots when there
|
|
680
|
+
// is no version data and no plugins) — previously only
|
|
681
|
+
// `dogsbay site build` emitted them, so every direct
|
|
682
|
+
// exportAstroProject consumer produced an unbuildable site
|
|
683
|
+
// (root-caused 2026-07-13 on the first comparison-site build).
|
|
684
|
+
emitSwitcherMap(pages, outputDir, options);
|
|
685
|
+
emitPluginRuntime({
|
|
686
|
+
outputDir,
|
|
687
|
+
clientModules: [],
|
|
688
|
+
styles: [],
|
|
689
|
+
clientConfigs: [],
|
|
690
|
+
});
|
|
235
691
|
emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, options);
|
|
236
692
|
console.log(`Generated ${generated} static .astro pages`);
|
|
237
693
|
if (alreadyScaffolded && !options.force && scaffoldSkipped > 0) {
|
|
@@ -268,7 +724,300 @@ function ensureDirectoryStructure(outputDir, basePath) {
|
|
|
268
724
|
*/
|
|
269
725
|
export function emitSiteConfig(outputDir, siteName, options) {
|
|
270
726
|
mkdirSync(join(outputDir, "src", "data"), { recursive: true });
|
|
271
|
-
writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
|
|
727
|
+
writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options, outputDir), null, 2));
|
|
728
|
+
// Auto-generated companion to astro.config.mjs. Carries the
|
|
729
|
+
// site/base values derived from dogsbay.config.yml's site.url so
|
|
730
|
+
// changes propagate without --force-rescaffolding the main
|
|
731
|
+
// astro.config.mjs (which is scaffold-once and may have author
|
|
732
|
+
// edits — custom integrations, build hooks, etc.). The main
|
|
733
|
+
// config imports `dogsbaySite` + `dogsbayBase` from here.
|
|
734
|
+
// See plans/astro-base-from-site-url.md.
|
|
735
|
+
const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
|
|
736
|
+
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
737
|
+
const dogsbaySiteJson = hasSiteUrl
|
|
738
|
+
? JSON.stringify(origin ?? options.siteUrl)
|
|
739
|
+
: "undefined";
|
|
740
|
+
const dogsbayBaseJson = astroBase ? JSON.stringify(astroBase) : "undefined";
|
|
741
|
+
// Workers subpath mounts need the build output to live under the mount
|
|
742
|
+
// path too — see workersSubpathOutDir. Undefined for every other target
|
|
743
|
+
// and for host-root sites, so nothing changes for existing deploys.
|
|
744
|
+
const dogsbayOutDir = workersSubpathOutDir(options.deploy, astroBase);
|
|
745
|
+
const dogsbayOutDirJson = dogsbayOutDir ? JSON.stringify(dogsbayOutDir) : "undefined";
|
|
746
|
+
// build.inlineStylesheets — defaults to "auto" (Astro's own
|
|
747
|
+
// default; matches our docs-first bias since theme.css is ~120KB
|
|
748
|
+
// and externalizing it lets the file cache cross-page). Authors
|
|
749
|
+
// wanting "always" / "never" set it via dogsbay.config.yml's
|
|
750
|
+
// build.inlineStylesheets. See docs/perf-tuning.md.
|
|
751
|
+
const dogsbayInline = options.inlineStylesheets ?? "auto";
|
|
752
|
+
writeFileSync(join(outputDir, "astro.config.dogsbay.mjs"), [
|
|
753
|
+
"// Auto-generated by `dogsbay site build` — DO NOT EDIT.",
|
|
754
|
+
"// Tracks site.url + derived Astro base + build behaviour from",
|
|
755
|
+
"// dogsbay.config.yml. Edit dogsbay.config.yml and rebuild;",
|
|
756
|
+
"// edits to this file will be overwritten on the next build.",
|
|
757
|
+
`export const dogsbaySite = ${dogsbaySiteJson};`,
|
|
758
|
+
`export const dogsbayBase = ${dogsbayBaseJson};`,
|
|
759
|
+
`export const dogsbayInlineStylesheets = ${JSON.stringify(dogsbayInline)};`,
|
|
760
|
+
`export const dogsbayOutDir = ${dogsbayOutDirJson};`,
|
|
761
|
+
"",
|
|
762
|
+
].join("\n"));
|
|
763
|
+
// dogsbay-mount.mjs — auto-generated, emitted only for subpath mounts.
|
|
764
|
+
//
|
|
765
|
+
// Two jobs that Astro cannot do itself once outDir moves to
|
|
766
|
+
// dist/<urlBase>/:
|
|
767
|
+
//
|
|
768
|
+
// clean `astro build` clears outDir, which is now the MOUNT dir,
|
|
769
|
+
// not dist/. Stale output therefore survives forever — a
|
|
770
|
+
// site migrating from the pre-mount layout keeps its old
|
|
771
|
+
// root-level index.html and _astro/, and changing site.url
|
|
772
|
+
// from /blog to /news strands the whole old tree. Both keep
|
|
773
|
+
// being uploaded, because `wrangler deploy` ships all of
|
|
774
|
+
// ./dist. dist/ is entirely derived, so clearing it is safe.
|
|
775
|
+
//
|
|
776
|
+
// NOTE robots.txt is deliberately NOT lifted. _headers is a CONFIG
|
|
777
|
+
// file that Workers reads from the assets root; robots.txt is a
|
|
778
|
+
// SERVED URL at /robots.txt, and on a subpath mount that path
|
|
779
|
+
// belongs to whichever worker owns the host root, not to this
|
|
780
|
+
// one. Lifting it would put a file at a path this worker's route
|
|
781
|
+
// never matches. The host-root worker's robots.txt is the
|
|
782
|
+
// authoritative one and must name this mount's sitemap — see
|
|
783
|
+
// plans/dogsbay-ai-site.md. The per-mount copy at
|
|
784
|
+
// <urlBase>/robots.txt is harmless but is NOT what crawlers read.
|
|
785
|
+
//
|
|
786
|
+
// finalize Workers Static Assets reads _headers only from the ROOT of
|
|
787
|
+
// assets.directory (./dist). Astro copies public/ into
|
|
788
|
+
// outDir, so _headers landed at dist/<urlBase>/_headers,
|
|
789
|
+
// where it does nothing — the RFC 8288 Link header pointing
|
|
790
|
+
// agents at llms.txt silently stopped applying, and the raw
|
|
791
|
+
// file was served as an asset. Move it up.
|
|
792
|
+
const mountHelperPath = join(outputDir, "dogsbay-mount.mjs");
|
|
793
|
+
if (!dogsbayOutDir && existsSync(mountHelperPath)) {
|
|
794
|
+
// No longer mounted (deploy target or site.url changed).
|
|
795
|
+
//
|
|
796
|
+
// Deleting it outright looks tidy and is wrong: package.json is
|
|
797
|
+
// scaffold-once, so the build script still runs
|
|
798
|
+
// `node ./dogsbay-mount.mjs clean && …` and the next `pnpm build` dies
|
|
799
|
+
// with ERR_MODULE_NOT_FOUND before Astro even starts. Downgrade it to
|
|
800
|
+
// a no-op instead, so the build keeps working while
|
|
801
|
+
// warnStaleMountBuildScript tells the author to update the script; only
|
|
802
|
+
// remove the file once nothing references it.
|
|
803
|
+
const pkgPath = join(outputDir, "package.json");
|
|
804
|
+
const stillReferenced = existsSync(pkgPath) && readFileSync(pkgPath, "utf-8").includes("dogsbay-mount.mjs");
|
|
805
|
+
if (stillReferenced) {
|
|
806
|
+
writeFileSync(mountHelperPath, [
|
|
807
|
+
"// AUTO-GENERATED by `dogsbay site build` — do not edit.",
|
|
808
|
+
"// This site is no longer mounted on a subpath, but package.json",
|
|
809
|
+
"// still calls this script, so it stays as a no-op rather than",
|
|
810
|
+
"// breaking the build. Update the build script and it goes away.",
|
|
811
|
+
"process.exit(0);",
|
|
812
|
+
"",
|
|
813
|
+
].join("\n"));
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
rmSync(mountHelperPath, { force: true });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
if (dogsbayOutDir) {
|
|
820
|
+
const mountDir = dogsbayOutDir.replace(/^\.\//, "");
|
|
821
|
+
writeFileSync(mountHelperPath, [
|
|
822
|
+
"// AUTO-GENERATED by `dogsbay site build` — do not edit.",
|
|
823
|
+
"// Subpath-mount build steps. See project.ts (dogsbay-mount.mjs).",
|
|
824
|
+
'import { rmSync, existsSync, mkdirSync, renameSync } from "node:fs";',
|
|
825
|
+
'import { dirname, join, resolve } from "node:path";',
|
|
826
|
+
'import { fileURLToPath } from "node:url";',
|
|
827
|
+
"",
|
|
828
|
+
`const MOUNT_DIR = ${JSON.stringify(mountDir)};`,
|
|
829
|
+
"// Resolve against THIS FILE, never process.cwd(). `pnpm build` runs",
|
|
830
|
+
"// with cwd set to the project, but this is an ordinary script sitting",
|
|
831
|
+
"// in the project root, and the natural CI/debug invocation",
|
|
832
|
+
"// `node path/to/site/dogsbay-mount.mjs clean` would otherwise",
|
|
833
|
+
"// recursively delete the CALLER's dist/.",
|
|
834
|
+
'const ROOT = dirname(fileURLToPath(import.meta.url));',
|
|
835
|
+
'const DIST = join(ROOT, "dist");',
|
|
836
|
+
'const mode = process.argv[2];',
|
|
837
|
+
"",
|
|
838
|
+
'if (mode === "clean") {',
|
|
839
|
+
' // Derived output only — astro build + pagefind regenerate all of it.',
|
|
840
|
+
' rmSync(DIST, { recursive: true, force: true });',
|
|
841
|
+
'} else if (mode === "finalize") {',
|
|
842
|
+
" const from = resolve(ROOT, `${MOUNT_DIR}/_headers`);",
|
|
843
|
+
' if (existsSync(from)) {',
|
|
844
|
+
' const to = join(DIST, "_headers");',
|
|
845
|
+
' mkdirSync(dirname(to), { recursive: true });',
|
|
846
|
+
' renameSync(from, to);',
|
|
847
|
+
" }",
|
|
848
|
+
'} else {',
|
|
849
|
+
' console.error("dogsbay-mount.mjs: expected `clean` or `finalize`");',
|
|
850
|
+
" process.exit(1);",
|
|
851
|
+
"}",
|
|
852
|
+
"",
|
|
853
|
+
].join("\n"));
|
|
854
|
+
}
|
|
855
|
+
warnStaleMountBuildScript(outputDir, dogsbayOutDir, astroBase);
|
|
856
|
+
// Migration check: pre-beta.20 sites have an astro.config.mjs that
|
|
857
|
+
// doesn't import the companion. Without the import, the values
|
|
858
|
+
// emitted above are unused and Astro's `base` stays unset — the
|
|
859
|
+
// exact bug this work was meant to close. Warn loudly, with the
|
|
860
|
+
// patch the user needs to apply, until astro.config.mjs is
|
|
861
|
+
// updated. We don't auto-patch because the file may have author
|
|
862
|
+
// edits (custom integrations, build hooks).
|
|
863
|
+
const astroConfigPath = join(outputDir, "astro.config.mjs");
|
|
864
|
+
if (existsSync(astroConfigPath)) {
|
|
865
|
+
const astroConfigSrc = readFileSync(astroConfigPath, "utf-8");
|
|
866
|
+
if (!astroConfigSrc.includes("astro.config.dogsbay.mjs")) {
|
|
867
|
+
console.warn([
|
|
868
|
+
"",
|
|
869
|
+
" ⚠ astro.config.mjs is missing the dogsbay companion import.",
|
|
870
|
+
" Without it, Astro's `base` config stays unset and assets",
|
|
871
|
+
" served from a host subpath (GH Pages project pages,",
|
|
872
|
+
" multi-mount Cloudflare) will 404.",
|
|
873
|
+
"",
|
|
874
|
+
" Add these two lines to astro.config.mjs:",
|
|
875
|
+
"",
|
|
876
|
+
' import {',
|
|
877
|
+
' dogsbaySite,',
|
|
878
|
+
' dogsbayBase,',
|
|
879
|
+
' dogsbayInlineStylesheets,',
|
|
880
|
+
' dogsbayOutDir,',
|
|
881
|
+
' } from "./astro.config.dogsbay.mjs";',
|
|
882
|
+
"",
|
|
883
|
+
" export default defineConfig({",
|
|
884
|
+
" ...(dogsbaySite ? { site: dogsbaySite } : {}),",
|
|
885
|
+
" ...(dogsbayBase ? { base: dogsbayBase } : {}),",
|
|
886
|
+
" ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
|
|
887
|
+
" build: { inlineStylesheets: dogsbayInlineStylesheets },",
|
|
888
|
+
" // ...your existing config...",
|
|
889
|
+
" });",
|
|
890
|
+
"",
|
|
891
|
+
" OR regenerate from template (overwrites your edits):",
|
|
892
|
+
" dogsbay site init . --scaffold-only --force",
|
|
893
|
+
"",
|
|
894
|
+
].join("\n"));
|
|
895
|
+
}
|
|
896
|
+
else if (dogsbayOutDir && !astroConfigSrc.includes("dogsbayOutDir")) {
|
|
897
|
+
// The companion is imported, but this scaffold predates outDir
|
|
898
|
+
// support. astro.config.mjs is scaffold-once, so an existing site
|
|
899
|
+
// that moves to a Workers subpath mount keeps a config that emits
|
|
900
|
+
// to dist/ root while its wrangler route asks for dist<urlBase>/.
|
|
901
|
+
// Every request 404s and the HTML looks fine locally, so warn.
|
|
902
|
+
console.warn([
|
|
903
|
+
"",
|
|
904
|
+
" ⚠ astro.config.mjs does not apply `dogsbayOutDir`.",
|
|
905
|
+
` This site deploys to Cloudflare Workers under "${dogsbayOutDir.replace("./dist", "")}",`,
|
|
906
|
+
" so the build output must live under that path or every",
|
|
907
|
+
" request 404s — Workers matches the full request pathname",
|
|
908
|
+
" and strips no prefix.",
|
|
909
|
+
"",
|
|
910
|
+
" Add to the companion import in astro.config.mjs:",
|
|
911
|
+
"",
|
|
912
|
+
" dogsbayOutDir,",
|
|
913
|
+
"",
|
|
914
|
+
" and to defineConfig:",
|
|
915
|
+
"",
|
|
916
|
+
" ...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),",
|
|
917
|
+
"",
|
|
918
|
+
].join("\n"));
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* Re-pin every `@dogsbay/*` dependency in an existing package.json to
|
|
924
|
+
* `version` (the building CLI's lockstep version), leaving non-Dogsbay deps
|
|
925
|
+
* and any `workspace:` / `file:` dev links untouched. Returns the changes made.
|
|
926
|
+
*
|
|
927
|
+
* package.json is scaffold-once, so without this an upgraded CLI keeps building
|
|
928
|
+
* against the lib versions written at first scaffold — the drift that strands a
|
|
929
|
+
* site on a stale DocsLayout that can't render the new emitter's output.
|
|
930
|
+
*/
|
|
931
|
+
function syncDogsbayDepVersions(pkgPath, version) {
|
|
932
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
933
|
+
const changed = [];
|
|
934
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
935
|
+
const deps = pkg[field];
|
|
936
|
+
if (!deps)
|
|
937
|
+
continue;
|
|
938
|
+
for (const [name, spec] of Object.entries(deps)) {
|
|
939
|
+
if (!name.startsWith("@dogsbay/"))
|
|
940
|
+
continue;
|
|
941
|
+
if (spec.startsWith("workspace:") || spec.startsWith("file:"))
|
|
942
|
+
continue;
|
|
943
|
+
if (spec !== version) {
|
|
944
|
+
deps[name] = version;
|
|
945
|
+
changed.push(`${name}: ${spec} -> ${version}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (changed.length > 0) {
|
|
950
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
951
|
+
}
|
|
952
|
+
return changed;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* Warn when the scaffold-once build script disagrees with the site's
|
|
956
|
+
* current mount.
|
|
957
|
+
*
|
|
958
|
+
* Lives here, not in `emitSiteScaffold`, because that function only runs
|
|
959
|
+
* on `site init` — a migration warning there would never fire for the
|
|
960
|
+
* sites that need it. Called from `emitSiteConfig`, which runs on every
|
|
961
|
+
* build.
|
|
962
|
+
*
|
|
963
|
+
* Both directions fail the same way: silently. Search stops working and
|
|
964
|
+
* nothing else about the build looks wrong.
|
|
965
|
+
*/
|
|
966
|
+
function warnStaleMountBuildScript(outputDir, outDir, urlBase) {
|
|
967
|
+
const pkgJsonPath = join(outputDir, "package.json");
|
|
968
|
+
if (!existsSync(pkgJsonPath))
|
|
969
|
+
return;
|
|
970
|
+
const pkgSrc = readFileSync(pkgJsonPath, "utf-8");
|
|
971
|
+
if (!outDir) {
|
|
972
|
+
// Moved OFF a mount: the script still clears dist/ and writes the
|
|
973
|
+
// Pagefind bundle to the old mount dir, while the page asks elsewhere.
|
|
974
|
+
if (!pkgSrc.includes("dogsbay-mount.mjs"))
|
|
975
|
+
return;
|
|
976
|
+
console.warn([
|
|
977
|
+
"",
|
|
978
|
+
" ⚠ package.json's build script still runs the subpath-mount steps,",
|
|
979
|
+
" but this site is no longer mounted on a subpath. dogsbay-mount.mjs",
|
|
980
|
+
" has been reduced to a no-op so the build keeps working, and the",
|
|
981
|
+
" Pagefind bundle is still being written to the old mount directory,",
|
|
982
|
+
" so Cmd+K will 404 until the script is updated.",
|
|
983
|
+
"",
|
|
984
|
+
" Change the build script back to:",
|
|
985
|
+
"",
|
|
986
|
+
" astro build && pagefind --site dist",
|
|
987
|
+
"",
|
|
988
|
+
].join("\n"));
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
// Moved ONTO a mount, or BETWEEN mounts.
|
|
992
|
+
//
|
|
993
|
+
// Gating on the mere presence of "dogsbay-mount.mjs" missed the second
|
|
994
|
+
// case: a site moving from /docs to /news regenerates MOUNT_DIR and
|
|
995
|
+
// astro build writes dist/news, but the scaffold-once script still runs
|
|
996
|
+
// `--output-path dist/docs/pagefind`. The bundle lands in a directory no
|
|
997
|
+
// page references and Cmd+K silently dies — the exact failure this
|
|
998
|
+
// warning exists for. Compare against the CURRENT mount instead.
|
|
999
|
+
if (!pkgSrc.includes("pagefind --site dist"))
|
|
1000
|
+
return;
|
|
1001
|
+
const mountDir = outDir.replace(/^\.\//, "");
|
|
1002
|
+
if (pkgSrc.includes(`--output-path ${mountDir}/pagefind`))
|
|
1003
|
+
return;
|
|
1004
|
+
const wanted = `node ./dogsbay-mount.mjs clean && astro build && ` +
|
|
1005
|
+
`pagefind --site dist --output-path ${mountDir}/pagefind && ` +
|
|
1006
|
+
`node ./dogsbay-mount.mjs finalize`;
|
|
1007
|
+
console.warn([
|
|
1008
|
+
"",
|
|
1009
|
+
" ⚠ package.json's build script predates subpath-mount support.",
|
|
1010
|
+
` This site is mounted at "${urlBase}", which needs three things`,
|
|
1011
|
+
" the old script does not do: clear dist/ (astro build only clears",
|
|
1012
|
+
" the mount dir, so stale output ships forever), write the Pagefind",
|
|
1013
|
+
" bundle under the mount (or Cmd+K 404s), and lift _headers to the",
|
|
1014
|
+
" assets root (or the llms.txt Link header stops applying).",
|
|
1015
|
+
"",
|
|
1016
|
+
" Change the build script to:",
|
|
1017
|
+
"",
|
|
1018
|
+
` ${wanted}`,
|
|
1019
|
+
"",
|
|
1020
|
+
].join("\n"));
|
|
272
1021
|
}
|
|
273
1022
|
export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
274
1023
|
let scaffoldFilesSkipped = 0;
|
|
@@ -293,10 +1042,32 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
293
1042
|
try {
|
|
294
1043
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
295
1044
|
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
296
|
-
// Caret on a stable version, exact pin on a prerelease
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
1045
|
+
// Caret on a stable version, exact pin on a prerelease.
|
|
1046
|
+
//
|
|
1047
|
+
// The reason given here used to be that "npm treats
|
|
1048
|
+
// `^0.2.0-beta.2` as NOT matching `0.2.0-beta.3`". That is FALSE.
|
|
1049
|
+
// Checked against semver 7:
|
|
1050
|
+
//
|
|
1051
|
+
// ^0.2.0-beta.96 matches 0.2.0-beta.97 -> true
|
|
1052
|
+
// ^0.2.0-beta.96 matches 0.2.0 -> true
|
|
1053
|
+
// ^0.2.0-beta.96 matches 0.2.1-beta.1 -> false
|
|
1054
|
+
//
|
|
1055
|
+
// A caret DOES pick up later prereleases of the same
|
|
1056
|
+
// major.minor.patch tuple. What it will not do is cross to a
|
|
1057
|
+
// prerelease of a DIFFERENT tuple — probably what the original
|
|
1058
|
+
// note was reaching for.
|
|
1059
|
+
//
|
|
1060
|
+
// The pin stays, for a reason that is actually true: a scaffolded
|
|
1061
|
+
// site commits its generated `astro/` output, so an exact version
|
|
1062
|
+
// guarantees the packages that produced the committed source are
|
|
1063
|
+
// the ones CI installs. A floating range lets a lockfile refresh
|
|
1064
|
+
// change a deployed site with nothing in the diff to show it.
|
|
1065
|
+
//
|
|
1066
|
+
// Our own site repos (dogsbay-ai-blog, dogsbay-ai-site) opt OUT
|
|
1067
|
+
// of that and track the `beta` dist-tag instead, because we are
|
|
1068
|
+
// the only consumers before release and the per-publish version
|
|
1069
|
+
// bump is not worth the commit. Their .gitignore explains the
|
|
1070
|
+
// trade and what to reverse at release.
|
|
300
1071
|
return /-/.test(pkg.version) ? pkg.version : `^${pkg.version}`;
|
|
301
1072
|
}
|
|
302
1073
|
catch {
|
|
@@ -312,12 +1083,28 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
312
1083
|
};
|
|
313
1084
|
// Per-deploy-target additions to package.json
|
|
314
1085
|
const isCloudflare = options.deploy === "cloudflare-workers";
|
|
1086
|
+
const isGitHubPages = options.deploy === "github-pages";
|
|
315
1087
|
const deployScripts = isCloudflare
|
|
316
1088
|
? { deploy: "pnpm build && wrangler deploy" }
|
|
317
1089
|
: {};
|
|
318
1090
|
const deployDevDeps = isCloudflare
|
|
319
1091
|
? { wrangler: "^4.0.0" }
|
|
320
1092
|
: {};
|
|
1093
|
+
// `--local` outside a workspace also needs a hoisted node_modules.
|
|
1094
|
+
//
|
|
1095
|
+
// With pnpm's default isolated layout, `file:`-linked packages resolve their
|
|
1096
|
+
// own dependencies from their own store path, and Astro's transitives then
|
|
1097
|
+
// fail to resolve from the SITE (measured: "Rolldown failed to resolve import
|
|
1098
|
+
// @astrojs/internal-helpers/path"). Hoisting is what the linked-package case
|
|
1099
|
+
// wants and costs nothing here — the site is disposable derived output, not a
|
|
1100
|
+
// library whose dependency isolation anyone relies on.
|
|
1101
|
+
if (options.local && !insideWs && writeScaffold) {
|
|
1102
|
+
writeFileSync(join(outputDir, ".npmrc"), "node-linker=hoisted\n");
|
|
1103
|
+
}
|
|
1104
|
+
// Subpath mounts move the Astro output to dist/<urlBase>/, so Pagefind
|
|
1105
|
+
// must write its bundle there too — see the build script below.
|
|
1106
|
+
const scaffoldUrlBase = parseSiteUrl(options.siteUrl).urlBase;
|
|
1107
|
+
const scaffoldOutDir = workersSubpathOutDir(options.deploy, scaffoldUrlBase);
|
|
321
1108
|
// package.json — scaffold-once. Maintainers add their own deps; the
|
|
322
1109
|
// detection of "already scaffolded" actually keys off this file's
|
|
323
1110
|
// existence, so it's also our sentinel.
|
|
@@ -331,20 +1118,45 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
331
1118
|
dev: "astro dev",
|
|
332
1119
|
// Pagefind runs after astro build; indexes the static dist/ output and
|
|
333
1120
|
// writes `dist/pagefind/` for the search UI to load lazily on Cmd+K.
|
|
334
|
-
|
|
1121
|
+
//
|
|
1122
|
+
// Under a subpath mount the Astro output moves to dist/<urlBase>/,
|
|
1123
|
+
// but `--site` stays `dist` on purpose: Pagefind derives result
|
|
1124
|
+
// URLs from the indexed root, so indexing dist/ keeps them as
|
|
1125
|
+
// `/<urlBase>/page/`. Only the BUNDLE has to move, or the page
|
|
1126
|
+
// requests `/<urlBase>/pagefind/pagefind.js` (the pagefindUrl prop
|
|
1127
|
+
// is built from the combined prefix) and gets a 404 — a path that
|
|
1128
|
+
// is not even matched by the emitted `<host>/<urlBase>/*` route,
|
|
1129
|
+
// so Cmd+K would be unreachable by any URL.
|
|
1130
|
+
// Subpath mounts bracket the build with dogsbay-mount.mjs:
|
|
1131
|
+
// `clean` clears dist/ (astro build only clears the MOUNT dir,
|
|
1132
|
+
// so stale layouts would ship forever), and `finalize` lifts
|
|
1133
|
+
// _headers to the assets root, where Workers actually reads it.
|
|
1134
|
+
build: scaffoldOutDir
|
|
1135
|
+
? `node ./dogsbay-mount.mjs clean && astro build && pagefind --site dist --output-path ${scaffoldOutDir.replace(/^\.\//, "")}/pagefind && node ./dogsbay-mount.mjs finalize`
|
|
1136
|
+
: "astro build && pagefind --site dist",
|
|
335
1137
|
preview: "astro preview",
|
|
336
1138
|
...deployScripts,
|
|
337
1139
|
},
|
|
338
1140
|
dependencies: {
|
|
339
|
-
astro: "^
|
|
340
|
-
|
|
1141
|
+
astro: "^7.0.4",
|
|
1142
|
+
// Sitemap is emitted directly by Dogsbay into
|
|
1143
|
+
// public/<basePath>/sitemap-{index,0}.xml so multi-mount
|
|
1144
|
+
// deploys don't collide at the host root. We deliberately
|
|
1145
|
+
// do NOT depend on @astrojs/sitemap (it hardcodes output to
|
|
1146
|
+
// dist/ root, which is what we're moving away from).
|
|
341
1147
|
// Pagefind is invoked from the build script (see scripts.build above).
|
|
342
1148
|
// Lives in dependencies (not devDependencies) so production builds
|
|
343
1149
|
// include it; the produced search index is shipped statically and
|
|
344
1150
|
// doesn't load this dep at runtime.
|
|
345
1151
|
pagefind: "^1.4.0",
|
|
346
|
-
tailwindcss: "^4.
|
|
347
|
-
|
|
1152
|
+
tailwindcss: "^4.3.2",
|
|
1153
|
+
// 4.3.x required on Astro 7: 4.2.x fails against vite 7.3
|
|
1154
|
+
// ("rollupOptions.input should not be an html file when
|
|
1155
|
+
// building for SSR" — surfaced on a fresh comparison-site
|
|
1156
|
+
// build 2026-07-13). The OLD ~4.2.2 pin guarded an Astro 6
|
|
1157
|
+
// rolldown incompatibility that no longer applies; the
|
|
1158
|
+
// monorepo apps build on 4.3.2.
|
|
1159
|
+
"@tailwindcss/vite": "^4.3.2",
|
|
348
1160
|
"tailwind-variants": "^0.3.0",
|
|
349
1161
|
shiki: "^4.0.0",
|
|
350
1162
|
"@shikijs/transformers": "^4.0.0",
|
|
@@ -360,14 +1172,32 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
360
1172
|
"@dogsbay/primitives": dogsbayDep("primitives"),
|
|
361
1173
|
"@dogsbay/icons": dogsbayDep("icons"),
|
|
362
1174
|
"@dogsbay/elements": dogsbayDep("elements"),
|
|
1175
|
+
// Transitive of `@dogsbay/primitives` (via
|
|
1176
|
+
// `@floating-ui/dom`). Listed at the top level because
|
|
1177
|
+
// npm doesn't hoist the second-level transitive when
|
|
1178
|
+
// `@dogsbay/primitives` is linked via `file:` (the
|
|
1179
|
+
// `--local` monorepo mode + the canary publish flow on
|
|
1180
|
+
// GH Pages CI both hit this). Surfaced during the
|
|
1181
|
+
// FastAPI import: Rollup failed with "Cannot resolve
|
|
1182
|
+
// @floating-ui/core" at astro build time.
|
|
1183
|
+
"@floating-ui/core": "^1.7.0",
|
|
363
1184
|
},
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
1185
|
+
// `--local` outside a workspace needs these.
|
|
1186
|
+
//
|
|
1187
|
+
// A `file:` dependency is installed from the package's OWN manifest,
|
|
1188
|
+
// and every @dogsbay/* manifest declares its siblings as `workspace:*`
|
|
1189
|
+
// — a protocol that only resolves inside the workspace. So `file:`
|
|
1190
|
+
// alone fails at install with ERR_PNPM_WORKSPACE_PKG_NOT_FOUND, naming
|
|
1191
|
+
// a transitive dependency the caller never wrote down. Overrides map
|
|
1192
|
+
// every sibling to the same checkout, which is what makes `--local`
|
|
1193
|
+
// installable rather than merely emitted.
|
|
1194
|
+
...(options.local && !insideWs ? { pnpm: { overrides: monorepoOverrides() } } : {}),
|
|
1195
|
+
// NOTE deliberately NO vite override: Astro 7 pairs with
|
|
1196
|
+
// vite 8. The old `overrides: { vite: "^7" }` (an Astro 6
|
|
1197
|
+
// era guard) FORCED vite 7 under astro 7.0.x and broke every
|
|
1198
|
+
// fresh scaffold build ("rollupOptions.input should not be
|
|
1199
|
+
// an html file when building for SSR") — root-caused
|
|
1200
|
+
// 2026-07-13 on the first standalone comparison-site build.
|
|
371
1201
|
...(Object.keys(deployDevDeps).length > 0
|
|
372
1202
|
? { devDependencies: deployDevDeps }
|
|
373
1203
|
: {}),
|
|
@@ -375,6 +1205,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
375
1205
|
}
|
|
376
1206
|
else {
|
|
377
1207
|
scaffoldFilesSkipped++;
|
|
1208
|
+
// package.json is scaffold-once, but the @dogsbay/* libraries ship in
|
|
1209
|
+
// lockstep with the CLI. Re-sync their pins to the building CLI's version
|
|
1210
|
+
// on every build so upgrading the CLI (e.g. `npm i -g dogsbay@beta`) can't
|
|
1211
|
+
// leave the astro project on stale libraries whose older components don't
|
|
1212
|
+
// render the new emitter's markup. Versioned (published) style only —
|
|
1213
|
+
// `workspace:*` / `file:` dev links are left untouched.
|
|
1214
|
+
if (!insideWs && !options.local) {
|
|
1215
|
+
const synced = syncDogsbayDepVersions(join(outputDir, "package.json"), dogsbayPeerVersion);
|
|
1216
|
+
if (synced.length > 0) {
|
|
1217
|
+
console.log(`Synced ${synced.length} @dogsbay/* dependency pin(s) to ${dogsbayPeerVersion}`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
378
1220
|
}
|
|
379
1221
|
// wrangler.jsonc — scaffold-once. Bindings, secrets, custom routes
|
|
380
1222
|
// belong here post-generation.
|
|
@@ -386,6 +1228,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
386
1228
|
scaffoldFilesSkipped++;
|
|
387
1229
|
}
|
|
388
1230
|
}
|
|
1231
|
+
// GitHub Pages deploy artifacts — workflow + .nojekyll. The actual
|
|
1232
|
+
// emission lives in `emitDeployArtifacts` so site-build can also
|
|
1233
|
+
// call it on existing sites without going through scaffold (a user
|
|
1234
|
+
// adds `deploy: github-pages` to dogsbay.config.yml and reruns
|
|
1235
|
+
// `site build` to get the workflow). At scaffold-time we pass
|
|
1236
|
+
// forceOverwrite=writeScaffold so `--force` regenerates from
|
|
1237
|
+
// template; on regular builds it stays write-if-missing.
|
|
1238
|
+
if (isGitHubPages) {
|
|
1239
|
+
emitDeployArtifacts(outputDir, options, {
|
|
1240
|
+
forceOverwrite: writeScaffold,
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
389
1243
|
// Generate astro.config.mjs
|
|
390
1244
|
// `preserveSymlinks: true` is used with --local to pin local file: deps to
|
|
391
1245
|
// their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
|
|
@@ -397,52 +1251,52 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
397
1251
|
preserveSymlinks: true,
|
|
398
1252
|
},`
|
|
399
1253
|
: "";
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// the generated config). Sitemap also filters out frontmatter-noindex pages.
|
|
404
|
-
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
405
|
-
const sitemapImport = hasSiteUrl ? `import sitemap from "@astrojs/sitemap";\n` : "";
|
|
406
|
-
// Strip any path component from site.url before emitting. The
|
|
407
|
-
// config validator already rejects `site.url` containing a path
|
|
408
|
-
// when `basePath` is non-empty (canonical URLs would double-count
|
|
409
|
-
// the prefix); this is a defensive normalisation for the case
|
|
410
|
-
// where the validator is bypassed or basePath is empty.
|
|
1254
|
+
// siteUrl gates absolute-URL emission (sitemap <loc> entries,
|
|
1255
|
+
// canonical tags). Without one, both are skipped — relative URLs
|
|
1256
|
+
// are still correct, the sitemap is just not generated.
|
|
411
1257
|
//
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
// to
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
// spread is a no-op. See plans/plugin-api.md.
|
|
1258
|
+
// Sitemap is emitted directly by Dogsbay (see emitSitemapFiles)
|
|
1259
|
+
// into public/<basePath>/sitemap-*.xml. We deliberately do NOT
|
|
1260
|
+
// wire @astrojs/sitemap here; that integration hardcodes output
|
|
1261
|
+
// to dist/ root, breaking multi-mount deploys.
|
|
1262
|
+
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
1263
|
+
// site.url's path component (if any) becomes Astro's `base`. The
|
|
1264
|
+
// origin alone goes into `site`. This split lets dogsbay model
|
|
1265
|
+
// both axes independently:
|
|
1266
|
+
// - Astro's `base` (= urlBase) controls the URL prefix Astro
|
|
1267
|
+
// bakes into HTML asset references (`<basePath>/_astro/...`)
|
|
1268
|
+
// and the routes Astro generates from src/pages.
|
|
1269
|
+
// - dogsbay's basePath controls the filesystem layout
|
|
1270
|
+
// (`src/pages/<basePath>/...`).
|
|
1271
|
+
// The two compose at emit time — combining for nav hrefs,
|
|
1272
|
+
// sitemap, llms.txt, etc. See plans/astro-base-from-site-url.md.
|
|
1273
|
+
const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
|
|
1274
|
+
// astro.config.mjs — scaffold-once, but the site/base values flow
|
|
1275
|
+
// through a separate auto-generated file (`astro.config.dogsbay.mjs`,
|
|
1276
|
+
// emitted unconditionally below) so dogsbay-derived values stay in
|
|
1277
|
+
// sync with `dogsbay.config.yml` even on existing sites where the
|
|
1278
|
+
// main config is preserved. Same pattern as
|
|
1279
|
+
// `astro.config.plugins.mjs` — the import line is the load-bearing
|
|
1280
|
+
// bit; the auto-file is what changes.
|
|
436
1281
|
if (writeScaffold) {
|
|
437
1282
|
writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
|
|
438
1283
|
import tailwindcss from "@tailwindcss/vite";
|
|
439
|
-
|
|
1284
|
+
import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
|
|
1285
|
+
import {
|
|
1286
|
+
dogsbaySite,
|
|
1287
|
+
dogsbayBase,
|
|
1288
|
+
dogsbayInlineStylesheets,
|
|
1289
|
+
dogsbayOutDir,
|
|
1290
|
+
} from "./astro.config.dogsbay.mjs";
|
|
440
1291
|
|
|
441
|
-
export default defineConfig({
|
|
1292
|
+
export default defineConfig({
|
|
1293
|
+
...(dogsbaySite ? { site: dogsbaySite } : {}),
|
|
1294
|
+
...(dogsbayBase ? { base: dogsbayBase } : {}),
|
|
1295
|
+
...(dogsbayOutDir ? { outDir: dogsbayOutDir } : {}),
|
|
442
1296
|
output: "static",
|
|
443
1297
|
build: {
|
|
444
|
-
inlineStylesheets:
|
|
445
|
-
}
|
|
1298
|
+
inlineStylesheets: dogsbayInlineStylesheets,
|
|
1299
|
+
},
|
|
446
1300
|
vite: {
|
|
447
1301
|
plugins: [tailwindcss()],
|
|
448
1302
|
resolve: {
|
|
@@ -464,6 +1318,9 @@ export default defineConfig({${siteField}
|
|
|
464
1318
|
else {
|
|
465
1319
|
scaffoldFilesSkipped++;
|
|
466
1320
|
}
|
|
1321
|
+
// astro.config.dogsbay.mjs is emitted by emitSiteConfig (called
|
|
1322
|
+
// above and on every site build) so site/base values stay in
|
|
1323
|
+
// sync without a re-scaffold. See its definition for rationale.
|
|
467
1324
|
// Always seed an empty astro.config.plugins.mjs so the import in
|
|
468
1325
|
// astro.config.mjs resolves before the first plugin-emitting
|
|
469
1326
|
// build. Subsequent builds replace it via emitPluginRuntime.
|
|
@@ -532,9 +1389,85 @@ export default defineConfig({${siteField}
|
|
|
532
1389
|
* redirect. Returns the count of pages emitted and the merged nav for
|
|
533
1390
|
* downstream consumers (llms.txt builder).
|
|
534
1391
|
*/
|
|
1392
|
+
/**
|
|
1393
|
+
* Emit `src/pages/404.astro`.
|
|
1394
|
+
*
|
|
1395
|
+
* The scaffold already sets `not_found_handling: "404-page"` in
|
|
1396
|
+
* wrangler.jsonc, but never emitted a page for it — so Workers had
|
|
1397
|
+
* nothing to serve and returned a bare 404 with a ZERO-BYTE body.
|
|
1398
|
+
* Correct status, no way to recover: an agent that mistypes a URL, or
|
|
1399
|
+
* follows a stale link, learns only that the page is gone.
|
|
1400
|
+
*
|
|
1401
|
+
* So this lists the routes worth trying next — the site index, the
|
|
1402
|
+
* sitemap, and llms.txt — which is what turns a dead end into a
|
|
1403
|
+
* redirectable one. The links are plain and visible rather than
|
|
1404
|
+
* decorative, because the reader here is as likely to be a crawler as
|
|
1405
|
+
* a person.
|
|
1406
|
+
*
|
|
1407
|
+
* Regenerated every build, so the links track the site. Skipped when
|
|
1408
|
+
* the author has removed the marker, matching every other emitted
|
|
1409
|
+
* page.
|
|
1410
|
+
*/
|
|
1411
|
+
function emitNotFoundPage(outputDir, options) {
|
|
1412
|
+
const path = join(outputDir, "src", "pages", "404.astro");
|
|
1413
|
+
if (existsSync(path)) {
|
|
1414
|
+
const existing = readFileSync(path, "utf-8");
|
|
1415
|
+
if (!existing.includes("AUTO-GENERATED by dogsbay site build"))
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
const combined = combinedPrefix(options);
|
|
1419
|
+
const home = combined || "/";
|
|
1420
|
+
const sitemap = withBasePath(combined, "/sitemap-index.xml");
|
|
1421
|
+
const llms = withBasePath(combined, "/llms.txt");
|
|
1422
|
+
const showLlms = options.llmsTxt !== false;
|
|
1423
|
+
writeFileSync(path, `---
|
|
1424
|
+
// AUTO-GENERATED by dogsbay site build — safe to delete; will regenerate next build.
|
|
1425
|
+
// To customize this page, remove the marker line above (or modify the file
|
|
1426
|
+
// in any way that drops it) and dogsbay will leave your edits alone.
|
|
1427
|
+
import "@/styles/global.css";
|
|
1428
|
+
import DocsLayout from "@dogsbay/docs-layout/DocsLayout.astro";
|
|
1429
|
+
import type { SiteConfig } from "@dogsbay/types";
|
|
1430
|
+
import navData from "@/data/nav.json";
|
|
1431
|
+
import siteConfigData from "@/data/site.json";
|
|
1432
|
+
const siteConfig = siteConfigData as SiteConfig;
|
|
1433
|
+
const favicon = (siteConfigData as { favicon?: string }).favicon;
|
|
1434
|
+
---
|
|
1435
|
+
|
|
1436
|
+
<DocsLayout
|
|
1437
|
+
siteName={siteConfig.siteName}
|
|
1438
|
+
title="Page not found"
|
|
1439
|
+
description="That page does not exist. Here is where to look instead."
|
|
1440
|
+
nav={navData as never}
|
|
1441
|
+
siteUrl={siteConfig.siteUrl}
|
|
1442
|
+
favicon={favicon}
|
|
1443
|
+
noindex={true}
|
|
1444
|
+
excludeFromSearch={true}
|
|
1445
|
+
ogType="website"
|
|
1446
|
+
>
|
|
1447
|
+
<article class="docs-prose">
|
|
1448
|
+
<h1>Page not found</h1>
|
|
1449
|
+
<p>There is no page at this address. It may have moved, or the link
|
|
1450
|
+
that brought you here may be out of date.</p>
|
|
1451
|
+
<p>Where to look instead:</p>
|
|
1452
|
+
<ul>
|
|
1453
|
+
<li><a href="${home}">Site index</a> — start here</li>
|
|
1454
|
+
<li><a href="${sitemap}">sitemap-index.xml</a> — every page on this site</li>${showLlms
|
|
1455
|
+
? `
|
|
1456
|
+
<li><a href="${llms}">llms.txt</a> — the same index, for agents</li>`
|
|
1457
|
+
: ""}
|
|
1458
|
+
</ul>
|
|
1459
|
+
</article>
|
|
1460
|
+
</DocsLayout>
|
|
1461
|
+
`);
|
|
1462
|
+
}
|
|
535
1463
|
export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
536
1464
|
const siteName = options.siteName || "Documentation";
|
|
1465
|
+
// basePath = filesystem layout prefix (where pages live under
|
|
1466
|
+
// src/pages/...). combined = the URL prefix HTML hrefs need
|
|
1467
|
+
// (urlBase + basePath). The two diverge whenever site.url has a
|
|
1468
|
+
// path component (GH Pages project pages, multi-mount Cloudflare).
|
|
537
1469
|
const basePath = normalizeBasePath(options.basePath);
|
|
1470
|
+
const combined = combinedPrefix(options);
|
|
538
1471
|
const baseSegments = basePathSegments(basePath);
|
|
539
1472
|
// Ensure dirs exist (callers may invoke us without going through the
|
|
540
1473
|
// full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
|
|
@@ -555,35 +1488,217 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
555
1488
|
// Remove existing entry for this section (full replace)
|
|
556
1489
|
existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
|
|
557
1490
|
&& item.label?.toLowerCase() !== section.toLowerCase());
|
|
558
|
-
|
|
1491
|
+
// Nav hrefs already carry the `combined` prefix (the importer
|
|
1492
|
+
// emits them via fileToHref(file, hrefPrefix=combined)).
|
|
1493
|
+
// prefixNavHrefs takes the existing prefix and weaves a section
|
|
1494
|
+
// segment into it.
|
|
1495
|
+
const prefixedNav = prefixNavHrefs(nav, section, combined);
|
|
559
1496
|
const sectionLabel = siteName
|
|
560
1497
|
|| section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
561
1498
|
existingNav.push({ label: sectionLabel, children: prefixedNav });
|
|
562
1499
|
outputNav = existingNav;
|
|
563
1500
|
}
|
|
1501
|
+
// Safety net: ensure every nav href carries the combined URL base,
|
|
1502
|
+
// regardless of which importer produced the nav. Idempotent — a nav
|
|
1503
|
+
// already prefixed at import time (dogsbay-md walking source paths) is
|
|
1504
|
+
// left untouched; a nav of pre-baked root-absolute hrefs (docusaurus
|
|
1505
|
+
// convert) gets the base it was missing. Keeps nav hrefs aligned with
|
|
1506
|
+
// the combined-prefixed `currentPath` so prev/next still matches.
|
|
1507
|
+
outputNav = prefixNavBaseHrefs(outputNav, combined);
|
|
564
1508
|
writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(outputNav, null, 2));
|
|
1509
|
+
// Also publish nav.json under public/_dogsbay/ so the
|
|
1510
|
+
// client-mode <DocsNavClient /> can fetch it at runtime
|
|
1511
|
+
// (Astro copies public/ to dist/ as-is at build). The src/data
|
|
1512
|
+
// copy stays for build-time imports (pagination, prev/next
|
|
1513
|
+
// calculation in each page) — the public copy is the on-the-wire
|
|
1514
|
+
// copy that the browser ever sees. Kept identical (same bytes,
|
|
1515
|
+
// same shape); the duplicate is cheap (one file) and keeps the
|
|
1516
|
+
// build-time and runtime worlds cleanly separated. See
|
|
1517
|
+
// plans/client-rendered-nav.md.
|
|
1518
|
+
// …UNDER the basePath, because that is where the client fetches it
|
|
1519
|
+
// from (`${basePath}/_dogsbay/nav.json`) and because two sites mounted
|
|
1520
|
+
// on one origin — e.g. two release-comparison pairs — would otherwise
|
|
1521
|
+
// both claim `/_dogsbay/nav.json` and collide. Found by the comparison
|
|
1522
|
+
// acceptance suite: the nav silently never hydrated (404), so no page
|
|
1523
|
+
// marks rendered at all.
|
|
1524
|
+
const publicNavDir = join(outputDir, "public", ...basePathSegments(normalizeBasePath(options.basePath)), "_dogsbay");
|
|
1525
|
+
mkdirSync(publicNavDir, { recursive: true });
|
|
1526
|
+
writeFileSync(join(publicNavDir, "nav.json"), JSON.stringify(outputNav));
|
|
565
1527
|
// Static assets (images etc.) — content-tier; always copy from the
|
|
566
1528
|
// user's source dir. If they removed an asset, we want it gone here
|
|
567
1529
|
// too. Skipped when sourceDir isn't supplied (programmatic callers
|
|
568
1530
|
// that want pure page emission).
|
|
569
|
-
if (options.
|
|
1531
|
+
if (options.assetSourceDirs && options.assetSourceDirs.length > 0) {
|
|
1532
|
+
// Multi-source: copy EACH source's assets under its version/locale
|
|
1533
|
+
// URL prefix (public/<prefix>/...), matching the per-source ref
|
|
1534
|
+
// rewrite. Replaces the single-sourceDir copy, which only handled
|
|
1535
|
+
// the first source and never version-namespaced.
|
|
1536
|
+
for (const { dir, urlPrefix } of options.assetSourceDirs) {
|
|
1537
|
+
copyAssets(dir, outputDir, options.imageOptimization, urlPrefix);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
else if (options.sourceDir) {
|
|
570
1541
|
copyAssets(options.sourceDir, outputDir, options.imageOptimization);
|
|
571
1542
|
}
|
|
1543
|
+
// External asset mounts (e.g. a Docusaurus `static/` dir) → public/_assets/,
|
|
1544
|
+
// preserving internal structure so the importer's `/_assets/...` image refs
|
|
1545
|
+
// resolve. Used by the convert --to astro path; the dogsbay-md → site build
|
|
1546
|
+
// path instead lands these under content/_assets and copyAssets picks them up.
|
|
1547
|
+
// A mount may target an `_assets` subdir (`into`) and restrict itself to
|
|
1548
|
+
// media files (`mediaOnly`) — used for docs-co-located images where the
|
|
1549
|
+
// mounted dir is the docs tree itself.
|
|
1550
|
+
if (options.assetMounts) {
|
|
1551
|
+
for (const mount of options.assetMounts) {
|
|
1552
|
+
if (!existsSync(mount.dir))
|
|
1553
|
+
continue;
|
|
1554
|
+
const dest = mount.into
|
|
1555
|
+
? join(outputDir, "public", mount.root ?? "_assets", mount.into)
|
|
1556
|
+
: join(outputDir, "public", mount.root ?? "_assets");
|
|
1557
|
+
if (mount.mediaOnly)
|
|
1558
|
+
copyMediaTree(mount.dir, dest);
|
|
1559
|
+
else
|
|
1560
|
+
cpSync(mount.dir, dest, { recursive: true });
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
572
1563
|
let generated = 0;
|
|
1564
|
+
// Tracks whether a real root home page (`content/index.md` → slug
|
|
1565
|
+
// "index") was emitted. Drives the root-served-site redirect fallback
|
|
1566
|
+
// below: a corpus whose nav root is e.g. `welcome/index` (OpenShift)
|
|
1567
|
+
// has no `/` page, so without a redirect `/` 404s. See
|
|
1568
|
+
// plans/dir-index-slug-nav-drop.md.
|
|
1569
|
+
let rootIndexEmitted = false;
|
|
1570
|
+
const generatedPaths = new Set();
|
|
1571
|
+
let pageFailures = 0;
|
|
573
1572
|
const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
|
|
574
1573
|
const useImageOpt = options.imageOptimization ?? false;
|
|
575
|
-
// hrefPrefix is the
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
//
|
|
579
|
-
|
|
580
|
-
|
|
1574
|
+
// hrefPrefix is the COMBINED prefix (urlBase + basePath) — what
|
|
1575
|
+
// rendered HTML hrefs need so internal links resolve under the
|
|
1576
|
+
// host's served subpath AND under the dogsbay basePath. For
|
|
1577
|
+
// simple host-apex deploys with basePath, urlBase is empty so
|
|
1578
|
+
// combined === basePath (back-compat). For GH Pages project pages
|
|
1579
|
+
// and multi-mount Cloudflare, combined adds the urlBase layer.
|
|
1580
|
+
const hrefPrefix = combined;
|
|
1581
|
+
// Route-exclusion gate. Two signals, either is enough:
|
|
1582
|
+
// - frontmatter `_fragment: true` (loader-stamped — the importer
|
|
1583
|
+
// knew this .md was include-only, not a navigable page)
|
|
1584
|
+
// - excludeFromRoutes match against the slug (project-declared,
|
|
1585
|
+
// in dogsbay.config.yml — for content under conventional
|
|
1586
|
+
// fragment dirs like `modules/`, `_attributes/`, `snippets/`)
|
|
1587
|
+
// Excluded pages stay on disk under content/ (so includes resolve)
|
|
1588
|
+
// but don't produce .astro / .md.ts routes. See plans/build-at-scale.md.
|
|
1589
|
+
//
|
|
1590
|
+
// Match semantics:
|
|
1591
|
+
// - A single-segment pattern (no `/`) matches ANY occurrence of
|
|
1592
|
+
// that segment in the slug path. So `modules` excludes both
|
|
1593
|
+
// `modules/foo` AND `welcome/modules/foo` AND
|
|
1594
|
+
// `drupal-build/openshift-enterprise/ai/includes/x`. This is
|
|
1595
|
+
// what AsciiBinder corpora want — fragment dirs symlink into
|
|
1596
|
+
// section dirs so the same `includes/` etc. appears at many
|
|
1597
|
+
// depths.
|
|
1598
|
+
// - A multi-segment pattern (contains `/`) matches as a leading
|
|
1599
|
+
// prefix (exact path-prefix). `welcome/_internal` excludes
|
|
1600
|
+
// only that specific path tree, not arbitrary `_internal/`
|
|
1601
|
+
// elsewhere.
|
|
1602
|
+
const isExcludedSlug = makeSlugExcluder(options.excludeFromRoutes);
|
|
1603
|
+
// Granularity (additive book view): roll each top-level nav group into one
|
|
1604
|
+
// `_book` page emitted alongside the topics. Kept out of nav; quarantined via
|
|
1605
|
+
// noindex + excludeFromSearch frontmatter so robots/sitemap/pagefind exclude
|
|
1606
|
+
// it, and — being local to this emitter — it never reaches the llms.txt builder
|
|
1607
|
+
// either. The `_book` page renders through the same path as any other page.
|
|
1608
|
+
// See plans/granularity-views.md.
|
|
1609
|
+
const bookPages = [];
|
|
1610
|
+
// topic slug → "<bookRoute>#<anchor>" — the per-topic "Read as single page"
|
|
1611
|
+
// button target. Empty unless granularity.book is on.
|
|
1612
|
+
const singlePageBySlug = new Map();
|
|
1613
|
+
// Emit a single-page (book) per nav node whose EFFECTIVE granularity resolves
|
|
1614
|
+
// to "yes". The annotation lives on each nav node (`singlePage`):
|
|
1615
|
+
// - "yes" → roll up this node's subtree into one page.
|
|
1616
|
+
// - "children" → make each direct child default to "yes" (chapters).
|
|
1617
|
+
// - "no" → no page (the default).
|
|
1618
|
+
// `granularity.book: true` is the legacy all-on switch: it seeds the top level
|
|
1619
|
+
// as "yes", so every top-level group rolls up — a node's own annotation still
|
|
1620
|
+
// wins. (This comment previously said "children", which is the same OUTCOME
|
|
1621
|
+
// but the wrong value, and disagreed with the config schema's wording.) A rollup whose estimated source exceeds maxSinglePageMB is SKIPPED with
|
|
1622
|
+
// a warning (no silent multi-minute page). See plans/granularity-views.md.
|
|
1623
|
+
const maxBookBytes = Math.round((options.granularity?.maxSinglePageMB ?? 3) * 1024 * 1024);
|
|
1624
|
+
const rootInheritYes = options.granularity?.book === true;
|
|
1625
|
+
const emitBook = (nav, label) => {
|
|
1626
|
+
const book = assembleBook(nav, pages, { hrefPrefix: combined, title: label });
|
|
1627
|
+
if (!book)
|
|
1628
|
+
return;
|
|
1629
|
+
const bytes = estimateTreeBytes(book.page.tree);
|
|
1630
|
+
if (bytes > maxBookBytes) {
|
|
1631
|
+
console.warn(`[dogsbay] granularity: skipping single-page "${label}" — ~${(bytes / 1048576).toFixed(1)} MB ` +
|
|
1632
|
+
`exceeds maxSinglePageMB (${options.granularity?.maxSinglePageMB ?? 3}). ` +
|
|
1633
|
+
`Mark it "single-page: children" in the nav to split it into chapters.`);
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
book.page.frontmatter = { ...book.page.frontmatter, excludeFromSearch: true };
|
|
1637
|
+
bookPages.push(book.page);
|
|
1638
|
+
const bookRoute = combined ? `${combined}/${book.page.slug}` : `/${book.page.slug}`;
|
|
1639
|
+
for (const [slug, anchor] of book.topicAnchors) {
|
|
1640
|
+
singlePageBySlug.set(slug, `${bookRoute}#${anchor}`);
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
const walkBooks = (items, inheritYes) => {
|
|
1644
|
+
for (const item of items) {
|
|
1645
|
+
const effective = item.singlePage ?? (inheritYes ? "yes" : "no");
|
|
1646
|
+
if (effective === "yes" && item.children?.length) {
|
|
1647
|
+
emitBook(item.children, item.label);
|
|
1648
|
+
}
|
|
1649
|
+
if (item.children?.length)
|
|
1650
|
+
walkBooks(item.children, effective === "children");
|
|
1651
|
+
}
|
|
1652
|
+
};
|
|
1653
|
+
const hasAnnotation = (items) => items.some((i) => i.singlePage !== undefined || (i.children && hasAnnotation(i.children)));
|
|
1654
|
+
if (rootInheritYes || hasAnnotation(outputNav)) {
|
|
1655
|
+
walkBooks(outputNav, rootInheritYes);
|
|
1656
|
+
}
|
|
1657
|
+
const emitPages = bookPages.length ? [...pages, ...bookPages] : pages;
|
|
1658
|
+
// Blog mode: compute the post list ONCE, then read adjacency and
|
|
1659
|
+
// reading time per page out of it. Both derive from the same sorted
|
|
1660
|
+
// list, so a post's "next" and its neighbour's "prev" cannot disagree.
|
|
1661
|
+
// Book rollups are excluded — they are quarantined aggregates, not posts.
|
|
1662
|
+
const blogData = options.blog
|
|
1663
|
+
? buildBlogData(pages, {
|
|
1664
|
+
basePath,
|
|
1665
|
+
section: options.section,
|
|
1666
|
+
excludeSlug: isExcludedSlug,
|
|
1667
|
+
// Post links must carry the COMBINED prefix — the path the host
|
|
1668
|
+
// serves under. With basePath alone, a site mounted at /blog
|
|
1669
|
+
// emitted /second-post/, which is outside its own route.
|
|
1670
|
+
urlPrefix: combinedPrefix(options),
|
|
1671
|
+
config: options.blog,
|
|
1672
|
+
})
|
|
1673
|
+
: undefined;
|
|
1674
|
+
const blogAdj = blogData ? blogAdjacency(blogData) : undefined;
|
|
1675
|
+
const blogMinutes = new Map(blogData ? blogData.posts.map((post) => [post.slug, post.readingMinutes]) : []);
|
|
1676
|
+
for (const page of emitPages) {
|
|
581
1677
|
try {
|
|
1678
|
+
// Skip excluded pages before any expensive work (tree rewrite,
|
|
1679
|
+
// serialize, IO).
|
|
1680
|
+
// BOTH signals. `ExportPage.fragment` is the typed field
|
|
1681
|
+
// (`@dogsbay/types` format.ts) that format-docusaurus sets in structural
|
|
1682
|
+
// mode; `frontmatter._fragment` is the loader-stamped one. Checking only
|
|
1683
|
+
// the second emitted every Docusaurus partial as its own routable,
|
|
1684
|
+
// empty-titled, orphan page — duplicating content already inlined into
|
|
1685
|
+
// the pages that included it, and landing in sitemap and search.
|
|
1686
|
+
const isFragment = page.fragment === true ||
|
|
1687
|
+
(page.frontmatter && page.frontmatter._fragment === true);
|
|
1688
|
+
if (isFragment || isExcludedSlug(page.slug)) {
|
|
1689
|
+
continue;
|
|
1690
|
+
}
|
|
582
1691
|
// Rewrite internal hrefs to match the output URL structure
|
|
583
1692
|
rewriteTreeHrefs(page.tree, hrefPrefix);
|
|
1693
|
+
// Same for raw image srcs — Astro doesn't auto-prefix
|
|
1694
|
+
// `<img src="/_assets/...">` so we do it here. Block images
|
|
1695
|
+
// strip the prefix back off for the `imageMap[...]` lookup
|
|
1696
|
+
// (see paragraphToAstro in serialize.ts).
|
|
1697
|
+
rewriteTreeImageSrcs(page.tree, hrefPrefix);
|
|
584
1698
|
const result = treeToAstro(page.tree, {
|
|
585
1699
|
imageOptimization: useImageOpt,
|
|
586
1700
|
codeBlockTitle: options.codeBlockTitle ?? true,
|
|
1701
|
+
combinedPrefix: hrefPrefix,
|
|
587
1702
|
});
|
|
588
1703
|
const imageSetup = useImageOpt ? [
|
|
589
1704
|
'',
|
|
@@ -597,11 +1712,32 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
597
1712
|
' imageMap[publicPath] = mod.default;',
|
|
598
1713
|
'}',
|
|
599
1714
|
] : [];
|
|
600
|
-
// Per-page meta from frontmatter
|
|
1715
|
+
// Per-page meta from frontmatter. When no description is authored,
|
|
1716
|
+
// derive one from the first prose paragraph so every page emits a
|
|
1717
|
+
// <meta name="description"> (Lighthouse flags pages without one).
|
|
1718
|
+
// See plans/auto-meta-description.md.
|
|
601
1719
|
const fm = (page.frontmatter ?? {});
|
|
602
|
-
|
|
1720
|
+
// A writer-authored frontmatter description is meta-only (not in the body),
|
|
1721
|
+
// so it's safe to also render as a visible lede. A DERIVED description is
|
|
1722
|
+
// pulled FROM the body, so rendering it as a lede always duplicates content
|
|
1723
|
+
// (e.g. a page whose first paragraph sits under "## Big picture"). Keep the
|
|
1724
|
+
// two separate: derive for the <meta> tag, but only lede the authored one.
|
|
1725
|
+
const fmDescription = typeof fm.description === "string" && fm.description.trim().length > 0
|
|
1726
|
+
? fm.description
|
|
1727
|
+
: undefined;
|
|
1728
|
+
const pageDescription = fmDescription || deriveDescription(page.tree) || "";
|
|
603
1729
|
const pageOgImage = fm.ogImage ?? "";
|
|
604
|
-
|
|
1730
|
+
// Noindex / nofollow are independent meta directives. Site-level
|
|
1731
|
+
// forces both bits site-wide (staging / compliance lockdown);
|
|
1732
|
+
// page frontmatter can ESCALATE either bit independently but
|
|
1733
|
+
// cannot opt out of a site-level lockdown. `draft: true` keeps
|
|
1734
|
+
// its existing role as a noindex shorthand. See
|
|
1735
|
+
// plans/site-level-robots-meta.md.
|
|
1736
|
+
const pageNoindex = options.noindex === true ||
|
|
1737
|
+
fm.noindex === true ||
|
|
1738
|
+
fm.draft === true;
|
|
1739
|
+
const pageNofollow = options.nofollow === true ||
|
|
1740
|
+
fm.nofollow === true;
|
|
605
1741
|
// Independent of noindex: pages can be excluded from in-site
|
|
606
1742
|
// Pagefind search even when external SEs should index them
|
|
607
1743
|
// (or vice versa). See DocsLayout's prop docs for the
|
|
@@ -620,6 +1756,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
620
1756
|
const pageCategory = Array.isArray(pageMeta?.category)
|
|
621
1757
|
? pageMeta.category
|
|
622
1758
|
: undefined;
|
|
1759
|
+
// "Last updated" date → DocsFooter. `updated` is the canonical PageMeta
|
|
1760
|
+
// field (parseMeta); also accept `lastUpdated` / `lastmod` for sitemap
|
|
1761
|
+
// parity. Plain string, rendered as-is.
|
|
1762
|
+
const rawLastUpdated = (typeof pageMeta?.updated === "string" && pageMeta.updated) ||
|
|
1763
|
+
(typeof fm.updated === "string" && fm.updated) ||
|
|
1764
|
+
(typeof fm.lastUpdated === "string" && fm.lastUpdated) ||
|
|
1765
|
+
(typeof fm.lastmod === "string" && fm.lastmod) ||
|
|
1766
|
+
null;
|
|
1767
|
+
// Canonical meta normalises to a full ISO datetime ("…T00:00:00.000Z").
|
|
1768
|
+
// Drop the time for display — these are dates, not timestamps. The footer
|
|
1769
|
+
// prettifies further; non-ISO strings pass through untouched.
|
|
1770
|
+
const pageLastUpdated = typeof rawLastUpdated === "string" && /^\d{4}-\d{2}-\d{2}T/.test(rawLastUpdated)
|
|
1771
|
+
? rawLastUpdated.slice(0, 10)
|
|
1772
|
+
: rawLastUpdated;
|
|
623
1773
|
// Custom-taxonomy values lifted from frontmatter into
|
|
624
1774
|
// `meta.taxonomies` by the importer (see `parseMeta` in
|
|
625
1775
|
// `@dogsbay/types`). Surfaced to DocsLayout so it can emit one
|
|
@@ -633,10 +1783,15 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
633
1783
|
: undefined;
|
|
634
1784
|
// `tagsIndexPath` flows to `<TagList>` for chip hrefs
|
|
635
1785
|
// (`${indexPath}/${tag}/`). Caller passes the raw config value
|
|
636
|
-
// (e.g. `/tags`); we bake
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
1786
|
+
// (e.g. `/tags`); we bake the COMBINED prefix (urlBase from
|
|
1787
|
+
// site.url's path + basePath) here so chips resolve under both
|
|
1788
|
+
// the host's served subpath AND the dogsbay basePath. With
|
|
1789
|
+
// basePath alone, chips 404 on GH Pages project deploys
|
|
1790
|
+
// (basePath="" + non-empty urlBase) — same shape as the
|
|
1791
|
+
// typeBadgeHref / statusBadgeHref composition in DocsLayout,
|
|
1792
|
+
// which already reads combined-prefixed values out of
|
|
1793
|
+
// siteConfig.taxonomyIndexPaths (baked in buildSiteConfig).
|
|
1794
|
+
const tagsIndexPath = withBasePath(combined, options.tagsIndexPath ?? "/tags");
|
|
640
1795
|
// Auto-lede detection. If the markdown body doesn't already
|
|
641
1796
|
// start with an H1 / leading paragraph, we ask DocsLayout to
|
|
642
1797
|
// render the frontmatter title / description at the top of
|
|
@@ -650,7 +1805,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
650
1805
|
page.title.length > 0;
|
|
651
1806
|
const autoLede = !isRedirect &&
|
|
652
1807
|
!bodyHasLede &&
|
|
653
|
-
|
|
1808
|
+
fmDescription !== undefined;
|
|
654
1809
|
// Per-page LLM action UI. Site-wide config in
|
|
655
1810
|
// `options.llmActions`; per-page opt-out via frontmatter
|
|
656
1811
|
// `llmActions: false`. Skipped on redirect pages (no real
|
|
@@ -668,12 +1823,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
668
1823
|
// available via other means. The "Open in" deep links work
|
|
669
1824
|
// regardless of mirror availability — agents that can't fetch
|
|
670
1825
|
// the page just see the URL in their chat.
|
|
1826
|
+
// pageHrefBase uses combined (urlBase + basePath) so the URL
|
|
1827
|
+
// resolves correctly when the host serves dist/ at a subpath
|
|
1828
|
+
// (GH Pages project page, multi-mount Cloudflare).
|
|
671
1829
|
const pageHrefBase = section
|
|
672
|
-
? (
|
|
673
|
-
: (
|
|
1830
|
+
? (combined ? `${combined}/${section}/${page.slug}` : `/${section}/${page.slug}`)
|
|
1831
|
+
: (combined ? `${combined}/${page.slug}` : `/${page.slug}`);
|
|
674
1832
|
const pageMdHref = `${pageHrefBase}.md`;
|
|
675
|
-
|
|
676
|
-
|
|
1833
|
+
// For absolute URLs (the "Copy as MD" deep link), use the
|
|
1834
|
+
// origin (no path) + the full combined path; siteUrl alone
|
|
1835
|
+
// would double-include the urlBase since pageHrefBase already
|
|
1836
|
+
// contains it.
|
|
1837
|
+
const { origin } = parseSiteUrl(options.siteUrl);
|
|
1838
|
+
const pageMdAbsoluteUrl = origin
|
|
1839
|
+
? origin + pageMdHref
|
|
677
1840
|
: pageMdHref;
|
|
678
1841
|
// Markdown body for the Copy button. Reuse the same serializer
|
|
679
1842
|
// that produces the .md mirror so what the user copies matches
|
|
@@ -690,6 +1853,12 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
690
1853
|
// full inset width; the prose-readable cap and the right TOC
|
|
691
1854
|
// would just steal pixels. See plans/openapi-builtin.md.
|
|
692
1855
|
const wideLayout = (page.tree ?? []).some((n) => n && n.type === "endpoint");
|
|
1856
|
+
// TOC placement (DocsLayout `toc` prop). Default "top" — the
|
|
1857
|
+
// expandable "On this page" disclosure that frees the right rail
|
|
1858
|
+
// for plugin content (e.g. Ask AI). "rail" keeps the classic
|
|
1859
|
+
// right-hand TOC; in that mode the rail belongs to the TOC, so the
|
|
1860
|
+
// RightRail plugin region (slot + stack) is not emitted.
|
|
1861
|
+
const tocMode = options.toc ?? "top";
|
|
693
1862
|
const pageLines = [
|
|
694
1863
|
"---",
|
|
695
1864
|
"// AUTO-GENERATED by `dogsbay convert` — do not edit.",
|
|
@@ -709,13 +1878,22 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
709
1878
|
// Always exists; passthrough <slot /> when no plugin
|
|
710
1879
|
// contributes a wrapper for the MarkdownContent slot.
|
|
711
1880
|
'import MarkdownContentStack from "@/components/wrappers/MarkdownContentStack.astro";',
|
|
1881
|
+
// RightRailStack — hosts plugin content (e.g. an Ask AI panel) in
|
|
1882
|
+
// the freed right rail via DocsLayout's `right-rail` named slot.
|
|
1883
|
+
// Only imported/used when the rail isn't the classic TOC's.
|
|
1884
|
+
...(tocMode !== "rail"
|
|
1885
|
+
? ['import RightRailStack from "@/components/wrappers/RightRailStack.astro";']
|
|
1886
|
+
: []),
|
|
712
1887
|
'const siteConfig = siteConfigData as SiteConfig;',
|
|
713
1888
|
...result.imports,
|
|
714
1889
|
...imageSetup,
|
|
715
1890
|
"",
|
|
716
1891
|
`const headings = ${JSON.stringify(page.headings || [])};`,
|
|
717
1892
|
`const nav = navData;`,
|
|
718
|
-
|
|
1893
|
+
// currentPath uses combined so it matches nav.json hrefs
|
|
1894
|
+
// (which are also combined-prefixed). getPagination compares
|
|
1895
|
+
// them as strings; mismatched prefixes break prev/next.
|
|
1896
|
+
`const currentPath = "${buildCurrentPath(combined, section, page.slug)}";`,
|
|
719
1897
|
// Filter nav to the current (locale, version) bucket
|
|
720
1898
|
// before computing prev/next — without this, pagination
|
|
721
1899
|
// walks the global nav and a "Next" link can leak from
|
|
@@ -724,15 +1902,31 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
724
1902
|
// this keeps prev/next aligned with what the reader
|
|
725
1903
|
// sees.
|
|
726
1904
|
`const _navForPagination = filterNavByAxis(nav as any[], {`,
|
|
727
|
-
|
|
1905
|
+
// Pass the REAL basePath ("" for a root-served site) — coercing ""
|
|
1906
|
+
// → "/docs" makes the filter look for `/docs/…` bucket hrefs that
|
|
1907
|
+
// don't exist, emptying the pagination nav so prev/next vanish on
|
|
1908
|
+
// every page of a root-served multi-source site.
|
|
1909
|
+
` basePath: ${JSON.stringify(basePath ?? "")},`,
|
|
1910
|
+
` namespace: ${JSON.stringify(page.multiSource?.namespace ?? null)} ?? undefined,`,
|
|
728
1911
|
` version: ${JSON.stringify(page.multiSource?.version ?? null)} ?? undefined,`,
|
|
729
1912
|
` locale: ${JSON.stringify(page.multiSource?.locale ?? null)} ?? undefined,`,
|
|
730
1913
|
`});`,
|
|
731
|
-
|
|
1914
|
+
// Blog adjacency is CHRONOLOGICAL, not navigational. getPagination
|
|
1915
|
+
// walks NavItem[] — sidebar order — and a blog has no sidebar; its
|
|
1916
|
+
// neighbours are "the post before/after this one in time". Emitting
|
|
1917
|
+
// the pair as literals also means the page does no adjacency work at
|
|
1918
|
+
// runtime.
|
|
1919
|
+
...(blogAdj
|
|
1920
|
+
? [
|
|
1921
|
+
`const prev = ${JSON.stringify(blogAdj.get(page.slug)?.prev ?? null)} ?? undefined;`,
|
|
1922
|
+
`const next = ${JSON.stringify(blogAdj.get(page.slug)?.next ?? null)} ?? undefined;`,
|
|
1923
|
+
]
|
|
1924
|
+
: [`const { prev, next } = getPagination(currentPath, _navForPagination);`]),
|
|
732
1925
|
`const title = ${JSON.stringify(page.title)};`,
|
|
733
1926
|
`const description = ${JSON.stringify(pageDescription)} || undefined;`,
|
|
734
1927
|
`const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
|
|
735
1928
|
`const noindex = ${JSON.stringify(pageNoindex)};`,
|
|
1929
|
+
`const nofollow = ${JSON.stringify(pageNofollow)};`,
|
|
736
1930
|
`const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
|
|
737
1931
|
`const pageTags = ${JSON.stringify(pageTags ?? null)};`,
|
|
738
1932
|
`const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
|
|
@@ -741,6 +1935,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
741
1935
|
`const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
|
|
742
1936
|
`const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
|
|
743
1937
|
`const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
|
|
1938
|
+
`const lastUpdated = ${JSON.stringify(pageLastUpdated)} || undefined;`,
|
|
744
1939
|
`const llmActionsProps = ${JSON.stringify(llmActionsEnabled
|
|
745
1940
|
? {
|
|
746
1941
|
enabled: true,
|
|
@@ -770,6 +1965,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
770
1965
|
` twitterHandle={siteConfig.twitterHandle || undefined}`,
|
|
771
1966
|
` themeColor={siteConfig.themeColor || undefined}`,
|
|
772
1967
|
` noindex={noindex}`,
|
|
1968
|
+
` nofollow={nofollow}`,
|
|
773
1969
|
` excludeFromSearch={excludeFromSearch}`,
|
|
774
1970
|
` plausibleDomain={siteConfig.plausible?.domain}`,
|
|
775
1971
|
` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
|
|
@@ -785,25 +1981,86 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
785
1981
|
` pageType={pageType ?? undefined}`,
|
|
786
1982
|
` audience={pageAudience ?? undefined}`,
|
|
787
1983
|
` category={pageCategory ?? undefined}`,
|
|
1984
|
+
` lastUpdated={lastUpdated}`,
|
|
788
1985
|
` taxonomies={pageTaxonomies ?? undefined}`,
|
|
789
1986
|
` autoH1={${autoH1}}`,
|
|
790
1987
|
` autoLede={${autoLede}}`,
|
|
791
1988
|
` llmActions={llmActionsProps}`,
|
|
1989
|
+
` linkIcons={siteConfig.linkIcons}`,
|
|
792
1990
|
` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
|
|
793
1991
|
` switcherMap={switcherMapData}`,
|
|
794
|
-
|
|
1992
|
+
// basePath here is the COMBINED URL prefix (urlBase from
|
|
1993
|
+
// site.url's path + dogsbay basePath). DocsLayout uses it
|
|
1994
|
+
// for switcher links, the footer llms.txt link, and the
|
|
1995
|
+
// <head> alternate link — all three need the full URL
|
|
1996
|
+
// prefix the host actually serves under. Empty string is
|
|
1997
|
+
// valid (root-served sites with no urlBase or basePath);
|
|
1998
|
+
// don't fall back to "/docs" — that would 404 for those.
|
|
1999
|
+
` basePath={${JSON.stringify(combined)}}`,
|
|
2000
|
+
// navMode — controls whether DocsLayout server-renders the
|
|
2001
|
+
// full sidebar nav tree per page (`ssr-full`) or emits a
|
|
2002
|
+
// client-hydrated placeholder (`client`, default). The
|
|
2003
|
+
// client mode shrinks per-page HTML dramatically at scale.
|
|
2004
|
+
// See plans/client-rendered-nav.md.
|
|
2005
|
+
` navMode={${JSON.stringify(options.navMode ?? "client")}}`,
|
|
2006
|
+
// Pagefind index URL — must include the combined prefix or
|
|
2007
|
+
// the loader 404s on subpath-mounted deploys. The pagefind
|
|
2008
|
+
// CLI writes to <astroOutput>/dist/pagefind/ which Astro
|
|
2009
|
+
// serves under its `base` (= urlBase); dogsbay's basePath
|
|
2010
|
+
// adds the second prefix layer. Empty combined → `/pagefind/`.
|
|
2011
|
+
` pagefindUrl={${JSON.stringify(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
|
|
2012
|
+
// Favicon — composed with combined prefix so the
|
|
2013
|
+
// <link rel="icon"> resolves on subpath-mounted deploys.
|
|
2014
|
+
//
|
|
2015
|
+
// Emitted ONLY when the file actually exists in public/. The
|
|
2016
|
+
// scaffold ships no favicon, so an unconditional link meant every
|
|
2017
|
+
// generated site advertised an icon that 404s. Authors drop
|
|
2018
|
+
// `public/favicon.ico` (or `.svg`) in their Astro project and the
|
|
2019
|
+
// link appears on the next build; DocsLayout takes `false` to mean
|
|
2020
|
+
// "emit no <link rel=icon>".
|
|
2021
|
+
` favicon={${JSON.stringify(faviconHref(outputDir, parseSiteUrl(options.siteUrl).urlBase))}}`,
|
|
795
2022
|
` wideLayout={${wideLayout}}`,
|
|
2023
|
+
// Blog chrome — sidebar dropped, byline on. Every other prop above
|
|
2024
|
+
// is unchanged, which is what keeps the blog visually continuous
|
|
2025
|
+
// with the docs rather than a lookalike.
|
|
2026
|
+
...(options.blog
|
|
2027
|
+
? [
|
|
2028
|
+
` chrome="blog"`,
|
|
2029
|
+
` author={${JSON.stringify(page.meta?.author ?? null)} ?? undefined}`,
|
|
2030
|
+
` publishedDate={${JSON.stringify(page.meta?.[options.blog.dateField] ?? null)} ?? undefined}`,
|
|
2031
|
+
` updatedDate={${JSON.stringify(page.meta?.updated ?? null)} ?? undefined}`,
|
|
2032
|
+
` readingMinutes={${blogMinutes.get(page.slug) ?? 1}}`,
|
|
2033
|
+
// Series position, derived in buildBlogData from publication
|
|
2034
|
+
// order — never hand-typed in prose, which goes stale the
|
|
2035
|
+
// moment another part lands.
|
|
2036
|
+
// String-guarded like buildBlogData's firstString: a
|
|
2037
|
+
// `heroImage: {src, alt}` object would otherwise reach
|
|
2038
|
+
// DocsLayout's `heroImage?: string` prop and render as
|
|
2039
|
+
// src="[object Object]".
|
|
2040
|
+
` heroImage={${JSON.stringify(typeof (page.frontmatter ?? {}).heroImage === "string"
|
|
2041
|
+
? (page.frontmatter ?? {}).heroImage
|
|
2042
|
+
: null)} ?? undefined}`,
|
|
2043
|
+
]
|
|
2044
|
+
: []),
|
|
2045
|
+
` toc={${JSON.stringify(tocMode)}}`,
|
|
796
2046
|
`>`,
|
|
2047
|
+
// RightRail region — assigned to DocsLayout's `right-rail` named
|
|
2048
|
+
// slot. Inert (empty passthrough) until a plugin contributes a
|
|
2049
|
+
// RightRail wrapper. Omitted in "rail" mode (TOC owns the rail).
|
|
2050
|
+
...(tocMode !== "rail"
|
|
2051
|
+
? [` <RightRailStack slot="right-rail" />`]
|
|
2052
|
+
: []),
|
|
797
2053
|
` <MarkdownContentStack>`,
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
2054
|
+
// Every page body gets the docs-prose typography wrapper — wide
|
|
2055
|
+
// (endpoint) pages included. Mixed prose+endpoint pages (MDX
|
|
2056
|
+
// imports) previously lost ALL typography because the wrapper
|
|
2057
|
+
// was skipped; API-region internals opt out via the `dba-api`
|
|
2058
|
+
// marker on ApiLayout + the `:not(.dba-api *)` guards in the
|
|
2059
|
+
// generated .docs-prose rules.
|
|
2060
|
+
` <article class="docs-prose">`,
|
|
2061
|
+
...singlePageLinkLines(singlePageBySlug.get(page.slug), 6),
|
|
2062
|
+
result.body.split("\n").map((l) => ` ${l}`).join("\n"),
|
|
2063
|
+
` </article>`,
|
|
807
2064
|
` </MarkdownContentStack>`,
|
|
808
2065
|
`</DocsLayout>`,
|
|
809
2066
|
];
|
|
@@ -828,6 +2085,9 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
828
2085
|
mkdirSync(dirname(pagePath), { recursive: true });
|
|
829
2086
|
writeFileSync(pagePath, pageLines.join("\n") + "\n");
|
|
830
2087
|
generated++;
|
|
2088
|
+
if (!section && page.slug === "index")
|
|
2089
|
+
rootIndexEmitted = true;
|
|
2090
|
+
generatedPaths.add(relative(outputDir, pagePath));
|
|
831
2091
|
// Companion .md endpoint for content negotiation. Prerendered, so
|
|
832
2092
|
// it's served as a static asset at runtime — no Worker overhead.
|
|
833
2093
|
//
|
|
@@ -843,6 +2103,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
843
2103
|
mkdirSync(dirname(mdEndpointPath), { recursive: true });
|
|
844
2104
|
const endpointBody = buildMdEndpoint(page, sourceRel);
|
|
845
2105
|
writeFileSync(mdEndpointPath, endpointBody);
|
|
2106
|
+
generatedPaths.add(relative(outputDir, mdEndpointPath));
|
|
846
2107
|
// Sibling-level mirror for the index page under a non-empty
|
|
847
2108
|
// basePath. baseSegments is empty when basePath is empty
|
|
848
2109
|
// (root-served sites); in that case the index slug is just
|
|
@@ -855,22 +2116,128 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
855
2116
|
const siblingPath = join(outputDir, "src", "pages", ...parentSegments, `${lastSeg}.md.ts`);
|
|
856
2117
|
mkdirSync(dirname(siblingPath), { recursive: true });
|
|
857
2118
|
writeFileSync(siblingPath, endpointBody);
|
|
2119
|
+
generatedPaths.add(relative(outputDir, siblingPath));
|
|
858
2120
|
}
|
|
859
2121
|
}
|
|
860
2122
|
}
|
|
861
2123
|
catch (err) {
|
|
862
2124
|
console.warn(`Warning: failed to generate ${page.slug}: ${err.message}`);
|
|
2125
|
+
// A failed page is absent from generatedPaths, which is
|
|
2126
|
+
// indistinguishable from a deleted one. Pruning is suppressed for
|
|
2127
|
+
// the whole run rather than deleting last build's still-good copy.
|
|
2128
|
+
pageFailures++;
|
|
863
2129
|
}
|
|
864
2130
|
}
|
|
865
2131
|
// Generate index redirect at src/pages/index.astro — sends `/` to the
|
|
866
|
-
// first nav href.
|
|
867
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
2132
|
+
// first nav href. Two cases need it:
|
|
2133
|
+
// - basePath set: `/` (host apex) isn't a content page, so redirect
|
|
2134
|
+
// into the basePath subtree.
|
|
2135
|
+
// - basePath empty BUT no root home page emitted (no
|
|
2136
|
+
// `content/index.md`): the corpus's entry point is a nested page
|
|
2137
|
+
// (e.g. OpenShift's `welcome/index`), so `/` would 404 without a
|
|
2138
|
+
// redirect. Only emit here for non-section builds and never when a
|
|
2139
|
+
// real root index page exists (writing a redirect would clobber it).
|
|
2140
|
+
// Use the base-prefixed nav (the same hrefs written to nav.json), not the
|
|
2141
|
+
// raw input `nav` — otherwise importers whose nav starts root-relative (e.g.
|
|
2142
|
+
// the docusaurus convert path) emit a redirect that drops the URL base and
|
|
2143
|
+
// sends `/` to the host root instead of into the site's subpath.
|
|
2144
|
+
// ─── Per-version subtree index redirects ─────────────────────────
|
|
2145
|
+
// A version subtree (`/3.29/…`, `/latest/…`) owns no page of its
|
|
2146
|
+
// own, so `/latest/` 404s while `/latest/about` resolves — and
|
|
2147
|
+
// `/latest/` is exactly what a reader types, what a switcher link
|
|
2148
|
+
// truncates to, and what a "latest" alias is FOR. Give each bucket
|
|
2149
|
+
// the same contract the site root gets: redirect to its first nav
|
|
2150
|
+
// entry.
|
|
2151
|
+
const subtreeRoots = axisSubtreeRoots(pages);
|
|
2152
|
+
for (const slugPrefix of subtreeRoots.keys()) {
|
|
2153
|
+
const target = findFirstNavHrefUnder(outputNav, [`${combined}/${slugPrefix}`]);
|
|
2154
|
+
if (!target)
|
|
2155
|
+
continue;
|
|
2156
|
+
const subtreeIndexPath = join(pagesDir, section ? section : "", slugPrefix, "index.astro");
|
|
2157
|
+
const subtreeIndexRel = relative(outputDir, subtreeIndexPath);
|
|
2158
|
+
// A real page at this route wins — never clobber content.
|
|
2159
|
+
if (generatedPaths.has(subtreeIndexRel))
|
|
2160
|
+
continue;
|
|
2161
|
+
mkdirSync(dirname(subtreeIndexPath), { recursive: true });
|
|
2162
|
+
writeFileSync(subtreeIndexPath,
|
|
2163
|
+
// Marked as ours so a later emitter (the blog index claims this
|
|
2164
|
+
// exact route) can tell a generated stub from a page a writer
|
|
2165
|
+
// wrote. Without the marker the stub looked hand-authored and
|
|
2166
|
+
// silently blocked the blog index from ever being emitted.
|
|
2167
|
+
`---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${target}");\n---\n`);
|
|
2168
|
+
generatedPaths.add(subtreeIndexRel);
|
|
2169
|
+
}
|
|
2170
|
+
// The site root targets the DEFAULT version's first entry, not the
|
|
2171
|
+
// first entry overall. Nav is assembled oldest-version-first, so
|
|
2172
|
+
// `findFirstNavHref` alone sends every reader arriving at `/` into
|
|
2173
|
+
// the OLDEST docs — the opposite of what `defaultVersion` declares.
|
|
2174
|
+
const defaultVersionPrefixes = options.defaultVersion
|
|
2175
|
+
? [...subtreeRoots]
|
|
2176
|
+
.filter(([, version]) => version === options.defaultVersion)
|
|
2177
|
+
.map(([slugPrefix]) => `${combined}/${slugPrefix}`)
|
|
2178
|
+
: [];
|
|
2179
|
+
const firstHref = (defaultVersionPrefixes.length > 0
|
|
2180
|
+
? findFirstNavHrefUnder(outputNav, defaultVersionPrefixes)
|
|
2181
|
+
: undefined) ?? findFirstNavHref(outputNav, basePath);
|
|
2182
|
+
const needsRootRedirect = basePath !== "" ||
|
|
2183
|
+
(!section && !rootIndexEmitted && firstHref !== "");
|
|
2184
|
+
if (needsRootRedirect) {
|
|
2185
|
+
const indexPath = join(outputDir, "src", "pages", "index.astro");
|
|
2186
|
+
writeFileSync(indexPath, `---\n// AUTO-GENERATED by dogsbay site build — safe to replace.\nreturn Astro.redirect("${firstHref}");\n---\n`);
|
|
2187
|
+
generatedPaths.add(relative(outputDir, indexPath));
|
|
2188
|
+
}
|
|
2189
|
+
// See pruneOrphanedPages for why each of these guards exists. They are
|
|
2190
|
+
// cheap; the failure they prevent is deleting a site.
|
|
2191
|
+
if (generatedPaths.size === 0) {
|
|
2192
|
+
// Zero pages is a broken input, not an empty site.
|
|
2193
|
+
}
|
|
2194
|
+
else if (pageFailures > 0) {
|
|
2195
|
+
console.warn(` Skipped pruning: ${pageFailures} page(s) failed to generate, so an ` +
|
|
2196
|
+
`orphan cannot be told from a failure.`);
|
|
2197
|
+
}
|
|
2198
|
+
else {
|
|
2199
|
+
const pruned = pruneOrphanedPages(outputDir, join(pagesDir, section ?? ""), generatedPaths);
|
|
2200
|
+
if (pruned.length > 0) {
|
|
2201
|
+
console.log(` Pruned ${pruned.length} page(s) whose source was deleted: ` +
|
|
2202
|
+
`${pruned.slice(0, 3).join(", ")}${pruned.length > 3 ? ", …" : ""}`);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
// After the prune, so it is never mistaken for an orphan, and after
|
|
2206
|
+
// outputNav exists so its links match the site that was just built.
|
|
2207
|
+
emitNotFoundPage(outputDir, options);
|
|
2208
|
+
return { generated, outputNav, generatedPaths };
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Copy each passthrough `.astro` source to its computed output path.
|
|
2212
|
+
* Aborts with a clear error if the destination is already in
|
|
2213
|
+
* `generatedPaths` (a generated page from `emitAstroPages` would
|
|
2214
|
+
* silently overwrite the hand-authored file otherwise).
|
|
2215
|
+
*/
|
|
2216
|
+
export function emitPassthroughAstroPages(copies, outputDir, generatedPaths) {
|
|
2217
|
+
if (copies.length === 0)
|
|
2218
|
+
return { copied: 0 };
|
|
2219
|
+
// Collision detection — a generated page and a passthrough page
|
|
2220
|
+
// would write to the same file. Refuse to overwrite; tell the
|
|
2221
|
+
// author exactly which two files conflict.
|
|
2222
|
+
const collisions = [];
|
|
2223
|
+
for (const copy of copies) {
|
|
2224
|
+
if (generatedPaths.has(copy.outputRelPath)) {
|
|
2225
|
+
collisions.push(copy.outputRelPath);
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
if (collisions.length > 0) {
|
|
2229
|
+
throw new Error(`Passthrough Astro page collides with a generated page:\n` +
|
|
2230
|
+
collisions.map((c) => ` - ${c}`).join("\n") + "\n" +
|
|
2231
|
+
`Rename the .astro source or remove the colliding entry from nav.yml.`);
|
|
2232
|
+
}
|
|
2233
|
+
let copied = 0;
|
|
2234
|
+
for (const copy of copies) {
|
|
2235
|
+
const dest = join(outputDir, copy.outputRelPath);
|
|
2236
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
2237
|
+
copyFileSync(copy.sourceAbs, dest);
|
|
2238
|
+
copied++;
|
|
872
2239
|
}
|
|
873
|
-
return {
|
|
2240
|
+
return { copied };
|
|
874
2241
|
}
|
|
875
2242
|
// ─── Tier 1: config-derived ─────────────────────────────────────────────
|
|
876
2243
|
// Files driven entirely by config + flags. Always regenerated; site
|
|
@@ -886,6 +2253,54 @@ export function emitConfigDerivedFiles(outputDir, options) {
|
|
|
886
2253
|
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
887
2254
|
writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
|
|
888
2255
|
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Per-deploy-target artifact emission.
|
|
2258
|
+
*
|
|
2259
|
+
* Called from `emitSiteScaffold` (with `forceOverwrite=writeScaffold`
|
|
2260
|
+
* so `--force` regenerates from template) and from `dogsbay site
|
|
2261
|
+
* build` (with `forceOverwrite=false` so an existing site can adopt
|
|
2262
|
+
* a deploy target by editing config and rebuilding — the missing
|
|
2263
|
+
* artifact gets created on the next build).
|
|
2264
|
+
*
|
|
2265
|
+
* Emit policy is the union: write when forced OR when the file is
|
|
2266
|
+
* missing. Author edits to e.g. the workflow YAML survive every
|
|
2267
|
+
* regular build.
|
|
2268
|
+
*
|
|
2269
|
+
* Currently handles `github-pages` (workflow + .nojekyll). The
|
|
2270
|
+
* existing `cloudflare-workers` artifacts (wrangler.jsonc + package
|
|
2271
|
+
* scripts) stay in the scaffold-only path because they overlap with
|
|
2272
|
+
* scaffold-only files (package.json scripts, devDependencies). A
|
|
2273
|
+
* future refactor could fold them in here too.
|
|
2274
|
+
*/
|
|
2275
|
+
export function emitDeployArtifacts(outputDir, options, opts = { forceOverwrite: false }) {
|
|
2276
|
+
if (options.deploy === "github-pages") {
|
|
2277
|
+
// GitHub reads workflows from <repo-root>/.github/workflows/, NOT
|
|
2278
|
+
// from inside subdirectories. Use projectDir (the repo root) for
|
|
2279
|
+
// the workflow file; fall back to outputDir when unset (flat
|
|
2280
|
+
// `dogsbay convert` flows where the Astro project IS the repo).
|
|
2281
|
+
const projectDir = options.projectDir ?? outputDir;
|
|
2282
|
+
// Path of the Astro output relative to the project root. Used by
|
|
2283
|
+
// the workflow's working-directory + cache-dependency-path so
|
|
2284
|
+
// pnpm install / pnpm run build target the right place. Empty
|
|
2285
|
+
// string when outputDir === projectDir (flat layout).
|
|
2286
|
+
const astroDirRel = relative(projectDir, outputDir).replace(/\\/g, "/");
|
|
2287
|
+
const workflowPath = join(projectDir, ".github", "workflows", "deploy.yml");
|
|
2288
|
+
if (opts.forceOverwrite || !existsSync(workflowPath)) {
|
|
2289
|
+
mkdirSync(dirname(workflowPath), { recursive: true });
|
|
2290
|
+
writeFileSync(workflowPath, buildGitHubPagesWorkflow(astroDirRel));
|
|
2291
|
+
}
|
|
2292
|
+
// .nojekyll — must exist in the deployed artifact root so GH
|
|
2293
|
+
// Pages skips Jekyll's `_underscored-paths` filter (Astro's
|
|
2294
|
+
// `_astro/` chunk dir gets eaten otherwise). Lives inside the
|
|
2295
|
+
// Astro project's `public/` so it's copied into `dist/` at
|
|
2296
|
+
// build time.
|
|
2297
|
+
const nojekyllPath = join(outputDir, "public", ".nojekyll");
|
|
2298
|
+
mkdirSync(dirname(nojekyllPath), { recursive: true });
|
|
2299
|
+
if (opts.forceOverwrite || !existsSync(nojekyllPath)) {
|
|
2300
|
+
writeFileSync(nojekyllPath, "");
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
889
2304
|
/**
|
|
890
2305
|
* Emit `src/data/switcherMap.json` describing per-page
|
|
891
2306
|
* version + locale equivalents. Always writes the file —
|
|
@@ -902,7 +2317,10 @@ export function emitConfigDerivedFiles(outputDir, options) {
|
|
|
902
2317
|
* baseline page in a multi-version site).
|
|
903
2318
|
*/
|
|
904
2319
|
export function emitSwitcherMap(pages, outputDir, options) {
|
|
905
|
-
|
|
2320
|
+
// Switcher URLs use combined so the link the dropdown emits
|
|
2321
|
+
// resolves under the host's served subpath (GH Pages project
|
|
2322
|
+
// pages, multi-mount Cloudflare).
|
|
2323
|
+
const combined = combinedPrefix(options);
|
|
906
2324
|
const dataDir = join(outputDir, "src", "data");
|
|
907
2325
|
const outPath = join(dataDir, "switcherMap.json");
|
|
908
2326
|
// Detect axis activation by inspecting the data the loader
|
|
@@ -941,7 +2359,7 @@ export function emitSwitcherMap(pages, outputDir, options) {
|
|
|
941
2359
|
const variant = {
|
|
942
2360
|
...(ms.locale !== undefined ? { locale: ms.locale } : {}),
|
|
943
2361
|
...(ms.version !== undefined ? { version: ms.version } : {}),
|
|
944
|
-
url: `${
|
|
2362
|
+
url: `${combined}/${page.slug}`,
|
|
945
2363
|
};
|
|
946
2364
|
if (!byLogicalKey[key])
|
|
947
2365
|
byLogicalKey[key] = [];
|
|
@@ -962,11 +2380,22 @@ function composeAxisHeader(declared, seen, defaultId, allowEol) {
|
|
|
962
2380
|
const out = [];
|
|
963
2381
|
const seenInDeclared = new Set();
|
|
964
2382
|
for (const d of declared) {
|
|
965
|
-
|
|
2383
|
+
// A `hidden` version is the NUMBER an alias stands in for — the
|
|
2384
|
+
// Docusaurus `lastVersion` + `path: "latest"` pair. Its pages are
|
|
2385
|
+
// served under the alias, so no page ever carries its id and `seen`
|
|
2386
|
+
// will never contain it. Admit it anyway: it is label-only metadata
|
|
2387
|
+
// (the switcher filters hidden rows out of the dropdown), and it is
|
|
2388
|
+
// the sole source of the "3.32 (latest)" label. Gating it on `seen`
|
|
2389
|
+
// silently degrades that row to a bare "latest".
|
|
2390
|
+
const aliasedNumber = allowEol && d.hidden === true;
|
|
2391
|
+
if (seen.has(d.id) || aliasedNumber) {
|
|
966
2392
|
out.push({
|
|
967
2393
|
id: d.id,
|
|
968
2394
|
...(d.label !== undefined ? { label: d.label } : {}),
|
|
2395
|
+
// eol + prerelease + hidden are version-only marks (allowEol gates them).
|
|
969
2396
|
...(allowEol && d.eol === true ? { eol: true } : {}),
|
|
2397
|
+
...(allowEol && d.prerelease === true ? { prerelease: true } : {}),
|
|
2398
|
+
...(allowEol && d.hidden === true ? { hidden: true } : {}),
|
|
970
2399
|
...(defaultId === d.id ? { default: true } : {}),
|
|
971
2400
|
});
|
|
972
2401
|
seenInDeclared.add(d.id);
|
|
@@ -1019,6 +2448,10 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
|
|
|
1019
2448
|
return;
|
|
1020
2449
|
const basePath = normalizeBasePath(options.basePath);
|
|
1021
2450
|
const baseSegments = basePathSegments(basePath);
|
|
2451
|
+
// combined drives the redirect URL (the user-facing path they
|
|
2452
|
+
// get bounced to); basePath stays the filesystem path under
|
|
2453
|
+
// src/pages/ where the stub lives.
|
|
2454
|
+
const combined = combinedPrefix(options);
|
|
1022
2455
|
// Index existing pages by (slug after locale segment) so we
|
|
1023
2456
|
// can detect missing translations cheaply. Key shape:
|
|
1024
2457
|
// `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
|
|
@@ -1052,7 +2485,7 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
|
|
|
1052
2485
|
const targetUrl = `${basePath}/${targetSlug}`;
|
|
1053
2486
|
if (existingByUrl.has(targetUrl))
|
|
1054
2487
|
continue; // already translated
|
|
1055
|
-
const defaultUrl = `${
|
|
2488
|
+
const defaultUrl = `${combined}/${defaultPage.slug}`;
|
|
1056
2489
|
const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
|
|
1057
2490
|
// Ensure parent dir exists; write a redirect-stub Astro
|
|
1058
2491
|
// file. Adding `.astro` to the leaf turns it into a
|
|
@@ -1094,10 +2527,20 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1094
2527
|
if (options.llmsTxt !== false) {
|
|
1095
2528
|
emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
|
|
1096
2529
|
// public/_headers — Cloudflare Workers / Pages convention. Adds an
|
|
1097
|
-
// RFC 8288 Link header pointing agents at
|
|
1098
|
-
// HTML. Emitted alongside
|
|
2530
|
+
// RFC 8288 Link header pointing agents at this mount's llms.txt
|
|
2531
|
+
// (basePath-prefixed) without parsing HTML. Emitted alongside
|
|
2532
|
+
// llms.txt so the two files travel together.
|
|
1099
2533
|
mkdirSync(join(outputDir, "public"), { recursive: true });
|
|
1100
|
-
|
|
2534
|
+
// _headers Link header points at the per-mount llms.txt at
|
|
2535
|
+
// <combined>/llms.txt — the URL agents would actually fetch.
|
|
2536
|
+
writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(combinedPrefix(options)));
|
|
2537
|
+
}
|
|
2538
|
+
// Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
|
|
2539
|
+
// public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
|
|
2540
|
+
// don't collide at host root. Gated on a valid http(s) siteUrl
|
|
2541
|
+
// because <loc> entries must be absolute.
|
|
2542
|
+
if (options.siteUrl && /^https?:\/\//.test(options.siteUrl)) {
|
|
2543
|
+
emitSitemapFiles(outputDir, options, pages);
|
|
1101
2544
|
}
|
|
1102
2545
|
// src/middleware.ts — Tier 1 (always update). Drives both the
|
|
1103
2546
|
// `Accept: text/markdown` content-negotiation rewrite (via
|
|
@@ -1109,12 +2552,43 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1109
2552
|
// OR the version axis has a defaultVersion set with ≥2
|
|
1110
2553
|
// declared versions. When neither applies, no middleware is
|
|
1111
2554
|
// emitted (keeps single-feature sites' src/ tidy).
|
|
1112
|
-
|
|
2555
|
+
// Middleware only does something on an SSR deploy. In Astro's static
|
|
2556
|
+
// output — which is what `deploy: cloudflare-workers` produces, and
|
|
2557
|
+
// what every Dogsbay site ships today — there is no runtime to invoke
|
|
2558
|
+
// it: every route is prerendered, and the guard inside the generated
|
|
2559
|
+
// middleware short-circuits at BUILD time because there is no client
|
|
2560
|
+
// whose Accept header could be honoured.
|
|
2561
|
+
//
|
|
2562
|
+
// It used to be emitted anyway. That is worse than not emitting it:
|
|
2563
|
+
// an external agent audit reported markdown content negotiation as
|
|
2564
|
+
// failing, and the file sitting in src/ implied the feature existed
|
|
2565
|
+
// and was broken, rather than being inapplicable to the target. Dead
|
|
2566
|
+
// code that claims a capability costs more than a missing feature.
|
|
2567
|
+
//
|
|
2568
|
+
// The `.md` mirrors are unaffected — they are real files, and they
|
|
2569
|
+
// are how agents actually fetch markdown here. What is lost on a
|
|
2570
|
+
// static deploy is only `Accept: text/markdown` negotiation on the
|
|
2571
|
+
// HTML URL, which needs a Worker running before asset serving.
|
|
2572
|
+
// See docs-dev/markdown-negotiation.md.
|
|
2573
|
+
const staticDeploy = options.deploy === "cloudflare-workers";
|
|
2574
|
+
const mdMirrorOn = options.mdMirror !== false && !staticDeploy;
|
|
1113
2575
|
const knownVersions = pageVersions(pages);
|
|
1114
2576
|
const knownLocales = pageLocales(pages);
|
|
1115
2577
|
const versionRedirectOn = options.defaultVersion !== undefined && knownVersions.length >= 2;
|
|
1116
2578
|
const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
|
|
1117
2579
|
const axisRedirectOn = versionRedirectOn || localeRedirectOn;
|
|
2580
|
+
const middlewarePath = join(outputDir, "src", "middleware.ts");
|
|
2581
|
+
if (!mdMirrorOn && !axisRedirectOn && existsSync(middlewarePath)) {
|
|
2582
|
+
// Remove a middleware emitted by an EARLIER build, from before this
|
|
2583
|
+
// stopped emitting for static targets. Left in place it keeps
|
|
2584
|
+
// implying a capability the deploy cannot provide, and a site that
|
|
2585
|
+
// commits its generated output would carry it indefinitely. Only
|
|
2586
|
+
// ours — an author-written middleware is left alone.
|
|
2587
|
+
const existing = readFileSync(middlewarePath, "utf-8");
|
|
2588
|
+
if (existing.includes("AUTO-GENERATED by `dogsbay site build`")) {
|
|
2589
|
+
rmSync(middlewarePath);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
1118
2592
|
if (mdMirrorOn || axisRedirectOn) {
|
|
1119
2593
|
// Taxonomy index paths share a single global namespace across
|
|
1120
2594
|
// locales / versions (one `/tags/` for the whole site, not one
|
|
@@ -1133,9 +2607,16 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1133
2607
|
mkdirSync(join(outputDir, "src"), { recursive: true });
|
|
1134
2608
|
writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
|
|
1135
2609
|
mdMirror: mdMirrorOn,
|
|
2610
|
+
// Same combined prefix the axis redirect uses — the middleware
|
|
2611
|
+
// compares against the request URL, which carries the served
|
|
2612
|
+
// subpath.
|
|
2613
|
+
mdMirrorBasePath: combinedPrefix(options),
|
|
1136
2614
|
axisRedirect: axisRedirectOn
|
|
1137
2615
|
? {
|
|
1138
|
-
|
|
2616
|
+
// Middleware compares paths against the request URL,
|
|
2617
|
+
// which carries the host's served subpath — so use the
|
|
2618
|
+
// combined prefix here.
|
|
2619
|
+
basePath: combinedPrefix(options),
|
|
1139
2620
|
...(versionRedirectOn
|
|
1140
2621
|
? {
|
|
1141
2622
|
defaultVersion: options.defaultVersion,
|
|
@@ -1209,21 +2690,147 @@ function buildRobotsTxt(options, hasSiteUrl) {
|
|
|
1209
2690
|
const aiInput = options.aiInput ?? "yes";
|
|
1210
2691
|
const aiTrain = options.aiTrain ?? "no";
|
|
1211
2692
|
const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
|
|
1212
|
-
|
|
1213
|
-
|
|
2693
|
+
// Per-mount sitemap path: each Dogsbay site emits its sitemap
|
|
2694
|
+
// index under <basePath>/, so robots.txt must point there too.
|
|
2695
|
+
// (Multi-mount deploys end up with one robots.txt per site at
|
|
2696
|
+
// their respective hosts / paths; each correctly references its
|
|
2697
|
+
// own mount's sitemap-index.)
|
|
2698
|
+
// Sitemap URL = origin + combined + /sitemap-index.xml. Use the
|
|
2699
|
+
// origin (no path) from site.url and the combined prefix (urlBase
|
|
2700
|
+
// + basePath); siteUrl could itself include a path component when
|
|
2701
|
+
// hosting on a subpath (GH Pages project page), so we strip it
|
|
2702
|
+
// here to avoid double-counting.
|
|
2703
|
+
const { origin } = parseSiteUrl(options.siteUrl);
|
|
2704
|
+
const combined = combinedPrefix(options);
|
|
2705
|
+
const sitemap = hasSiteUrl && origin
|
|
2706
|
+
? `Sitemap: ${origin}${withBasePath(combined, "/sitemap-index.xml")}\n`
|
|
2707
|
+
: "";
|
|
2708
|
+
// Llms-Txt: line — non-standard but follows the same shape as
|
|
2709
|
+
// `Sitemap:`. Crawlers and agents that scan robots.txt before
|
|
2710
|
+
// fetching pages get a direct pointer at the per-mount llms.txt.
|
|
2711
|
+
// RFC 9309 explicitly permits unknown directives ("intentionally
|
|
2712
|
+
// permissive of such future extensions") so this is harmless to
|
|
2713
|
+
// standards-compliant parsers. Emitted alongside Sitemap when
|
|
2714
|
+
// siteUrl is set; absolute URLs only (relative paths would be
|
|
2715
|
+
// ambiguous without a base).
|
|
2716
|
+
// OPT-IN (`agent.llmsTxtDirective`), default off. RFC 9309 permits
|
|
2717
|
+
// unknown directives, but Lighthouse's validator reports this one as
|
|
2718
|
+
// `Unknown directive` and fails the "robots.txt is valid" audit —
|
|
2719
|
+
// measured: SEO 100 -> 92 on every page. Nothing consumes the line
|
|
2720
|
+
// either; `Llms-Txt:` is not part of the llms.txt proposal, and
|
|
2721
|
+
// agents find the file at its well-known path. A measured penalty
|
|
2722
|
+
// for a speculative benefit is not a sensible default.
|
|
2723
|
+
//
|
|
2724
|
+
// `llms.txt` itself is still emitted; this controls only the pointer.
|
|
2725
|
+
const llmsTxt = options.llmsTxtDirective === true &&
|
|
2726
|
+
options.llmsTxt !== false &&
|
|
2727
|
+
hasSiteUrl &&
|
|
2728
|
+
origin
|
|
2729
|
+
? `Llms-Txt: ${origin}${withBasePath(combined, "/llms.txt")}\n`
|
|
1214
2730
|
: "";
|
|
1215
|
-
return
|
|
2731
|
+
return `${contentSignalPreamble(options)}User-agent: *\nAllow: /\n${contentSignal}${sitemap}${llmsTxt}`;
|
|
2732
|
+
}
|
|
2733
|
+
/**
|
|
2734
|
+
* The Article 4 reservation-of-rights preamble, as robots.txt comments.
|
|
2735
|
+
*
|
|
2736
|
+
* Opt-in via `agent.contentSignal.preamble`. Default off: it is 25
|
|
2737
|
+
* lines of legal text on every site, and it asserts a legal position
|
|
2738
|
+
* that is the operator's to take, not ours to take for them.
|
|
2739
|
+
*
|
|
2740
|
+
* Why it is worth having at all — `Content-Signal: ai-train=no` on its
|
|
2741
|
+
* own is a preference. Article 4(3) of EU Directive 2019/790 makes the
|
|
2742
|
+
* text-and-data-mining exception conditional on rights not having been
|
|
2743
|
+
* "expressly reserved in an appropriate manner", including
|
|
2744
|
+
* machine-readable means. The preamble is what turns the signal into
|
|
2745
|
+
* that express reservation, which is the difference between a request
|
|
2746
|
+
* and a reservation with legal effect in the EU.
|
|
2747
|
+
*
|
|
2748
|
+
* The wording is Cloudflare's Content Signals Policy text, which is
|
|
2749
|
+
* published under CC0 precisely so it can be reproduced. It is
|
|
2750
|
+
* reproduced rather than linked because a reservation has to be present
|
|
2751
|
+
* where the machine reads it.
|
|
2752
|
+
*
|
|
2753
|
+
* Kept BYTE-IDENTICAL to the published policy on purpose. It is a legal
|
|
2754
|
+
* instrument, not prose we own: reword it and a site is asserting
|
|
2755
|
+
* something subtly different from what its operator believes it is
|
|
2756
|
+
* asserting. `tests/robots-preamble.test.ts` pins the text.
|
|
2757
|
+
*/
|
|
2758
|
+
function contentSignalPreamble(options) {
|
|
2759
|
+
if (options.contentSignalPreamble !== true)
|
|
2760
|
+
return "";
|
|
2761
|
+
return `# As a condition of accessing this website, you agree to abide by the following
|
|
2762
|
+
# content signals:
|
|
2763
|
+
|
|
2764
|
+
# (a) If a Content-Signal = yes, you may collect content for the corresponding
|
|
2765
|
+
# use.
|
|
2766
|
+
# (b) If a Content-Signal = no, you may not collect content for the
|
|
2767
|
+
# corresponding use.
|
|
2768
|
+
# (c) If the website operator does not include a Content-Signal for a
|
|
2769
|
+
# corresponding use, the website operator neither grants nor restricts
|
|
2770
|
+
# permission via Content-Signal with respect to the corresponding use.
|
|
2771
|
+
|
|
2772
|
+
# The content signals and their meanings are:
|
|
2773
|
+
|
|
2774
|
+
# search: building a search index and providing search results (e.g., returning
|
|
2775
|
+
# hyperlinks and short excerpts from your website's contents). Search does not
|
|
2776
|
+
# include providing AI-generated search summaries.
|
|
2777
|
+
# ai-input: inputting content into one or more AI models (e.g., retrieval
|
|
2778
|
+
# augmented generation, grounding, or other real-time taking of content for
|
|
2779
|
+
# generative AI search answers).
|
|
2780
|
+
# ai-train: training or fine-tuning AI models.
|
|
2781
|
+
|
|
2782
|
+
# ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF
|
|
2783
|
+
# RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT
|
|
2784
|
+
# AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET.
|
|
2785
|
+
|
|
2786
|
+
`;
|
|
1216
2787
|
}
|
|
1217
2788
|
/**
|
|
1218
2789
|
* Build the contents of `public/_headers` (Cloudflare Pages / Workers
|
|
1219
2790
|
* Static Assets convention). Emits a global RFC 8288 Link header
|
|
1220
|
-
* pointing at
|
|
2791
|
+
* pointing at this mount's llms.txt index, so agents don't need to
|
|
1221
2792
|
* parse HTML to discover the LLM-friendly content listing.
|
|
2793
|
+
*
|
|
2794
|
+
* The Link target is basePath-prefixed (`</docs/llms.txt>` for a
|
|
2795
|
+
* `/docs` mount) — matches where the platform actually emits
|
|
2796
|
+
* llms.txt under the per-mount layout.
|
|
1222
2797
|
*/
|
|
1223
|
-
|
|
2798
|
+
/**
|
|
2799
|
+
* URL for `<link rel="icon">`, or `false` when the project ships no
|
|
2800
|
+
* favicon.
|
|
2801
|
+
*
|
|
2802
|
+
* The scaffold deliberately ships none — authors drop their own into
|
|
2803
|
+
* `public/`. Emitting the link unconditionally therefore advertised a
|
|
2804
|
+
* 404 on every generated site. Checks `.ico` then `.svg`, matching the
|
|
2805
|
+
* two the scaffold documents.
|
|
2806
|
+
*/
|
|
2807
|
+
export function faviconHref(outputDir, urlBase) {
|
|
2808
|
+
// urlBase, NOT the combined prefix. `public/` is copied to the ROOT of
|
|
2809
|
+
// outDir, so a favicon serves from <urlBase>/favicon.ico on all three
|
|
2810
|
+
// layouts — mounted (outDir = dist/<urlBase>), GitHub Pages (host
|
|
2811
|
+
// supplies <urlBase>), and host-root. Composing with basePath as well
|
|
2812
|
+
// produced /blog/docs/favicon.ico for a file that serves from
|
|
2813
|
+
// /blog/favicon.ico: a 404 with a passing existence check in front of
|
|
2814
|
+
// it. Per-mount files (sitemap, llms.txt, nav.json) DO live under
|
|
2815
|
+
// public/<basePath>/ and legitimately use the combined prefix; the
|
|
2816
|
+
// root-level ones (favicon, robots.txt, _headers) do not.
|
|
2817
|
+
//
|
|
2818
|
+
// .png is in the list because it is the most common form — both
|
|
2819
|
+
// packages/cli/templates and apps/material-docs hardcode a .png — and
|
|
2820
|
+
// omitting it would silently emit no icon for authors who did the right
|
|
2821
|
+
// thing.
|
|
2822
|
+
for (const name of ["favicon.ico", "favicon.svg", "favicon.png"]) {
|
|
2823
|
+
if (existsSync(join(outputDir, "public", name))) {
|
|
2824
|
+
return urlBase ? `${urlBase}/${name}` : `/${name}`;
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
return false;
|
|
2828
|
+
}
|
|
2829
|
+
function buildHeadersFile(basePath) {
|
|
2830
|
+
const llmsHref = withBasePath(basePath, "/llms.txt");
|
|
1224
2831
|
return [
|
|
1225
2832
|
"/*",
|
|
1226
|
-
|
|
2833
|
+
` Link: <${llmsHref}>; rel="describedby"; type="text/plain"`,
|
|
1227
2834
|
"",
|
|
1228
2835
|
].join("\n");
|
|
1229
2836
|
}
|
|
@@ -1232,20 +2839,28 @@ function buildMiddlewareSource(config) {
|
|
|
1232
2839
|
"// AUTO-GENERATED by `dogsbay site build` — do not edit.",
|
|
1233
2840
|
"// Composes the docs-layout middleware helpers.",
|
|
1234
2841
|
"//",
|
|
1235
|
-
"//
|
|
1236
|
-
"//
|
|
1237
|
-
"//
|
|
1238
|
-
"//
|
|
1239
|
-
"//
|
|
1240
|
-
"//
|
|
1241
|
-
"//
|
|
1242
|
-
"//
|
|
1243
|
-
"//
|
|
1244
|
-
"//
|
|
1245
|
-
"//
|
|
2842
|
+
"// Static-prerender guard:",
|
|
2843
|
+
"// In Astro's static output mode, this middleware is invoked",
|
|
2844
|
+
"// for every prerendered route at build time. Reading",
|
|
2845
|
+
"// `context.request.headers` there triggers an Astro warning",
|
|
2846
|
+
"// per page (\"Astro.request.headers was used during static",
|
|
2847
|
+
"// render\"), which floods `dogsbay site build` / `site preview`",
|
|
2848
|
+
"// output. Worse, the negotiation can't actually happen at",
|
|
2849
|
+
"// build time — there's no runtime client whose Accept header",
|
|
2850
|
+
"// we'd be honoring.",
|
|
2851
|
+
"//",
|
|
2852
|
+
"// We guard with `context.isPrerendered` so prerendered routes",
|
|
2853
|
+
"// short-circuit to `next()` immediately. At runtime in static",
|
|
2854
|
+
"// deploys, middleware doesn't fire at all (no server); at",
|
|
2855
|
+
"// runtime in SSR / hybrid deploys, only dynamic routes fire,",
|
|
2856
|
+
"// which is exactly when negotiation makes sense.",
|
|
1246
2857
|
"//",
|
|
1247
|
-
"//
|
|
1248
|
-
"//
|
|
2858
|
+
"// Markdown content negotiation:",
|
|
2859
|
+
"// For pure-static deploys, `Accept: text/markdown` is honored",
|
|
2860
|
+
"// by the platform (Cloudflare _headers + Worker, Netlify Edge",
|
|
2861
|
+
"// functions). Agents that can't send Accept headers should",
|
|
2862
|
+
"// follow the page's <link rel=\"alternate\" type=\"text/markdown\">",
|
|
2863
|
+
"// to fetch the .md mirror directly (e.g. /docs.md).",
|
|
1249
2864
|
'import { defineMiddleware } from "astro:middleware";',
|
|
1250
2865
|
];
|
|
1251
2866
|
if (config.mdMirror) {
|
|
@@ -1259,9 +2874,14 @@ function buildMiddlewareSource(config) {
|
|
|
1259
2874
|
lines.push(`const AXIS_REDIRECT_CONFIG = ${JSON.stringify(config.axisRedirect, null, 2)};`, "");
|
|
1260
2875
|
}
|
|
1261
2876
|
lines.push("export const onRequest = defineMiddleware((context, next) => {");
|
|
2877
|
+
// Skip prerendered routes — see file-top comment for the rationale.
|
|
2878
|
+
// Avoids per-page Astro.request.headers warnings during build, and
|
|
2879
|
+
// matches runtime semantics (middleware doesn't fire on prerendered
|
|
2880
|
+
// routes when deployed).
|
|
2881
|
+
lines.push(" if (context.isPrerendered) return next();");
|
|
1262
2882
|
lines.push(" const url = new URL(context.request.url);");
|
|
1263
2883
|
if (config.mdMirror) {
|
|
1264
|
-
lines.push(' const accept = context.request.headers.get("accept");',
|
|
2884
|
+
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);");
|
|
1265
2885
|
}
|
|
1266
2886
|
if (config.axisRedirect) {
|
|
1267
2887
|
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);", " }");
|
|
@@ -1290,8 +2910,18 @@ function buildMdEndpoint(page, sourceRel) {
|
|
|
1290
2910
|
].join("\n");
|
|
1291
2911
|
}
|
|
1292
2912
|
/**
|
|
1293
|
-
* Emit
|
|
1294
|
-
*
|
|
2913
|
+
* Emit per-mount llms.txt + llms-full.txt + per-section indexes.
|
|
2914
|
+
*
|
|
2915
|
+
* Files live under `public/<basePath>/...` so multiple Dogsbay sites
|
|
2916
|
+
* can mount on the same host (`/docs/llms.txt` + `/api/llms.txt` +
|
|
2917
|
+
* `/handbook/llms.txt`) without colliding at the root. When basePath
|
|
2918
|
+
* is empty, this collapses to `public/llms.txt` — the single-site
|
|
2919
|
+
* llmstxt.org-spec layout.
|
|
2920
|
+
*
|
|
2921
|
+
* The host root `/llms.txt` is intentionally NOT emitted by the
|
|
2922
|
+
* platform: it's the user's umbrella file, analogous to
|
|
2923
|
+
* `sitemap-index.xml`. Multi-mount deploys hand-write a top-level
|
|
2924
|
+
* `/llms.txt` that links to each per-mount index.
|
|
1295
2925
|
*
|
|
1296
2926
|
* Per-section files are written for every top-level nav group that
|
|
1297
2927
|
* resolves to a site directory (either via `group.href` or via the
|
|
@@ -1303,26 +2933,102 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
|
|
|
1303
2933
|
description: options.description,
|
|
1304
2934
|
siteUrl: options.siteUrl,
|
|
1305
2935
|
};
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
2936
|
+
// hrefPrefix is the COMBINED prefix — used for the URL paths that
|
|
2937
|
+
// appear inside the llms.txt body (so agents fetch the correct
|
|
2938
|
+
// host-relative URLs). Filesystem layout uses basePath alone:
|
|
2939
|
+
// `public/<basePath>/llms.txt` matches the existing per-mount
|
|
2940
|
+
// delivery shape.
|
|
2941
|
+
const hrefPrefix = combinedPrefix(options);
|
|
2942
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
2943
|
+
const baseSegments = basePathSegments(basePath);
|
|
2944
|
+
const mountDir = join(outputDir, "public", ...baseSegments);
|
|
2945
|
+
mkdirSync(mountDir, { recursive: true });
|
|
2946
|
+
writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, {
|
|
2947
|
+
hrefPrefix,
|
|
2948
|
+
aggregates: options.aggregates,
|
|
2949
|
+
whenToUse: options.llmsWhenToUse,
|
|
2950
|
+
}));
|
|
2951
|
+
writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
|
|
1311
2952
|
summary: "body",
|
|
1312
2953
|
serializePage: serializePageMd,
|
|
1313
2954
|
hrefPrefix,
|
|
1314
2955
|
}));
|
|
2956
|
+
// Per-section files. `deriveSectionDir` returns a host-absolute
|
|
2957
|
+
// path derived from nav hrefs, which since the combined-prefix
|
|
2958
|
+
// refactor (commit 132891e) include urlBase + basePath — NOT just
|
|
2959
|
+
// basePath. So joining its return onto public/ directly would
|
|
2960
|
+
// double-prefix into `public/<urlBase>/<basePath>/<section>/llms.txt`,
|
|
2961
|
+
// which then serves at `<urlBase>/<urlBase>/<basePath>/<section>/...`
|
|
2962
|
+
// once Astro's base prefix is applied at request time.
|
|
2963
|
+
//
|
|
2964
|
+
// Strip the combined prefix off the section dir to get just the
|
|
2965
|
+
// section tail, then re-prepend basePath via mountDir. Result:
|
|
2966
|
+
// `public/<basePath>/<section>/llms.txt`, served under the deploy's
|
|
2967
|
+
// base mount as `<urlBase>/<basePath>/<section>/llms.txt`.
|
|
2968
|
+
const combinedSegs = hrefPrefix.replace(/^\//, "");
|
|
1315
2969
|
for (const group of nav) {
|
|
1316
2970
|
if (!group.children || group.children.length === 0)
|
|
1317
2971
|
continue;
|
|
1318
2972
|
const dir = deriveSectionDir(group);
|
|
1319
2973
|
if (!dir)
|
|
1320
2974
|
continue;
|
|
1321
|
-
|
|
2975
|
+
let relDir;
|
|
2976
|
+
if (combinedSegs && dir === combinedSegs) {
|
|
2977
|
+
relDir = "";
|
|
2978
|
+
}
|
|
2979
|
+
else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
|
|
2980
|
+
relDir = dir.slice(combinedSegs.length + 1);
|
|
2981
|
+
}
|
|
2982
|
+
else {
|
|
2983
|
+
// Defensive: if for some reason the dir doesn't carry the
|
|
2984
|
+
// combined prefix (older importer, manual nav.yml, etc.), fall
|
|
2985
|
+
// back to the raw value rather than rooting at /.
|
|
2986
|
+
relDir = dir;
|
|
2987
|
+
}
|
|
2988
|
+
const sectionPath = relDir
|
|
2989
|
+
? join(mountDir, relDir, "llms.txt")
|
|
2990
|
+
: join(mountDir, "llms.txt");
|
|
1322
2991
|
mkdirSync(dirname(sectionPath), { recursive: true });
|
|
1323
2992
|
writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
|
|
1324
2993
|
}
|
|
1325
2994
|
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Emit per-mount sitemap files.
|
|
2997
|
+
*
|
|
2998
|
+
* Writes `public/<basePath>/sitemap-index.xml` + `sitemap-0.xml`.
|
|
2999
|
+
* The index lists the single sub-sitemap today; future splits add
|
|
3000
|
+
* more sub-sitemap entries as the page count grows past
|
|
3001
|
+
* sitemaps.org's 50K-URL recommendation.
|
|
3002
|
+
*
|
|
3003
|
+
* Caller has already guarded on a valid http(s) `siteUrl` — without
|
|
3004
|
+
* one, `<loc>` entries can't be absolute and crawlers reject the
|
|
3005
|
+
* file. Skip emission rather than write a broken sitemap.
|
|
3006
|
+
*/
|
|
3007
|
+
function emitSitemapFiles(outputDir, options, pages) {
|
|
3008
|
+
// Filesystem path uses basePath (sitemap files live in
|
|
3009
|
+
// public/<basePath>/sitemap-*.xml). The URL prefix encoded into
|
|
3010
|
+
// each <loc> uses combined so the absolute URLs resolve under the
|
|
3011
|
+
// host's served subpath. buildSitemap strips path off siteUrl
|
|
3012
|
+
// internally, so passing siteUrl + combined as basePath gives
|
|
3013
|
+
// origin + combined as the final URL.
|
|
3014
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
3015
|
+
const combined = combinedPrefix(options);
|
|
3016
|
+
const baseSegments = basePathSegments(basePath);
|
|
3017
|
+
const mountDir = join(outputDir, "public", ...baseSegments);
|
|
3018
|
+
mkdirSync(mountDir, { recursive: true });
|
|
3019
|
+
writeFileSync(join(mountDir, "sitemap-0.xml"), buildSitemap(pages, {
|
|
3020
|
+
siteUrl: options.siteUrl,
|
|
3021
|
+
basePath: combined,
|
|
3022
|
+
siteNoindex: options.noindex === true,
|
|
3023
|
+
}));
|
|
3024
|
+
writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({
|
|
3025
|
+
siteUrl: options.siteUrl,
|
|
3026
|
+
basePath: combined,
|
|
3027
|
+
extraSitemaps: (options.aggregates ?? [])
|
|
3028
|
+
.map((a) => a.sitemap)
|
|
3029
|
+
.filter((u) => !!u),
|
|
3030
|
+
}));
|
|
3031
|
+
}
|
|
1326
3032
|
/**
|
|
1327
3033
|
* Pick a directory under `public/` for a top-level nav group. Prefers
|
|
1328
3034
|
* the group's own href (already a `/docs/x/y` path); otherwise falls
|
|
@@ -1375,6 +3081,55 @@ function findFirstNavHref(items, fallback) {
|
|
|
1375
3081
|
}
|
|
1376
3082
|
return fallback;
|
|
1377
3083
|
}
|
|
3084
|
+
/**
|
|
3085
|
+
* URL subtree root of every multi-source axis bucket, mapped to the
|
|
3086
|
+
* version that bucket carries. Keys are slug prefixes (no leading or
|
|
3087
|
+
* trailing slash), e.g. `3.29` or `calico/en/3.29`.
|
|
3088
|
+
*
|
|
3089
|
+
* `multiSource.originalSlug` is the slug as the importer produced it,
|
|
3090
|
+
* BEFORE the loader applied the axis prefix — so stripping it off the
|
|
3091
|
+
* emitted slug yields that bucket's prefix exactly. Deriving it that
|
|
3092
|
+
* way rather than by segment index matters because how many segments
|
|
3093
|
+
* precede the version depends on which axes are active
|
|
3094
|
+
* (`/<namespace>/<locale>/<version>/<slug>`), and a longest-common-
|
|
3095
|
+
* prefix guess collapses to nothing when two products share a version
|
|
3096
|
+
* id.
|
|
3097
|
+
*/
|
|
3098
|
+
function axisSubtreeRoots(pages) {
|
|
3099
|
+
const roots = new Map();
|
|
3100
|
+
for (const page of pages) {
|
|
3101
|
+
const meta = page.multiSource;
|
|
3102
|
+
if (!meta?.version || meta.originalSlug === undefined)
|
|
3103
|
+
continue;
|
|
3104
|
+
if (!page.slug.endsWith(meta.originalSlug))
|
|
3105
|
+
continue;
|
|
3106
|
+
const prefix = page.slug
|
|
3107
|
+
.slice(0, page.slug.length - meta.originalSlug.length)
|
|
3108
|
+
.replace(/^\/+|\/+$/g, "");
|
|
3109
|
+
if (prefix)
|
|
3110
|
+
roots.set(prefix, meta.version);
|
|
3111
|
+
}
|
|
3112
|
+
return roots;
|
|
3113
|
+
}
|
|
3114
|
+
/**
|
|
3115
|
+
* First nav href (in nav order) living under any of `prefixes`, which
|
|
3116
|
+
* are URL paths without a trailing slash. Used to pick a redirect
|
|
3117
|
+
* target for a subtree root that has no page of its own.
|
|
3118
|
+
*/
|
|
3119
|
+
function findFirstNavHrefUnder(items, prefixes) {
|
|
3120
|
+
for (const item of items) {
|
|
3121
|
+
const href = item.href;
|
|
3122
|
+
if (href && prefixes.some((prefix) => href.startsWith(`${prefix}/`))) {
|
|
3123
|
+
return href;
|
|
3124
|
+
}
|
|
3125
|
+
if (item.children) {
|
|
3126
|
+
const found = findFirstNavHrefUnder(item.children, prefixes);
|
|
3127
|
+
if (found)
|
|
3128
|
+
return found;
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
return undefined;
|
|
3132
|
+
}
|
|
1378
3133
|
function copyComponents(outputDir) {
|
|
1379
3134
|
const componentsSource = resolveComponentsSource();
|
|
1380
3135
|
if (!componentsSource)
|
|
@@ -1400,6 +3155,11 @@ function copyComponents(outputDir) {
|
|
|
1400
3155
|
"response-tabs", "schema-viewer", "code-samples", "copy-button",
|
|
1401
3156
|
"markdown-example",
|
|
1402
3157
|
"accordion", "link-card", "avatar", "math",
|
|
3158
|
+
// Icon resolves @ui/icon/Icon.astro → built-time SVG inlining
|
|
3159
|
+
// via @dogsbay/icons. Used by `:::cards` `{icon=...}` and the
|
|
3160
|
+
// inline `:icon[name]` directive. Without this entry every page
|
|
3161
|
+
// emitting the icon import 500s with "module not found".
|
|
3162
|
+
"icon",
|
|
1403
3163
|
];
|
|
1404
3164
|
for (const name of needed) {
|
|
1405
3165
|
const src = join(componentsSource, name);
|
|
@@ -1409,23 +3169,70 @@ function copyComponents(outputDir) {
|
|
|
1409
3169
|
}
|
|
1410
3170
|
}
|
|
1411
3171
|
}
|
|
1412
|
-
|
|
3172
|
+
/** Media files worth carrying from a `mediaOnly` asset mount. */
|
|
3173
|
+
const MOUNT_MEDIA_EXTS = new Set([
|
|
3174
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
|
|
3175
|
+
".svg", ".ico", ".pdf", ".mp4", ".webm",
|
|
3176
|
+
]);
|
|
3177
|
+
/** Recursively copy only media files, preserving relative structure. */
|
|
3178
|
+
function copyMediaTree(srcDir, destDir) {
|
|
3179
|
+
for (const entry of readdirSync(srcDir)) {
|
|
3180
|
+
const full = join(srcDir, entry);
|
|
3181
|
+
if (statSync(full).isDirectory()) {
|
|
3182
|
+
copyMediaTree(full, join(destDir, entry));
|
|
3183
|
+
continue;
|
|
3184
|
+
}
|
|
3185
|
+
const dot = entry.lastIndexOf(".");
|
|
3186
|
+
if (dot === -1 || !MOUNT_MEDIA_EXTS.has(entry.slice(dot).toLowerCase()))
|
|
3187
|
+
continue;
|
|
3188
|
+
mkdirSync(destDir, { recursive: true });
|
|
3189
|
+
cpSync(full, join(destDir, entry));
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
function copyAssets(sourceDir, outputDir, imageOptimization, urlPrefix) {
|
|
1413
3193
|
// sourceDir is already the docs dir (e.g. .../fastapi/docs/en/docs)
|
|
1414
3194
|
const searchDir = sourceDir;
|
|
3195
|
+
// A version/locale/namespace URL segment (e.g. "8.8") the content's
|
|
3196
|
+
// asset refs were rewritten with — so each source's assets land under
|
|
3197
|
+
// public/<prefix>/... and same-path images across versions never
|
|
3198
|
+
// collide. Empty for single-source builds (today's flat behaviour).
|
|
3199
|
+
const prefixParts = (urlPrefix ?? "")
|
|
3200
|
+
.split("/")
|
|
3201
|
+
.map((p) => p.trim())
|
|
3202
|
+
.filter(Boolean);
|
|
3203
|
+
const withPrefix = (rel) => prefixParts.length > 0 ? join(...prefixParts, rel) : rel;
|
|
1415
3204
|
// Raster images benefit from Astro optimization (WebP, dimensions)
|
|
1416
3205
|
const optimizableExts = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
|
|
1417
3206
|
// SVGs, icons, PDFs always go to public/ (no optimization needed)
|
|
1418
3207
|
const passthroughExts = new Set([".svg", ".ico", ".pdf"]);
|
|
1419
3208
|
function walk(dir) {
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
3209
|
+
// withFileTypes gives LSTAT semantics, so a symlink reports
|
|
3210
|
+
// isSymbolicLink() and never isDirectory(). That matters more than it
|
|
3211
|
+
// looks: AsciiBinder corpora symlink every section back to a shared
|
|
3212
|
+
// images/ (331 of them in openshift-docs), and statSync FOLLOWED those
|
|
3213
|
+
// links — copying the whole 64 MB image tree once per link. Measured:
|
|
3214
|
+
// kebab.png landed in public/ 77 times, public/ reached 4.6 GB from a
|
|
3215
|
+
// 273 MB checkout, full expansion ~21 GB. On CI the runner filled its
|
|
3216
|
+
// disk and died so hard it could not write its own log.
|
|
3217
|
+
//
|
|
3218
|
+
// Dormant only because those links pointed at content/images/ while
|
|
3219
|
+
// images lived in content/_assets/images/, so they dangled and the walk
|
|
3220
|
+
// skipped them. Moving images to content/images/ made all 331 resolve
|
|
3221
|
+
// and the latent bug fired.
|
|
3222
|
+
//
|
|
3223
|
+
// A symlinked directory is a second path to files this walk already
|
|
3224
|
+
// reaches by their real one, so skipping links copies each file once.
|
|
3225
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
3226
|
+
if (entry.isSymbolicLink())
|
|
3227
|
+
continue;
|
|
3228
|
+
const full = join(dir, entry.name);
|
|
3229
|
+
if (entry.isDirectory()) {
|
|
1423
3230
|
walk(full);
|
|
1424
3231
|
}
|
|
1425
3232
|
else {
|
|
1426
|
-
const ext = entry.substring(entry.lastIndexOf(".")).toLowerCase();
|
|
3233
|
+
const ext = entry.name.substring(entry.name.lastIndexOf(".")).toLowerCase();
|
|
1427
3234
|
if (optimizableExts.has(ext)) {
|
|
1428
|
-
const rel = relative(searchDir, full);
|
|
3235
|
+
const rel = withPrefix(relative(searchDir, full));
|
|
1429
3236
|
// Always copy to public/ so inline <img src="/..."> works
|
|
1430
3237
|
const pubDest = join(outputDir, "public", rel);
|
|
1431
3238
|
mkdirSync(dirname(pubDest), { recursive: true });
|
|
@@ -1440,7 +3247,7 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
|
|
|
1440
3247
|
}
|
|
1441
3248
|
else if (passthroughExts.has(ext)) {
|
|
1442
3249
|
// SVGs, icons, PDFs always go to public/
|
|
1443
|
-
const rel = relative(searchDir, full);
|
|
3250
|
+
const rel = withPrefix(relative(searchDir, full));
|
|
1444
3251
|
const dest = join(outputDir, "public", rel);
|
|
1445
3252
|
mkdirSync(dirname(dest), { recursive: true });
|
|
1446
3253
|
cpSync(full, dest);
|
|
@@ -1454,60 +3261,232 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
|
|
|
1454
3261
|
catch { /* source may not exist */ }
|
|
1455
3262
|
}
|
|
1456
3263
|
// ── CSS generation (ported from import-mkdocs.ts) ───────
|
|
3264
|
+
/**
|
|
3265
|
+
* Build the `@source inline("...")` directive that pins the
|
|
3266
|
+
* grid-tone palette into the generated stylesheet.
|
|
3267
|
+
*
|
|
3268
|
+
* Why we need it: tone classes like `bg-primary/10` only appear in
|
|
3269
|
+
* `.astro` pages emitted by Dogsbay's grid-item serializer. When
|
|
3270
|
+
* Tailwind's content scanner doesn't pick them up — because the
|
|
3271
|
+
* page lives outside the default scan globs, or because a class
|
|
3272
|
+
* is composed at the boundary of an interpolation — they get
|
|
3273
|
+
* purged. Result observed in dogsbay-docs-markdown audit: half
|
|
3274
|
+
* the grid demo cells render with no background. Pinning forces
|
|
3275
|
+
* generation regardless of scanner reach.
|
|
3276
|
+
*
|
|
3277
|
+
* Single source of truth: derived from TONE_CLASSES so any new
|
|
3278
|
+
* tone added to the palette is automatically safelisted.
|
|
3279
|
+
*/
|
|
3280
|
+
function buildToneSafelist() {
|
|
3281
|
+
const seen = new Set();
|
|
3282
|
+
for (const classes of Object.values(TONE_CLASSES)) {
|
|
3283
|
+
for (const cls of classes.split(/\s+/)) {
|
|
3284
|
+
if (cls)
|
|
3285
|
+
seen.add(cls);
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
return [...seen].sort().join(" ");
|
|
3289
|
+
}
|
|
1457
3290
|
function generateGlobalCss() {
|
|
1458
3291
|
return `@import "tailwindcss";
|
|
1459
3292
|
@import "./theme.css";
|
|
1460
3293
|
|
|
3294
|
+
/* Custom elements have no user-agent style, so an unstyled one is
|
|
3295
|
+
display:inline — on which w-full does nothing and a border paints
|
|
3296
|
+
as detached fragments rather than around the content. @dogsbay/elements
|
|
3297
|
+
injects these same defaults at runtime, which is what reaches EXISTING
|
|
3298
|
+
sites (global.css is scaffolded once and never rewritten). Having them
|
|
3299
|
+
here too means a new site is correct at CSS time, with no flash before
|
|
3300
|
+
the module script runs.
|
|
3301
|
+
|
|
3302
|
+
:where() keeps specificity at zero, so any utility class wins. */
|
|
3303
|
+
:where(db-tabs, db-accordion, db-collapsible, db-card, db-steps, db-code-block) {
|
|
3304
|
+
display: block;
|
|
3305
|
+
}
|
|
3306
|
+
:where(db-link-button) {
|
|
3307
|
+
display: inline-block;
|
|
3308
|
+
}
|
|
3309
|
+
|
|
1461
3310
|
/* Scan @dogsbay packages for Tailwind classes */
|
|
1462
3311
|
@source "../../node_modules/@dogsbay/ui/src";
|
|
1463
3312
|
@source "../../node_modules/@dogsbay/docs-layout/src";
|
|
1464
3313
|
|
|
1465
|
-
/*
|
|
3314
|
+
/* Pin the grid-tone palette. These classes are emitted into
|
|
3315
|
+
markdown-generated .astro pages by the grid-item serializer
|
|
3316
|
+
(TONE_CLASSES in @dogsbay/format-astro). Without inlining,
|
|
3317
|
+
opacity-modified utilities like bg-primary/10 get purged when
|
|
3318
|
+
Tailwind doesn't see them in the scanned globs, leaving grid
|
|
3319
|
+
demo cells with no visible background. */
|
|
3320
|
+
@source inline("${buildToneSafelist()}");
|
|
3321
|
+
|
|
3322
|
+
/* Prose typography for rendered content.
|
|
3323
|
+
Every element rule is guarded with :not(.dba-api *): API-reference
|
|
3324
|
+
regions (ApiLayout root carries the dba-api marker) compose their own
|
|
3325
|
+
typography from utility classes, and unguarded .docs-prose rules would
|
|
3326
|
+
out-specify them. The guard lets mixed prose+endpoint pages (MDX
|
|
3327
|
+
imports) keep full typography outside the cards. */
|
|
1466
3328
|
.docs-prose {
|
|
1467
3329
|
line-height: 1.7;
|
|
1468
3330
|
|
|
1469
|
-
& 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; }
|
|
1470
|
-
& 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; }
|
|
1471
|
-
& h3 { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
|
|
1472
|
-
& h4 { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
|
3331
|
+
& h1:not(.dba-api *) { font-family: var(--font-heading); font-size: 2rem; font-weight: 700; margin-top: 0; margin-bottom: 1rem; line-height: 1.2; letter-spacing: -0.025em; }
|
|
3332
|
+
& h2:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.5rem; font-weight: 600; margin-top: 2.5rem; margin-bottom: 0.75rem; line-height: 1.3; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; letter-spacing: -0.015em; }
|
|
3333
|
+
& h3:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.25rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.5rem; line-height: 1.4; }
|
|
3334
|
+
& h4:not(.dba-api *) { font-family: var(--font-heading); font-size: 1.1rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
|
1473
3335
|
|
|
1474
|
-
& p { margin-top: 0.75rem; margin-bottom: 0.75rem; }
|
|
3336
|
+
& p:not(.dba-api *) { margin-top: 0.75rem; margin-bottom: 0.75rem; }
|
|
1475
3337
|
|
|
1476
|
-
|
|
1477
|
-
|
|
3338
|
+
/* The :not([data-variant]) guard exempts anchors that ARE components. A
|
|
3339
|
+
link-button renders as an <a> carrying bg-primary + text-primary-foreground
|
|
3340
|
+
and a data-variant attribute, and this rule repainted its text with
|
|
3341
|
+
var(--primary) — primary on primary: an unreadable solid block in light
|
|
3342
|
+
mode, a blank one in dark. The secondary variant survived only because its
|
|
3343
|
+
background is light enough to read primary-coloured text, which is why it
|
|
3344
|
+
looked like a colour bug in one button rather than a cascade bug in both.
|
|
3345
|
+
(No backticks in this comment: the whole stylesheet is a template
|
|
3346
|
+
literal.) */
|
|
3347
|
+
& a:not(.dba-api *):not([data-variant]) { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
3348
|
+
& a:hover:not(.dba-api *):not([data-variant]) { opacity: 0.8; }
|
|
1478
3349
|
|
|
1479
|
-
& strong { font-weight: 600; }
|
|
1480
|
-
& 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); }
|
|
1481
|
-
& pre code { padding: 0; background: none; font-size: 1em; }
|
|
3350
|
+
& strong:not(.dba-api *) { font-weight: 600; }
|
|
3351
|
+
& code:not(.dba-api *) { font-size: 0.875em; padding: 0.15em 0.35em; border-radius: 0.25rem; background: var(--muted); font-family: var(--font-code, ui-monospace, monospace); }
|
|
3352
|
+
& pre code:not(.dba-api *) { padding: 0; background: none; font-size: 1em; }
|
|
1482
3353
|
|
|
1483
3354
|
/* Spacing between consecutive block elements (code blocks, alerts, etc.) */
|
|
1484
3355
|
& > * + * { margin-top: 0.75rem; }
|
|
1485
|
-
& li > * + * { margin-top: 0.75rem; }
|
|
3356
|
+
& li > * + *:not(.dba-api *) { margin-top: 0.75rem; }
|
|
1486
3357
|
|
|
1487
|
-
& ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
1488
|
-
& ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
1489
|
-
& li { margin: 0.25rem 0; }
|
|
1490
|
-
& li > ul, & li > ol { margin: 0.25rem 0; }
|
|
3358
|
+
& ul:not(.dba-api *) { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
3359
|
+
& ol:not(.dba-api *) { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
|
3360
|
+
& li:not(.dba-api *) { margin: 0.25rem 0; }
|
|
3361
|
+
& li > ul:not(.dba-api *), & li > ol:not(.dba-api *) { margin: 0.25rem 0; }
|
|
1491
3362
|
|
|
1492
|
-
& blockquote { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
|
|
3363
|
+
& blockquote:not(.dba-api *) { border-left: 4px solid var(--border); padding-left: 1rem; color: var(--muted-foreground); font-style: italic; margin: 1rem 0; }
|
|
1493
3364
|
|
|
1494
|
-
& hr { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
|
3365
|
+
& hr:not(.dba-api *) { border: none; border-top: 1px solid var(--border); margin: 2rem 0; }
|
|
1495
3366
|
|
|
1496
|
-
& table { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
|
|
1497
|
-
& th { text-align: left; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
|
|
1498
|
-
& td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
|
|
3367
|
+
& table:not(.dba-api *) { width: 100%; border-collapse: collapse; margin: 1rem 0; font-size: 0.875rem; }
|
|
3368
|
+
& th:not(.dba-api *) { text-align: left; vertical-align: bottom; font-weight: 600; padding: 0.5rem; border-bottom: 2px solid var(--border); }
|
|
3369
|
+
& td:not(.dba-api *) { padding: 0.5rem; vertical-align: top; border-bottom: 1px solid var(--border); }
|
|
1499
3370
|
|
|
1500
|
-
|
|
3371
|
+
/* Tailwind Preflight sets display: block on every img, which turns an
|
|
3372
|
+
INLINE icon into its own line and splits the sentence around it —
|
|
3373
|
+
AsciiDoc UI macros render mid-step and were breaking every procedure
|
|
3374
|
+
that used one. inline-block restores the flow and leaves a standalone
|
|
3375
|
+
image (the only child of its paragraph) looking exactly as before. */
|
|
3376
|
+
& img:not(.dba-api *) { display: inline-block; max-width: 100%; border-radius: 0.5rem; }
|
|
3377
|
+
/* A standalone image — the only child of its paragraph — keeps block
|
|
3378
|
+
layout, so the vertical rhythm and the absence of baseline descender
|
|
3379
|
+
space are exactly what they were before inline icons were fixed. */
|
|
3380
|
+
& p > img:only-child:not(.dba-api *) { display: block; }
|
|
1501
3381
|
|
|
1502
3382
|
& .heading-anchor { text-decoration: none; opacity: 0; margin-right: 0.25rem; transition: opacity 0.2s; }
|
|
1503
3383
|
& h1:hover .heading-anchor, & h2:hover .heading-anchor, & h3:hover .heading-anchor, & h4:hover .heading-anchor { opacity: 0.4; }
|
|
1504
3384
|
|
|
1505
|
-
& details { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
|
|
1506
|
-
& summary { cursor: pointer; font-weight: 600; }
|
|
3385
|
+
& details:not(.dba-api *) { border: 1px solid var(--border); border-radius: 0.5rem; padding: 1rem; margin: 1rem 0; }
|
|
3386
|
+
& summary:not(.dba-api *) { cursor: pointer; font-weight: 600; }
|
|
1507
3387
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
3388
|
+
/* mark is emitted by the highlight directive AND can arrive as raw HTML
|
|
3389
|
+
from an importer. Without a rule here it renders as the browser default
|
|
3390
|
+
yellow, which clashes in light mode and is unreadable on a dark page
|
|
3391
|
+
(nothing declares color-scheme). Token-based so it follows the theme. */
|
|
3392
|
+
& mark:not(.dba-api *) { background: var(--dsb-mark-bg, rgb(254 240 138 / 0.6)); color: inherit; border-radius: 0.125rem; padding: 0 0.125rem; }
|
|
3393
|
+
&:where(.dark *) mark:not(.dba-api *), .dark & mark:not(.dba-api *) { background: var(--dsb-mark-bg-dark, rgb(234 179 8 / 0.3)); }
|
|
3394
|
+
|
|
3395
|
+
& dl:not(.dba-api *) { margin: 1rem 0; }
|
|
3396
|
+
& dt:not(.dba-api *) { font-weight: 600; margin-top: 0.75rem; }
|
|
3397
|
+
& dd:not(.dba-api *) { margin-left: 1.5rem; color: var(--muted-foreground); }
|
|
3398
|
+
|
|
3399
|
+
/* In-cell admonitions. Inside a raw HTML table cell nothing
|
|
3400
|
+
markdown-native works, so the AsciiDoc engine emits a neutral
|
|
3401
|
+
dl.db-admonition shape (DD-010) and this rule renders it as a compact
|
|
3402
|
+
callout box — same contract the Obsidian plugin styles. Per-type
|
|
3403
|
+
accent from theme tokens; overrides the generic dl/dt/dd rules above. */
|
|
3404
|
+
& dl.db-admonition {
|
|
3405
|
+
margin: 0.75rem 0;
|
|
3406
|
+
padding: 0.5rem 0.75rem;
|
|
3407
|
+
border-left: 3px solid var(--info);
|
|
3408
|
+
border-radius: 0.25rem;
|
|
3409
|
+
background: color-mix(in oklab, var(--info) 8%, transparent);
|
|
3410
|
+
}
|
|
3411
|
+
/* The LABEL blends the accent toward the foreground; the border and the
|
|
3412
|
+
tint keep the accent at full strength, so the callout still reads as
|
|
3413
|
+
its colour while the text stays legible.
|
|
3414
|
+
|
|
3415
|
+
Measured from painted pixels on a real page, light mode, 13px/600 —
|
|
3416
|
+
the accent used raw is a genuine AA failure, not one of axe's oklch
|
|
3417
|
+
misreads:
|
|
3418
|
+
|
|
3419
|
+
--info raw 2.18:1 mixed 5.66:1
|
|
3420
|
+
--warning raw 2.25:1 mixed 5.74:1
|
|
3421
|
+
--destructive raw 5.97:1 mixed 10.98:1
|
|
3422
|
+
|
|
3423
|
+
Mixed with var(--foreground), not black, on purpose. Black fixes
|
|
3424
|
+
light and BREAKS dark: 70% black measures 5.21:1 light but 3.25:1
|
|
3425
|
+
dark, because the tint it sits on is dark too. --foreground is
|
|
3426
|
+
near-black in light and near-white in dark, so one rule serves both
|
|
3427
|
+
(dark measures 10.96 / 10.87 / 9.79). A fixed colour used in both
|
|
3428
|
+
themes is a bug waiting. */
|
|
3429
|
+
& dl.db-admonition > dt {
|
|
3430
|
+
margin-top: 0;
|
|
3431
|
+
font-weight: 600;
|
|
3432
|
+
font-size: 0.8125rem;
|
|
3433
|
+
text-transform: uppercase;
|
|
3434
|
+
letter-spacing: 0.03em;
|
|
3435
|
+
color: color-mix(in oklab, var(--info) 60%, var(--foreground));
|
|
3436
|
+
}
|
|
3437
|
+
& dl.db-admonition > dd { margin-left: 0; margin-top: 0.25rem; color: var(--foreground); }
|
|
3438
|
+
& dl.db-admonition-warning, & dl.db-admonition-caution {
|
|
3439
|
+
border-left-color: var(--warning);
|
|
3440
|
+
background: color-mix(in oklab, var(--warning) 8%, transparent);
|
|
3441
|
+
}
|
|
3442
|
+
& dl.db-admonition-warning > dt, & dl.db-admonition-caution > dt { color: color-mix(in oklab, var(--warning) 60%, var(--foreground)); }
|
|
3443
|
+
& dl.db-admonition-important {
|
|
3444
|
+
border-left-color: var(--destructive);
|
|
3445
|
+
background: color-mix(in oklab, var(--destructive) 8%, transparent);
|
|
3446
|
+
}
|
|
3447
|
+
& dl.db-admonition-important > dt { color: color-mix(in oklab, var(--destructive) 60%, var(--foreground)); }
|
|
3448
|
+
|
|
3449
|
+
/* Granularity book-view affordances (plans/granularity-views.md):
|
|
3450
|
+
"Read as single page →" on topics, "Open as page ↗" on book sections.
|
|
3451
|
+
Deliberately subtle/muted — the book has one per section. */
|
|
3452
|
+
& .dsb-read-single-page {
|
|
3453
|
+
display: inline-block;
|
|
3454
|
+
margin: 0 0 1.25rem;
|
|
3455
|
+
font-size: 0.8125rem;
|
|
3456
|
+
color: var(--muted-foreground);
|
|
3457
|
+
text-decoration: none;
|
|
3458
|
+
}
|
|
3459
|
+
& .dsb-read-single-page:hover { color: var(--foreground); text-decoration: underline; }
|
|
3460
|
+
& .dsb-open-as-page { margin: -0.25rem 0 0.5rem; font-size: 0.75rem; }
|
|
3461
|
+
& .dsb-open-as-page a { color: var(--muted-foreground); text-decoration: none; }
|
|
3462
|
+
& .dsb-open-as-page a:hover { color: var(--foreground); text-decoration: underline; }
|
|
3463
|
+
|
|
3464
|
+
/* AsciiDoc block roles ([role="x"] / [.x]) preserved as classes (#384).
|
|
3465
|
+
_abstract marks a module's lead/intro paragraph; .lead/.small are
|
|
3466
|
+
Asciidoctor built-ins; _additional-resources marks the trailing
|
|
3467
|
+
"Additional resources" section heading. */
|
|
3468
|
+
& ._abstract, & .abstract, & .lead {
|
|
3469
|
+
font-size: 1.05rem;
|
|
3470
|
+
line-height: 1.6;
|
|
3471
|
+
color: var(--muted-foreground);
|
|
3472
|
+
}
|
|
3473
|
+
& .small { font-size: 0.875rem; }
|
|
3474
|
+
& h2._additional-resources, & h3._additional-resources {
|
|
3475
|
+
font-size: 1.15rem;
|
|
3476
|
+
margin-top: 2rem;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
/* Related resources (#384 R3): [role="_additional-resources"] + .Title +
|
|
3480
|
+
list folds to a <section class="related-resources"> (→ DITA related-links). */
|
|
3481
|
+
& .related-resources {
|
|
3482
|
+
margin-top: 2rem;
|
|
3483
|
+
padding-top: 1rem;
|
|
3484
|
+
border-top: 1px solid var(--border);
|
|
3485
|
+
}
|
|
3486
|
+
& .related-resources-title {
|
|
3487
|
+
font-weight: 600;
|
|
3488
|
+
margin: 0 0 0.5rem;
|
|
3489
|
+
}
|
|
1511
3490
|
}
|
|
1512
3491
|
|
|
1513
3492
|
@utility scrollbar-none {
|
|
@@ -1571,23 +3550,24 @@ const THEME_DEFAULT = {
|
|
|
1571
3550
|
--api-panel-fg: oklch(0.926 0.013 253.833);
|
|
1572
3551
|
|
|
1573
3552
|
/* API semantic colors — HTTP methods, types, status codes */
|
|
1574
|
-
|
|
1575
|
-
--api-
|
|
1576
|
-
--api-
|
|
1577
|
-
--api-
|
|
1578
|
-
--api-
|
|
1579
|
-
--api-
|
|
1580
|
-
--api-
|
|
1581
|
-
--api-
|
|
1582
|
-
--api-
|
|
1583
|
-
--api-type-
|
|
1584
|
-
--api-type-
|
|
1585
|
-
--api-type-
|
|
1586
|
-
--api-type-
|
|
1587
|
-
--api-
|
|
1588
|
-
--api-status-
|
|
1589
|
-
--api-status-
|
|
1590
|
-
--api-status-
|
|
3553
|
+
/* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
|
|
3554
|
+
--api-get: oklch(0.435 0.095 162);
|
|
3555
|
+
--api-post: oklch(0.45 0.174 259);
|
|
3556
|
+
--api-put: oklch(0.455 0.105 63);
|
|
3557
|
+
--api-patch: oklch(0.455 0.13 45);
|
|
3558
|
+
--api-delete: oklch(0.45 0.179 22);
|
|
3559
|
+
--api-head: oklch(0.47 0.222 286);
|
|
3560
|
+
--api-required: oklch(0.45 0.179 22);
|
|
3561
|
+
--api-deprecated: oklch(0.455 0.105 63);
|
|
3562
|
+
--api-type-string: oklch(0.435 0.095 162);
|
|
3563
|
+
--api-type-number: oklch(0.45 0.174 259);
|
|
3564
|
+
--api-type-boolean: oklch(0.455 0.105 63);
|
|
3565
|
+
--api-type-object: oklch(0.47 0.222 286);
|
|
3566
|
+
--api-type-array: oklch(0.455 0.13 45);
|
|
3567
|
+
--api-status-2xx: oklch(0.435 0.095 162);
|
|
3568
|
+
--api-status-3xx: oklch(0.45 0.174 259);
|
|
3569
|
+
--api-status-4xx: oklch(0.455 0.105 63);
|
|
3570
|
+
--api-status-5xx: oklch(0.45 0.179 22);
|
|
1591
3571
|
|
|
1592
3572
|
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
1593
3573
|
--font-heading: var(--font-sans);
|
|
@@ -1624,6 +3604,25 @@ const THEME_DEFAULT = {
|
|
|
1624
3604
|
--sidebar-ring: oklch(0.439 0.01 286.375);
|
|
1625
3605
|
--code-background: oklch(0.18 0.014 253.833);
|
|
1626
3606
|
--code-foreground: oklch(0.926 0.013 253.833);
|
|
3607
|
+
|
|
3608
|
+
/* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
|
|
3609
|
+
--api-get: oklch(0.720 0.135 162);
|
|
3610
|
+
--api-post: oklch(0.755 0.174 259);
|
|
3611
|
+
--api-put: oklch(0.745 0.15 63);
|
|
3612
|
+
--api-patch: oklch(0.725 0.18 45);
|
|
3613
|
+
--api-delete: oklch(0.805 0.194 22);
|
|
3614
|
+
--api-head: oklch(0.740 0.182 286);
|
|
3615
|
+
--api-required: oklch(0.805 0.194 22);
|
|
3616
|
+
--api-deprecated: oklch(0.745 0.15 63);
|
|
3617
|
+
--api-type-string: oklch(0.690 0.135 162);
|
|
3618
|
+
--api-type-number: oklch(0.725 0.174 259);
|
|
3619
|
+
--api-type-boolean: oklch(0.720 0.15 63);
|
|
3620
|
+
--api-type-object: oklch(0.740 0.182 286);
|
|
3621
|
+
--api-type-array: oklch(0.725 0.18 45);
|
|
3622
|
+
--api-status-2xx: oklch(0.720 0.135 162);
|
|
3623
|
+
--api-status-3xx: oklch(0.755 0.174 259);
|
|
3624
|
+
--api-status-4xx: oklch(0.770 0.15 63);
|
|
3625
|
+
--api-status-5xx: oklch(0.805 0.194 22);
|
|
1627
3626
|
}`,
|
|
1628
3627
|
};
|
|
1629
3628
|
/**
|
|
@@ -1690,23 +3689,24 @@ const THEME_MINTLIFY = {
|
|
|
1690
3689
|
--api-panel-fg: oklch(0.926 0.013 253.833);
|
|
1691
3690
|
|
|
1692
3691
|
/* API semantic colors — HTTP methods, types, status codes */
|
|
1693
|
-
|
|
1694
|
-
--api-
|
|
1695
|
-
--api-
|
|
1696
|
-
--api-
|
|
1697
|
-
--api-
|
|
1698
|
-
--api-
|
|
1699
|
-
--api-
|
|
1700
|
-
--api-
|
|
1701
|
-
--api-
|
|
1702
|
-
--api-type-
|
|
1703
|
-
--api-type-
|
|
1704
|
-
--api-type-
|
|
1705
|
-
--api-type-
|
|
1706
|
-
--api-
|
|
1707
|
-
--api-status-
|
|
1708
|
-
--api-status-
|
|
1709
|
-
--api-status-
|
|
3692
|
+
/* Light mode: darkened for WCAG AA on the 20%/10% tints. See .dark. */
|
|
3693
|
+
--api-get: oklch(0.435 0.095 162);
|
|
3694
|
+
--api-post: oklch(0.45 0.174 259);
|
|
3695
|
+
--api-put: oklch(0.455 0.105 63);
|
|
3696
|
+
--api-patch: oklch(0.455 0.13 45);
|
|
3697
|
+
--api-delete: oklch(0.45 0.179 22);
|
|
3698
|
+
--api-head: oklch(0.47 0.222 286);
|
|
3699
|
+
--api-required: oklch(0.45 0.179 22);
|
|
3700
|
+
--api-deprecated: oklch(0.455 0.105 63);
|
|
3701
|
+
--api-type-string: oklch(0.435 0.095 162);
|
|
3702
|
+
--api-type-number: oklch(0.45 0.174 259);
|
|
3703
|
+
--api-type-boolean: oklch(0.455 0.105 63);
|
|
3704
|
+
--api-type-object: oklch(0.47 0.222 286);
|
|
3705
|
+
--api-type-array: oklch(0.455 0.13 45);
|
|
3706
|
+
--api-status-2xx: oklch(0.435 0.095 162);
|
|
3707
|
+
--api-status-3xx: oklch(0.45 0.174 259);
|
|
3708
|
+
--api-status-4xx: oklch(0.455 0.105 63);
|
|
3709
|
+
--api-status-5xx: oklch(0.45 0.179 22);
|
|
1710
3710
|
|
|
1711
3711
|
/* Fonts — system sans for body, JetBrains Mono for code */
|
|
1712
3712
|
--font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
@@ -1744,6 +3744,25 @@ const THEME_MINTLIFY = {
|
|
|
1744
3744
|
--sidebar-ring: oklch(0.45 0.01 260);
|
|
1745
3745
|
--code-background: oklch(0.2 0.01 260);
|
|
1746
3746
|
--code-foreground: oklch(0.9 0.01 260);
|
|
3747
|
+
|
|
3748
|
+
/* API semantic colors — lightened for dark mode (AA on 20%/10% tints). */
|
|
3749
|
+
--api-get: oklch(0.720 0.135 162);
|
|
3750
|
+
--api-post: oklch(0.755 0.174 259);
|
|
3751
|
+
--api-put: oklch(0.745 0.15 63);
|
|
3752
|
+
--api-patch: oklch(0.725 0.18 45);
|
|
3753
|
+
--api-delete: oklch(0.805 0.194 22);
|
|
3754
|
+
--api-head: oklch(0.740 0.182 286);
|
|
3755
|
+
--api-required: oklch(0.805 0.194 22);
|
|
3756
|
+
--api-deprecated: oklch(0.745 0.15 63);
|
|
3757
|
+
--api-type-string: oklch(0.690 0.135 162);
|
|
3758
|
+
--api-type-number: oklch(0.725 0.174 259);
|
|
3759
|
+
--api-type-boolean: oklch(0.720 0.15 63);
|
|
3760
|
+
--api-type-object: oklch(0.740 0.182 286);
|
|
3761
|
+
--api-type-array: oklch(0.725 0.18 45);
|
|
3762
|
+
--api-status-2xx: oklch(0.720 0.135 162);
|
|
3763
|
+
--api-status-3xx: oklch(0.755 0.174 259);
|
|
3764
|
+
--api-status-4xx: oklch(0.770 0.15 63);
|
|
3765
|
+
--api-status-5xx: oklch(0.805 0.194 22);
|
|
1747
3766
|
}`,
|
|
1748
3767
|
};
|
|
1749
3768
|
function writeThemeFile(path, themeName) {
|
|
@@ -1894,6 +3913,37 @@ function isInsideWorkspace(outputDir) {
|
|
|
1894
3913
|
}
|
|
1895
3914
|
return false;
|
|
1896
3915
|
}
|
|
3916
|
+
/**
|
|
3917
|
+
* Every @dogsbay/* package in this checkout, mapped to its `file:` path.
|
|
3918
|
+
*
|
|
3919
|
+
* Enumerated from disk rather than hard-coded: the set of packages changes, and
|
|
3920
|
+
* a stale list fails as a confusing install error naming a package nobody
|
|
3921
|
+
* mentioned.
|
|
3922
|
+
*/
|
|
3923
|
+
function monorepoOverrides() {
|
|
3924
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
3925
|
+
const overrides = {};
|
|
3926
|
+
if (!existsSync(root))
|
|
3927
|
+
return overrides;
|
|
3928
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
3929
|
+
if (!entry.isDirectory())
|
|
3930
|
+
continue;
|
|
3931
|
+
const manifest = join(root, entry.name, "package.json");
|
|
3932
|
+
if (!existsSync(manifest))
|
|
3933
|
+
continue;
|
|
3934
|
+
try {
|
|
3935
|
+
const { name } = JSON.parse(readFileSync(manifest, "utf-8"));
|
|
3936
|
+
if (name?.startsWith("@dogsbay/"))
|
|
3937
|
+
overrides[name] = `file:${join(root, entry.name)}`;
|
|
3938
|
+
}
|
|
3939
|
+
catch {
|
|
3940
|
+
// A malformed manifest in the checkout is not this function's problem;
|
|
3941
|
+
// skipping it degrades to the previous behaviour for that one package
|
|
3942
|
+
// rather than failing the whole export.
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
return overrides;
|
|
3946
|
+
}
|
|
1897
3947
|
function resolveMonorepoPkg(name) {
|
|
1898
3948
|
const thisDir = dirname(fileURLToPath(import.meta.url));
|
|
1899
3949
|
// From packages/format-astro/src/ → ../../{name}
|