@iyulab/canopy-page 0.9.0 → 0.10.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ Notable changes to canopy-page. The format follows
7
7
  The `settings.json` contract is what consuming projects plan their upgrades around, so changes
8
8
  to it — its fields, its validation, and what the checks reject — are what this file is about.
9
9
 
10
+ ## [0.10.0] — 2026-08-11
11
+
12
+ ### Added
13
+
14
+ - **New `watch` command.** `canopy-page watch [site-dir] [-o out] [--port n]` builds once, then
15
+ rebuilds on every source change and serves the result on `http://localhost:<port>/` (default
16
+ `8080`, refused if already taken rather than silently moved). A failed rebuild is reported on
17
+ the console and leaves the last successful build being served — the process itself never exits
18
+ on a broken save.
19
+
10
20
  ## [0.9.0] — 2026-08-11
11
21
 
12
22
  ### Added
package/README.md CHANGED
@@ -60,6 +60,7 @@ Node 22 or newer.
60
60
  npx canopy-page init docs/site # write a settings file (and a home page, if needed)
61
61
  npx canopy-page check docs/site # report anything broken, without building
62
62
  npx canopy-page build docs/site -o dist/help
63
+ npx canopy-page watch docs/site # rebuild on change, serve it locally
63
64
  ```
64
65
 
65
66
  `init` never replaces a settings file that is already there, and writes no page into a folder
@@ -75,6 +76,7 @@ fail. Both leave with a non-zero exit code when they do, which is all a pipeline
75
76
  | `canopy-page init [site-dir]` | Write a settings file naming the site after its folder |
76
77
  | `canopy-page check [site-dir]` | Check settings and references; build nothing |
77
78
  | `canopy-page build [site-dir] [-o out]` | Check, then publish to `out` (default `./site`) |
79
+ | `canopy-page watch [site-dir] [-o out] [--port n]` | Build, then rebuild on change and serve it locally (default port `8080`) |
78
80
 
79
81
  `[site-dir]` is the folder holding `settings.json`, and defaults to the current one.
80
82
 
@@ -12,6 +12,12 @@ export type ParsedArgs = {
12
12
  command: "build";
13
13
  dir: string;
14
14
  out: string;
15
+ } | {
16
+ ok: true;
17
+ command: "watch";
18
+ dir: string;
19
+ out: string;
20
+ port: number;
15
21
  } | {
16
22
  ok: true;
17
23
  command: "check" | "init";
package/dist/cli-args.js CHANGED
@@ -12,16 +12,19 @@ export const USAGE = [
12
12
  " init [site-dir] Start a site: write a settings file",
13
13
  " check [site-dir] Check the site without publishing it",
14
14
  " build [site-dir] Check the site, then publish it",
15
+ " watch [site-dir] Build, then rebuild on change and serve it",
15
16
  "",
16
17
  " [site-dir] Folder holding settings.json (defaults to .)",
17
- " -o, --out <dir> Where build writes the site (defaults to ./site)",
18
+ " -o, --out <dir> Where build/watch writes the site (defaults to ./site)",
19
+ " --port <n> Port watch serves on (defaults to 8080)",
18
20
  ].join("\n");
19
21
  const OUT_FLAGS = new Set(["-o", "--out"]);
22
+ const PORT_FLAGS = new Set(["--port"]);
20
23
  export function parseArgs(argv) {
21
24
  const [command, ...rest] = argv;
22
25
  if (command === undefined)
23
26
  return { ok: false, error: USAGE };
24
- if (command === "build" || command === "check" || command === "init") {
27
+ if (command === "build" || command === "check" || command === "init" || command === "watch") {
25
28
  return parseCommand(command, rest);
26
29
  }
27
30
  if (command.startsWith("-")) {
@@ -31,18 +34,30 @@ export function parseArgs(argv) {
31
34
  }
32
35
  return { ok: false, error: `${USAGE}\n\nUnknown command "${command}".` };
33
36
  }
37
+ function parsePort(value, flag) {
38
+ if (value === undefined || value.startsWith("-")) {
39
+ return { ok: false, error: `${flag} needs a port number.` };
40
+ }
41
+ const parsed = Number(value);
42
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
43
+ return { ok: false, error: `${flag} needs a port number between 1 and 65535, got "${value}".` };
44
+ }
45
+ return parsed;
46
+ }
34
47
  function parseCommand(command, argv) {
35
48
  const positional = [];
36
49
  let out;
50
+ let port;
37
51
  for (let i = 0; i < argv.length; i += 1) {
38
52
  const arg = argv[i];
39
53
  if (OUT_FLAGS.has(arg)) {
40
- if (command !== "build") {
41
- // Only build writes a site, so an output directory elsewhere is not a
42
- // harmless extra: whoever passed it expects a site to appear somewhere.
54
+ if (command !== "build" && command !== "watch") {
55
+ // Only build and watch write a site, so an output directory elsewhere
56
+ // is not a harmless extra: whoever passed it expects a site to appear
57
+ // somewhere.
43
58
  return {
44
59
  ok: false,
45
- error: `${arg} is for build, which writes a site; ${command} does not.`,
60
+ error: `${arg} is for build/watch, which write a site; ${command} does not.`,
46
61
  };
47
62
  }
48
63
  const value = argv[i + 1];
@@ -53,6 +68,20 @@ function parseCommand(command, argv) {
53
68
  i += 1;
54
69
  continue;
55
70
  }
71
+ if (PORT_FLAGS.has(arg)) {
72
+ if (command !== "watch") {
73
+ return {
74
+ ok: false,
75
+ error: `${arg} is for watch, which serves the site; ${command} does not.`,
76
+ };
77
+ }
78
+ const parsed = parsePort(argv[i + 1], arg);
79
+ if (typeof parsed !== "number")
80
+ return parsed;
81
+ port = parsed;
82
+ i += 1;
83
+ continue;
84
+ }
56
85
  if (arg.startsWith("-")) {
57
86
  return { ok: false, error: `${USAGE}\n\nUnknown option "${arg}".` };
58
87
  }
@@ -66,5 +95,7 @@ function parseCommand(command, argv) {
66
95
  const dir = positional[0] ?? ".";
67
96
  if (command === "build")
68
97
  return { ok: true, command, dir, out: out ?? "site" };
98
+ if (command === "watch")
99
+ return { ok: true, command, dir, out: out ?? "site", port: port ?? 8080 };
69
100
  return { ok: true, command, dir };
70
101
  }
package/dist/cli.js CHANGED
@@ -4,6 +4,7 @@ import { checkSite } from "./check.js";
4
4
  import { initSite, InitError } from "./init.js";
5
5
  import { parseArgs } from "./cli-args.js";
6
6
  import { SiteError } from "./site.js";
7
+ import { WatchError, watchSite } from "./watch.js";
7
8
  async function main() {
8
9
  const args = parseArgs(process.argv.slice(2));
9
10
  if (!args.ok) {
@@ -19,6 +20,32 @@ async function main() {
19
20
  console.log("canopy-page: run `canopy-page build` to publish it");
20
21
  return;
21
22
  }
23
+ if (args.command === "watch") {
24
+ const handle = await watchSite({ dir: args.dir, out: args.out, port: args.port });
25
+ if (handle === undefined) {
26
+ // The initial build already printed why — buildSite/reportFindings own
27
+ // that message, watch has nothing to add.
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+ let closing = false;
32
+ const shutdown = () => {
33
+ if (closing)
34
+ return;
35
+ closing = true;
36
+ void handle
37
+ .close()
38
+ .then(() => process.exit(0))
39
+ .catch((error) => {
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ console.error(`canopy-page: error while shutting down — ${message}`);
42
+ process.exit(1);
43
+ });
44
+ };
45
+ process.on("SIGINT", shutdown);
46
+ process.on("SIGTERM", shutdown);
47
+ return;
48
+ }
22
49
  process.exitCode =
23
50
  args.command === "build"
24
51
  ? await buildSite({ dir: args.dir, out: args.out })
@@ -27,7 +54,7 @@ async function main() {
27
54
  main().catch((error) => {
28
55
  // A site error is about the site, not about canopy-page: the message is the
29
56
  // whole of what a reader needs, and a stack trace on top of it only buries it.
30
- if (error instanceof SiteError || error instanceof InitError) {
57
+ if (error instanceof SiteError || error instanceof InitError || error instanceof WatchError) {
31
58
  console.error(`error: ${error.message}`);
32
59
  }
33
60
  else
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Something about running `watch` itself, as opposed to `check.ts`'s findings
3
+ * (about the site) or `InitError` (about `init`) — a port already in use is
4
+ * the case this exists for. The message is the whole of what a reader needs,
5
+ * same reasoning as `SiteError` in `site.ts`.
6
+ */
7
+ export declare class WatchError extends Error {
8
+ }
9
+ /** A running preview server, and how to stop it. */
10
+ export interface StaticServer {
11
+ /** The port actually bound — the same value passed in, unless it was 0. */
12
+ readonly port: number;
13
+ close(): Promise<void>;
14
+ }
15
+ /** Serve the files in `root` over HTTP on `port` (0 for an OS-assigned port). */
16
+ export declare function serveStatic(root: string, port: number): Promise<StaticServer>;
17
+ /** What a watch run needs to know. */
18
+ export interface WatchOptions {
19
+ /** Directory holding the settings file. */
20
+ dir: string;
21
+ /** Directory to write the site into, and to serve. */
22
+ out: string;
23
+ /** Port to serve on. */
24
+ port: number;
25
+ /** Called after every rebuild attempt (not the initial build) with its exit code. */
26
+ onRebuild?: (code: number) => void;
27
+ }
28
+ /** A running watch session, and how to stop it. */
29
+ export interface WatchHandle {
30
+ /** The port actually bound. */
31
+ readonly port: number;
32
+ close(): Promise<void>;
33
+ }
34
+ /**
35
+ * Build once, then rebuild on every source change and serve the result.
36
+ *
37
+ * The server binds before the first build runs: a port already in use is an
38
+ * environment problem that has nothing to do with the site, and failing on
39
+ * it immediately — via `WatchError`, which propagates uncaught to `cli.ts` —
40
+ * costs nothing. Binding after a multi-second build would waste that build
41
+ * on a run that was always going to fail.
42
+ *
43
+ * A failed *rebuild* (as opposed to this first build) is reported and
44
+ * nothing else: `buildSite`'s existing contract (an error means nothing is
45
+ * written) means the previous, working output just keeps being served. The
46
+ * process only ever stops on the initial build failing — there is nothing to
47
+ * serve yet, so the server is closed again — or on the caller closing it.
48
+ *
49
+ * `buildSite` is not guaranteed to only resolve with an exit code: a
50
+ * malformed `settings.json` (edited mid-session) or a transient fs error
51
+ * (e.g. a lock held by an editor's save, more likely here since the watcher
52
+ * below has no `awaitWriteFinish`) surfaces as a thrown `SiteError` instead.
53
+ * Both the initial build and every rebuild treat a throw exactly like a
54
+ * nonzero exit code — the alternative, letting a rebuild's rejection go
55
+ * uncaught, would take down the whole watch process over one bad save.
56
+ */
57
+ export declare function watchSite(options: WatchOptions): Promise<WatchHandle | undefined>;
package/dist/watch.js ADDED
@@ -0,0 +1,294 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import http from "node:http";
3
+ import { basename, extname, resolve, sep } from "node:path";
4
+ import { watch as watchFiles } from "chokidar";
5
+ import { buildSite } from "./build.js";
6
+ /**
7
+ * Serving a build's output locally during authoring.
8
+ *
9
+ * This is a preview server for one author on one machine, not a production
10
+ * origin: no range requests, no caching headers, no compression. The only
11
+ * thing it has to get right is not serving a path outside the directory it
12
+ * was told to serve.
13
+ */
14
+ const MIME_TYPES = {
15
+ ".html": "text/html; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8",
17
+ ".js": "text/javascript; charset=utf-8",
18
+ ".json": "application/json; charset=utf-8",
19
+ ".svg": "image/svg+xml",
20
+ ".png": "image/png",
21
+ ".jpg": "image/jpeg",
22
+ ".jpeg": "image/jpeg",
23
+ ".gif": "image/gif",
24
+ ".webp": "image/webp",
25
+ ".ico": "image/x-icon",
26
+ ".txt": "text/plain; charset=utf-8",
27
+ ".xml": "application/xml; charset=utf-8",
28
+ ".woff": "font/woff",
29
+ ".woff2": "font/woff2",
30
+ };
31
+ function contentType(filePath) {
32
+ return MIME_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
33
+ }
34
+ /**
35
+ * Check if a resolved file path contains any dot-prefixed segments (hidden files
36
+ * or directories). A request for `/.git/config` has `basename(...) === "config"`,
37
+ * which doesn't start with `.`, but the path contains the hidden segment `.git`.
38
+ * We reject any such path to avoid serving hidden version control, metadata, or
39
+ * temporary files that happen to exist under the served root.
40
+ */
41
+ function hasHiddenSegment(root, filePath) {
42
+ const relative = filePath.slice(root.length);
43
+ const segments = relative.split(sep).filter((s) => s.length > 0);
44
+ return segments.some((s) => s.startsWith("."));
45
+ }
46
+ /**
47
+ * Something about running `watch` itself, as opposed to `check.ts`'s findings
48
+ * (about the site) or `InitError` (about `init`) — a port already in use is
49
+ * the case this exists for. The message is the whole of what a reader needs,
50
+ * same reasoning as `SiteError` in `site.ts`.
51
+ */
52
+ export class WatchError extends Error {
53
+ }
54
+ /**
55
+ * Resolve a request URL to a path inside `root`, refusing anything that
56
+ * would land outside it.
57
+ *
58
+ * A percent-encoded "..%2F" is exactly as much a traversal attempt as a
59
+ * literal one, so decoding happens before the containment check runs — a
60
+ * check against the raw string would let the encoded form through.
61
+ *
62
+ * Malformed percent-encoding (e.g. %zz, lone %) throws URIError in
63
+ * decodeURIComponent — catching it here prevents an unhandled rejection
64
+ * that would hang the connection.
65
+ */
66
+ function resolveRequestPath(root, url) {
67
+ let decoded;
68
+ try {
69
+ decoded = decodeURIComponent(url.split("?")[0] ?? "/");
70
+ }
71
+ catch {
72
+ // Malformed percent-encoding results in 404
73
+ return undefined;
74
+ }
75
+ const target = resolve(root, decoded.replace(/^\/+/, ""));
76
+ const rootWithSep = root.endsWith(sep) ? root : root + sep;
77
+ if (target !== root && !target.startsWith(rootWithSep))
78
+ return undefined;
79
+ return target;
80
+ }
81
+ /** Resolve a request URL to the file that answers it, following one directory→index.html hop. */
82
+ async function resolveFile(root, url, triedIndex = false) {
83
+ const target = resolveRequestPath(root, url);
84
+ if (target === undefined)
85
+ return undefined;
86
+ try {
87
+ const stats = await stat(target);
88
+ if (stats.isDirectory()) {
89
+ if (triedIndex)
90
+ return undefined;
91
+ return resolveFile(root, `${url.replace(/\/?$/, "/")}index.html`, true);
92
+ }
93
+ return target;
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ /** Serve the files in `root` over HTTP on `port` (0 for an OS-assigned port). */
100
+ export function serveStatic(root, port) {
101
+ const absoluteRoot = resolve(root);
102
+ // Path segments this server never has a reason to answer for — the same
103
+ // reasoning `isSkippedDir` in vault.ts applies to what a build publishes.
104
+ const server = http.createServer((req, res) => {
105
+ void (async () => {
106
+ const file = await resolveFile(absoluteRoot, req.url ?? "/");
107
+ if (file === undefined || hasHiddenSegment(absoluteRoot, file)) {
108
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
109
+ res.end("Not Found");
110
+ return;
111
+ }
112
+ try {
113
+ const body = await readFile(file);
114
+ res.writeHead(200, { "content-type": contentType(file) });
115
+ res.end(body);
116
+ }
117
+ catch {
118
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
119
+ res.end("Not Found");
120
+ }
121
+ })();
122
+ });
123
+ return new Promise((resolvePromise, reject) => {
124
+ server.once("error", (error) => {
125
+ reject(error.code === "EADDRINUSE"
126
+ ? new WatchError(`Port ${port} is already in use — pick another with --port.`)
127
+ : error);
128
+ });
129
+ server.listen(port, () => {
130
+ const address = server.address();
131
+ const actualPort = typeof address === "object" && address !== null ? address.port : port;
132
+ resolvePromise({
133
+ port: actualPort,
134
+ close: () => new Promise((res, rej) => {
135
+ server.close((err) => (err ? rej(err) : res()));
136
+ }),
137
+ });
138
+ });
139
+ });
140
+ }
141
+ const DEBOUNCE_MS = 300;
142
+ /** `Error#message` if it's one, else a `String()` fallback for whatever else a rejection carries. */
143
+ function errorMessage(error) {
144
+ return error instanceof Error ? error.message : String(error);
145
+ }
146
+ /**
147
+ * Build once, then rebuild on every source change and serve the result.
148
+ *
149
+ * The server binds before the first build runs: a port already in use is an
150
+ * environment problem that has nothing to do with the site, and failing on
151
+ * it immediately — via `WatchError`, which propagates uncaught to `cli.ts` —
152
+ * costs nothing. Binding after a multi-second build would waste that build
153
+ * on a run that was always going to fail.
154
+ *
155
+ * A failed *rebuild* (as opposed to this first build) is reported and
156
+ * nothing else: `buildSite`'s existing contract (an error means nothing is
157
+ * written) means the previous, working output just keeps being served. The
158
+ * process only ever stops on the initial build failing — there is nothing to
159
+ * serve yet, so the server is closed again — or on the caller closing it.
160
+ *
161
+ * `buildSite` is not guaranteed to only resolve with an exit code: a
162
+ * malformed `settings.json` (edited mid-session) or a transient fs error
163
+ * (e.g. a lock held by an editor's save, more likely here since the watcher
164
+ * below has no `awaitWriteFinish`) surfaces as a thrown `SiteError` instead.
165
+ * Both the initial build and every rebuild treat a throw exactly like a
166
+ * nonzero exit code — the alternative, letting a rebuild's rejection go
167
+ * uncaught, would take down the whole watch process over one bad save.
168
+ */
169
+ export async function watchSite(options) {
170
+ const { dir, out, port, onRebuild } = options;
171
+ const server = await serveStatic(out, port);
172
+ let initialCode;
173
+ try {
174
+ initialCode = await buildSite({ dir, out });
175
+ }
176
+ catch (error) {
177
+ console.error(`canopy-page: build failed — ${errorMessage(error)}`);
178
+ await server.close();
179
+ return undefined;
180
+ }
181
+ if (initialCode !== 0) {
182
+ await server.close();
183
+ return undefined;
184
+ }
185
+ const absoluteDir = resolve(dir);
186
+ const resolvedOut = resolve(out);
187
+ const outWithSep = resolvedOut.endsWith(sep) ? resolvedOut : resolvedOut + sep;
188
+ let rebuildTimer;
189
+ let rebuilding = false;
190
+ let rebuildQueued = false;
191
+ // Tracks the rebuild currently running (if any) so close() can drain it
192
+ // instead of resolving while it's still writing to `out` in the
193
+ // background — see close() below.
194
+ let currentRebuild;
195
+ function runRebuild() {
196
+ if (rebuilding) {
197
+ rebuildQueued = true;
198
+ return;
199
+ }
200
+ rebuilding = true;
201
+ currentRebuild = buildSite({ dir, out })
202
+ .then((code) => {
203
+ console.log(code === 0
204
+ ? "canopy-page: rebuilt"
205
+ : "canopy-page: rebuild failed — serving the last successful build");
206
+ onRebuild?.(code);
207
+ }, (error) => {
208
+ // Same outcome as a nonzero exit code — the last successful build
209
+ // keeps being served — reported distinctly since it's a different
210
+ // failure shape. onRebuild still fires so a caller waiting on it
211
+ // (a test, or a future CLI status line) doesn't hang forever.
212
+ console.error(`canopy-page: rebuild failed — serving the last successful build (${errorMessage(error)})`);
213
+ onRebuild?.(1);
214
+ })
215
+ .finally(() => {
216
+ rebuilding = false;
217
+ currentRebuild = undefined;
218
+ if (rebuildQueued) {
219
+ rebuildQueued = false;
220
+ runRebuild();
221
+ }
222
+ });
223
+ }
224
+ function scheduleRebuild() {
225
+ if (rebuildTimer !== undefined)
226
+ clearTimeout(rebuildTimer);
227
+ rebuildTimer = setTimeout(runRebuild, DEBOUNCE_MS);
228
+ }
229
+ // Ignoring is structural only — dot-directories, node_modules, and the
230
+ // resolved output directory itself (unignored, a build watching its own
231
+ // output would rebuild forever). Content-level exclusions (settings.exclude)
232
+ // are deliberately not repeated here: vault.ts already restates canopy's
233
+ // exclusion rules once as a tracked debt, and a watch trigger that fires on
234
+ // an excluded file costs one redundant rebuild, not a wrong answer.
235
+ const watcher = watchFiles(absoluteDir, {
236
+ ignoreInitial: true,
237
+ ignored: (watchedPath) => {
238
+ const resolved = resolve(watchedPath);
239
+ if (resolved === absoluteDir)
240
+ return false;
241
+ if (resolved === resolvedOut || resolved.startsWith(outWithSep))
242
+ return true;
243
+ const name = basename(resolved);
244
+ return name === "node_modules" || name.startsWith(".");
245
+ },
246
+ });
247
+ // chokidar.watch() returns before its initial directory scan finishes
248
+ // arming the underlying OS watches — a file saved in that window can go
249
+ // unnoticed. Waiting for "ready" here means the promise this function
250
+ // returns is only kept once a change is guaranteed to be caught, which is
251
+ // what lets a caller (a test, or a human saving a file right after start)
252
+ // trust that watch mode is actually watching.
253
+ await new Promise((res) => watcher.once("ready", res));
254
+ watcher.on("all", scheduleRebuild);
255
+ // chokidar emits "error" for real, plausible triggers — a watched folder
256
+ // renamed or deleted mid-session, EPERM on Windows, ENOSPC from an inotify
257
+ // watch limit on Linux, EACCES on an unreadable subdirectory. An
258
+ // EventEmitter's "error" event with no listener throws uncaught, which
259
+ // would take down the whole watch process — exactly what this file's other
260
+ // error handling (buildSite failures never killing the process) exists to
261
+ // avoid. Reporting and continuing is the same "keep running" contract.
262
+ watcher.on("error", (error) => {
263
+ console.error(`canopy-page: watch error — ${errorMessage(error)}`);
264
+ });
265
+ console.log(`canopy-page: watching ${dir}, serving http://localhost:${server.port}/`);
266
+ return {
267
+ port: server.port,
268
+ close: async () => {
269
+ if (rebuildTimer !== undefined)
270
+ clearTimeout(rebuildTimer);
271
+ // A rebuild queued behind one already in flight (see runRebuild) hasn't
272
+ // started yet — cancelling it here means close() doesn't leave a fresh,
273
+ // unsignaled canopy build spawning after it returns, same as clearing
274
+ // rebuildTimer above does for one that was merely scheduled.
275
+ rebuildQueued = false;
276
+ // Closing the watcher first means no further change can schedule a new
277
+ // rebuild while we drain below — only what's already running is left
278
+ // to wait out, since rebuildQueued was just cleared above.
279
+ await watcher.close();
280
+ // A rebuild already in flight keeps writing to `out` and calling
281
+ // onRebuild after this close() would otherwise have returned, which
282
+ // breaks the "everything this started has stopped" contract close()
283
+ // is supposed to have. The loop (rather than a single await) guards
284
+ // against runRebuild reassigning currentRebuild synchronously from
285
+ // inside the previous one's `finally` — which can no longer chain into
286
+ // an actual rebuild now that rebuildQueued is cleared, but leaves the
287
+ // loop here as the correct shape regardless.
288
+ while (currentRebuild !== undefined) {
289
+ await currentRebuild;
290
+ }
291
+ await server.close();
292
+ },
293
+ };
294
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/canopy-page",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Authoring pipeline for documentation sites: one settings file, integrity checks, and a build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -53,6 +53,7 @@
53
53
  "vitest": "^4.1.9"
54
54
  },
55
55
  "dependencies": {
56
- "@iyulab/canopy": "^0.10.0"
56
+ "@iyulab/canopy": "^0.10.0",
57
+ "chokidar": "^5.0.0"
57
58
  }
58
59
  }