@iyulab/canopy-page 0.8.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 +26 -0
- package/README.md +5 -0
- package/dist/cli-args.d.ts +6 -0
- package/dist/cli-args.js +37 -6
- package/dist/cli.js +28 -1
- package/dist/nav.d.ts +7 -0
- package/dist/nav.js +13 -4
- package/dist/site.js +14 -0
- 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,32 @@ 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
|
+
|
|
20
|
+
## [0.9.0] — 2026-08-11
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **`check` warns when a section's sidebar heading falls back to a raw directory name.** A
|
|
25
|
+
section with no `label` and no index page was already published this way — the fallback
|
|
26
|
+
itself is sound — but silently: the directory name it falls back to is a filesystem detail,
|
|
27
|
+
not a name an author chose, and there was previously no way to find out a sidebar's top-level
|
|
28
|
+
heading was about to read that way instead. `check`/`build` now report it as a warning (the
|
|
29
|
+
build still succeeds) naming the section and the label it fell back to.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **Upgraded to canopy 0.10.0**, the release the heading custom-id syntax, the sidebar
|
|
34
|
+
active-page tint, and page-content typography build on.
|
|
35
|
+
|
|
10
36
|
## [0.8.0] — 2026-08-11
|
|
11
37
|
|
|
12
38
|
### 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
|
|
|
@@ -217,6 +219,9 @@ Warnings — reported, and the build continues:
|
|
|
217
219
|
- An `exclude` pattern that matched nothing, which usually means a path written from the wrong
|
|
218
220
|
place. Extension patterns are left alone: `*.tmp` in a site with no scratch files is a rule
|
|
219
221
|
about what may never ship, not a claim that something is there
|
|
222
|
+
- A section with no `label` and no index page, whose sidebar heading falls back to its own
|
|
223
|
+
directory name — a filesystem detail, not a name anyone chose. Add a `label`, or an index page
|
|
224
|
+
for the section to name itself
|
|
220
225
|
|
|
221
226
|
Checking reads the settings and each page. It never renders, so it is fast enough to sit at the
|
|
222
227
|
front of a pipeline, at the scale a product manual reaches. References inside fenced
|
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/nav.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface NavTranslation {
|
|
|
42
42
|
orphans: string[];
|
|
43
43
|
/** Pages the settings place more than once. */
|
|
44
44
|
duplicates: string[];
|
|
45
|
+
/**
|
|
46
|
+
* Top-level section paths labeled by their own directory name because the
|
|
47
|
+
* settings file wrote no `label` and the section has no index page to name
|
|
48
|
+
* it instead — see `NavReport.rawSlugLabels` for why this is scoped to
|
|
49
|
+
* sections rather than every subdirectory that falls back the same way.
|
|
50
|
+
*/
|
|
51
|
+
rawSlugLabels: string[];
|
|
45
52
|
}
|
|
46
53
|
/**
|
|
47
54
|
* Turn settings plus the site's pages into the spec canopy builds from.
|
package/dist/nav.js
CHANGED
|
@@ -162,7 +162,10 @@ function translateSection(section, index, report) {
|
|
|
162
162
|
// Same rule as a derived directory: the page fronting a section names it, and
|
|
163
163
|
// only a section with no index page needs a name written for it here. A label
|
|
164
164
|
// in the settings file still wins — that is what writing one is for.
|
|
165
|
-
const
|
|
165
|
+
const usesRawSlugLabel = section.label === undefined && sectionIndex === undefined;
|
|
166
|
+
if (usesRawSlugLabel)
|
|
167
|
+
report.rawSlugLabels.push(section.path);
|
|
168
|
+
const label = section.label ?? (usesRawSlugLabel ? lastSegment(section.path) : undefined);
|
|
166
169
|
return {
|
|
167
170
|
...(label === undefined ? {} : { label }),
|
|
168
171
|
...(sectionIndex === undefined ? {} : { path: sectionIndex }),
|
|
@@ -191,14 +194,20 @@ function narrowTo(pages, index) {
|
|
|
191
194
|
export function translateNav(settings, index) {
|
|
192
195
|
const sections = settings.sections ?? [];
|
|
193
196
|
if (sections.length === 0) {
|
|
194
|
-
return { missing: [], orphans: [], duplicates: [] };
|
|
197
|
+
return { missing: [], orphans: [], duplicates: [], rawSlugLabels: [] };
|
|
195
198
|
}
|
|
196
199
|
const placements = new Map();
|
|
197
200
|
const missing = [];
|
|
201
|
+
const rawSlugLabels = [];
|
|
198
202
|
const place = (page) => {
|
|
199
203
|
placements.set(page, (placements.get(page) ?? 0) + 1);
|
|
200
204
|
};
|
|
201
|
-
const report = {
|
|
205
|
+
const report = {
|
|
206
|
+
missing,
|
|
207
|
+
place,
|
|
208
|
+
isPlaced: (page) => placements.has(page),
|
|
209
|
+
rawSlugLabels,
|
|
210
|
+
};
|
|
202
211
|
const items = sections.map((section) => translateSection(section, index, report));
|
|
203
212
|
// The root index is the site's home page: it is reached without navigation, so
|
|
204
213
|
// it is neither placed by a section nor counted as something nobody placed.
|
|
@@ -231,5 +240,5 @@ export function translateNav(settings, index) {
|
|
|
231
240
|
.filter(([, count]) => count > 1)
|
|
232
241
|
.map(([page]) => page)
|
|
233
242
|
.sort();
|
|
234
|
-
return { spec: { items }, missing, orphans, duplicates };
|
|
243
|
+
return { spec: { items }, missing, orphans, duplicates, rawSlugLabels };
|
|
235
244
|
}
|
package/dist/site.js
CHANGED
|
@@ -94,6 +94,20 @@ export function navFindings(nav) {
|
|
|
94
94
|
nav.orphans.map((page) => ` ${page}`).join("\n"),
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
|
+
for (const path of nav.rawSlugLabels) {
|
|
98
|
+
// Publishable either way — canopy-page's own fallback is sound, and the
|
|
99
|
+
// build is not wrong to use it. What is wrong is doing so silently: the
|
|
100
|
+
// directory name it falls back to is written for a filesystem, not a
|
|
101
|
+
// reader, and the author has no other way to find out their sidebar's
|
|
102
|
+
// top-level heading is about to read that way instead of a name they chose.
|
|
103
|
+
const slug = path.split("/").pop() ?? path;
|
|
104
|
+
findings.push({
|
|
105
|
+
level: "warning",
|
|
106
|
+
message: `settings: section "${path}" has no "label" and no index page, ` +
|
|
107
|
+
`so its sidebar heading falls back to the directory name "${slug}". ` +
|
|
108
|
+
'Add a "label", or an index page for the section to name itself',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
97
111
|
return findings;
|
|
98
112
|
}
|
|
99
113
|
/** Print findings in the order given, and report whether any of them stops a build. */
|
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.
|
|
56
|
+
"@iyulab/canopy": "^0.10.0",
|
|
57
|
+
"chokidar": "^5.0.0"
|
|
57
58
|
}
|
|
58
59
|
}
|