dogsbay 0.2.0-beta.7 → 0.2.0-beta.8

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,7 +1,7 @@
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
6
  * dev → astro dev (live HMR for already-built pages)
7
7
  * preview → astro build && astro preview
@@ -11,12 +11,16 @@
11
11
  * existing script) is *not* what runs here — we shell out to `astro`
12
12
  * directly so this works whether the user uses pnpm / npm / yarn.
13
13
  *
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.
14
+ * `site dev` also installs a content watcher that re-runs
15
+ * `dogsbay site build` whenever a markdown / yaml / json file under
16
+ * any configured source path changes (or `dogsbay.config.yml`
17
+ * itself). Astro's own dev server then hot-reloads the regenerated
18
+ * `.astro` pages. Without this, NEW files in `content/` weren't
19
+ * picked up by site dev — only edits to existing files surfaced
20
+ * (because Astro's watcher only sees `astro/src/`).
17
21
  */
18
22
  import { spawn } from "node:child_process";
19
- import { existsSync } from "node:fs";
23
+ import { existsSync, watch as fsWatch } from "node:fs";
20
24
  import { dirname, join, resolve } from "node:path";
21
25
  import pc from "picocolors";
22
26
  import { findConfig, loadConfig, resolveOutputDir } from "../config/index.js";
@@ -63,18 +67,26 @@ function runPackageManagerInstall(pm, cwd) {
63
67
  });
64
68
  }
65
69
  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);
70
+ const { siteRoot, outputDir } = await prepareForAstro(cwd, options);
71
+ const stopWatcher = startContentWatcher(siteRoot, outputDir, options);
72
+ try {
73
+ const code = await runner(outputDir, ["dev"]);
74
+ stopWatcher();
75
+ process.exit(code);
76
+ }
77
+ catch (err) {
78
+ stopWatcher();
79
+ throw err;
80
+ }
69
81
  }
70
82
  export async function sitePreview(cwd, options, runner = defaultRunner) {
71
- const siteRoot = await prepareForAstro(cwd, options);
83
+ const { outputDir } = await prepareForAstro(cwd, options);
72
84
  // Two-step: produce dist/ then serve it. Each spawn is independent
73
85
  // so we can still surface its exit code cleanly.
74
- const buildCode = await runner(siteRoot, ["build"]);
86
+ const buildCode = await runner(outputDir, ["build"]);
75
87
  if (buildCode !== 0)
76
88
  process.exit(buildCode);
77
- const previewCode = await runner(siteRoot, ["preview"]);
89
+ const previewCode = await runner(outputDir, ["preview"]);
78
90
  process.exit(previewCode);
79
91
  }
80
92
  async function prepareForAstro(cwd, options) {
@@ -117,7 +129,149 @@ async function prepareForAstro(cwd, options) {
117
129
  publish: options.full === true,
118
130
  });
119
131
  }
120
- return outputDir;
132
+ return { siteRoot, outputDir };
133
+ }
134
+ /**
135
+ * Watch the project's content paths + config file, and re-run
136
+ * `dogsbay site build` whenever something changes. Astro's own
137
+ * watcher then picks up the regenerated `astro/src/pages/*.astro`
138
+ * files and hot-reloads.
139
+ *
140
+ * Returns a cleanup fn that closes all the watchers.
141
+ *
142
+ * Debounces aggressively — many editors fire 3-5 fs events per save
143
+ * (write + rename + close-write etc.), and a single Vim save bursts
144
+ * across multiple files. 300ms is the sweet spot: low enough that
145
+ * the user feels the rebuild as immediate, high enough to coalesce
146
+ * a save burst into one rebuild.
147
+ */
148
+ function startContentWatcher(siteRoot, outputDir, options) {
149
+ // Re-load the config to know which paths to watch. We load it
150
+ // from disk on first event too (so config edits update the watch
151
+ // set on the fly).
152
+ let config = loadConfig(findOrFail(siteRoot, options.config));
153
+ // Track which paths we've armed a watcher on. fs.watch with
154
+ // recursive: true scoops up everything under each root.
155
+ const watchers = new Set();
156
+ const armed = new Set();
157
+ const arm = (root) => {
158
+ if (armed.has(root))
159
+ return;
160
+ if (!existsSync(root))
161
+ return;
162
+ armed.add(root);
163
+ try {
164
+ const w = fsWatch(root, { recursive: true }, (_event, filename) => {
165
+ if (!filename)
166
+ return;
167
+ const fname = filename.toString();
168
+ if (shouldIgnore(fname))
169
+ return;
170
+ scheduleBuild();
171
+ });
172
+ watchers.add(w);
173
+ }
174
+ catch (err) {
175
+ console.error(pc.yellow(` warn: could not watch ${root}: ${err.message}`));
176
+ }
177
+ };
178
+ // Always watch the config file itself (one level up — fs.watch on
179
+ // a single file works on macOS/Windows; on Linux some filesystems
180
+ // require watching the parent dir).
181
+ arm(siteRoot);
182
+ // Watch each local source path declared in the config.
183
+ for (const source of config.content?.sources ?? []) {
184
+ if (typeof source.path === "string") {
185
+ const abs = resolve(siteRoot, source.path);
186
+ arm(abs);
187
+ }
188
+ }
189
+ // Debounced rebuild loop. If a build is in flight when a new
190
+ // event arrives, mark dirty + rebuild after the current one
191
+ // finishes. Avoids overlapping builds racing on the same files.
192
+ let timer = null;
193
+ let building = false;
194
+ let dirty = false;
195
+ const scheduleBuild = () => {
196
+ dirty = true;
197
+ if (timer)
198
+ clearTimeout(timer);
199
+ timer = setTimeout(runBuild, 300);
200
+ };
201
+ const runBuild = async () => {
202
+ if (building)
203
+ return; // a build is in flight; the dirty flag handles re-run
204
+ if (!dirty)
205
+ return;
206
+ dirty = false;
207
+ building = true;
208
+ try {
209
+ console.log(pc.cyan("[dogsbay] content changed — rebuilding…"));
210
+ // Reload config to pick up dogsbay.config.yml edits.
211
+ config = loadConfig(findOrFail(siteRoot, options.config));
212
+ // Re-arm any newly-added source paths.
213
+ for (const source of config.content?.sources ?? []) {
214
+ if (typeof source.path === "string") {
215
+ arm(resolve(siteRoot, source.path));
216
+ }
217
+ }
218
+ await siteBuild(siteRoot, {
219
+ includeDrafts: true,
220
+ publish: options.full === true,
221
+ });
222
+ console.log(pc.green("[dogsbay] rebuild complete"));
223
+ }
224
+ catch (err) {
225
+ console.error(pc.red(`[dogsbay] rebuild failed: ${err.message}`));
226
+ }
227
+ finally {
228
+ building = false;
229
+ // Coalesced changes during the build → run again.
230
+ if (dirty)
231
+ setImmediate(runBuild);
232
+ }
233
+ };
234
+ // Diagnostic: show what's being watched on startup.
235
+ console.log(pc.gray(`[dogsbay] watching ${armed.size} path${armed.size === 1 ? "" : "s"} for content changes`));
236
+ return () => {
237
+ if (timer)
238
+ clearTimeout(timer);
239
+ for (const w of watchers) {
240
+ try {
241
+ w.close();
242
+ }
243
+ catch { /* ignore */ }
244
+ }
245
+ watchers.clear();
246
+ armed.clear();
247
+ };
248
+ }
249
+ /**
250
+ * Heuristic: skip events on paths the user doesn't care about.
251
+ * Filters out the output dir (would loop), VCS metadata, OS junk,
252
+ * and editor swap files.
253
+ */
254
+ function shouldIgnore(filename) {
255
+ if (filename.startsWith(".git/") || filename === ".git")
256
+ return true;
257
+ if (filename.startsWith("node_modules/"))
258
+ return true;
259
+ if (filename.startsWith("astro/"))
260
+ return true; // output dir — would loop
261
+ if (filename.startsWith("dist/"))
262
+ return true;
263
+ if (filename.startsWith(".dogsbay/"))
264
+ return true; // skill symlinks
265
+ if (filename.endsWith(".swp") || filename.endsWith(".swo"))
266
+ return true;
267
+ if (filename.endsWith("~"))
268
+ return true;
269
+ if (filename.endsWith(".DS_Store"))
270
+ return true;
271
+ return false;
272
+ }
273
+ function findOrFail(siteRoot, explicit) {
274
+ return resolveConfigPath(siteRoot, explicit);
121
275
  }
122
276
  function resolveConfigPath(startDir, explicit) {
123
277
  if (explicit) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dogsbay",
3
- "version": "0.2.0-beta.7",
3
+ "version": "0.2.0-beta.8",
4
4
  "description": "CLI for Dogsbay — scaffold, build, and serve documentation sites with markdown / MkDocs / Obsidian / OpenAPI sources",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,14 +31,14 @@
31
31
  "picocolors": "^1.1.0",
32
32
  "prompts": "^2.4.2",
33
33
  "yaml": "^2.8.3",
34
- "@dogsbay/format-mkdocs": "0.2.0-beta.7",
35
- "@dogsbay/format-astro": "0.2.0-beta.7",
36
- "@dogsbay/format-mdx": "0.2.0-beta.7",
37
- "@dogsbay/format-obsidian": "0.2.0-beta.7",
38
- "@dogsbay/format-starlight": "0.2.0-beta.7",
39
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.7",
40
- "@dogsbay/format-openapi": "0.2.0-beta.7",
41
- "@dogsbay/types": "0.2.0-beta.7"
34
+ "@dogsbay/format-mkdocs": "0.2.0-beta.8",
35
+ "@dogsbay/format-astro": "0.2.0-beta.8",
36
+ "@dogsbay/format-obsidian": "0.2.0-beta.8",
37
+ "@dogsbay/format-mdx": "0.2.0-beta.8",
38
+ "@dogsbay/format-starlight": "0.2.0-beta.8",
39
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.8",
40
+ "@dogsbay/format-openapi": "0.2.0-beta.8",
41
+ "@dogsbay/types": "0.2.0-beta.8"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^22.0.0",