@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 +36 -0
- package/README.md +16 -2
- package/dist/assets/mobile-nav.js +81 -0
- package/dist/assets-bundle.js +3 -2
- package/dist/cli-args.d.ts +6 -0
- package/dist/cli-args.js +37 -6
- package/dist/cli.js +28 -1
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +1 -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,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
|
-
- **
|
|
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
|
+
})();
|
package/dist/assets-bundle.js
CHANGED
|
@@ -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
|
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/settings.d.ts
CHANGED
|
@@ -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
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.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.
|
|
56
|
+
"@iyulab/canopy": "^0.11.0",
|
|
57
|
+
"chokidar": "^5.0.0"
|
|
57
58
|
}
|
|
58
59
|
}
|