@iyulab/canopy-page 0.1.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 ADDED
@@ -0,0 +1,80 @@
1
+ # Changelog
2
+
3
+ Notable changes to canopy-page. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ The `settings.json` contract is what consuming projects plan their upgrades around, so changes
8
+ to it — its fields, its validation, and what the checks reject — are what this file is about.
9
+
10
+ ## [0.1.0] — 2026-08-07
11
+
12
+ First release. Development before it is recorded here in one block rather than reconstructed as
13
+ versions that never shipped.
14
+
15
+ ### Added
16
+
17
+ - `settings.json`: one file beside the markdown holding everything a site needs to build. Every
18
+ field is an override, so `{}` is valid and a folder of markdown builds with navigation derived
19
+ from its folder tree. Fields: `title`, `description`, `lang`, `icon`, `exclude`, `sections`
20
+ - Strict, positional validation. An unknown key is rejected rather than ignored, since a mistyped
21
+ one that is quietly dropped presents as a tool disobeying its configuration, and every message
22
+ names the position it is about (`sections[0].items[1]`). Paths are normalized and refused if
23
+ they leave the site, at the setting that named them rather than later at a missing file
24
+ - `sections`: ordered regions of one site — a guide, a release log — with `label`, `order`
25
+ (`asc`/`desc`), or an explicit `items` list. Entries are page paths or nested groups, written
26
+ with or without an extension; `dir/*` and `dir/**` expand to the pages there that are not
27
+ placed already, so `["guide/install", "guide/*"]` reads as "this page first, then the rest".
28
+ Pages no section mentions are placed inside their own section and reported, rather than left
29
+ unreachable
30
+ - `canopy-page build [site-dir] [-o out]`: checks the site, then publishes it in one pass, so
31
+ links and backlinks resolve across the whole of it
32
+ - `canopy-page check [site-dir]`: the same checks without building — a settings reference
33
+ matching no page, a page placed twice, a link pointing at nothing published, an image that is
34
+ not there, a wikilink matching no page (which renders as plain text rather than as a broken
35
+ link, so the message says so). Findings name the page and line. Non-zero exit on any error,
36
+ which is its whole contract with a pipeline
37
+ - Warnings, which are reported without stopping a build: a root-absolute reference
38
+ (`/assets/logo.png`) with nothing published at that path, and an `exclude` pattern that matched
39
+ nothing. Where a root-absolute path resolves depends on what the site is served from, so it
40
+ cannot be called an error — but a site served from its own root is the ordinary case, and the
41
+ `public/`-style folder other generators map onto the root does not exist here, so such
42
+ references silently 404. An exclusion written from the wrong place — `_archive` for what is
43
+ really `docs/_archive` — leaves the folder published while the file reads as though it does not.
44
+ Extension patterns are left alone: `*.tmp` in a site with no scratch files states a rule about
45
+ what may never ship, not a claim that something is there
46
+ - `canopy-page init [site-dir]`: a settings file naming the site after its folder, and a home
47
+ page when there is nothing to publish yet. It never replaces an existing settings file, and
48
+ writes no page into a folder that already holds markdown
49
+
50
+ ### Notes
51
+
52
+ - A page is shown under the name canopy gives it — its frontmatter `title`, else the heading it
53
+ opens with, else its filename — and a section whose directory holds an index page is named by
54
+ that page. No `label` is written for those, because writing one would override the document's
55
+ own name with a directory name every time. `label` remains for the cases the documents cannot
56
+ answer, and still wins when written
57
+ - A reference target is percent-decoded before it is resolved, so `a%20b/note.md` and
58
+ `<a b/note.md>` are checked as the one document they address. Editors write the first form on
59
+ their own for any path containing a space — and this checker's own advice for a destination
60
+ that stops at a space is to write the space as `%20`, which it would otherwise have rejected.
61
+ Decoding is per segment, so `%2F` stays a character inside a name; a malformed escape leaves
62
+ the reference alone, which is what the renderer does with it
63
+ - Canopy is driven through its command line rather than its library API: it is the same door
64
+ every other consumer uses, and a door only stays wide enough if the people who could have gone
65
+ around it do not
66
+ - References inside fenced or inline code are not checked — a fenced example of a broken link is
67
+ documentation, not a broken link
68
+ - The settings file is never published. A file of the same name deeper in the site is content,
69
+ and ships
70
+ - The exclusion dialect is four shapes and no more: a directory, that directory and everything
71
+ beneath it, an extension at any depth, and one exact path. A pattern outside it — `images/*.md`
72
+ — is refused at validation rather than left to match nothing, which is the answer an unknown key
73
+ gets and for the same reason
74
+ - A link whose destination stops at a space is reported as that. An unbracketed destination ends
75
+ at the first space, so `[x](../a b/c.md)` links `../a`, and naming the truncated target alone
76
+ would describe something the author never wrote
77
+ - A section's heading links its own index page, so naming that page in `items` is not a second
78
+ placement. A page the settings genuinely list twice still is
79
+ - A reference ending in `/` names a directory, and is answered by the index page that directory is
80
+ entered by
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iyulab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,189 @@
1
+ # canopy-page
2
+
3
+ > One settings file, one command, one documentation site.
4
+
5
+ **canopy-page** turns a folder of markdown into a published documentation site. It owns the
6
+ authoring pipeline around the rendering: the settings a site is configured by, the checks that
7
+ keep broken references from shipping, and the build that ties them together. The rendering
8
+ itself is [canopy](https://github.com/iyulab/canopy)'s job, and canopy-page drives it.
9
+
10
+ ---
11
+
12
+ ## Why
13
+
14
+ Documentation sites in a product repository tend to be rebuilt from scratch each time: a site
15
+ generator, a hand-maintained sidebar, a script that checks images, another that lists release
16
+ notes newest-first. The parts that differ between products are small. The parts that repeat are
17
+ the ones this package holds.
18
+
19
+ - **One contract.** A `settings.json` beside the markdown, so a site is reproducible from its
20
+ source tree rather than from a build script holding half the configuration.
21
+ - **Checks that run before publishing.** A dead link costs nothing to find now and is expensive
22
+ to find after deployment, where it presents as a reader hitting a 404.
23
+ - **One site, built in one pass.** Links and backlinks resolve across the whole of it, so a guide
24
+ and the release notes it refers to stay connected.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ npm install --save-dev @iyulab/canopy-page
30
+ ```
31
+
32
+ Node 22 or newer.
33
+
34
+ ## Getting started
35
+
36
+ ```sh
37
+ npx canopy-page init docs/site # write a settings file (and a home page, if needed)
38
+ npx canopy-page check docs/site # report anything broken, without building
39
+ npx canopy-page build docs/site -o dist/help
40
+ ```
41
+
42
+ `init` never replaces a settings file that is already there, and writes no page into a folder
43
+ that already holds markdown — an existing set of documents is being adopted, not started.
44
+
45
+ `build` runs the same checks `check` does, on the same view of the site, and stops if any of them
46
+ fail. Both leave with a non-zero exit code when they do, which is all a pipeline needs.
47
+
48
+ ## Commands
49
+
50
+ | Command | What it does |
51
+ |---|---|
52
+ | `canopy-page init [site-dir]` | Write a settings file naming the site after its folder |
53
+ | `canopy-page check [site-dir]` | Check settings and references; build nothing |
54
+ | `canopy-page build [site-dir] [-o out]` | Check, then publish to `out` (default `./site`) |
55
+
56
+ `[site-dir]` is the folder holding `settings.json`, and defaults to the current one.
57
+
58
+ ## settings.json
59
+
60
+ Every field is an override, so `{}` is a valid settings file: a folder of markdown builds with
61
+ its navigation derived from the folder tree. Settings exist for what a tree cannot say by itself
62
+ — the order of a release log, a label that is not a directory name, a draft folder that stays
63
+ unpublished.
64
+
65
+ ```json
66
+ {
67
+ "title": "Product Help",
68
+ "description": "How to use it",
69
+ "lang": "en-GB",
70
+ "icon": "assets/favicon.png",
71
+ "exclude": ["_drafts", "*.tmp"],
72
+ "sections": [
73
+ { "path": "guide", "label": "Guide", "items": [
74
+ { "label": "Orders", "items": ["guide/orders/list", "guide/orders/detail"] },
75
+ "guide/settings/*"
76
+ ]},
77
+ { "path": "release-notes", "label": "Release notes", "order": "desc" }
78
+ ]
79
+ }
80
+ ```
81
+
82
+ | Field | Meaning |
83
+ |---|---|
84
+ | `title` | Site name. Defaults to the folder's name |
85
+ | `description` | Fills `<meta name="description">`, which is what link previews show |
86
+ | `lang` | BCP 47 tag for `<html lang>`. Worth setting for any non-English site: assistive technology reads pronunciation from it |
87
+ | `icon` | Favicon, relative to the settings file. Must be a published file |
88
+ | `exclude` | Paths to leave unpublished: a directory (`_drafts` or `_drafts/**`), an extension at any depth (`*.tmp`), or one exact path. Patterns are relative to the settings file, and a shape outside that list — `images/*.md` — is refused rather than left to match nothing |
89
+ | `sections` | Ordered regions of the site — see below |
90
+
91
+ The settings file itself is never published, and neither is anything `exclude` names. A file
92
+ named `settings.json` deeper in the site is content, and ships.
93
+
94
+ Validation is strict: an unknown key is rejected rather than ignored, because a mistyped one that
95
+ is quietly dropped looks like a tool disobeying its configuration. Every message names the
96
+ position it is about, down to `sections[0].items[1]`.
97
+
98
+ ### Sections
99
+
100
+ A settings file describes **one site**, built in one pass. `sections` name ordered regions within
101
+ it — a guide, a release log — rather than separate builds. Two genuinely independent sites are
102
+ two settings files.
103
+
104
+ | Field | Meaning |
105
+ |---|---|
106
+ | `path` | The directory this section covers |
107
+ | `label` | Heading shown for it. Defaults to the directory name |
108
+ | `order` | `asc` or `desc` for the pages inside. `desc` is what a release log wants |
109
+ | `items` | Explicit contents, in display order. Cannot be combined with `order` — a list *is* an order |
110
+
111
+ An entry in `items` is a page path, or a group:
112
+
113
+ ```json
114
+ { "label": "Orders", "items": ["guide/orders/list", "guide/orders/detail"] }
115
+ ```
116
+
117
+ Paths may be written with or without their extension. Two glob shapes are understood: `dir/*` is
118
+ the pages directly in a directory, `dir/**` is every page beneath it. A glob means the pages there
119
+ **that are not placed already**, which is what makes `["guide/install", "guide/*"]` read the way
120
+ it looks — this page first, then the rest.
121
+
122
+ Pages no section mentions are placed anyway, inside their own section where they have one, and
123
+ reported. A page that exists but cannot be reached is a worse outcome than one shown in an order
124
+ nobody chose, and listing three pages of a folder and forgetting the fourth describes an
125
+ oversight rather than a decision to hide it.
126
+
127
+ Where no ordering is asked for at all, no navigation spec is produced and canopy derives the
128
+ navigation itself. Ordering derived here follows file names rather than page titles.
129
+
130
+ A page is shown under the name canopy gives it: its frontmatter `title`, else the heading it opens
131
+ with, else its filename. That usually means a section needs no `label` at all — `label` is for the
132
+ cases the documents cannot answer, and it still overrides them when written. A section whose
133
+ directory holds an `index` page is named by that page for the same reason.
134
+
135
+ A section's heading already links its own index page, so naming that page in `items` asks for what
136
+ is there rather than for a second copy of it, and is not counted as placing it twice.
137
+
138
+ ## What `check` reports
139
+
140
+ Errors — these stop a build:
141
+
142
+ - A settings reference that matches no page, or a page placed more than once
143
+ - A link that points at nothing published, naming the page and the line
144
+ - An image that is not a published file
145
+ - A wikilink that matches no page. It renders as plain text rather than as a broken link, so the
146
+ message says so — otherwise nobody knows what they are looking for
147
+
148
+ A link whose destination stops at a space is reported as that, rather than as the truncated path
149
+ it becomes. An unbracketed destination ends at the first space — `[x](../a b/c.md)` links `../a`
150
+ and leaves the rest as text — so the target the message would otherwise name is one nobody wrote.
151
+
152
+ Warnings — reported, and the build continues:
153
+
154
+ - Pages no section covers
155
+ - A root-absolute reference (`/assets/logo.png`) with nothing published at that path. Where such a
156
+ path resolves depends on what the site is served from, which is not a checker's to know — but a
157
+ site served from its own root is the ordinary case, and a `public/`-style folder that other
158
+ generators map onto the root does not exist here, so these silently 404. A warning rather than
159
+ an error, because mounting the site elsewhere would make it right
160
+ - An `exclude` pattern that matched nothing, which usually means a path written from the wrong
161
+ place. Extension patterns are left alone: `*.tmp` in a site with no scratch files is a rule
162
+ about what may never ship, not a claim that something is there
163
+
164
+ Checking reads the settings and each page. It never renders, so it is fast enough to sit at the
165
+ front of a pipeline, at the scale a product manual reaches. References inside fenced
166
+ or inline code are ignored: a fenced example of a broken link is documentation, not a broken link.
167
+ What canopy states it leaves alone is left alone here too — absolute URLs, protocol-relative URLs,
168
+ bare fragments, and paths above the site root. A target ending in `/` names a directory, and is
169
+ answered by the index page that directory is entered by.
170
+
171
+ ## What belongs where
172
+
173
+ canopy-page owns the authoring pipeline; canopy owns the rendering.
174
+
175
+ | | canopy-page | canopy |
176
+ |---|---|---|
177
+ | Configuration | `settings.json` and its validation | — |
178
+ | Structure | sections, order, labels, globs | navigation tree, link resolution |
179
+ | Integrity | reference checks, exit codes | — |
180
+ | Output | — | HTML, assets, backlinks, outlines, site shell |
181
+
182
+ canopy is driven through its command line rather than its library API, on purpose: it is the same
183
+ door every other consumer uses, and a door only stays wide enough if the people who could have
184
+ gone around it do not. Where the command line cannot express something, that is worth raising
185
+ with canopy rather than working around here.
186
+
187
+ ## License
188
+
189
+ MIT
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Building a site: check first, then hand the whole of it to canopy in one pass.
3
+ *
4
+ * Checking before building is the order every documentation set that has been
5
+ * burned by this arrives at. A broken reference costs nothing to find now and
6
+ * is expensive to find after deployment, where it presents as a reader hitting
7
+ * a 404 rather than as a build saying which line is wrong.
8
+ */
9
+ /** Where a build reads from and writes to. */
10
+ export interface BuildOptions {
11
+ /** Directory holding the settings file. */
12
+ dir: string;
13
+ /** Directory to write the site into. */
14
+ out: string;
15
+ }
16
+ /** Build the site in `dir` into `out`, returning the exit code to leave with. */
17
+ export declare function buildSite({ dir, out }: BuildOptions): Promise<number>;
package/dist/build.js ADDED
@@ -0,0 +1,57 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { runCanopy } from "./canopy.js";
5
+ import { siteFindings } from "./check.js";
6
+ import { loadSite, reportFindings } from "./site.js";
7
+ /**
8
+ * Translate settings into canopy's arguments.
9
+ *
10
+ * Everything a settings file says about the site itself is already something
11
+ * canopy takes: this is a translation, not a layer of behaviour of its own. The
12
+ * navigation spec is the one thing that has to be materialized, since canopy
13
+ * reads it from a file.
14
+ */
15
+ function canopyArgs(site, out, navPath) {
16
+ const { settings } = site;
17
+ return [
18
+ "build",
19
+ site.root,
20
+ out,
21
+ ...(settings.title === undefined ? [] : ["--site-title", settings.title]),
22
+ ...(settings.description === undefined ? [] : ["--site-description", settings.description]),
23
+ ...(settings.lang === undefined ? [] : ["--lang", settings.lang]),
24
+ ...(settings.icon === undefined ? [] : ["--site-icon", settings.icon]),
25
+ ...(navPath === undefined ? [] : ["--nav", navPath]),
26
+ // The settings file is configuration rather than content, and canopy has no
27
+ // reason to know it exists; excluding it keeps it off the published site.
28
+ ...["--exclude", "settings.json"],
29
+ ...(settings.exclude ?? []).flatMap((pattern) => ["--exclude", pattern]),
30
+ ];
31
+ }
32
+ /** Build the site in `dir` into `out`, returning the exit code to leave with. */
33
+ export async function buildSite({ dir, out }) {
34
+ const site = await loadSite(dir);
35
+ // The same checks `check` runs, on the same view of the site, so a build can
36
+ // never publish something a passing check said was sound.
37
+ if (reportFindings(await siteFindings(site)))
38
+ return 1;
39
+ // The spec is derived from settings and means nothing on its own, so it lives
40
+ // in a temporary file rather than in the site or its output: writing it beside
41
+ // the source would leave a generated file for someone to edit by hand, and
42
+ // writing it into the output would ship it.
43
+ let workDir;
44
+ let navPath;
45
+ try {
46
+ if (site.nav.spec !== undefined) {
47
+ workDir = await mkdtemp(path.join(tmpdir(), "canopy-page-"));
48
+ navPath = path.join(workDir, "nav.json");
49
+ await writeFile(navPath, JSON.stringify(site.nav.spec, null, 2), "utf8");
50
+ }
51
+ return await runCanopy(canopyArgs(site, path.resolve(out), navPath));
52
+ }
53
+ finally {
54
+ if (workDir !== undefined)
55
+ await rm(workDir, { recursive: true, force: true });
56
+ }
57
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Running canopy.
3
+ *
4
+ * canopy-page drives canopy through its command line rather than importing its
5
+ * library, deliberately: it is the same door every other consumer uses, and a
6
+ * door only stays wide enough if the people who could have gone around it do
7
+ * not. A gap in the CLI that canopy-page routed around would be a gap nobody
8
+ * else gets fixed either.
9
+ *
10
+ * The executable is located through the dependency itself, so the version that
11
+ * runs is the version this package resolved — not whatever a `canopy` on the
12
+ * PATH happens to be.
13
+ */
14
+ /**
15
+ * Absolute path of canopy's executable.
16
+ *
17
+ * Resolved as a sibling of the package entry point, because the package does not
18
+ * expose its own `package.json` and so its `bin` declaration cannot be read.
19
+ * Spawning the JavaScript file with this process's Node is what keeps this
20
+ * working the same on every platform: `node_modules/.bin/canopy` is a shell
21
+ * script on one and a `.cmd` on another, and running either would mean handing
22
+ * arguments to a shell to re-parse.
23
+ */
24
+ export declare function canopyExecutable(): string;
25
+ /** Run canopy with the given arguments, inheriting stdio, and return its exit code. */
26
+ export declare function runCanopy(args: readonly string[]): Promise<number>;
package/dist/canopy.js ADDED
@@ -0,0 +1,44 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /**
5
+ * Running canopy.
6
+ *
7
+ * canopy-page drives canopy through its command line rather than importing its
8
+ * library, deliberately: it is the same door every other consumer uses, and a
9
+ * door only stays wide enough if the people who could have gone around it do
10
+ * not. A gap in the CLI that canopy-page routed around would be a gap nobody
11
+ * else gets fixed either.
12
+ *
13
+ * The executable is located through the dependency itself, so the version that
14
+ * runs is the version this package resolved — not whatever a `canopy` on the
15
+ * PATH happens to be.
16
+ */
17
+ /**
18
+ * Absolute path of canopy's executable.
19
+ *
20
+ * Resolved as a sibling of the package entry point, because the package does not
21
+ * expose its own `package.json` and so its `bin` declaration cannot be read.
22
+ * Spawning the JavaScript file with this process's Node is what keeps this
23
+ * working the same on every platform: `node_modules/.bin/canopy` is a shell
24
+ * script on one and a `.cmd` on another, and running either would mean handing
25
+ * arguments to a shell to re-parse.
26
+ */
27
+ export function canopyExecutable() {
28
+ const entry = fileURLToPath(import.meta.resolve("@iyulab/canopy"));
29
+ return path.join(path.dirname(entry), "cli.js");
30
+ }
31
+ /** Run canopy with the given arguments, inheriting stdio, and return its exit code. */
32
+ export async function runCanopy(args) {
33
+ const child = spawn(process.execPath, [canopyExecutable(), ...args], {
34
+ stdio: "inherit",
35
+ });
36
+ return new Promise((resolve, reject) => {
37
+ child.on("error", reject);
38
+ child.on("close", (code, signal) => {
39
+ // A signal death has no exit code, and reporting success for it would let
40
+ // a killed build pass a pipeline.
41
+ resolve(code ?? (signal === null ? 1 : 1));
42
+ });
43
+ });
44
+ }
@@ -0,0 +1,10 @@
1
+ import { type Finding, type LoadedSite } from "./site.js";
2
+ /** Check every page's references, returning one finding per broken reference. */
3
+ export declare function referenceFindings(site: LoadedSite): Promise<Finding[]>;
4
+ /**
5
+ * Everything worth saying about a site, in the order a reader wants it: what
6
+ * the settings got wrong first, then what the pages point at.
7
+ */
8
+ export declare function siteFindings(site: LoadedSite): Promise<Finding[]>;
9
+ /** Check the site in `dir`, returning the exit code to leave with. */
10
+ export declare function checkSite(dir: string): Promise<number>;
package/dist/check.js ADDED
@@ -0,0 +1,157 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { decodeTarget, extractReferences, isExternalUrl, resolveFrom, targetPath, } from "./references.js";
4
+ import { loadSite, navFindings, reportFindings, settingsFindings, } from "./site.js";
5
+ import { toPageKey } from "./vault.js";
6
+ /**
7
+ * Checking a site's references before it is published.
8
+ *
9
+ * Every finding here is something a reader would meet as a 404 or a missing
10
+ * image after deployment — the most expensive place to learn about it, and the
11
+ * one where nobody is watching a build log. Finding it costs a directory listing
12
+ * and a read of each page.
13
+ *
14
+ * The checker asks whether *anything* is at the other end of a reference, never
15
+ * which of several candidates the renderer would choose. That question has an
16
+ * answer only the renderer owns, and a checker that answered it differently
17
+ * would report failures on links that build perfectly well.
18
+ */
19
+ /**
20
+ * A path anchored to wherever the site is served from, rather than to the page.
21
+ *
22
+ * canopy leaves these exactly as written, because only the deployment knows what
23
+ * `/` is. That is a reason not to rewrite one — not a reason to say nothing
24
+ * about it. A site served from the root, which is the ordinary case, resolves
25
+ * these against its own root, and whether anything is there is a question this
26
+ * can answer.
27
+ */
28
+ function isRootAbsolute(url) {
29
+ return url.startsWith("/") && !url.startsWith("//");
30
+ }
31
+ /**
32
+ * Does anything published sit at this path — a page, a copied file, or the index
33
+ * page a directory is entered by?
34
+ *
35
+ * A target written with a trailing slash names a directory, and a directory is
36
+ * served by its index page. `/update-note/` is a correct link to a site holding
37
+ * `update-note/index.md`, so reading it as a missing file would report a working
38
+ * link as broken.
39
+ */
40
+ function existsInSite(site, sitePath) {
41
+ const directory = sitePath.endsWith("/");
42
+ const bare = directory ? sitePath.replace(/\/+$/, "") : sitePath;
43
+ if (directory)
44
+ return site.index.resolve(`${bare}/index`) !== undefined;
45
+ if (site.index.resolve(bare) !== undefined)
46
+ return true;
47
+ const key = bare.toLowerCase();
48
+ return site.index.assets.some((asset) => asset.toLowerCase() === key);
49
+ }
50
+ /**
51
+ * Does any page answer to this wikilink target?
52
+ *
53
+ * Wikilinks address a note by name or by path, tree-wide. Which note wins when a
54
+ * name is ambiguous is the renderer's rule; whether one exists at all is not, so
55
+ * that is all this asks.
56
+ */
57
+ function wikilinkExists(site, target) {
58
+ const key = toPageKey(target);
59
+ if (key.includes("/"))
60
+ return site.index.resolve(target) !== undefined;
61
+ return site.index.pages.some((page) => toPageKey(page).split("/").pop() === key);
62
+ }
63
+ /** Check every page's references, returning one finding per broken reference. */
64
+ export async function referenceFindings(site) {
65
+ const findings = [];
66
+ for (const page of site.index.pages) {
67
+ const markdown = await readFile(path.join(site.root, page), "utf8");
68
+ for (const reference of extractReferences(markdown)) {
69
+ const where = `${page}:${reference.line}`;
70
+ if (reference.kind === "wikilink") {
71
+ if (!wikilinkExists(site, reference.target)) {
72
+ findings.push({
73
+ level: "error",
74
+ // Naming the consequence matters: an unresolved wikilink is not left
75
+ // visibly broken, it renders as plain text, so nobody notices.
76
+ message: `${where}: [[${reference.target}]] matches no page, and will render as plain text`,
77
+ });
78
+ }
79
+ continue;
80
+ }
81
+ const url = targetPath(reference.target);
82
+ if (isRootAbsolute(url)) {
83
+ const atRoot = decodeTarget(url.replace(/^\/+/, "")) ?? url.replace(/^\/+/, "");
84
+ if (atRoot === "" || existsInSite(site, atRoot))
85
+ continue;
86
+ findings.push({
87
+ level: "warning",
88
+ message: `${where}: ${reference.kind} "${reference.target}" — ` +
89
+ `nothing is published at "${atRoot}". A root-absolute path resolves ` +
90
+ "against wherever the site is served from, so this is right only if " +
91
+ "something else answers it there",
92
+ });
93
+ continue;
94
+ }
95
+ if (isExternalUrl(url))
96
+ continue;
97
+ // Classified as a URL above, resolved as a path from here — so the
98
+ // encoding an editor wrote is undone only after the cases that are about
99
+ // URL syntax have been answered.
100
+ const decoded = decodeTarget(url);
101
+ // An escape the renderer cannot read either: it leaves the link as
102
+ // written, so there is no published target to hold the page to.
103
+ if (decoded === undefined)
104
+ continue;
105
+ const resolved = resolveFrom(page, decoded);
106
+ // A target that walks above the site root addresses something outside it,
107
+ // which the renderer leaves alone and this has no standing to judge.
108
+ if (resolved === undefined || resolved === "")
109
+ continue;
110
+ // Resolution drops a trailing slash along with the empty segment it makes,
111
+ // and with it the fact that the target named a directory.
112
+ if (existsInSite(site, decoded.endsWith("/") ? `${resolved}/` : resolved))
113
+ continue;
114
+ if (reference.cutAtSpace) {
115
+ findings.push({
116
+ level: "error",
117
+ message: `${where}: ${reference.kind} destination stops at a space, so it addresses ` +
118
+ `"${reference.target}" and the rest of the line is left as text. ` +
119
+ "Wrap the path in <> or write the space as %20",
120
+ });
121
+ continue;
122
+ }
123
+ findings.push({
124
+ level: "error",
125
+ message: reference.kind === "image"
126
+ ? `${where}: image "${reference.target}" is not a published file`
127
+ : `${where}: link "${reference.target}" points at nothing published`,
128
+ });
129
+ }
130
+ }
131
+ return findings;
132
+ }
133
+ /**
134
+ * Everything worth saying about a site, in the order a reader wants it: what
135
+ * the settings got wrong first, then what the pages point at.
136
+ */
137
+ export async function siteFindings(site) {
138
+ return [
139
+ ...settingsFindings(site),
140
+ ...navFindings(site.nav),
141
+ ...(await referenceFindings(site)),
142
+ ];
143
+ }
144
+ /** Check the site in `dir`, returning the exit code to leave with. */
145
+ export async function checkSite(dir) {
146
+ const site = await loadSite(dir);
147
+ const findings = await siteFindings(site);
148
+ const failed = reportFindings(findings);
149
+ if (!failed) {
150
+ // "nothing broken" after a screen of warnings reads as a contradiction, so
151
+ // the closing line says which of the two happened.
152
+ const warnings = findings.length;
153
+ console.log(`canopy-page: ${site.index.pages.length} page(s) checked, ` +
154
+ (warnings === 0 ? "nothing broken" : `nothing broken, ${warnings} warning(s)`));
155
+ }
156
+ return failed ? 1 : 0;
157
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Parsing the command line, kept apart from acting on it.
3
+ *
4
+ * Argument handling is where a tool is least forgiving and most tested: a
5
+ * mistyped flag has to say so rather than being ignored, since a build that
6
+ * quietly used a default nobody asked for is indistinguishable from a working
7
+ * one until the site is deployed.
8
+ */
9
+ /** A parsed invocation, or the reason it could not be parsed. */
10
+ export type ParsedArgs = {
11
+ ok: true;
12
+ command: "build";
13
+ dir: string;
14
+ out: string;
15
+ } | {
16
+ ok: true;
17
+ command: "check" | "init";
18
+ dir: string;
19
+ } | {
20
+ ok: false;
21
+ error: string;
22
+ };
23
+ export declare const USAGE: string;
24
+ export declare function parseArgs(argv: readonly string[]): ParsedArgs;