@iyulab/canopy-page 0.9.0 → 0.11.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,42 @@ 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.11.0] — 2026-08-17
11
+
12
+ ### Added
13
+
14
+ - **`strings.breadcrumb`**, overriding the accessible label of the topbar's new ancestor-trail
15
+ nav (canopy 0.11.0). Rides the same validated `strings` object every other reader-chrome
16
+ override already does.
17
+
18
+ ### Changed
19
+
20
+ - **Upgraded to canopy 0.11.0** — collapsible sidebar groups and the breadcrumb trail (above),
21
+ a stronger sidebar current-page tint, a placeholder and icon on the search input, styled
22
+ topbar links, and the on-page outline now following the article instead of preceding it. See
23
+ [canopy's own changelog](https://github.com/iyulab/canopy/blob/main/CHANGELOG.md#0110--2026-08-17)
24
+ for the full list.
25
+
26
+ ### Fixed
27
+
28
+ - **The mobile nav no longer opens on every page load.** canopy's shell ships the site
29
+ navigation as `<details open>` unconditionally — the one default that works with no script at
30
+ all — but on a narrow viewport, that meant a reader landed on a full navigation (rendered as a
31
+ full-screen overlay by canopy's own mobile styling) in front of the article they followed a
32
+ link to read, every single page. canopy-page's script now closes it by default on a narrow
33
+ viewport and remembers a reader's own choice to leave it open for the rest of that browser
34
+ session. A wide viewport, and a build with no script attached, are unaffected.
35
+
36
+ ## [0.10.0] — 2026-08-11
37
+
38
+ ### Added
39
+
40
+ - **New `watch` command.** `canopy-page watch [site-dir] [-o out] [--port n]` builds once, then
41
+ rebuilds on every source change and serves the result on `http://localhost:<port>/` (default
42
+ `8080`, refused if already taken rather than silently moved). A failed rebuild is reported on
43
+ the console and leaves the last successful build being served — the process itself never exits
44
+ on a broken save.
45
+
10
46
  ## [0.9.0] — 2026-08-11
11
47
 
12
48
  ### Added
package/README.md CHANGED
@@ -40,7 +40,12 @@ go away.
40
40
  system setting
41
41
  - **Prev/next cards** linking to a page's neighbors in the sidebar's own order, and **backlinks**
42
42
  listing every page that points to it
43
- - **A full-screen menu on narrow screens**, rather than one that pushes the page's content down
43
+ - **Sidebar groups collapse**, open exactly along the path to the page you're on and closed
44
+ everywhere else
45
+ - **A breadcrumb trail** in the topbar, when there's a title, a logo, `home`, or search for it to
46
+ sit beside
47
+ - **A full-screen menu on narrow screens** that starts closed instead of opening on every page,
48
+ remembering a reader's own choice to leave it open for the rest of that visit
44
49
  - **Sitemap and `robots.txt`**, once `siteUrl` is set
45
50
 
46
51
  See it live at <https://iyulab.github.io/canopy-page>, or read
@@ -60,6 +65,7 @@ Node 22 or newer.
60
65
  npx canopy-page init docs/site # write a settings file (and a home page, if needed)
61
66
  npx canopy-page check docs/site # report anything broken, without building
62
67
  npx canopy-page build docs/site -o dist/help
68
+ npx canopy-page watch docs/site # rebuild on change, serve it locally
63
69
  ```
64
70
 
65
71
  `init` never replaces a settings file that is already there, and writes no page into a folder
@@ -75,6 +81,7 @@ fail. Both leave with a non-zero exit code when they do, which is all a pipeline
75
81
  | `canopy-page init [site-dir]` | Write a settings file naming the site after its folder |
76
82
  | `canopy-page check [site-dir]` | Check settings and references; build nothing |
77
83
  | `canopy-page build [site-dir] [-o out]` | Check, then publish to `out` (default `./site`) |
84
+ | `canopy-page watch [site-dir] [-o out] [--port n]` | Build, then rebuild on change and serve it locally (default port `8080`) |
78
85
 
79
86
  `[site-dir]` is the folder holding `settings.json`, and defaults to the current one.
80
87
 
@@ -110,7 +117,7 @@ unpublished.
110
117
  | `title` | Site name. Defaults to the folder's name |
111
118
  | `description` | Fills `<meta name="description">`, which is what link previews show |
112
119
  | `lang` | BCP 47 tag for `<html lang>`. Worth setting for any non-English site: assistive technology reads pronunciation from it |
113
- | `strings` | Overrides for the reader chrome's own text — `search`, `toggleTheme`, `siteNav`, `pageNav`, `onThisPage`, `indexTitle` (the auto-generated contents page's title/heading), `backlinks` (a page's "linked references" heading), `searchFailed` (the client search's failure message). `lang` only changes what `<html lang>` declares; this text is canopy's own UI or canopy-page's own search script, not vault content, so it stays English otherwise. No built-in translation table — the same reasoning `home.label` already follows: link text has to be written in the site's own language. Keys left out keep their English default |
120
+ | `strings` | Overrides for the reader chrome's own text — `search`, `toggleTheme`, `siteNav`, `pageNav`, `onThisPage`, `indexTitle` (the auto-generated contents page's title/heading), `backlinks` (a page's "linked references" heading), `breadcrumb` (the topbar's ancestor-trail nav's label), `searchFailed` (the client search's failure message). `lang` only changes what `<html lang>` declares; this text is canopy's own UI or canopy-page's own search script, not vault content, so it stays English otherwise. No built-in translation table — the same reasoning `home.label` already follows: link text has to be written in the site's own language. Keys left out keep their English default |
114
121
  | `icon` | Favicon, relative to the settings file. Must be a published file |
115
122
  | `tokens` | CSS of design-token overrides, relative to the settings file. Appended *after* canopy's own tokens, so a file naming one value keeps the rest. It is configuration rather than content, so — unlike `icon` and `logo` — it is excluded from the published site automatically. Absent: canopy's default palette |
116
123
  | `logo` | Image shown beside the site title, relative to the settings file. Must be a published file — the opposite direction from `tokens`, because this one is content. Rendered with an empty `alt`, deliberately: the site title beside it already names the site, so there is no separate text to give it. Absent: the sidebar header shows the title text alone |
@@ -143,6 +150,13 @@ specificity over a bare selector — so a bare `:root` appended after that block
143
150
  schemes. A one-block file naming only a light-mode colour would ship that colour onto a dark
144
151
  sidebar too.
145
152
 
153
+ Unlike `settings.json` (below), `tokens` gets no such validation — it's a plain CSS file appended
154
+ to canopy's own, and CSS silently ignores a custom property nothing reads. A file carried over
155
+ from a previous documentation tool's own token names (or a `.dark`/`.light` class toggle instead
156
+ of canopy's `[data-theme]` attribute) parses fine, builds fine, and changes nothing: the site
157
+ keeps canopy's default colours with no error anywhere. If a `tokens` override doesn't show up on
158
+ the built site, that silent mismatch — not a build failure — is the first thing to check.
159
+
146
160
  The settings file itself is never published, and neither is anything `exclude` names. A file
147
161
  named `settings.json` deeper in the site is content, and ships.
148
162
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * canopy-page's mobile nav default — vanilla JS, no dependencies. Wires the
3
+ * `<details class="canopy-nav" open>` disclosure canopy's shell always ships
4
+ * open: canopy itself writes no JavaScript (see canopy's docs/SCOPE.md), so
5
+ * a static page has no way to default that state per breakpoint on its own,
6
+ * and `open` has to pick one default for every viewport. It picks the safe
7
+ * one — a script-free reader on any screen still gets a working, fully
8
+ * visible navigation — but on a narrow viewport that leaves every fresh page
9
+ * load showing the full site navigation (canopy's shell renders an open
10
+ * `.canopy-nav` there as a full-screen overlay) in front of the article a
11
+ * reader actually followed a link to see.
12
+ *
13
+ * This script narrows that default, never widens it: a wide viewport is left
14
+ * exactly as canopy's shell already renders it (open, no toggle needed). On
15
+ * a narrow one it starts closed, unless the reader already chose to leave it
16
+ * open earlier in the same session — sessionStorage rather than
17
+ * localStorage, since a stale "open" choice from a different visit days ago
18
+ * would be more surprising than useful.
19
+ *
20
+ * `shouldOpenOnLoad` is exposed for tests: it is the one piece of this file
21
+ * with real logic, and it needs no DOM to run.
22
+ */
23
+ var CanopyMobileNav = (function () {
24
+ "use strict";
25
+
26
+ var STORAGE_KEY = "canopy-nav-open";
27
+ var NARROW_QUERY = "(max-width: 40rem)";
28
+
29
+ /**
30
+ * Whether the nav should start open, given the viewport it's rendering at
31
+ * and the reader's last explicit choice this session (undefined if they
32
+ * never toggled it). A wide viewport always opens — matching the shell's
33
+ * own always-there design there, which this script only ever narrows for
34
+ * a narrow viewport, never overrides for a wide one. A narrow viewport
35
+ * starts closed unless the reader explicitly left it open last time.
36
+ */
37
+ function shouldOpenOnLoad(isNarrowViewport, storedChoice) {
38
+ if (!isNarrowViewport) return true;
39
+ return storedChoice === "open";
40
+ }
41
+
42
+ function readStoredChoice() {
43
+ try {
44
+ return sessionStorage.getItem(STORAGE_KEY) || undefined;
45
+ } catch (e) {
46
+ // Storage access can throw (private browsing with storage disabled) —
47
+ // a reader who hits that just gets the narrow-viewport default every
48
+ // load, not a broken page.
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ function storeChoice(open) {
54
+ try {
55
+ sessionStorage.setItem(STORAGE_KEY, open ? "open" : "closed");
56
+ } catch (e) {
57
+ // Same as above: a reader who can't write storage just loses the
58
+ // memory across pages, not the toggle itself.
59
+ }
60
+ }
61
+
62
+ function main() {
63
+ var nav = document.querySelector(".canopy-nav");
64
+ if (!nav || typeof matchMedia !== "function") return;
65
+
66
+ var isNarrow = matchMedia(NARROW_QUERY).matches;
67
+ nav.open = shouldOpenOnLoad(isNarrow, readStoredChoice());
68
+
69
+ // A reader's own click (native <details> toggling, no JS involved in the
70
+ // click itself) still fires this — the same event a script-driven
71
+ // assignment above fires too, so the very first automatic close is
72
+ // recorded as "closed" for free rather than needing a separate write.
73
+ nav.addEventListener("toggle", function () {
74
+ storeChoice(nav.open);
75
+ });
76
+ }
77
+
78
+ if (typeof document !== "undefined") main();
79
+
80
+ return { shouldOpenOnLoad: shouldOpenOnLoad };
81
+ })();
@@ -34,10 +34,11 @@ const SEARCH_FAILED_DEFAULT = "Search failed to load.";
34
34
  * asset stays valid, readable JavaScript on its own.
35
35
  */
36
36
  export async function assembleScript(searchFailed) {
37
- const [search, scrollspy, themeToggle] = await Promise.all([
37
+ const [search, scrollspy, themeToggle, mobileNav] = await Promise.all([
38
38
  readAsset("search.js"),
39
39
  readAsset("scrollspy.js"),
40
40
  readAsset("theme-toggle.js"),
41
+ readAsset("mobile-nav.js"),
41
42
  ]);
42
43
  // A function replacer, not a replacement string: String.replace treats
43
44
  // "$&"/"$'"/"$$" etc. in a replacement string as patterns, and a site
@@ -45,7 +46,7 @@ export async function assembleScript(searchFailed) {
45
46
  const searchWithStrings = searchFailed === undefined
46
47
  ? search
47
48
  : search.replace(JSON.stringify(SEARCH_FAILED_DEFAULT), () => JSON.stringify(searchFailed));
48
- return `${searchWithStrings}\n${scrollspy}\n${themeToggle}`;
49
+ return `${searchWithStrings}\n${scrollspy}\n${themeToggle}\n${mobileNav}`;
49
50
  }
50
51
  /**
51
52
  * CSS canopy-page contributes on top of a site's own tokens, carried via
@@ -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
@@ -131,6 +131,8 @@ export interface Settings {
131
131
  indexTitle?: string;
132
132
  /** Heading over a page's list of pages that link to it. */
133
133
  backlinks?: string;
134
+ /** Accessible label for the topbar's ancestor-trail nav. */
135
+ breadcrumb?: string;
134
136
  /**
135
137
  * Message shown in place of results when the client search index fails to
136
138
  * load. This key rides the same JSON `--strings` flag as every other one
package/dist/settings.js CHANGED
@@ -67,6 +67,7 @@ export const STRINGS_KEYS = new Set([
67
67
  "onThisPage",
68
68
  "indexTitle",
69
69
  "backlinks",
70
+ "breadcrumb",
70
71
  "searchFailed",
71
72
  ]);
72
73
  export const NAV_ITEM_KEYS = new Set(["label", "path", "items"]);
@@ -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.11.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.11.0",
57
+ "chokidar": "^5.0.0"
57
58
  }
58
59
  }