@dogsbay/format-astro 0.2.0-beta.5 → 0.2.0-beta.50
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 +105 -7
- package/dist/base-path.d.ts.map +1 -1
- package/dist/base-path.js +136 -7
- package/dist/base-path.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/llms-txt.d.ts +13 -2
- package/dist/llms-txt.d.ts.map +1 -1
- package/dist/llms-txt.js +79 -12
- package/dist/llms-txt.js.map +1 -1
- package/dist/project.d.ts +152 -5
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +859 -98
- package/dist/project.js.map +1 -1
- package/dist/serialize.d.ts +22 -0
- package/dist/serialize.d.ts.map +1 -1
- package/dist/serialize.js +146 -17
- package/dist/serialize.js.map +1 -1
- package/dist/sitemap.d.ts +57 -0
- package/dist/sitemap.d.ts.map +1 -0
- package/dist/sitemap.js +136 -0
- package/dist/sitemap.js.map +1 -0
- package/dist/taxonomy.d.ts +37 -0
- package/dist/taxonomy.d.ts.map +1 -1
- package/dist/taxonomy.js +60 -19
- package/dist/taxonomy.js.map +1 -1
- package/package.json +4 -4
package/dist/project.js
CHANGED
|
@@ -4,13 +4,28 @@
|
|
|
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, } 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
12
|
import { buildLlmsTxt, buildSectionLlmsTxt, buildLlmsFullTxt } from "./llms-txt.js";
|
|
13
|
-
import {
|
|
13
|
+
import { buildSitemap, buildSitemapIndex } from "./sitemap.js";
|
|
14
|
+
import { normalizeBasePath, basePathSegments, buildCurrentPath, withBasePath, parseSiteUrl, combinePrefix, } from "./base-path.js";
|
|
15
|
+
/**
|
|
16
|
+
* Combined URL prefix = urlBase (Astro `base` from site.url path) +
|
|
17
|
+
* basePath (filesystem layout prefix). Every URL emitter (nav,
|
|
18
|
+
* sitemap, llms.txt, .md mirror, _headers, taxonomy) uses this for
|
|
19
|
+
* href output. Filesystem-layout consumers (mkdir, page output
|
|
20
|
+
* paths) keep using basePath alone — Astro's `base` config adds the
|
|
21
|
+
* urlBase prefix at route time.
|
|
22
|
+
*
|
|
23
|
+
* See plans/astro-base-from-site-url.md.
|
|
24
|
+
*/
|
|
25
|
+
function combinedPrefix(options) {
|
|
26
|
+
const { urlBase } = parseSiteUrl(options.siteUrl);
|
|
27
|
+
return combinePrefix(urlBase, normalizeBasePath(options.basePath));
|
|
28
|
+
}
|
|
14
29
|
import { detectLeadingNodes } from "./lead.js";
|
|
15
30
|
/**
|
|
16
31
|
* Recursively prefix all hrefs in a nav tree.
|
|
@@ -97,6 +112,55 @@ function rewriteHref(href, prefix) {
|
|
|
97
112
|
return href;
|
|
98
113
|
return prefix + href;
|
|
99
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Rewrite image srcs in inline nodes + raw HTML to include the
|
|
117
|
+
* combined URL prefix.
|
|
118
|
+
*
|
|
119
|
+
* Astro auto-prefixes `<a href>` and image imports going through
|
|
120
|
+
* `<AstroImage>`, but raw `<img src="...">` HTML in template
|
|
121
|
+
* output is left untouched. The serializer emits raw `<img>` for
|
|
122
|
+
* inline images and falls back to it for non-optimized block
|
|
123
|
+
* images, so we have to prefix manually before serialization to
|
|
124
|
+
* make `/_assets/...` paths resolve under subpath-mounted deploys
|
|
125
|
+
* (GH Pages project pages, multi-mount Cloudflare).
|
|
126
|
+
*
|
|
127
|
+
* Symmetric with rewriteTreeHrefs — same skip-rules (external,
|
|
128
|
+
* anchors, already-prefixed). Block images keep their prefix
|
|
129
|
+
* stripped back off for the `imageMap[...]` lookup key (see
|
|
130
|
+
* paragraphToAstro in serialize.ts) so Astro's image optimization
|
|
131
|
+
* still finds the source.
|
|
132
|
+
*/
|
|
133
|
+
function rewriteTreeImageSrcs(nodes, prefix) {
|
|
134
|
+
for (const node of nodes) {
|
|
135
|
+
if (node.inline) {
|
|
136
|
+
rewriteInlineImageSrcs(node.inline, prefix);
|
|
137
|
+
}
|
|
138
|
+
if (node.html) {
|
|
139
|
+
node.html = rewriteHtmlImageSrcs(node.html, prefix);
|
|
140
|
+
}
|
|
141
|
+
if (node.children) {
|
|
142
|
+
rewriteTreeImageSrcs(node.children, prefix);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function rewriteInlineImageSrcs(nodes, prefix) {
|
|
147
|
+
for (const node of nodes) {
|
|
148
|
+
if (node.type === "image" && typeof node.src === "string") {
|
|
149
|
+
node.src = rewriteHref(node.src, prefix);
|
|
150
|
+
}
|
|
151
|
+
else if (node.type === "link") {
|
|
152
|
+
// Links wrap inline children (which may include images) — same
|
|
153
|
+
// recursion shape as rewriteInlineHrefs.
|
|
154
|
+
rewriteInlineImageSrcs(node.children, prefix);
|
|
155
|
+
}
|
|
156
|
+
else if (node.type === "highlight" && node.children) {
|
|
157
|
+
rewriteInlineImageSrcs(node.children, prefix);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function rewriteHtmlImageSrcs(html, prefix) {
|
|
162
|
+
return html.replace(/(<img\b[^>]*\ssrc=")(\/[^"]+)"/g, (_match, before, src) => `${before}${rewriteHref(src, prefix)}"`);
|
|
163
|
+
}
|
|
100
164
|
/**
|
|
101
165
|
* Build a `wrangler.jsonc` for Cloudflare Workers Static Assets.
|
|
102
166
|
*
|
|
@@ -147,6 +211,125 @@ function buildWranglerConfig(siteName, options) {
|
|
|
147
211
|
lines.push(`}`);
|
|
148
212
|
return lines.join("\n") + "\n";
|
|
149
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* Build the GitHub Actions workflow YAML for `actions/deploy-pages`.
|
|
216
|
+
*
|
|
217
|
+
* The workflow:
|
|
218
|
+
* 1. Checks out the repo on every push to the default branch.
|
|
219
|
+
* 2. Installs node + pnpm at the Astro project directory, runs
|
|
220
|
+
* `dogsbay site build` (via `pnpm dlx` since Dogsbay is a
|
|
221
|
+
* global CLI, not a project dep), then `pnpm run build`
|
|
222
|
+
* (which runs `astro build && pagefind`).
|
|
223
|
+
* 3. Uploads `<astroDirRel>/dist` as a Pages artifact via
|
|
224
|
+
* `actions/upload-pages-artifact`.
|
|
225
|
+
* 4. Deploys via `actions/deploy-pages`.
|
|
226
|
+
*
|
|
227
|
+
* `astroDirRel` is the path of the Astro output relative to the
|
|
228
|
+
* repo root (typically "astro" — the default config has
|
|
229
|
+
* `output: ./astro`). Empty string is allowed when the project is
|
|
230
|
+
* flat (outputDir === projectDir); the workflow degrades naturally
|
|
231
|
+
* by omitting the `defaults: working-directory` block.
|
|
232
|
+
*
|
|
233
|
+
* Author edits — extra build steps, secrets, deploy gating — survive
|
|
234
|
+
* subsequent `dogsbay site build` runs because the file is written
|
|
235
|
+
* write-if-missing (see emitDeployArtifacts). To start over, delete
|
|
236
|
+
* the workflow file and rebuild.
|
|
237
|
+
*
|
|
238
|
+
* Note on basePath: GitHub Pages serves project sites at
|
|
239
|
+
* `https://<user>.github.io/<repo>/`. Authors who want their docs at
|
|
240
|
+
* the repo root should set `site.basePath: /<repo-name>` (or empty
|
|
241
|
+
* for user/org pages). The platform's basePath plumbing handles all
|
|
242
|
+
* URL rewriting; this workflow doesn't need to know about it.
|
|
243
|
+
*/
|
|
244
|
+
function buildGitHubPagesWorkflow(astroDirRel) {
|
|
245
|
+
// When the Astro output IS the project root, drop the working-
|
|
246
|
+
// directory block and reference cache + artifact paths without a
|
|
247
|
+
// prefix. This is the flat-layout case (rare for site-init flows;
|
|
248
|
+
// common for `dogsbay convert` outputs that get manually wired up).
|
|
249
|
+
const isFlat = astroDirRel === "" || astroDirRel === ".";
|
|
250
|
+
const workingDirBlock = isFlat
|
|
251
|
+
? ""
|
|
252
|
+
: `
|
|
253
|
+
defaults:
|
|
254
|
+
run:
|
|
255
|
+
working-directory: ${astroDirRel}`;
|
|
256
|
+
const cacheDep = isFlat
|
|
257
|
+
? "pnpm-lock.yaml"
|
|
258
|
+
: `${astroDirRel}/pnpm-lock.yaml`;
|
|
259
|
+
const artifactPath = isFlat ? "dist" : `${astroDirRel}/dist`;
|
|
260
|
+
return `# Deploy to GitHub Pages.
|
|
261
|
+
# Generated by \`dogsbay site init --deploy=github-pages\` (or by
|
|
262
|
+
# adding \`deploy: { target: github-pages }\` to dogsbay.config.yml
|
|
263
|
+
# and running \`dogsbay site build\`). Author edits survive every
|
|
264
|
+
# subsequent build — the file is never overwritten. To regenerate
|
|
265
|
+
# from template, delete the file and rebuild.
|
|
266
|
+
#
|
|
267
|
+
# Repo settings: Settings → Pages → Source = "GitHub Actions".
|
|
268
|
+
name: Deploy to GitHub Pages
|
|
269
|
+
|
|
270
|
+
on:
|
|
271
|
+
push:
|
|
272
|
+
branches: [main]
|
|
273
|
+
workflow_dispatch:
|
|
274
|
+
|
|
275
|
+
permissions:
|
|
276
|
+
contents: read
|
|
277
|
+
pages: write
|
|
278
|
+
id-token: write
|
|
279
|
+
|
|
280
|
+
# Allow only one concurrent deployment, skipping queued runs.
|
|
281
|
+
concurrency:
|
|
282
|
+
group: pages
|
|
283
|
+
cancel-in-progress: false
|
|
284
|
+
|
|
285
|
+
jobs:
|
|
286
|
+
build:
|
|
287
|
+
runs-on: ubuntu-latest${workingDirBlock}
|
|
288
|
+
steps:
|
|
289
|
+
- uses: actions/checkout@v4
|
|
290
|
+
|
|
291
|
+
- uses: pnpm/action-setup@v4
|
|
292
|
+
with:
|
|
293
|
+
version: 10
|
|
294
|
+
|
|
295
|
+
- uses: actions/setup-node@v4
|
|
296
|
+
with:
|
|
297
|
+
# Astro 6 requires Node ^20.19.5 || >=22.12.0; pin 22 for
|
|
298
|
+
# forward-compat (Node 20 LTS is fine for Astro 5 sites
|
|
299
|
+
# but the Dogsbay scaffold targets Astro 6).
|
|
300
|
+
node-version: 22
|
|
301
|
+
cache: pnpm
|
|
302
|
+
cache-dependency-path: ${cacheDep}
|
|
303
|
+
|
|
304
|
+
- name: Install dependencies
|
|
305
|
+
run: pnpm install --frozen-lockfile
|
|
306
|
+
|
|
307
|
+
# \`dogsbay\` is a global CLI, not a project dep — pnpm dlx
|
|
308
|
+
# fetches it on demand. To pin a version, replace with e.g.
|
|
309
|
+
# \`pnpm dlx dogsbay@0.2.0-beta.18 site build\`.
|
|
310
|
+
- name: Build with Dogsbay
|
|
311
|
+
run: pnpm dlx dogsbay@beta site build
|
|
312
|
+
|
|
313
|
+
- name: Build Astro site
|
|
314
|
+
run: pnpm run build
|
|
315
|
+
|
|
316
|
+
- name: Upload Pages artifact
|
|
317
|
+
uses: actions/upload-pages-artifact@v3
|
|
318
|
+
with:
|
|
319
|
+
path: ${artifactPath}
|
|
320
|
+
|
|
321
|
+
deploy:
|
|
322
|
+
needs: build
|
|
323
|
+
runs-on: ubuntu-latest
|
|
324
|
+
environment:
|
|
325
|
+
name: github-pages
|
|
326
|
+
url: \${{ steps.deployment.outputs.page_url }}
|
|
327
|
+
steps:
|
|
328
|
+
- name: Deploy to GitHub Pages
|
|
329
|
+
id: deployment
|
|
330
|
+
uses: actions/deploy-pages@v4
|
|
331
|
+
`;
|
|
332
|
+
}
|
|
150
333
|
/**
|
|
151
334
|
* Construct the SiteConfig object that gets serialized to
|
|
152
335
|
* `src/data/site.json`. Backward-compatible: existing fields keep their
|
|
@@ -156,8 +339,6 @@ function buildSiteConfig(siteName, options) {
|
|
|
156
339
|
const cfg = {
|
|
157
340
|
siteName,
|
|
158
341
|
repoUrl: options.repoUrl || "",
|
|
159
|
-
editUri: options.editUri || "blob/main/docs/",
|
|
160
|
-
copyright: options.copyright || "",
|
|
161
342
|
};
|
|
162
343
|
if (options.siteUrl)
|
|
163
344
|
cfg.siteUrl = options.siteUrl;
|
|
@@ -169,6 +350,15 @@ function buildSiteConfig(siteName, options) {
|
|
|
169
350
|
cfg.twitterHandle = options.twitterHandle;
|
|
170
351
|
if (options.themeColor)
|
|
171
352
|
cfg.themeColor = options.themeColor;
|
|
353
|
+
// editUri + copyright follow the same omit-on-empty pattern as the
|
|
354
|
+
// optional fields above; previously they were always written
|
|
355
|
+
// (editUri defaulted to "blob/main/docs/", copyright to ""), which
|
|
356
|
+
// left zombie config in src/data/site.json. Downstream guards already
|
|
357
|
+
// treat empty / undefined as "don't render" so this is purely a tidy.
|
|
358
|
+
if (options.editUri)
|
|
359
|
+
cfg.editUri = options.editUri;
|
|
360
|
+
if (options.copyright)
|
|
361
|
+
cfg.copyright = options.copyright;
|
|
172
362
|
if (options.brandKeywords && options.brandKeywords.length > 0) {
|
|
173
363
|
cfg.brandKeywords = options.brandKeywords;
|
|
174
364
|
}
|
|
@@ -187,11 +377,58 @@ function buildSiteConfig(siteName, options) {
|
|
|
187
377
|
}
|
|
188
378
|
if (options.taxonomyIndexPaths &&
|
|
189
379
|
Object.keys(options.taxonomyIndexPaths).length > 0) {
|
|
190
|
-
|
|
380
|
+
// Bake basePath into every emitted indexPath so consumers
|
|
381
|
+
// (TypeBadge / StatusBadge / future components) compose hrefs
|
|
382
|
+
// like `${indexPath}/<value>/` and resolve under the configured
|
|
383
|
+
// site base. Without the prefix, `/by-type/tutorial/` 404s on
|
|
384
|
+
// any site with `site.basePath` set. Caller passes raw config
|
|
385
|
+
// values (`/by-type`, `/tags`, etc.) — basePath threading is
|
|
386
|
+
// this emitter's responsibility, matching how `page.url` is
|
|
387
|
+
// already prefixed in the taxonomy data file.
|
|
388
|
+
// Taxonomy index paths are baked into site.json so components
|
|
389
|
+
// (TagList, TaxonomyIndex, TypeBadge) emit correct hrefs at
|
|
390
|
+
// runtime. Use combined so these resolve under the host's
|
|
391
|
+
// served subpath.
|
|
392
|
+
const taxoPrefix = combinedPrefix(options);
|
|
393
|
+
cfg.taxonomyIndexPaths = Object.fromEntries(Object.entries(options.taxonomyIndexPaths).map(([name, raw]) => [
|
|
394
|
+
name,
|
|
395
|
+
withBasePath(taxoPrefix, raw),
|
|
396
|
+
]));
|
|
191
397
|
}
|
|
192
398
|
if (options.taxonomyDisplay &&
|
|
193
399
|
Object.keys(options.taxonomyDisplay).length > 0) {
|
|
194
|
-
|
|
400
|
+
// Flatten prefix labels into top-level entries so the
|
|
401
|
+
// search-facets resolver finds them after DocsLayout splits
|
|
402
|
+
// slash-nested tags into per-prefix Pagefind filter divs.
|
|
403
|
+
//
|
|
404
|
+
// Input:
|
|
405
|
+
// tags.prefixes = { difficulty: { label, color }, ... }
|
|
406
|
+
// tags.labels = { "difficulty/1": "Beginner", "difficulty/2": ... }
|
|
407
|
+
// Output additions (kept alongside the original `tags` entry):
|
|
408
|
+
// difficulty.labels = { "1": "Beginner", "2": ... }
|
|
409
|
+
//
|
|
410
|
+
// Resolver does `display[facetName].labels[value]` — facet name
|
|
411
|
+
// is now `difficulty`, value is `1`, → "Beginner". See
|
|
412
|
+
// plans/per-prefix-search-facets.md.
|
|
413
|
+
const flat = { ...options.taxonomyDisplay };
|
|
414
|
+
const tagsDisplay = options.taxonomyDisplay.tags;
|
|
415
|
+
if (tagsDisplay?.prefixes) {
|
|
416
|
+
for (const prefix of Object.keys(tagsDisplay.prefixes)) {
|
|
417
|
+
if (flat[prefix])
|
|
418
|
+
continue; // top-level entry wins
|
|
419
|
+
const leafLabels = {};
|
|
420
|
+
if (tagsDisplay.labels) {
|
|
421
|
+
const needle = `${prefix}/`;
|
|
422
|
+
for (const [slug, label] of Object.entries(tagsDisplay.labels)) {
|
|
423
|
+
if (slug.startsWith(needle)) {
|
|
424
|
+
leafLabels[slug.slice(needle.length)] = label;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
flat[prefix] = { labels: leafLabels };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
cfg.taxonomyDisplay = flat;
|
|
195
432
|
}
|
|
196
433
|
return cfg;
|
|
197
434
|
}
|
|
@@ -257,6 +494,74 @@ function ensureDirectoryStructure(outputDir, basePath) {
|
|
|
257
494
|
export function emitSiteConfig(outputDir, siteName, options) {
|
|
258
495
|
mkdirSync(join(outputDir, "src", "data"), { recursive: true });
|
|
259
496
|
writeFileSync(join(outputDir, "src", "data", "site.json"), JSON.stringify(buildSiteConfig(siteName, options), null, 2));
|
|
497
|
+
// Auto-generated companion to astro.config.mjs. Carries the
|
|
498
|
+
// site/base values derived from dogsbay.config.yml's site.url so
|
|
499
|
+
// changes propagate without --force-rescaffolding the main
|
|
500
|
+
// astro.config.mjs (which is scaffold-once and may have author
|
|
501
|
+
// edits — custom integrations, build hooks, etc.). The main
|
|
502
|
+
// config imports `dogsbaySite` + `dogsbayBase` from here.
|
|
503
|
+
// See plans/astro-base-from-site-url.md.
|
|
504
|
+
const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
|
|
505
|
+
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
506
|
+
const dogsbaySiteJson = hasSiteUrl
|
|
507
|
+
? JSON.stringify(origin ?? options.siteUrl)
|
|
508
|
+
: "undefined";
|
|
509
|
+
const dogsbayBaseJson = astroBase ? JSON.stringify(astroBase) : "undefined";
|
|
510
|
+
// build.inlineStylesheets — defaults to "auto" (Astro's own
|
|
511
|
+
// default; matches our docs-first bias since theme.css is ~120KB
|
|
512
|
+
// and externalizing it lets the file cache cross-page). Authors
|
|
513
|
+
// wanting "always" / "never" set it via dogsbay.config.yml's
|
|
514
|
+
// build.inlineStylesheets. See docs/perf-tuning.md.
|
|
515
|
+
const dogsbayInline = options.inlineStylesheets ?? "auto";
|
|
516
|
+
writeFileSync(join(outputDir, "astro.config.dogsbay.mjs"), [
|
|
517
|
+
"// Auto-generated by `dogsbay site build` — DO NOT EDIT.",
|
|
518
|
+
"// Tracks site.url + derived Astro base + build behaviour from",
|
|
519
|
+
"// dogsbay.config.yml. Edit dogsbay.config.yml and rebuild;",
|
|
520
|
+
"// edits to this file will be overwritten on the next build.",
|
|
521
|
+
`export const dogsbaySite = ${dogsbaySiteJson};`,
|
|
522
|
+
`export const dogsbayBase = ${dogsbayBaseJson};`,
|
|
523
|
+
`export const dogsbayInlineStylesheets = ${JSON.stringify(dogsbayInline)};`,
|
|
524
|
+
"",
|
|
525
|
+
].join("\n"));
|
|
526
|
+
// Migration check: pre-beta.20 sites have an astro.config.mjs that
|
|
527
|
+
// doesn't import the companion. Without the import, the values
|
|
528
|
+
// emitted above are unused and Astro's `base` stays unset — the
|
|
529
|
+
// exact bug this work was meant to close. Warn loudly, with the
|
|
530
|
+
// patch the user needs to apply, until astro.config.mjs is
|
|
531
|
+
// updated. We don't auto-patch because the file may have author
|
|
532
|
+
// edits (custom integrations, build hooks).
|
|
533
|
+
const astroConfigPath = join(outputDir, "astro.config.mjs");
|
|
534
|
+
if (existsSync(astroConfigPath)) {
|
|
535
|
+
const astroConfigSrc = readFileSync(astroConfigPath, "utf-8");
|
|
536
|
+
if (!astroConfigSrc.includes("astro.config.dogsbay.mjs")) {
|
|
537
|
+
console.warn([
|
|
538
|
+
"",
|
|
539
|
+
" ⚠ astro.config.mjs is missing the dogsbay companion import.",
|
|
540
|
+
" Without it, Astro's `base` config stays unset and assets",
|
|
541
|
+
" served from a host subpath (GH Pages project pages,",
|
|
542
|
+
" multi-mount Cloudflare) will 404.",
|
|
543
|
+
"",
|
|
544
|
+
" Add these two lines to astro.config.mjs:",
|
|
545
|
+
"",
|
|
546
|
+
' import {',
|
|
547
|
+
' dogsbaySite,',
|
|
548
|
+
' dogsbayBase,',
|
|
549
|
+
' dogsbayInlineStylesheets,',
|
|
550
|
+
' } from "./astro.config.dogsbay.mjs";',
|
|
551
|
+
"",
|
|
552
|
+
" export default defineConfig({",
|
|
553
|
+
" ...(dogsbaySite ? { site: dogsbaySite } : {}),",
|
|
554
|
+
" ...(dogsbayBase ? { base: dogsbayBase } : {}),",
|
|
555
|
+
" build: { inlineStylesheets: dogsbayInlineStylesheets },",
|
|
556
|
+
" // ...your existing config...",
|
|
557
|
+
" });",
|
|
558
|
+
"",
|
|
559
|
+
" OR regenerate from template (overwrites your edits):",
|
|
560
|
+
" dogsbay site init . --scaffold-only --force",
|
|
561
|
+
"",
|
|
562
|
+
].join("\n"));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
260
565
|
}
|
|
261
566
|
export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
262
567
|
let scaffoldFilesSkipped = 0;
|
|
@@ -300,6 +605,7 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
300
605
|
};
|
|
301
606
|
// Per-deploy-target additions to package.json
|
|
302
607
|
const isCloudflare = options.deploy === "cloudflare-workers";
|
|
608
|
+
const isGitHubPages = options.deploy === "github-pages";
|
|
303
609
|
const deployScripts = isCloudflare
|
|
304
610
|
? { deploy: "pnpm build && wrangler deploy" }
|
|
305
611
|
: {};
|
|
@@ -325,14 +631,25 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
325
631
|
},
|
|
326
632
|
dependencies: {
|
|
327
633
|
astro: "^6.0.0",
|
|
328
|
-
|
|
634
|
+
// Sitemap is emitted directly by Dogsbay into
|
|
635
|
+
// public/<basePath>/sitemap-{index,0}.xml so multi-mount
|
|
636
|
+
// deploys don't collide at the host root. We deliberately
|
|
637
|
+
// do NOT depend on @astrojs/sitemap (it hardcodes output to
|
|
638
|
+
// dist/ root, which is what we're moving away from).
|
|
329
639
|
// Pagefind is invoked from the build script (see scripts.build above).
|
|
330
640
|
// Lives in dependencies (not devDependencies) so production builds
|
|
331
641
|
// include it; the produced search index is shipped statically and
|
|
332
642
|
// doesn't load this dep at runtime.
|
|
333
643
|
pagefind: "^1.4.0",
|
|
334
644
|
tailwindcss: "^4.0.0",
|
|
335
|
-
|
|
645
|
+
// Pinned to 4.2.x — `@tailwindcss/vite` 4.3.x ships an
|
|
646
|
+
// oxcResolvePlugin shape that breaks Astro 6's
|
|
647
|
+
// rolldown-vite ("Missing field tsconfigPaths in
|
|
648
|
+
// oxcResolvePlugin"). Surfaced during the FastAPI import
|
|
649
|
+
// (~150-page MkDocs site) on a fresh `dogsbay site init`.
|
|
650
|
+
// Drop the ~ when Astro 6 picks up a compatible rolldown
|
|
651
|
+
// build OR @tailwindcss/vite restores the prior shape.
|
|
652
|
+
"@tailwindcss/vite": "~4.2.2",
|
|
336
653
|
"tailwind-variants": "^0.3.0",
|
|
337
654
|
shiki: "^4.0.0",
|
|
338
655
|
"@shikijs/transformers": "^4.0.0",
|
|
@@ -348,6 +665,15 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
348
665
|
"@dogsbay/primitives": dogsbayDep("primitives"),
|
|
349
666
|
"@dogsbay/icons": dogsbayDep("icons"),
|
|
350
667
|
"@dogsbay/elements": dogsbayDep("elements"),
|
|
668
|
+
// Transitive of `@dogsbay/primitives` (via
|
|
669
|
+
// `@floating-ui/dom`). Listed at the top level because
|
|
670
|
+
// npm doesn't hoist the second-level transitive when
|
|
671
|
+
// `@dogsbay/primitives` is linked via `file:` (the
|
|
672
|
+
// `--local` monorepo mode + the canary publish flow on
|
|
673
|
+
// GH Pages CI both hit this). Surfaced during the
|
|
674
|
+
// FastAPI import: Rollup failed with "Cannot resolve
|
|
675
|
+
// @floating-ui/core" at astro build time.
|
|
676
|
+
"@floating-ui/core": "^1.7.0",
|
|
351
677
|
},
|
|
352
678
|
// Pin transitive Vite to 7. Vite 8 just released; Astro 6
|
|
353
679
|
// peer-deps Vite 7 and prints a warning when 8 is hoisted.
|
|
@@ -374,6 +700,18 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
374
700
|
scaffoldFilesSkipped++;
|
|
375
701
|
}
|
|
376
702
|
}
|
|
703
|
+
// GitHub Pages deploy artifacts — workflow + .nojekyll. The actual
|
|
704
|
+
// emission lives in `emitDeployArtifacts` so site-build can also
|
|
705
|
+
// call it on existing sites without going through scaffold (a user
|
|
706
|
+
// adds `deploy: github-pages` to dogsbay.config.yml and reruns
|
|
707
|
+
// `site build` to get the workflow). At scaffold-time we pass
|
|
708
|
+
// forceOverwrite=writeScaffold so `--force` regenerates from
|
|
709
|
+
// template; on regular builds it stays write-if-missing.
|
|
710
|
+
if (isGitHubPages) {
|
|
711
|
+
emitDeployArtifacts(outputDir, options, {
|
|
712
|
+
forceOverwrite: writeScaffold,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
377
715
|
// Generate astro.config.mjs
|
|
378
716
|
// `preserveSymlinks: true` is used with --local to pin local file: deps to
|
|
379
717
|
// their on-disk paths. Inside a pnpm workspace this breaks Astro's internal
|
|
@@ -385,52 +723,50 @@ export function emitSiteScaffold(outputDir, siteName, options, writeScaffold) {
|
|
|
385
723
|
preserveSymlinks: true,
|
|
386
724
|
},`
|
|
387
725
|
: "";
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
// the generated config). Sitemap also filters out frontmatter-noindex pages.
|
|
392
|
-
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
393
|
-
const sitemapImport = hasSiteUrl ? `import sitemap from "@astrojs/sitemap";\n` : "";
|
|
394
|
-
// Strip any path component from site.url before emitting. The
|
|
395
|
-
// config validator already rejects `site.url` containing a path
|
|
396
|
-
// when `basePath` is non-empty (canonical URLs would double-count
|
|
397
|
-
// the prefix); this is a defensive normalisation for the case
|
|
398
|
-
// where the validator is bypassed or basePath is empty.
|
|
726
|
+
// siteUrl gates absolute-URL emission (sitemap <loc> entries,
|
|
727
|
+
// canonical tags). Without one, both are skipped — relative URLs
|
|
728
|
+
// are still correct, the sitemap is just not generated.
|
|
399
729
|
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
//
|
|
403
|
-
// to
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
// spread is a no-op. See plans/plugin-api.md.
|
|
730
|
+
// Sitemap is emitted directly by Dogsbay (see emitSitemapFiles)
|
|
731
|
+
// into public/<basePath>/sitemap-*.xml. We deliberately do NOT
|
|
732
|
+
// wire @astrojs/sitemap here; that integration hardcodes output
|
|
733
|
+
// to dist/ root, breaking multi-mount deploys.
|
|
734
|
+
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
735
|
+
// site.url's path component (if any) becomes Astro's `base`. The
|
|
736
|
+
// origin alone goes into `site`. This split lets dogsbay model
|
|
737
|
+
// both axes independently:
|
|
738
|
+
// - Astro's `base` (= urlBase) controls the URL prefix Astro
|
|
739
|
+
// bakes into HTML asset references (`<basePath>/_astro/...`)
|
|
740
|
+
// and the routes Astro generates from src/pages.
|
|
741
|
+
// - dogsbay's basePath controls the filesystem layout
|
|
742
|
+
// (`src/pages/<basePath>/...`).
|
|
743
|
+
// The two compose at emit time — combining for nav hrefs,
|
|
744
|
+
// sitemap, llms.txt, etc. See plans/astro-base-from-site-url.md.
|
|
745
|
+
const { origin, urlBase: astroBase } = parseSiteUrl(options.siteUrl);
|
|
746
|
+
// astro.config.mjs — scaffold-once, but the site/base values flow
|
|
747
|
+
// through a separate auto-generated file (`astro.config.dogsbay.mjs`,
|
|
748
|
+
// emitted unconditionally below) so dogsbay-derived values stay in
|
|
749
|
+
// sync with `dogsbay.config.yml` even on existing sites where the
|
|
750
|
+
// main config is preserved. Same pattern as
|
|
751
|
+
// `astro.config.plugins.mjs` — the import line is the load-bearing
|
|
752
|
+
// bit; the auto-file is what changes.
|
|
424
753
|
if (writeScaffold) {
|
|
425
754
|
writeFileSync(join(outputDir, "astro.config.mjs"), `import { defineConfig } from "astro/config";
|
|
426
755
|
import tailwindcss from "@tailwindcss/vite";
|
|
427
|
-
|
|
756
|
+
import { pluginAliases, pluginFsAllow } from "./astro.config.plugins.mjs";
|
|
757
|
+
import {
|
|
758
|
+
dogsbaySite,
|
|
759
|
+
dogsbayBase,
|
|
760
|
+
dogsbayInlineStylesheets,
|
|
761
|
+
} from "./astro.config.dogsbay.mjs";
|
|
428
762
|
|
|
429
|
-
export default defineConfig({
|
|
763
|
+
export default defineConfig({
|
|
764
|
+
...(dogsbaySite ? { site: dogsbaySite } : {}),
|
|
765
|
+
...(dogsbayBase ? { base: dogsbayBase } : {}),
|
|
430
766
|
output: "static",
|
|
431
767
|
build: {
|
|
432
|
-
inlineStylesheets:
|
|
433
|
-
}
|
|
768
|
+
inlineStylesheets: dogsbayInlineStylesheets,
|
|
769
|
+
},
|
|
434
770
|
vite: {
|
|
435
771
|
plugins: [tailwindcss()],
|
|
436
772
|
resolve: {
|
|
@@ -452,6 +788,9 @@ export default defineConfig({${siteField}
|
|
|
452
788
|
else {
|
|
453
789
|
scaffoldFilesSkipped++;
|
|
454
790
|
}
|
|
791
|
+
// astro.config.dogsbay.mjs is emitted by emitSiteConfig (called
|
|
792
|
+
// above and on every site build) so site/base values stay in
|
|
793
|
+
// sync without a re-scaffold. See its definition for rationale.
|
|
455
794
|
// Always seed an empty astro.config.plugins.mjs so the import in
|
|
456
795
|
// astro.config.mjs resolves before the first plugin-emitting
|
|
457
796
|
// build. Subsequent builds replace it via emitPluginRuntime.
|
|
@@ -522,7 +861,12 @@ export default defineConfig({${siteField}
|
|
|
522
861
|
*/
|
|
523
862
|
export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
524
863
|
const siteName = options.siteName || "Documentation";
|
|
864
|
+
// basePath = filesystem layout prefix (where pages live under
|
|
865
|
+
// src/pages/...). combined = the URL prefix HTML hrefs need
|
|
866
|
+
// (urlBase + basePath). The two diverge whenever site.url has a
|
|
867
|
+
// path component (GH Pages project pages, multi-mount Cloudflare).
|
|
525
868
|
const basePath = normalizeBasePath(options.basePath);
|
|
869
|
+
const combined = combinedPrefix(options);
|
|
526
870
|
const baseSegments = basePathSegments(basePath);
|
|
527
871
|
// Ensure dirs exist (callers may invoke us without going through the
|
|
528
872
|
// full exportAstroProject orchestrator, e.g. dogsbay convert at Step 7).
|
|
@@ -543,13 +887,29 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
543
887
|
// Remove existing entry for this section (full replace)
|
|
544
888
|
existingNav = existingNav.filter((item) => item.label?.toLowerCase() !== siteName.toLowerCase()
|
|
545
889
|
&& item.label?.toLowerCase() !== section.toLowerCase());
|
|
546
|
-
|
|
890
|
+
// Nav hrefs already carry the `combined` prefix (the importer
|
|
891
|
+
// emits them via fileToHref(file, hrefPrefix=combined)).
|
|
892
|
+
// prefixNavHrefs takes the existing prefix and weaves a section
|
|
893
|
+
// segment into it.
|
|
894
|
+
const prefixedNav = prefixNavHrefs(nav, section, combined);
|
|
547
895
|
const sectionLabel = siteName
|
|
548
896
|
|| section.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
549
897
|
existingNav.push({ label: sectionLabel, children: prefixedNav });
|
|
550
898
|
outputNav = existingNav;
|
|
551
899
|
}
|
|
552
900
|
writeFileSync(join(outputDir, "src", "data", "nav.json"), JSON.stringify(outputNav, null, 2));
|
|
901
|
+
// Also publish nav.json under public/_dogsbay/ so the
|
|
902
|
+
// client-mode <DocsNavClient /> can fetch it at runtime
|
|
903
|
+
// (Astro copies public/ to dist/ as-is at build). The src/data
|
|
904
|
+
// copy stays for build-time imports (pagination, prev/next
|
|
905
|
+
// calculation in each page) — the public copy is the on-the-wire
|
|
906
|
+
// copy that the browser ever sees. Kept identical (same bytes,
|
|
907
|
+
// same shape); the duplicate is cheap (one file) and keeps the
|
|
908
|
+
// build-time and runtime worlds cleanly separated. See
|
|
909
|
+
// plans/client-rendered-nav.md.
|
|
910
|
+
const publicNavDir = join(outputDir, "public", "_dogsbay");
|
|
911
|
+
mkdirSync(publicNavDir, { recursive: true });
|
|
912
|
+
writeFileSync(join(publicNavDir, "nav.json"), JSON.stringify(outputNav));
|
|
553
913
|
// Static assets (images etc.) — content-tier; always copy from the
|
|
554
914
|
// user's source dir. If they removed an asset, we want it gone here
|
|
555
915
|
// too. Skipped when sourceDir isn't supplied (programmatic callers
|
|
@@ -558,20 +918,75 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
558
918
|
copyAssets(options.sourceDir, outputDir, options.imageOptimization);
|
|
559
919
|
}
|
|
560
920
|
let generated = 0;
|
|
921
|
+
const generatedPaths = new Set();
|
|
561
922
|
const pagesDir = join(outputDir, "src", "pages", ...baseSegments);
|
|
562
923
|
const useImageOpt = options.imageOptimization ?? false;
|
|
563
|
-
// hrefPrefix is the
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
|
|
924
|
+
// hrefPrefix is the COMBINED prefix (urlBase + basePath) — what
|
|
925
|
+
// rendered HTML hrefs need so internal links resolve under the
|
|
926
|
+
// host's served subpath AND under the dogsbay basePath. For
|
|
927
|
+
// simple host-apex deploys with basePath, urlBase is empty so
|
|
928
|
+
// combined === basePath (back-compat). For GH Pages project pages
|
|
929
|
+
// and multi-mount Cloudflare, combined adds the urlBase layer.
|
|
930
|
+
const hrefPrefix = combined;
|
|
931
|
+
// Route-exclusion gate. Two signals, either is enough:
|
|
932
|
+
// - frontmatter `_fragment: true` (loader-stamped — the importer
|
|
933
|
+
// knew this .md was include-only, not a navigable page)
|
|
934
|
+
// - excludeFromRoutes match against the slug (project-declared,
|
|
935
|
+
// in dogsbay.config.yml — for content under conventional
|
|
936
|
+
// fragment dirs like `modules/`, `_attributes/`, `snippets/`)
|
|
937
|
+
// Excluded pages stay on disk under content/ (so includes resolve)
|
|
938
|
+
// but don't produce .astro / .md.ts routes. See plans/build-at-scale.md.
|
|
939
|
+
//
|
|
940
|
+
// Match semantics:
|
|
941
|
+
// - A single-segment pattern (no `/`) matches ANY occurrence of
|
|
942
|
+
// that segment in the slug path. So `modules` excludes both
|
|
943
|
+
// `modules/foo` AND `welcome/modules/foo` AND
|
|
944
|
+
// `drupal-build/openshift-enterprise/ai/includes/x`. This is
|
|
945
|
+
// what AsciiBinder corpora want — fragment dirs symlink into
|
|
946
|
+
// section dirs so the same `includes/` etc. appears at many
|
|
947
|
+
// depths.
|
|
948
|
+
// - A multi-segment pattern (contains `/`) matches as a leading
|
|
949
|
+
// prefix (exact path-prefix). `welcome/_internal` excludes
|
|
950
|
+
// only that specific path tree, not arbitrary `_internal/`
|
|
951
|
+
// elsewhere.
|
|
952
|
+
const excludePatterns = (options.excludeFromRoutes ?? []).map((p) => p.replace(/^\/+/, "").replace(/\/+$/, ""));
|
|
953
|
+
function isExcludedSlug(slug) {
|
|
954
|
+
if (excludePatterns.length === 0)
|
|
955
|
+
return false;
|
|
956
|
+
const segments = slug.split("/");
|
|
957
|
+
for (const pattern of excludePatterns) {
|
|
958
|
+
if (pattern.includes("/")) {
|
|
959
|
+
// Multi-segment → leading-prefix match.
|
|
960
|
+
if (slug === pattern || slug.startsWith(pattern + "/"))
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
// Single-segment → any-segment match.
|
|
965
|
+
if (segments.includes(pattern))
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
568
971
|
for (const page of pages) {
|
|
569
972
|
try {
|
|
973
|
+
// Skip excluded pages before any expensive work (tree rewrite,
|
|
974
|
+
// serialize, IO).
|
|
975
|
+
const isFragment = page.frontmatter && page.frontmatter._fragment === true;
|
|
976
|
+
if (isFragment || isExcludedSlug(page.slug)) {
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
570
979
|
// Rewrite internal hrefs to match the output URL structure
|
|
571
980
|
rewriteTreeHrefs(page.tree, hrefPrefix);
|
|
981
|
+
// Same for raw image srcs — Astro doesn't auto-prefix
|
|
982
|
+
// `<img src="/_assets/...">` so we do it here. Block images
|
|
983
|
+
// strip the prefix back off for the `imageMap[...]` lookup
|
|
984
|
+
// (see paragraphToAstro in serialize.ts).
|
|
985
|
+
rewriteTreeImageSrcs(page.tree, hrefPrefix);
|
|
572
986
|
const result = treeToAstro(page.tree, {
|
|
573
987
|
imageOptimization: useImageOpt,
|
|
574
988
|
codeBlockTitle: options.codeBlockTitle ?? true,
|
|
989
|
+
combinedPrefix: hrefPrefix,
|
|
575
990
|
});
|
|
576
991
|
const imageSetup = useImageOpt ? [
|
|
577
992
|
'',
|
|
@@ -589,7 +1004,17 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
589
1004
|
const fm = (page.frontmatter ?? {});
|
|
590
1005
|
const pageDescription = fm.description ?? "";
|
|
591
1006
|
const pageOgImage = fm.ogImage ?? "";
|
|
592
|
-
|
|
1007
|
+
// Noindex / nofollow are independent meta directives. Site-level
|
|
1008
|
+
// forces both bits site-wide (staging / compliance lockdown);
|
|
1009
|
+
// page frontmatter can ESCALATE either bit independently but
|
|
1010
|
+
// cannot opt out of a site-level lockdown. `draft: true` keeps
|
|
1011
|
+
// its existing role as a noindex shorthand. See
|
|
1012
|
+
// plans/site-level-robots-meta.md.
|
|
1013
|
+
const pageNoindex = options.noindex === true ||
|
|
1014
|
+
fm.noindex === true ||
|
|
1015
|
+
fm.draft === true;
|
|
1016
|
+
const pageNofollow = options.nofollow === true ||
|
|
1017
|
+
fm.nofollow === true;
|
|
593
1018
|
// Independent of noindex: pages can be excluded from in-site
|
|
594
1019
|
// Pagefind search even when external SEs should index them
|
|
595
1020
|
// (or vice versa). See DocsLayout's prop docs for the
|
|
@@ -608,7 +1033,28 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
608
1033
|
const pageCategory = Array.isArray(pageMeta?.category)
|
|
609
1034
|
? pageMeta.category
|
|
610
1035
|
: undefined;
|
|
611
|
-
|
|
1036
|
+
// Custom-taxonomy values lifted from frontmatter into
|
|
1037
|
+
// `meta.taxonomies` by the importer (see `parseMeta` in
|
|
1038
|
+
// `@dogsbay/types`). Surfaced to DocsLayout so it can emit one
|
|
1039
|
+
// `<div data-pagefind-filter="<name>:<value>">` per entry — this
|
|
1040
|
+
// is what makes user-declared taxonomies (`difficulty`, `team`,
|
|
1041
|
+
// anything not in the five built-ins) appear as visible facet
|
|
1042
|
+
// checkboxes in the search dialog. Without this passthrough
|
|
1043
|
+
// they're silently dropped after the importer.
|
|
1044
|
+
const pageTaxonomies = pageMeta?.taxonomies && Object.keys(pageMeta.taxonomies).length > 0
|
|
1045
|
+
? pageMeta.taxonomies
|
|
1046
|
+
: undefined;
|
|
1047
|
+
// `tagsIndexPath` flows to `<TagList>` for chip hrefs
|
|
1048
|
+
// (`${indexPath}/${tag}/`). Caller passes the raw config value
|
|
1049
|
+
// (e.g. `/tags`); we bake the COMBINED prefix (urlBase from
|
|
1050
|
+
// site.url's path + basePath) here so chips resolve under both
|
|
1051
|
+
// the host's served subpath AND the dogsbay basePath. With
|
|
1052
|
+
// basePath alone, chips 404 on GH Pages project deploys
|
|
1053
|
+
// (basePath="" + non-empty urlBase) — same shape as the
|
|
1054
|
+
// typeBadgeHref / statusBadgeHref composition in DocsLayout,
|
|
1055
|
+
// which already reads combined-prefixed values out of
|
|
1056
|
+
// siteConfig.taxonomyIndexPaths (baked in buildSiteConfig).
|
|
1057
|
+
const tagsIndexPath = withBasePath(combined, options.tagsIndexPath ?? "/tags");
|
|
612
1058
|
// Auto-lede detection. If the markdown body doesn't already
|
|
613
1059
|
// start with an H1 / leading paragraph, we ask DocsLayout to
|
|
614
1060
|
// render the frontmatter title / description at the top of
|
|
@@ -640,12 +1086,20 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
640
1086
|
// available via other means. The "Open in" deep links work
|
|
641
1087
|
// regardless of mirror availability — agents that can't fetch
|
|
642
1088
|
// the page just see the URL in their chat.
|
|
1089
|
+
// pageHrefBase uses combined (urlBase + basePath) so the URL
|
|
1090
|
+
// resolves correctly when the host serves dist/ at a subpath
|
|
1091
|
+
// (GH Pages project page, multi-mount Cloudflare).
|
|
643
1092
|
const pageHrefBase = section
|
|
644
|
-
? (
|
|
645
|
-
: (
|
|
1093
|
+
? (combined ? `${combined}/${section}/${page.slug}` : `/${section}/${page.slug}`)
|
|
1094
|
+
: (combined ? `${combined}/${page.slug}` : `/${page.slug}`);
|
|
646
1095
|
const pageMdHref = `${pageHrefBase}.md`;
|
|
647
|
-
|
|
648
|
-
|
|
1096
|
+
// For absolute URLs (the "Copy as MD" deep link), use the
|
|
1097
|
+
// origin (no path) + the full combined path; siteUrl alone
|
|
1098
|
+
// would double-include the urlBase since pageHrefBase already
|
|
1099
|
+
// contains it.
|
|
1100
|
+
const { origin } = parseSiteUrl(options.siteUrl);
|
|
1101
|
+
const pageMdAbsoluteUrl = origin
|
|
1102
|
+
? origin + pageMdHref
|
|
649
1103
|
: pageMdHref;
|
|
650
1104
|
// Markdown body for the Copy button. Reuse the same serializer
|
|
651
1105
|
// that produces the .md mirror so what the user copies matches
|
|
@@ -687,7 +1141,10 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
687
1141
|
"",
|
|
688
1142
|
`const headings = ${JSON.stringify(page.headings || [])};`,
|
|
689
1143
|
`const nav = navData;`,
|
|
690
|
-
|
|
1144
|
+
// currentPath uses combined so it matches nav.json hrefs
|
|
1145
|
+
// (which are also combined-prefixed). getPagination compares
|
|
1146
|
+
// them as strings; mismatched prefixes break prev/next.
|
|
1147
|
+
`const currentPath = "${buildCurrentPath(combined, section, page.slug)}";`,
|
|
691
1148
|
// Filter nav to the current (locale, version) bucket
|
|
692
1149
|
// before computing prev/next — without this, pagination
|
|
693
1150
|
// walks the global nav and a "Next" link can leak from
|
|
@@ -705,12 +1162,14 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
705
1162
|
`const description = ${JSON.stringify(pageDescription)} || undefined;`,
|
|
706
1163
|
`const ogImage = ${JSON.stringify(pageOgImage)} || undefined;`,
|
|
707
1164
|
`const noindex = ${JSON.stringify(pageNoindex)};`,
|
|
1165
|
+
`const nofollow = ${JSON.stringify(pageNofollow)};`,
|
|
708
1166
|
`const excludeFromSearch = ${JSON.stringify(pageExcludeFromSearch)};`,
|
|
709
1167
|
`const pageTags = ${JSON.stringify(pageTags ?? null)};`,
|
|
710
1168
|
`const pageStatus = ${JSON.stringify(pageStatus ?? null)};`,
|
|
711
1169
|
`const pageType = ${JSON.stringify(pageTypeStr ?? null)};`,
|
|
712
1170
|
`const pageAudience = ${JSON.stringify(pageAudience ?? null)};`,
|
|
713
1171
|
`const pageCategory = ${JSON.stringify(pageCategory ?? null)};`,
|
|
1172
|
+
`const pageTaxonomies = ${JSON.stringify(pageTaxonomies ?? null)};`,
|
|
714
1173
|
`const tagsIndexPath = ${JSON.stringify(tagsIndexPath)};`,
|
|
715
1174
|
`const llmActionsProps = ${JSON.stringify(llmActionsEnabled
|
|
716
1175
|
? {
|
|
@@ -741,6 +1200,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
741
1200
|
` twitterHandle={siteConfig.twitterHandle || undefined}`,
|
|
742
1201
|
` themeColor={siteConfig.themeColor || undefined}`,
|
|
743
1202
|
` noindex={noindex}`,
|
|
1203
|
+
` nofollow={nofollow}`,
|
|
744
1204
|
` excludeFromSearch={excludeFromSearch}`,
|
|
745
1205
|
` plausibleDomain={siteConfig.plausible?.domain}`,
|
|
746
1206
|
` plausibleScriptUrl={siteConfig.plausible?.scriptUrl}`,
|
|
@@ -756,12 +1216,39 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
756
1216
|
` pageType={pageType ?? undefined}`,
|
|
757
1217
|
` audience={pageAudience ?? undefined}`,
|
|
758
1218
|
` category={pageCategory ?? undefined}`,
|
|
1219
|
+
` taxonomies={pageTaxonomies ?? undefined}`,
|
|
759
1220
|
` autoH1={${autoH1}}`,
|
|
760
1221
|
` autoLede={${autoLede}}`,
|
|
761
1222
|
` llmActions={llmActionsProps}`,
|
|
762
1223
|
` multiSource={${JSON.stringify(page.multiSource ?? null)} ?? undefined}`,
|
|
763
1224
|
` switcherMap={switcherMapData}`,
|
|
764
|
-
|
|
1225
|
+
// basePath here is the COMBINED URL prefix (urlBase from
|
|
1226
|
+
// site.url's path + dogsbay basePath). DocsLayout uses it
|
|
1227
|
+
// for switcher links, the footer llms.txt link, and the
|
|
1228
|
+
// <head> alternate link — all three need the full URL
|
|
1229
|
+
// prefix the host actually serves under. Empty string is
|
|
1230
|
+
// valid (root-served sites with no urlBase or basePath);
|
|
1231
|
+
// don't fall back to "/docs" — that would 404 for those.
|
|
1232
|
+
` basePath={${JSON.stringify(combined)}}`,
|
|
1233
|
+
// navMode — controls whether DocsLayout server-renders the
|
|
1234
|
+
// full sidebar nav tree per page (`ssr-full`) or emits a
|
|
1235
|
+
// client-hydrated placeholder (`client`, default). The
|
|
1236
|
+
// client mode shrinks per-page HTML dramatically at scale.
|
|
1237
|
+
// See plans/client-rendered-nav.md.
|
|
1238
|
+
` navMode={${JSON.stringify(options.navMode ?? "client")}}`,
|
|
1239
|
+
// Pagefind index URL — must include the combined prefix or
|
|
1240
|
+
// the loader 404s on subpath-mounted deploys. The pagefind
|
|
1241
|
+
// CLI writes to <astroOutput>/dist/pagefind/ which Astro
|
|
1242
|
+
// serves under its `base` (= urlBase); dogsbay's basePath
|
|
1243
|
+
// adds the second prefix layer. Empty combined → `/pagefind/`.
|
|
1244
|
+
` pagefindUrl={${JSON.stringify(combined ? `${combined}/pagefind/` : "/pagefind/")}}`,
|
|
1245
|
+
// Favicon — composed with combined prefix so the
|
|
1246
|
+
// <link rel="icon"> resolves on subpath-mounted deploys.
|
|
1247
|
+
// Authors who want a different favicon override via the
|
|
1248
|
+
// `favicon` slot on DocsLayout, or drop the file at
|
|
1249
|
+
// `public/favicon.ico` in their Astro project (which is
|
|
1250
|
+
// what the default points at).
|
|
1251
|
+
` favicon={${JSON.stringify(combined ? `${combined}/favicon.ico` : "/favicon.ico")}}`,
|
|
765
1252
|
` wideLayout={${wideLayout}}`,
|
|
766
1253
|
`>`,
|
|
767
1254
|
` <MarkdownContentStack>`,
|
|
@@ -798,6 +1285,7 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
798
1285
|
mkdirSync(dirname(pagePath), { recursive: true });
|
|
799
1286
|
writeFileSync(pagePath, pageLines.join("\n") + "\n");
|
|
800
1287
|
generated++;
|
|
1288
|
+
generatedPaths.add(relative(outputDir, pagePath));
|
|
801
1289
|
// Companion .md endpoint for content negotiation. Prerendered, so
|
|
802
1290
|
// it's served as a static asset at runtime — no Worker overhead.
|
|
803
1291
|
//
|
|
@@ -838,9 +1326,43 @@ export async function emitAstroPages(pages, nav, outputDir, options) {
|
|
|
838
1326
|
// redirect target), and writing a redirect would clobber it.
|
|
839
1327
|
if (basePath !== "") {
|
|
840
1328
|
const firstHref = findFirstNavHref(nav, basePath);
|
|
841
|
-
|
|
1329
|
+
const indexPath = join(outputDir, "src", "pages", "index.astro");
|
|
1330
|
+
writeFileSync(indexPath, `---\nreturn Astro.redirect("${firstHref}");\n---\n`);
|
|
1331
|
+
generatedPaths.add(relative(outputDir, indexPath));
|
|
842
1332
|
}
|
|
843
|
-
return { generated, outputNav };
|
|
1333
|
+
return { generated, outputNav, generatedPaths };
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Copy each passthrough `.astro` source to its computed output path.
|
|
1337
|
+
* Aborts with a clear error if the destination is already in
|
|
1338
|
+
* `generatedPaths` (a generated page from `emitAstroPages` would
|
|
1339
|
+
* silently overwrite the hand-authored file otherwise).
|
|
1340
|
+
*/
|
|
1341
|
+
export function emitPassthroughAstroPages(copies, outputDir, generatedPaths) {
|
|
1342
|
+
if (copies.length === 0)
|
|
1343
|
+
return { copied: 0 };
|
|
1344
|
+
// Collision detection — a generated page and a passthrough page
|
|
1345
|
+
// would write to the same file. Refuse to overwrite; tell the
|
|
1346
|
+
// author exactly which two files conflict.
|
|
1347
|
+
const collisions = [];
|
|
1348
|
+
for (const copy of copies) {
|
|
1349
|
+
if (generatedPaths.has(copy.outputRelPath)) {
|
|
1350
|
+
collisions.push(copy.outputRelPath);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (collisions.length > 0) {
|
|
1354
|
+
throw new Error(`Passthrough Astro page collides with a generated page:\n` +
|
|
1355
|
+
collisions.map((c) => ` - ${c}`).join("\n") + "\n" +
|
|
1356
|
+
`Rename the .astro source or remove the colliding entry from nav.yml.`);
|
|
1357
|
+
}
|
|
1358
|
+
let copied = 0;
|
|
1359
|
+
for (const copy of copies) {
|
|
1360
|
+
const dest = join(outputDir, copy.outputRelPath);
|
|
1361
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
1362
|
+
copyFileSync(copy.sourceAbs, dest);
|
|
1363
|
+
copied++;
|
|
1364
|
+
}
|
|
1365
|
+
return { copied };
|
|
844
1366
|
}
|
|
845
1367
|
// ─── Tier 1: config-derived ─────────────────────────────────────────────
|
|
846
1368
|
// Files driven entirely by config + flags. Always regenerated; site
|
|
@@ -856,6 +1378,54 @@ export function emitConfigDerivedFiles(outputDir, options) {
|
|
|
856
1378
|
const hasSiteUrl = Boolean(options.siteUrl && /^https?:\/\//.test(options.siteUrl));
|
|
857
1379
|
writeFileSync(join(outputDir, "public", "robots.txt"), buildRobotsTxt(options, hasSiteUrl));
|
|
858
1380
|
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Per-deploy-target artifact emission.
|
|
1383
|
+
*
|
|
1384
|
+
* Called from `emitSiteScaffold` (with `forceOverwrite=writeScaffold`
|
|
1385
|
+
* so `--force` regenerates from template) and from `dogsbay site
|
|
1386
|
+
* build` (with `forceOverwrite=false` so an existing site can adopt
|
|
1387
|
+
* a deploy target by editing config and rebuilding — the missing
|
|
1388
|
+
* artifact gets created on the next build).
|
|
1389
|
+
*
|
|
1390
|
+
* Emit policy is the union: write when forced OR when the file is
|
|
1391
|
+
* missing. Author edits to e.g. the workflow YAML survive every
|
|
1392
|
+
* regular build.
|
|
1393
|
+
*
|
|
1394
|
+
* Currently handles `github-pages` (workflow + .nojekyll). The
|
|
1395
|
+
* existing `cloudflare-workers` artifacts (wrangler.jsonc + package
|
|
1396
|
+
* scripts) stay in the scaffold-only path because they overlap with
|
|
1397
|
+
* scaffold-only files (package.json scripts, devDependencies). A
|
|
1398
|
+
* future refactor could fold them in here too.
|
|
1399
|
+
*/
|
|
1400
|
+
export function emitDeployArtifacts(outputDir, options, opts = { forceOverwrite: false }) {
|
|
1401
|
+
if (options.deploy === "github-pages") {
|
|
1402
|
+
// GitHub reads workflows from <repo-root>/.github/workflows/, NOT
|
|
1403
|
+
// from inside subdirectories. Use projectDir (the repo root) for
|
|
1404
|
+
// the workflow file; fall back to outputDir when unset (flat
|
|
1405
|
+
// `dogsbay convert` flows where the Astro project IS the repo).
|
|
1406
|
+
const projectDir = options.projectDir ?? outputDir;
|
|
1407
|
+
// Path of the Astro output relative to the project root. Used by
|
|
1408
|
+
// the workflow's working-directory + cache-dependency-path so
|
|
1409
|
+
// pnpm install / pnpm run build target the right place. Empty
|
|
1410
|
+
// string when outputDir === projectDir (flat layout).
|
|
1411
|
+
const astroDirRel = relative(projectDir, outputDir).replace(/\\/g, "/");
|
|
1412
|
+
const workflowPath = join(projectDir, ".github", "workflows", "deploy.yml");
|
|
1413
|
+
if (opts.forceOverwrite || !existsSync(workflowPath)) {
|
|
1414
|
+
mkdirSync(dirname(workflowPath), { recursive: true });
|
|
1415
|
+
writeFileSync(workflowPath, buildGitHubPagesWorkflow(astroDirRel));
|
|
1416
|
+
}
|
|
1417
|
+
// .nojekyll — must exist in the deployed artifact root so GH
|
|
1418
|
+
// Pages skips Jekyll's `_underscored-paths` filter (Astro's
|
|
1419
|
+
// `_astro/` chunk dir gets eaten otherwise). Lives inside the
|
|
1420
|
+
// Astro project's `public/` so it's copied into `dist/` at
|
|
1421
|
+
// build time.
|
|
1422
|
+
const nojekyllPath = join(outputDir, "public", ".nojekyll");
|
|
1423
|
+
mkdirSync(dirname(nojekyllPath), { recursive: true });
|
|
1424
|
+
if (opts.forceOverwrite || !existsSync(nojekyllPath)) {
|
|
1425
|
+
writeFileSync(nojekyllPath, "");
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
859
1429
|
/**
|
|
860
1430
|
* Emit `src/data/switcherMap.json` describing per-page
|
|
861
1431
|
* version + locale equivalents. Always writes the file —
|
|
@@ -872,7 +1442,10 @@ export function emitConfigDerivedFiles(outputDir, options) {
|
|
|
872
1442
|
* baseline page in a multi-version site).
|
|
873
1443
|
*/
|
|
874
1444
|
export function emitSwitcherMap(pages, outputDir, options) {
|
|
875
|
-
|
|
1445
|
+
// Switcher URLs use combined so the link the dropdown emits
|
|
1446
|
+
// resolves under the host's served subpath (GH Pages project
|
|
1447
|
+
// pages, multi-mount Cloudflare).
|
|
1448
|
+
const combined = combinedPrefix(options);
|
|
876
1449
|
const dataDir = join(outputDir, "src", "data");
|
|
877
1450
|
const outPath = join(dataDir, "switcherMap.json");
|
|
878
1451
|
// Detect axis activation by inspecting the data the loader
|
|
@@ -911,7 +1484,7 @@ export function emitSwitcherMap(pages, outputDir, options) {
|
|
|
911
1484
|
const variant = {
|
|
912
1485
|
...(ms.locale !== undefined ? { locale: ms.locale } : {}),
|
|
913
1486
|
...(ms.version !== undefined ? { version: ms.version } : {}),
|
|
914
|
-
url: `${
|
|
1487
|
+
url: `${combined}/${page.slug}`,
|
|
915
1488
|
};
|
|
916
1489
|
if (!byLogicalKey[key])
|
|
917
1490
|
byLogicalKey[key] = [];
|
|
@@ -989,6 +1562,10 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
|
|
|
989
1562
|
return;
|
|
990
1563
|
const basePath = normalizeBasePath(options.basePath);
|
|
991
1564
|
const baseSegments = basePathSegments(basePath);
|
|
1565
|
+
// combined drives the redirect URL (the user-facing path they
|
|
1566
|
+
// get bounced to); basePath stays the filesystem path under
|
|
1567
|
+
// src/pages/ where the stub lives.
|
|
1568
|
+
const combined = combinedPrefix(options);
|
|
992
1569
|
// Index existing pages by (slug after locale segment) so we
|
|
993
1570
|
// can detect missing translations cheaply. Key shape:
|
|
994
1571
|
// `<other-axis-prefix>/<originalSlug>` where other-axis-prefix
|
|
@@ -1022,7 +1599,7 @@ export function emitMissingTranslationStubs(pages, outputDir, options) {
|
|
|
1022
1599
|
const targetUrl = `${basePath}/${targetSlug}`;
|
|
1023
1600
|
if (existingByUrl.has(targetUrl))
|
|
1024
1601
|
continue; // already translated
|
|
1025
|
-
const defaultUrl = `${
|
|
1602
|
+
const defaultUrl = `${combined}/${defaultPage.slug}`;
|
|
1026
1603
|
const filePath = join(outputDir, "src", "pages", ...baseSegments, ...targetSlug.split("/"));
|
|
1027
1604
|
// Ensure parent dir exists; write a redirect-stub Astro
|
|
1028
1605
|
// file. Adding `.astro` to the leaf turns it into a
|
|
@@ -1064,10 +1641,20 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1064
1641
|
if (options.llmsTxt !== false) {
|
|
1065
1642
|
emitLlmsTxtFiles(outputDir, siteName, options, outputNav, pages);
|
|
1066
1643
|
// public/_headers — Cloudflare Workers / Pages convention. Adds an
|
|
1067
|
-
// RFC 8288 Link header pointing agents at
|
|
1068
|
-
// HTML. Emitted alongside
|
|
1644
|
+
// RFC 8288 Link header pointing agents at this mount's llms.txt
|
|
1645
|
+
// (basePath-prefixed) without parsing HTML. Emitted alongside
|
|
1646
|
+
// llms.txt so the two files travel together.
|
|
1069
1647
|
mkdirSync(join(outputDir, "public"), { recursive: true });
|
|
1070
|
-
|
|
1648
|
+
// _headers Link header points at the per-mount llms.txt at
|
|
1649
|
+
// <combined>/llms.txt — the URL agents would actually fetch.
|
|
1650
|
+
writeFileSync(join(outputDir, "public", "_headers"), buildHeadersFile(combinedPrefix(options)));
|
|
1651
|
+
}
|
|
1652
|
+
// Sitemap — emitted by Dogsbay (not @astrojs/sitemap) into
|
|
1653
|
+
// public/<basePath>/sitemap-{index,0}.xml so multi-mount deploys
|
|
1654
|
+
// don't collide at host root. Gated on a valid http(s) siteUrl
|
|
1655
|
+
// because <loc> entries must be absolute.
|
|
1656
|
+
if (options.siteUrl && /^https?:\/\//.test(options.siteUrl)) {
|
|
1657
|
+
emitSitemapFiles(outputDir, options, pages);
|
|
1071
1658
|
}
|
|
1072
1659
|
// src/middleware.ts — Tier 1 (always update). Drives both the
|
|
1073
1660
|
// `Accept: text/markdown` content-negotiation rewrite (via
|
|
@@ -1086,12 +1673,29 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1086
1673
|
const localeRedirectOn = options.defaultLocale !== undefined && knownLocales.length >= 2;
|
|
1087
1674
|
const axisRedirectOn = versionRedirectOn || localeRedirectOn;
|
|
1088
1675
|
if (mdMirrorOn || axisRedirectOn) {
|
|
1676
|
+
// Taxonomy index paths share a single global namespace across
|
|
1677
|
+
// locales / versions (one `/tags/` for the whole site, not one
|
|
1678
|
+
// per locale). The redirect helper has to know to skip them or
|
|
1679
|
+
// it will 302 chip hrefs to non-existent locale-prefixed routes.
|
|
1680
|
+
// Strip leading `/` and pull just the first segment so a config
|
|
1681
|
+
// like `/tags` becomes the global-prefix entry `tags`.
|
|
1682
|
+
const globalPrefixes = [];
|
|
1683
|
+
if (options.taxonomyIndexPaths) {
|
|
1684
|
+
for (const raw of Object.values(options.taxonomyIndexPaths)) {
|
|
1685
|
+
const first = raw.replace(/^\/+/, "").split("/")[0];
|
|
1686
|
+
if (first)
|
|
1687
|
+
globalPrefixes.push(first);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1089
1690
|
mkdirSync(join(outputDir, "src"), { recursive: true });
|
|
1090
1691
|
writeFileSync(join(outputDir, "src", "middleware.ts"), buildMiddlewareSource({
|
|
1091
1692
|
mdMirror: mdMirrorOn,
|
|
1092
1693
|
axisRedirect: axisRedirectOn
|
|
1093
1694
|
? {
|
|
1094
|
-
|
|
1695
|
+
// Middleware compares paths against the request URL,
|
|
1696
|
+
// which carries the host's served subpath — so use the
|
|
1697
|
+
// combined prefix here.
|
|
1698
|
+
basePath: combinedPrefix(options),
|
|
1095
1699
|
...(versionRedirectOn
|
|
1096
1700
|
? {
|
|
1097
1701
|
defaultVersion: options.defaultVersion,
|
|
@@ -1104,6 +1708,7 @@ export function emitAgentReadinessFiles(pages, outputNav, outputDir, siteName, o
|
|
|
1104
1708
|
knownLocales,
|
|
1105
1709
|
}
|
|
1106
1710
|
: {}),
|
|
1711
|
+
...(globalPrefixes.length > 0 ? { globalPrefixes } : {}),
|
|
1107
1712
|
}
|
|
1108
1713
|
: undefined,
|
|
1109
1714
|
}));
|
|
@@ -1164,21 +1769,49 @@ function buildRobotsTxt(options, hasSiteUrl) {
|
|
|
1164
1769
|
const aiInput = options.aiInput ?? "yes";
|
|
1165
1770
|
const aiTrain = options.aiTrain ?? "no";
|
|
1166
1771
|
const contentSignal = `Content-Signal: search=${search}, ai-input=${aiInput}, ai-train=${aiTrain}\n`;
|
|
1167
|
-
|
|
1168
|
-
|
|
1772
|
+
// Per-mount sitemap path: each Dogsbay site emits its sitemap
|
|
1773
|
+
// index under <basePath>/, so robots.txt must point there too.
|
|
1774
|
+
// (Multi-mount deploys end up with one robots.txt per site at
|
|
1775
|
+
// their respective hosts / paths; each correctly references its
|
|
1776
|
+
// own mount's sitemap-index.)
|
|
1777
|
+
// Sitemap URL = origin + combined + /sitemap-index.xml. Use the
|
|
1778
|
+
// origin (no path) from site.url and the combined prefix (urlBase
|
|
1779
|
+
// + basePath); siteUrl could itself include a path component when
|
|
1780
|
+
// hosting on a subpath (GH Pages project page), so we strip it
|
|
1781
|
+
// here to avoid double-counting.
|
|
1782
|
+
const { origin } = parseSiteUrl(options.siteUrl);
|
|
1783
|
+
const combined = combinedPrefix(options);
|
|
1784
|
+
const sitemap = hasSiteUrl && origin
|
|
1785
|
+
? `Sitemap: ${origin}${withBasePath(combined, "/sitemap-index.xml")}\n`
|
|
1169
1786
|
: "";
|
|
1170
|
-
|
|
1787
|
+
// Llms-Txt: line — non-standard but follows the same shape as
|
|
1788
|
+
// `Sitemap:`. Crawlers and agents that scan robots.txt before
|
|
1789
|
+
// fetching pages get a direct pointer at the per-mount llms.txt.
|
|
1790
|
+
// RFC 9309 explicitly permits unknown directives ("intentionally
|
|
1791
|
+
// permissive of such future extensions") so this is harmless to
|
|
1792
|
+
// standards-compliant parsers. Emitted alongside Sitemap when
|
|
1793
|
+
// siteUrl is set; absolute URLs only (relative paths would be
|
|
1794
|
+
// ambiguous without a base).
|
|
1795
|
+
const llmsTxt = options.llmsTxt !== false && hasSiteUrl && origin
|
|
1796
|
+
? `Llms-Txt: ${origin}${withBasePath(combined, "/llms.txt")}\n`
|
|
1797
|
+
: "";
|
|
1798
|
+
return `User-agent: *\nAllow: /\n${contentSignal}${sitemap}${llmsTxt}`;
|
|
1171
1799
|
}
|
|
1172
1800
|
/**
|
|
1173
1801
|
* Build the contents of `public/_headers` (Cloudflare Pages / Workers
|
|
1174
1802
|
* Static Assets convention). Emits a global RFC 8288 Link header
|
|
1175
|
-
* pointing at
|
|
1803
|
+
* pointing at this mount's llms.txt index, so agents don't need to
|
|
1176
1804
|
* parse HTML to discover the LLM-friendly content listing.
|
|
1805
|
+
*
|
|
1806
|
+
* The Link target is basePath-prefixed (`</docs/llms.txt>` for a
|
|
1807
|
+
* `/docs` mount) — matches where the platform actually emits
|
|
1808
|
+
* llms.txt under the per-mount layout.
|
|
1177
1809
|
*/
|
|
1178
|
-
function buildHeadersFile() {
|
|
1810
|
+
function buildHeadersFile(basePath) {
|
|
1811
|
+
const llmsHref = withBasePath(basePath, "/llms.txt");
|
|
1179
1812
|
return [
|
|
1180
1813
|
"/*",
|
|
1181
|
-
|
|
1814
|
+
` Link: <${llmsHref}>; rel="describedby"; type="text/plain"`,
|
|
1182
1815
|
"",
|
|
1183
1816
|
].join("\n");
|
|
1184
1817
|
}
|
|
@@ -1187,20 +1820,28 @@ function buildMiddlewareSource(config) {
|
|
|
1187
1820
|
"// AUTO-GENERATED by `dogsbay site build` — do not edit.",
|
|
1188
1821
|
"// Composes the docs-layout middleware helpers.",
|
|
1189
1822
|
"//",
|
|
1190
|
-
"//
|
|
1191
|
-
"//
|
|
1192
|
-
"//
|
|
1193
|
-
"//
|
|
1194
|
-
"//
|
|
1195
|
-
"//
|
|
1196
|
-
"//
|
|
1197
|
-
"//
|
|
1198
|
-
"//
|
|
1199
|
-
"//
|
|
1200
|
-
"//
|
|
1823
|
+
"// Static-prerender guard:",
|
|
1824
|
+
"// In Astro's static output mode, this middleware is invoked",
|
|
1825
|
+
"// for every prerendered route at build time. Reading",
|
|
1826
|
+
"// `context.request.headers` there triggers an Astro warning",
|
|
1827
|
+
"// per page (\"Astro.request.headers was used during static",
|
|
1828
|
+
"// render\"), which floods `dogsbay site build` / `site preview`",
|
|
1829
|
+
"// output. Worse, the negotiation can't actually happen at",
|
|
1830
|
+
"// build time — there's no runtime client whose Accept header",
|
|
1831
|
+
"// we'd be honoring.",
|
|
1832
|
+
"//",
|
|
1833
|
+
"// We guard with `context.isPrerendered` so prerendered routes",
|
|
1834
|
+
"// short-circuit to `next()` immediately. At runtime in static",
|
|
1835
|
+
"// deploys, middleware doesn't fire at all (no server); at",
|
|
1836
|
+
"// runtime in SSR / hybrid deploys, only dynamic routes fire,",
|
|
1837
|
+
"// which is exactly when negotiation makes sense.",
|
|
1201
1838
|
"//",
|
|
1202
|
-
"//
|
|
1203
|
-
"//
|
|
1839
|
+
"// Markdown content negotiation:",
|
|
1840
|
+
"// For pure-static deploys, `Accept: text/markdown` is honored",
|
|
1841
|
+
"// by the platform (Cloudflare _headers + Worker, Netlify Edge",
|
|
1842
|
+
"// functions). Agents that can't send Accept headers should",
|
|
1843
|
+
"// follow the page's <link rel=\"alternate\" type=\"text/markdown\">",
|
|
1844
|
+
"// to fetch the .md mirror directly (e.g. /docs.md).",
|
|
1204
1845
|
'import { defineMiddleware } from "astro:middleware";',
|
|
1205
1846
|
];
|
|
1206
1847
|
if (config.mdMirror) {
|
|
@@ -1214,6 +1855,11 @@ function buildMiddlewareSource(config) {
|
|
|
1214
1855
|
lines.push(`const AXIS_REDIRECT_CONFIG = ${JSON.stringify(config.axisRedirect, null, 2)};`, "");
|
|
1215
1856
|
}
|
|
1216
1857
|
lines.push("export const onRequest = defineMiddleware((context, next) => {");
|
|
1858
|
+
// Skip prerendered routes — see file-top comment for the rationale.
|
|
1859
|
+
// Avoids per-page Astro.request.headers warnings during build, and
|
|
1860
|
+
// matches runtime semantics (middleware doesn't fire on prerendered
|
|
1861
|
+
// routes when deployed).
|
|
1862
|
+
lines.push(" if (context.isPrerendered) return next();");
|
|
1217
1863
|
lines.push(" const url = new URL(context.request.url);");
|
|
1218
1864
|
if (config.mdMirror) {
|
|
1219
1865
|
lines.push(' const accept = context.request.headers.get("accept");', " const mdTarget = shouldRewriteToMarkdown(accept, url.pathname);", " if (mdTarget) return context.rewrite(mdTarget);");
|
|
@@ -1245,8 +1891,18 @@ function buildMdEndpoint(page, sourceRel) {
|
|
|
1245
1891
|
].join("\n");
|
|
1246
1892
|
}
|
|
1247
1893
|
/**
|
|
1248
|
-
* Emit
|
|
1249
|
-
*
|
|
1894
|
+
* Emit per-mount llms.txt + llms-full.txt + per-section indexes.
|
|
1895
|
+
*
|
|
1896
|
+
* Files live under `public/<basePath>/...` so multiple Dogsbay sites
|
|
1897
|
+
* can mount on the same host (`/docs/llms.txt` + `/api/llms.txt` +
|
|
1898
|
+
* `/handbook/llms.txt`) without colliding at the root. When basePath
|
|
1899
|
+
* is empty, this collapses to `public/llms.txt` — the single-site
|
|
1900
|
+
* llmstxt.org-spec layout.
|
|
1901
|
+
*
|
|
1902
|
+
* The host root `/llms.txt` is intentionally NOT emitted by the
|
|
1903
|
+
* platform: it's the user's umbrella file, analogous to
|
|
1904
|
+
* `sitemap-index.xml`. Multi-mount deploys hand-write a top-level
|
|
1905
|
+
* `/llms.txt` that links to each per-mount index.
|
|
1250
1906
|
*
|
|
1251
1907
|
* Per-section files are written for every top-level nav group that
|
|
1252
1908
|
* resolves to a site directory (either via `group.href` or via the
|
|
@@ -1258,26 +1914,92 @@ function emitLlmsTxtFiles(outputDir, siteName, options, nav, pages) {
|
|
|
1258
1914
|
description: options.description,
|
|
1259
1915
|
siteUrl: options.siteUrl,
|
|
1260
1916
|
};
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1917
|
+
// hrefPrefix is the COMBINED prefix — used for the URL paths that
|
|
1918
|
+
// appear inside the llms.txt body (so agents fetch the correct
|
|
1919
|
+
// host-relative URLs). Filesystem layout uses basePath alone:
|
|
1920
|
+
// `public/<basePath>/llms.txt` matches the existing per-mount
|
|
1921
|
+
// delivery shape.
|
|
1922
|
+
const hrefPrefix = combinedPrefix(options);
|
|
1923
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
1924
|
+
const baseSegments = basePathSegments(basePath);
|
|
1925
|
+
const mountDir = join(outputDir, "public", ...baseSegments);
|
|
1926
|
+
mkdirSync(mountDir, { recursive: true });
|
|
1927
|
+
writeFileSync(join(mountDir, "llms.txt"), buildLlmsTxt(siteConfig, nav, pages, { hrefPrefix }));
|
|
1928
|
+
writeFileSync(join(mountDir, "llms-full.txt"), buildLlmsFullTxt(siteConfig, nav, pages, {
|
|
1266
1929
|
summary: "body",
|
|
1267
1930
|
serializePage: serializePageMd,
|
|
1268
1931
|
hrefPrefix,
|
|
1269
1932
|
}));
|
|
1933
|
+
// Per-section files. `deriveSectionDir` returns a host-absolute
|
|
1934
|
+
// path derived from nav hrefs, which since the combined-prefix
|
|
1935
|
+
// refactor (commit 132891e) include urlBase + basePath — NOT just
|
|
1936
|
+
// basePath. So joining its return onto public/ directly would
|
|
1937
|
+
// double-prefix into `public/<urlBase>/<basePath>/<section>/llms.txt`,
|
|
1938
|
+
// which then serves at `<urlBase>/<urlBase>/<basePath>/<section>/...`
|
|
1939
|
+
// once Astro's base prefix is applied at request time.
|
|
1940
|
+
//
|
|
1941
|
+
// Strip the combined prefix off the section dir to get just the
|
|
1942
|
+
// section tail, then re-prepend basePath via mountDir. Result:
|
|
1943
|
+
// `public/<basePath>/<section>/llms.txt`, served under the deploy's
|
|
1944
|
+
// base mount as `<urlBase>/<basePath>/<section>/llms.txt`.
|
|
1945
|
+
const combinedSegs = hrefPrefix.replace(/^\//, "");
|
|
1270
1946
|
for (const group of nav) {
|
|
1271
1947
|
if (!group.children || group.children.length === 0)
|
|
1272
1948
|
continue;
|
|
1273
1949
|
const dir = deriveSectionDir(group);
|
|
1274
1950
|
if (!dir)
|
|
1275
1951
|
continue;
|
|
1276
|
-
|
|
1952
|
+
let relDir;
|
|
1953
|
+
if (combinedSegs && dir === combinedSegs) {
|
|
1954
|
+
relDir = "";
|
|
1955
|
+
}
|
|
1956
|
+
else if (combinedSegs && dir.startsWith(`${combinedSegs}/`)) {
|
|
1957
|
+
relDir = dir.slice(combinedSegs.length + 1);
|
|
1958
|
+
}
|
|
1959
|
+
else {
|
|
1960
|
+
// Defensive: if for some reason the dir doesn't carry the
|
|
1961
|
+
// combined prefix (older importer, manual nav.yml, etc.), fall
|
|
1962
|
+
// back to the raw value rather than rooting at /.
|
|
1963
|
+
relDir = dir;
|
|
1964
|
+
}
|
|
1965
|
+
const sectionPath = relDir
|
|
1966
|
+
? join(mountDir, relDir, "llms.txt")
|
|
1967
|
+
: join(mountDir, "llms.txt");
|
|
1277
1968
|
mkdirSync(dirname(sectionPath), { recursive: true });
|
|
1278
1969
|
writeFileSync(sectionPath, buildSectionLlmsTxt(siteConfig, group, pages, { hrefPrefix }));
|
|
1279
1970
|
}
|
|
1280
1971
|
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Emit per-mount sitemap files.
|
|
1974
|
+
*
|
|
1975
|
+
* Writes `public/<basePath>/sitemap-index.xml` + `sitemap-0.xml`.
|
|
1976
|
+
* The index lists the single sub-sitemap today; future splits add
|
|
1977
|
+
* more sub-sitemap entries as the page count grows past
|
|
1978
|
+
* sitemaps.org's 50K-URL recommendation.
|
|
1979
|
+
*
|
|
1980
|
+
* Caller has already guarded on a valid http(s) `siteUrl` — without
|
|
1981
|
+
* one, `<loc>` entries can't be absolute and crawlers reject the
|
|
1982
|
+
* file. Skip emission rather than write a broken sitemap.
|
|
1983
|
+
*/
|
|
1984
|
+
function emitSitemapFiles(outputDir, options, pages) {
|
|
1985
|
+
// Filesystem path uses basePath (sitemap files live in
|
|
1986
|
+
// public/<basePath>/sitemap-*.xml). The URL prefix encoded into
|
|
1987
|
+
// each <loc> uses combined so the absolute URLs resolve under the
|
|
1988
|
+
// host's served subpath. buildSitemap strips path off siteUrl
|
|
1989
|
+
// internally, so passing siteUrl + combined as basePath gives
|
|
1990
|
+
// origin + combined as the final URL.
|
|
1991
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
1992
|
+
const combined = combinedPrefix(options);
|
|
1993
|
+
const baseSegments = basePathSegments(basePath);
|
|
1994
|
+
const mountDir = join(outputDir, "public", ...baseSegments);
|
|
1995
|
+
mkdirSync(mountDir, { recursive: true });
|
|
1996
|
+
writeFileSync(join(mountDir, "sitemap-0.xml"), buildSitemap(pages, {
|
|
1997
|
+
siteUrl: options.siteUrl,
|
|
1998
|
+
basePath: combined,
|
|
1999
|
+
siteNoindex: options.noindex === true,
|
|
2000
|
+
}));
|
|
2001
|
+
writeFileSync(join(mountDir, "sitemap-index.xml"), buildSitemapIndex({ siteUrl: options.siteUrl, basePath: combined }));
|
|
2002
|
+
}
|
|
1281
2003
|
/**
|
|
1282
2004
|
* Pick a directory under `public/` for a top-level nav group. Prefers
|
|
1283
2005
|
* the group's own href (already a `/docs/x/y` path); otherwise falls
|
|
@@ -1355,6 +2077,11 @@ function copyComponents(outputDir) {
|
|
|
1355
2077
|
"response-tabs", "schema-viewer", "code-samples", "copy-button",
|
|
1356
2078
|
"markdown-example",
|
|
1357
2079
|
"accordion", "link-card", "avatar", "math",
|
|
2080
|
+
// Icon resolves @ui/icon/Icon.astro → built-time SVG inlining
|
|
2081
|
+
// via @dogsbay/icons. Used by `:::cards` `{icon=...}` and the
|
|
2082
|
+
// inline `:icon[name]` directive. Without this entry every page
|
|
2083
|
+
// emitting the icon import 500s with "module not found".
|
|
2084
|
+
"icon",
|
|
1358
2085
|
];
|
|
1359
2086
|
for (const name of needed) {
|
|
1360
2087
|
const src = join(componentsSource, name);
|
|
@@ -1409,6 +2136,32 @@ function copyAssets(sourceDir, outputDir, imageOptimization) {
|
|
|
1409
2136
|
catch { /* source may not exist */ }
|
|
1410
2137
|
}
|
|
1411
2138
|
// ── CSS generation (ported from import-mkdocs.ts) ───────
|
|
2139
|
+
/**
|
|
2140
|
+
* Build the `@source inline("...")` directive that pins the
|
|
2141
|
+
* grid-tone palette into the generated stylesheet.
|
|
2142
|
+
*
|
|
2143
|
+
* Why we need it: tone classes like `bg-primary/10` only appear in
|
|
2144
|
+
* `.astro` pages emitted by Dogsbay's grid-item serializer. When
|
|
2145
|
+
* Tailwind's content scanner doesn't pick them up — because the
|
|
2146
|
+
* page lives outside the default scan globs, or because a class
|
|
2147
|
+
* is composed at the boundary of an interpolation — they get
|
|
2148
|
+
* purged. Result observed in dogsbay-docs-markdown audit: half
|
|
2149
|
+
* the grid demo cells render with no background. Pinning forces
|
|
2150
|
+
* generation regardless of scanner reach.
|
|
2151
|
+
*
|
|
2152
|
+
* Single source of truth: derived from TONE_CLASSES so any new
|
|
2153
|
+
* tone added to the palette is automatically safelisted.
|
|
2154
|
+
*/
|
|
2155
|
+
function buildToneSafelist() {
|
|
2156
|
+
const seen = new Set();
|
|
2157
|
+
for (const classes of Object.values(TONE_CLASSES)) {
|
|
2158
|
+
for (const cls of classes.split(/\s+/)) {
|
|
2159
|
+
if (cls)
|
|
2160
|
+
seen.add(cls);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
return [...seen].sort().join(" ");
|
|
2164
|
+
}
|
|
1412
2165
|
function generateGlobalCss() {
|
|
1413
2166
|
return `@import "tailwindcss";
|
|
1414
2167
|
@import "./theme.css";
|
|
@@ -1417,6 +2170,14 @@ function generateGlobalCss() {
|
|
|
1417
2170
|
@source "../../node_modules/@dogsbay/ui/src";
|
|
1418
2171
|
@source "../../node_modules/@dogsbay/docs-layout/src";
|
|
1419
2172
|
|
|
2173
|
+
/* Pin the grid-tone palette. These classes are emitted into
|
|
2174
|
+
markdown-generated .astro pages by the grid-item serializer
|
|
2175
|
+
(TONE_CLASSES in @dogsbay/format-astro). Without inlining,
|
|
2176
|
+
opacity-modified utilities like bg-primary/10 get purged when
|
|
2177
|
+
Tailwind doesn't see them in the scanned globs, leaving grid
|
|
2178
|
+
demo cells with no visible background. */
|
|
2179
|
+
@source inline("${buildToneSafelist()}");
|
|
2180
|
+
|
|
1420
2181
|
/* Prose typography for rendered content */
|
|
1421
2182
|
.docs-prose {
|
|
1422
2183
|
line-height: 1.7;
|