dogsbay 0.2.0-beta.2 → 0.2.0-beta.21

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.
@@ -1,24 +1,34 @@
1
1
  /**
2
2
  * `dogsbay site dev` and `dogsbay site preview` — thin wrappers that
3
- * run `dogsbay site build` once, then hand control over to the Astro
4
- * CLI inside the site directory.
3
+ * run `dogsbay site build`, watch content for changes, and hand
4
+ * control over to the Astro CLI inside the site directory.
5
5
  *
6
- * dev → astro dev (live HMR for already-built pages)
7
- * preview → astro build && astro preview
6
+ * dev → astro dev (live HMR for already-built pages)
7
+ * preview → <pm> run build astro preview
8
8
  *
9
9
  * The site dir is found by locating `dogsbay.config.{yml,yaml,json}`
10
- * at or above cwd (or via --config). `npm run build` (the user's
11
- * existing script) is *not* what runs here — we shell out to `astro`
12
- * directly so this works whether the user uses pnpm / npm / yarn.
10
+ * at or above cwd (or via --config).
13
11
  *
14
- * Content file-watching is deferred. Today, editing source markdown
15
- * requires re-running `dogsbay site build` manually; Astro's dev
16
- * server reloads when the generated `.astro` files change.
12
+ * site dev shells `npx astro dev` directly — fast, no script
13
+ * indirection. site preview needs the production build to match
14
+ * what's actually deployed, which means running the scaffolded
15
+ * package.json `build` script (chains `astro build && pagefind
16
+ * --site dist` so Cmd+K search works in the previewed dist/). The
17
+ * package manager is auto-detected (pnpm if on PATH, otherwise npm).
18
+ *
19
+ * `site dev` also installs a content watcher that re-runs
20
+ * `dogsbay site build` whenever a markdown / yaml / json file under
21
+ * any configured source path changes (or `dogsbay.config.yml`
22
+ * itself). Astro's own dev server then hot-reloads the regenerated
23
+ * `.astro` pages. Without this, NEW files in `content/` weren't
24
+ * picked up by site dev — only edits to existing files surfaced
25
+ * (because Astro's watcher only sees `astro/src/`).
17
26
  */
18
27
  import { spawn } from "node:child_process";
19
28
  import { existsSync } from "node:fs";
20
29
  import { dirname, join, resolve } from "node:path";
21
30
  import pc from "picocolors";
31
+ import chokidar from "chokidar";
22
32
  import { findConfig, loadConfig, resolveOutputDir } from "../config/index.js";
23
33
  import { siteBuild } from "./site-build.js";
24
34
  const defaultRunner = (siteRoot, args) => new Promise((resolve) => {
@@ -32,6 +42,18 @@ const defaultRunner = (siteRoot, args) => new Promise((resolve) => {
32
42
  resolve(1);
33
43
  });
34
44
  });
45
+ const defaultBuildRunner = (siteRoot) => new Promise((resolve) => {
46
+ const pm = pickPackageManager();
47
+ const child = spawn(pm, ["run", "build"], {
48
+ cwd: siteRoot,
49
+ stdio: "inherit",
50
+ });
51
+ child.on("exit", (code) => resolve(code ?? 0));
52
+ child.on("error", (err) => {
53
+ console.error(pc.red(`Error: failed to spawn ${pm} run build: ${err.message}`));
54
+ resolve(1);
55
+ });
56
+ });
35
57
  /**
36
58
  * Pick the first available package manager from a preference list.
37
59
  * Defaults to pnpm (matches the dogsbay tooling chain); falls back
@@ -63,18 +85,28 @@ function runPackageManagerInstall(pm, cwd) {
63
85
  });
64
86
  }
65
87
  export async function siteDev(cwd, options, runner = defaultRunner) {
66
- const siteRoot = await prepareForAstro(cwd, options);
67
- const code = await runner(siteRoot, ["dev"]);
68
- process.exit(code);
88
+ const { siteRoot, outputDir } = await prepareForAstro(cwd, options);
89
+ const stopWatcher = startContentWatcher(siteRoot, outputDir, options);
90
+ try {
91
+ const code = await runner(outputDir, ["dev"]);
92
+ stopWatcher();
93
+ process.exit(code);
94
+ }
95
+ catch (err) {
96
+ stopWatcher();
97
+ throw err;
98
+ }
69
99
  }
70
- export async function sitePreview(cwd, options, runner = defaultRunner) {
71
- const siteRoot = await prepareForAstro(cwd, options);
72
- // Two-step: produce dist/ then serve it. Each spawn is independent
73
- // so we can still surface its exit code cleanly.
74
- const buildCode = await runner(siteRoot, ["build"]);
100
+ export async function sitePreview(cwd, options, runner = defaultRunner, buildRunner = defaultBuildRunner) {
101
+ const { outputDir } = await prepareForAstro(cwd, options);
102
+ // Two-step: produce dist/ via the scaffolded `build` script
103
+ // (astro build + pagefind), then serve it via astro preview. The
104
+ // build script is the source of truth — `astro build` alone would
105
+ // skip pagefind and Cmd+K search in the previewed dist/ would 404.
106
+ const buildCode = await buildRunner(outputDir);
75
107
  if (buildCode !== 0)
76
108
  process.exit(buildCode);
77
- const previewCode = await runner(siteRoot, ["preview"]);
109
+ const previewCode = await runner(outputDir, ["preview"]);
78
110
  process.exit(previewCode);
79
111
  }
80
112
  async function prepareForAstro(cwd, options) {
@@ -110,14 +142,140 @@ async function prepareForAstro(cwd, options) {
110
142
  if (!options.noBuild) {
111
143
  // Drafts visible during local preview — site dev is the writer's
112
144
  // iteration loop. Production `dogsbay site build` filters drafts.
113
- // Default mode is primary-only; `--full` opts into the publish
114
- // matrix for previewing switcher chrome.
145
+ // site dev defaults to primary-only for fast iteration; --full
146
+ // opts into the publish matrix for previewing switcher chrome.
147
+ // (Production `dogsbay site build` defaults to full matrix; only
148
+ // site dev / preview default to primary-only.)
115
149
  await siteBuild(siteRoot, {
116
150
  includeDrafts: true,
117
- publish: options.full === true,
151
+ primaryOnly: options.full !== true,
118
152
  });
119
153
  }
120
- return outputDir;
154
+ return { siteRoot, outputDir };
155
+ }
156
+ /**
157
+ * Watch the project's content paths + config file, and re-run
158
+ * `dogsbay site build` whenever something changes. Astro's own
159
+ * watcher then picks up the regenerated `astro/src/pages/*.astro`
160
+ * files and hot-reloads.
161
+ *
162
+ * Returns a cleanup fn that closes all the watchers.
163
+ *
164
+ * Debounces aggressively — many editors fire 3-5 fs events per save
165
+ * (write + rename + close-write etc.), and a single Vim save bursts
166
+ * across multiple files. 300ms is the sweet spot: low enough that
167
+ * the user feels the rebuild as immediate, high enough to coalesce
168
+ * a save burst into one rebuild.
169
+ */
170
+ function startContentWatcher(siteRoot, outputDir, options) {
171
+ // Re-load the config to know which paths to watch. We load it
172
+ // from disk on first event too (so config edits update the watch
173
+ // set on the fly).
174
+ let config = loadConfig(findOrFail(siteRoot, options.config));
175
+ // chokidar (vs Node's fs.watch) — fs.watch with `recursive: true`
176
+ // on Linux drops events from atomic-replace editor saves (Vim,
177
+ // VS Code, Helix all write tmp + rename), so nav.yml edits never
178
+ // surfaced as [dogsbay] rebuilds. chokidar handles atomic-replace
179
+ // correctly because it watches paths by name and re-arms on
180
+ // rename. `awaitWriteFinish` further coalesces multi-event saves
181
+ // into a single emit.
182
+ //
183
+ // Watch siteRoot recursively + each declared source path. The
184
+ // top-level `ignored` patterns keep us out of the output dir
185
+ // (otherwise rebuilds would self-trigger), VCS metadata, OS
186
+ // junk, and editor swap files.
187
+ const watchPaths = collectWatchPaths(siteRoot, config);
188
+ const watcher = chokidar.watch(watchPaths, {
189
+ ignoreInitial: true,
190
+ ignored: [
191
+ /(^|[\\/])\.git([\\/]|$)/,
192
+ /(^|[\\/])node_modules([\\/]|$)/,
193
+ /(^|[\\/])astro([\\/]|$)/,
194
+ /(^|[\\/])dist([\\/]|$)/,
195
+ /(^|[\\/])\.dogsbay([\\/]|$)/,
196
+ /\.swp$/,
197
+ /\.swo$/,
198
+ /~$/,
199
+ /\.DS_Store$/,
200
+ ],
201
+ awaitWriteFinish: {
202
+ stabilityThreshold: 50,
203
+ pollInterval: 10,
204
+ },
205
+ });
206
+ // Debounced rebuild loop. If a build is in flight when a new
207
+ // event arrives, mark dirty + rebuild after the current one
208
+ // finishes. Avoids overlapping builds racing on the same files.
209
+ let timer = null;
210
+ let building = false;
211
+ let dirty = false;
212
+ const scheduleBuild = () => {
213
+ dirty = true;
214
+ if (timer)
215
+ clearTimeout(timer);
216
+ timer = setTimeout(runBuild, 300);
217
+ };
218
+ const runBuild = async () => {
219
+ if (building)
220
+ return; // a build is in flight; the dirty flag handles re-run
221
+ if (!dirty)
222
+ return;
223
+ dirty = false;
224
+ building = true;
225
+ try {
226
+ console.log(pc.cyan("[dogsbay] content changed — rebuilding…"));
227
+ // Reload config to pick up dogsbay.config.yml edits.
228
+ config = loadConfig(findOrFail(siteRoot, options.config));
229
+ // Re-arm any newly-added source paths (chokidar dedupes
230
+ // already-watched roots internally).
231
+ const newPaths = collectWatchPaths(siteRoot, config);
232
+ watcher.add(newPaths);
233
+ await siteBuild(siteRoot, {
234
+ includeDrafts: true,
235
+ primaryOnly: options.full !== true,
236
+ });
237
+ console.log(pc.green("[dogsbay] rebuild complete"));
238
+ }
239
+ catch (err) {
240
+ console.error(pc.red(`[dogsbay] rebuild failed: ${err.message}`));
241
+ }
242
+ finally {
243
+ building = false;
244
+ // Coalesced changes during the build → run again.
245
+ if (dirty)
246
+ setImmediate(runBuild);
247
+ }
248
+ };
249
+ watcher.on("all", (_event, _path) => scheduleBuild());
250
+ // Diagnostic: show what's being watched on startup.
251
+ console.log(pc.gray(`[dogsbay] watching ${watchPaths.length} path${watchPaths.length === 1 ? "" : "s"} for content changes`));
252
+ return () => {
253
+ if (timer)
254
+ clearTimeout(timer);
255
+ void watcher.close();
256
+ };
257
+ }
258
+ /**
259
+ * Collect the absolute paths chokidar should watch: the site root
260
+ * (so `dogsbay.config.yml` edits trigger a reload) plus every
261
+ * declared `content.sources[].path`. Skips entries that don't exist
262
+ * on disk yet — chokidar would otherwise log a noisy ENOENT.
263
+ */
264
+ function collectWatchPaths(siteRoot, config) {
265
+ const out = new Set();
266
+ if (existsSync(siteRoot))
267
+ out.add(siteRoot);
268
+ for (const source of config.content?.sources ?? []) {
269
+ if (typeof source.path === "string") {
270
+ const abs = resolve(siteRoot, source.path);
271
+ if (existsSync(abs))
272
+ out.add(abs);
273
+ }
274
+ }
275
+ return [...out];
276
+ }
277
+ function findOrFail(siteRoot, explicit) {
278
+ return resolveConfigPath(siteRoot, explicit);
121
279
  }
122
280
  function resolveConfigPath(startDir, explicit) {
123
281
  if (explicit) {
@@ -101,61 +101,214 @@ export async function siteInit(targetDir, options) {
101
101
  }
102
102
  // Emit the static scaffold into the configured output dir
103
103
  // (default ./astro). The project root keeps only the config and
104
- // human-edited files (content/, theme/, public/).
104
+ // human-edited files (content/, theme/, public/). projectDir
105
+ // (= absTarget) lets deploy emitters write artifacts that GitHub
106
+ // Actions reads from the repo root rather than the Astro subdir.
105
107
  const outputDir = resolveOutputDir(config, configPath);
106
108
  const astroOpts = configToAstroOptions(config);
109
+ astroOpts.projectDir = absTarget;
107
110
  if (options.local)
108
111
  astroOpts.local = true;
109
112
  emitSiteScaffold(outputDir, config.site.name, astroOpts, true);
110
113
  // Seed starter content so the first `dogsbay site build` succeeds
111
- // without manual intervention. Only writes when this is a fresh
112
- // init (writeConfig=true; scaffold-only consumers already have
113
- // content) and the content dir doesn't exist yet — never
114
- // overwrites user files. See plans/beta-launch-followups.md.
115
- let starterContentPath = null;
114
+ // without manual intervention. Writes index.md, getting-started.md,
115
+ // and nav.yml when (a) this is a fresh init (writeConfig=true;
116
+ // scaffold-only consumers already have content) and (b) the
117
+ // content dir doesn't exist yet. Never overwrites user files. See
118
+ // plans/beta-launch-followups.md.
119
+ let starterContentPaths = [];
116
120
  if (writeConfig) {
117
- starterContentPath = seedStarterContent(absTarget, config);
121
+ starterContentPaths = seedStarterContent(absTarget, config);
118
122
  }
119
- printNextSteps(absTarget, outputDir, config, starterContentPath);
123
+ printNextSteps(absTarget, outputDir, config, starterContentPaths);
120
124
  }
121
125
  /**
122
- * Create a starter `<content>/index.md` if the content dir doesn't
123
- * already exist. Returns the path written, or null when nothing
124
- * was created (dir already populated). Never overwrites.
126
+ * Create a starter content set if the content dir doesn't already
127
+ * exist. Writes `index.md`, `getting-started.md`, and `nav.yml`
128
+ * so the user has a working multi-page site with a navigation
129
+ * structure to learn from. Returns the list of paths written, or
130
+ * an empty array when nothing was created. Never overwrites.
125
131
  */
126
132
  function seedStarterContent(absTarget, config) {
127
133
  const sources = config.content.sources ?? [];
128
134
  const first = sources[0];
129
135
  if (!first?.path)
130
- return null;
136
+ return [];
131
137
  const contentDir = isAbsolute(first.path)
132
138
  ? first.path
133
139
  : join(absTarget, first.path);
134
140
  if (existsSync(contentDir))
135
- return null; // user already has content
141
+ return []; // user already has content
136
142
  mkdirSync(contentDir, { recursive: true });
143
+ const written = [];
144
+ const siteName = config.site.name?.trim() || "your documentation site";
145
+ const basePath = config.site.basePath ?? "/docs";
137
146
  const indexPath = join(contentDir, "index.md");
138
147
  writeFileSync(indexPath, `---
139
- title: ${config.site.name || "Welcome"}
148
+ title: Welcome
140
149
  description: Edit content/index.md to get started.
141
150
  ---
142
151
 
143
- # ${config.site.name || "Welcome"}
152
+ # Welcome
144
153
 
145
- This is your starter page. Replace this content with your own
146
- markdown every \`.md\` file under \`content/\` becomes a page.
154
+ This is the starting point of ${siteName}, built with
155
+ [Dogsbay](https://github.com/dogsbay/dogsbay). Replace this
156
+ content with your own — every \`.md\` file under \`content/\` becomes
157
+ a page.
147
158
 
148
- ## What's next
159
+ > [!TIP]
160
+ > Edit this file (\`content/index.md\`) and save. The dev server
161
+ > reloads automatically.
149
162
 
150
- - Edit this file (\`content/index.md\`) to change the home page.
151
- - Add more \`.md\` files alongside it for additional pages.
152
- - Run \`dogsbay site dev\` to preview live as you edit.
153
- - See \`dogsbay.config.yml\` for site-wide settings (theme, agent
154
- readiness, deploy target, etc.).
163
+ ## Get started in 60 seconds
155
164
 
156
- For the full reference, see https://github.com/dogsbay/dogsbay.
165
+ :::steps
166
+ 1. **Edit a page**
167
+ Open \`content/index.md\` and change something. Save — the
168
+ preview updates live.
169
+
170
+ 2. **Add a page**
171
+ Create \`content/about.md\` with frontmatter and a heading.
172
+
173
+ 3. **Wire it in**
174
+ Add the new page to \`content/nav.yml\` so it shows up in the
175
+ sidebar.
176
+ :::
177
+
178
+ ## Where to go next
179
+
180
+ :::cards
181
+ - **[Getting started](${basePath}/getting-started)** {icon="rocket"}
182
+ Three-minute orientation to editing, adding, and grouping pages.
183
+
184
+ - **[Configuration](${basePath}/getting-started)** {icon="settings"}
185
+ Site name, theme, base path, and per-source settings live in
186
+ \`dogsbay.config.yml\`.
187
+
188
+ - **[Source on GitHub](https://github.com/dogsbay/dogsbay)** {icon="github"}
189
+ Star, browse, file an issue, or follow the roadmap.
190
+
191
+ - **[Plugins](https://github.com/dogsbay/dogsbay/tree/main/docs)** {icon="puzzle"}
192
+ Image zoom, TypeDoc, and your own — the plugin API is small,
193
+ typed, and explicit.
194
+ :::
195
+
196
+ ## Markdown that does more
197
+
198
+ Cards, steps, tabs, callouts, fenced code — Dogsbay markdown is
199
+ a small superset of CommonMark with directives for the components
200
+ docs sites need most. Here's the same config in two formats:
201
+
202
+ :::tabs
203
+ YAML
204
+ : \`\`\`yaml
205
+ site:
206
+ name: ${siteName.includes("documentation") ? "Acme Docs" : siteName}
207
+ url: https://example.com
208
+ content:
209
+ sources:
210
+ - path: ./content
211
+ from: dogsbay-md
212
+ \`\`\`
213
+
214
+ JSON
215
+ : \`\`\`json
216
+ {
217
+ "site": { "name": "${siteName.includes("documentation") ? "Acme Docs" : siteName}" },
218
+ "content": {
219
+ "sources": [{ "path": "./content", "from": "dogsbay-md" }]
220
+ }
221
+ }
222
+ \`\`\`
223
+ :::
224
+
225
+ For the full markdown reference, see
226
+ [github.com/dogsbay/dogsbay](https://github.com/dogsbay/dogsbay).
227
+ `);
228
+ written.push(indexPath);
229
+ const gettingStartedPath = join(contentDir, "getting-started.md");
230
+ writeFileSync(gettingStartedPath, `---
231
+ title: Getting started
232
+ description: Quick orientation for new contributors.
233
+ ---
234
+
235
+ # Getting started
236
+
237
+ Three minutes to your first edit. Powered by
238
+ [Dogsbay](https://github.com/dogsbay/dogsbay).
239
+
240
+ ## 1. Edit a page
241
+
242
+ Open \`content/index.md\` in your editor and change something.
243
+ Save — the dev server reloads automatically.
244
+
245
+ ## 2. Add a page
246
+
247
+ Create a new file like \`content/about.md\`:
248
+
249
+ \`\`\`md
250
+ ---
251
+ title: About
252
+ ---
253
+
254
+ # About
255
+
256
+ Whatever you want to say here.
257
+ \`\`\`
258
+
259
+ Then add it to \`content/nav.yml\` so it appears in the sidebar.
260
+ Each entry is a single-key map — key = label, value = file path:
261
+
262
+ \`\`\`yaml
263
+ - About: about.md
264
+ \`\`\`
265
+
266
+ External URLs work the same way:
267
+
268
+ \`\`\`yaml
269
+ - GitHub: https://github.com/your-org/your-repo
270
+ \`\`\`
271
+
272
+ ## 3. Group pages
273
+
274
+ To create a section in the sidebar, give the entry a list of
275
+ children instead of a single file:
276
+
277
+ \`\`\`yaml
278
+ - Guides:
279
+ - Configuration: guides/configuration.md
280
+ - Deployment: guides/deployment.md
281
+ \`\`\`
282
+
283
+ The folder structure under \`content/\` doesn't have to match the
284
+ nav — but it usually does, because it makes URLs predictable.
285
+ `);
286
+ written.push(gettingStartedPath);
287
+ const navPath = join(contentDir, "nav.yml");
288
+ writeFileSync(navPath, `# Sidebar navigation. Loaded by Dogsbay automatically — name this
289
+ # file nav.yml, nav.yaml, or nav.json (in that order of precedence).
290
+ #
291
+ # Each entry is a single-key map. The key is the sidebar label.
292
+ # The value is one of:
293
+ # - a file path (relative to content/, e.g. "guide.md")
294
+ # - an absolute URL (e.g. "https://...")
295
+ # - a list of child entries (creates a group)
296
+ #
297
+ # Examples:
298
+ # - Home: index.md # leaf — file path
299
+ # - GitHub: https://github.com/… # leaf — external URL
300
+ # - Guides: # group — children below
301
+ # - Configuration: guides/config.md
302
+ # - Deployment: guides/deploy.md
303
+ #
304
+ # Edit freely as you add or rearrange pages. See
305
+ # https://github.com/dogsbay/dogsbay for the full nav-file reference.
306
+
307
+ - Home: index.md
308
+ - Getting started: getting-started.md
157
309
  `);
158
- return indexPath;
310
+ written.push(navPath);
311
+ return written;
159
312
  }
160
313
  // ─── Resolution: flags + prompts → DogsbayConfig ─────────────────────────
161
314
  async function resolveConfig(opts, interactive) {
@@ -168,10 +321,15 @@ function resolveNonInteractive(opts) {
168
321
  if (!opts.siteName?.trim()) {
169
322
  throw new Error("--site-name is required when running non-interactively (no TTY or --yes).");
170
323
  }
171
- if (!opts.content?.trim()) {
172
- throw new Error("--content is required when running non-interactively (no TTY or --yes).");
173
- }
174
- return applyDefaults(buildConfig(opts));
324
+ // Default --content to ./content (matches the interactive default
325
+ // and the convention every other Dogsbay command assumes). This
326
+ // makes `dogsbay site init <dir> --yes --site-name X` Just Work
327
+ // without forcing the writer to remember a redundant flag.
328
+ const resolved = {
329
+ ...opts,
330
+ content: opts.content?.trim() || "./content",
331
+ };
332
+ return applyDefaults(buildConfig(resolved));
175
333
  }
176
334
  async function resolveInteractive(opts) {
177
335
  const answers = await prompts([
@@ -378,6 +536,9 @@ function buildConfig(opts) {
378
536
  if (opts.deploy === "cloudflare-workers") {
379
537
  config.deploy = { target: "cloudflare-workers" };
380
538
  }
539
+ else if (opts.deploy === "github-pages") {
540
+ config.deploy = { target: "github-pages" };
541
+ }
381
542
  if (opts.plausibleDomain?.trim()) {
382
543
  config.analytics = {
383
544
  plausible: {
@@ -401,14 +562,14 @@ function findExistingConfig(dir) {
401
562
  }
402
563
  return null;
403
564
  }
404
- function printNextSteps(absTarget, outputDir, config, starterContentPath) {
565
+ function printNextSteps(absTarget, outputDir, config, starterContentPaths) {
405
566
  void config;
406
567
  const sameDir = absTarget === outputDir;
407
568
  console.log("");
408
569
  console.log(pc.green("Wrote:"));
409
570
  console.log(` ${absTarget}/dogsbay.config.yml`);
410
- if (starterContentPath) {
411
- console.log(` ${starterContentPath}`);
571
+ for (const p of starterContentPaths) {
572
+ console.log(` ${p}`);
412
573
  }
413
574
  console.log(` ${outputDir}/package.json`);
414
575
  console.log(` ${outputDir}/astro.config.mjs`);
@@ -73,10 +73,17 @@ export function applyDefaults(config) {
73
73
  function fillTaxonomyDefaults(raw) {
74
74
  const out = {};
75
75
  for (const [name, entry] of Object.entries(raw)) {
76
+ // Declaring `prefixes:` (with their own labels / colors) is a strong
77
+ // signal the writer wants `/tags/<prefix>/` to be a real browsable
78
+ // index — not just a styling axis. Default `hierarchical` to true
79
+ // in that case so prefix-index routes get emitted and the
80
+ // breadcrumb / sub-tag links don't 404. Writers can opt out with
81
+ // `hierarchical: false` explicitly.
82
+ const hasPrefixes = entry.prefixes !== undefined && Object.keys(entry.prefixes).length > 0;
76
83
  out[name] = {
77
84
  indexPath: entry.indexPath ?? `/${name}`,
78
85
  values: entry.values,
79
- hierarchical: entry.hierarchical ?? false,
86
+ hierarchical: entry.hierarchical ?? hasPrefixes,
80
87
  prefixes: entry.prefixes,
81
88
  labels: entry.labels,
82
89
  };
@@ -260,38 +260,12 @@ function validateSite(site, sourcePath) {
260
260
  `or repeated slashes. Got ${JSON.stringify(s.basePath)}`);
261
261
  }
262
262
  }
263
- // Cross-field check: site.url must be host-only (no path) when
264
- // a non-empty basePath is set. Astro itself splits these as
265
- // `site` (origin) + `base` (path); double-counting the prefix
266
- // produces canonical URLs like https://example.com/docs/docs/.
267
- // Detect by parsing site.url as a URL and rejecting any non-`/`
268
- // pathname when basePath is configured.
269
- if (typeof s.url === "string" && s.url.length > 0) {
270
- let parsedUrl;
271
- try {
272
- parsedUrl = new URL(s.url);
273
- }
274
- catch {
275
- // Invalid URL — leave further checks for downstream Astro;
276
- // we only want to catch the host+path overlap here.
277
- }
278
- const basePath = typeof s.basePath === "string" ? s.basePath : undefined;
279
- const basePathIsSet = basePath !== undefined && basePath !== "" && basePath !== "/";
280
- if (parsedUrl && basePathIsSet) {
281
- const urlPath = parsedUrl.pathname.replace(/\/+$/, ""); // strip trailing /
282
- if (urlPath !== "" && urlPath !== "/") {
283
- const origin = `${parsedUrl.protocol}//${parsedUrl.host}`;
284
- throw new Error(`site.url must be host-only when site.basePath is set in ${sourcePath}; ` +
285
- `got ${JSON.stringify(s.url)}, expected ${JSON.stringify(origin)} ` +
286
- `with basePath ${JSON.stringify(basePath)}.\n\n` +
287
- `Astro splits these into:\n` +
288
- ` site: ${JSON.stringify(origin)}\n` +
289
- ` base: ${JSON.stringify(basePath)}\n` +
290
- `Without this fix, canonical URLs double-count the prefix ` +
291
- `(e.g. ${origin}${urlPath}${basePath}/).`);
292
- }
293
- }
294
- }
263
+ // site.url and site.basePath are now independent prefixes that
264
+ // compose at emit time. The path component of site.url drives
265
+ // Astro's `base` (where the served space sits in the host);
266
+ // basePath stays as content's filesystem position within that
267
+ // served space. See plans/astro-base-from-site-url.md and
268
+ // packages/format-astro/src/base-path.ts:resolvePrefixes.
295
269
  return s;
296
270
  }
297
271
  const VALID_FROM = [
@@ -22,6 +22,7 @@ export function configToAstroOptions(config) {
22
22
  plausibleDomain: config.analytics?.plausible?.domain,
23
23
  plausibleScriptUrl: config.analytics?.plausible?.scriptUrl,
24
24
  deploy: config.deploy?.target,
25
+ inlineStylesheets: config.build?.inlineStylesheets,
25
26
  llmsTxt: config.agent?.llmsTxt,
26
27
  mdMirror: config.agent?.mdMirror,
27
28
  aiTrain: config.agent?.contentSignal?.aiTrain,
@@ -1,4 +1,4 @@
1
- import { normalizeBasePath } from "@dogsbay/format-astro";
1
+ import { normalizeBasePath, resolvePrefixes } from "@dogsbay/format-astro";
2
2
  import { plugin as mkdocsPlugin } from "@dogsbay/format-mkdocs/cli";
3
3
  import { plugin as astroPlugin } from "@dogsbay/format-astro/cli";
4
4
  import { plugin as obsidianPlugin } from "@dogsbay/format-obsidian/cli";
@@ -396,16 +396,17 @@ function buildImportOptions(config, source) {
396
396
  if (config.taxonomies) {
397
397
  opts.taxonomyNames = Object.keys(config.taxonomies);
398
398
  }
399
- // Pass site.basePath through as `hrefPrefix` so nav-builders
400
- // produce hrefs matching where pages will actually be emitted.
401
- // `normalizeBasePath(undefined)` returns the platform default
402
- // (`/docs`) so we ALWAYS thread a value — leaving it unset would
403
- // force every importer to invent its own fallback (which they did,
404
- // inconsistently). Importers that care about basePath are free to
405
- // ignore it, but the default makes "namespace-prefixed nav builds
406
- // correctly" the path of least resistance. See
407
- // plans/configurable-base-path.md and the api-docs example in
408
- // plans/openapi-builtin.md.
409
- opts.hrefPrefix = normalizeBasePath(config.site.basePath);
399
+ // Pass the COMBINED URL prefix (urlBase from site.url's path +
400
+ // site.basePath) as `hrefPrefix` so nav-builders produce hrefs
401
+ // matching the served URL of each page. Two prefix layers
402
+ // because subpath-mounted deploys (GH Pages project pages,
403
+ // multi-mount Cloudflare under one Worker) add a host-level
404
+ // prefix on top of dogsbay's basePath. See
405
+ // plans/astro-base-from-site-url.md.
406
+ //
407
+ // Back-compat: when site.url is origin-only (no path), urlBase
408
+ // is empty and combined === basePath, so existing sites see no
409
+ // change.
410
+ opts.hrefPrefix = resolvePrefixes(config.site.url, config.site.basePath).combined;
410
411
  return opts;
411
412
  }