@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 +80 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/build.d.ts +17 -0
- package/dist/build.js +57 -0
- package/dist/canopy.d.ts +26 -0
- package/dist/canopy.js +44 -0
- package/dist/check.d.ts +10 -0
- package/dist/check.js +157 -0
- package/dist/cli-args.d.ts +24 -0
- package/dist/cli-args.js +70 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +36 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/init.d.ts +44 -0
- package/dist/init.js +54 -0
- package/dist/nav.d.ts +56 -0
- package/dist/nav.js +235 -0
- package/dist/references.d.ts +79 -0
- package/dist/references.js +181 -0
- package/dist/settings.d.ts +81 -0
- package/dist/settings.js +236 -0
- package/dist/site.d.ts +54 -0
- package/dist/site.js +108 -0
- package/dist/vault.d.ts +54 -0
- package/dist/vault.js +188 -0
- package/package.json +54 -0
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
|
package/dist/build.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/canopy.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/check.d.ts
ADDED
|
@@ -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;
|