@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 +10 -0
- package/README.md +2 -0
- package/dist/cli-args.d.ts +6 -0
- package/dist/cli-args.js +37 -6
- package/dist/cli.js +28 -1
- package/dist/watch.d.ts +57 -0
- package/dist/watch.js +294 -0
- package/package.json +3 -2
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
|
|
package/dist/cli-args.d.ts
CHANGED
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
|
|
42
|
-
// harmless extra: whoever passed it expects a site to appear
|
|
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
|
|
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
|
package/dist/watch.d.ts
ADDED
|
@@ -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.
|
|
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
|
}
|