@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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Finding the references a document makes: links, images, wikilinks.
3
+ *
4
+ * A checker needs to know what a page points at before anything is built, which
5
+ * means reading markdown without rendering it. This is deliberately a smaller
6
+ * job than parsing: it looks for the shapes that address a file and ignores
7
+ * everything else about the document.
8
+ *
9
+ * Code is skipped — fenced blocks and inline spans — because a fenced example
10
+ * of a broken link is documentation, not a broken link. That is the one place a
11
+ * naive scan gets it loudly wrong, and it is the case documentation sites hit
12
+ * most, since they are full of examples.
13
+ *
14
+ * What this does *not* do is decide which page a reference resolves to when
15
+ * several could match. That question belongs to the renderer, and answering it
16
+ * differently here would produce a checker that disagrees with the build. The
17
+ * checker only ever asks whether anything is there.
18
+ */
19
+ const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
20
+ const INLINE_CODE = /`[^`]*`/g;
21
+ // `![alt](url)` and `[text](url)`, with the URL taken up to whitespace or `)`.
22
+ // Angle-bracketed URLs (`[x](<a b.md>)`) are the escape hatch for spaces.
23
+ const INLINE_LINK = /(!?)\[[^\]]*\]\(\s*(<[^>]*>|[^\s)]*)/g;
24
+ // `[id]: url "optional title"` — where a reference-style link's target lives.
25
+ const LINK_DEFINITION = /^\s{0,3}\[[^\]]+\]:\s*(<[^>]*>|\S+)/;
26
+ const WIKILINK = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
27
+ const HTML_IMG = /<img\b[^>]*\bsrc\s*=\s*["']([^"']*)["']/gi;
28
+ function unwrap(url) {
29
+ return url.startsWith("<") && url.endsWith(">") ? url.slice(1, -1) : url;
30
+ }
31
+ /**
32
+ * Did this destination stop at a space that was meant to be part of it?
33
+ *
34
+ * An unbracketed destination ends at the first space, so `(../a b/c.md)` links
35
+ * `../a` and leaves ` b/c.md` as text. What follows a real destination is
36
+ * optional whitespace, an optional quoted title, and `)`. Anything else means
37
+ * the path was cut, which is worth saying — the alternative is a message about
38
+ * a target the author never wrote.
39
+ */
40
+ function cutAtSpace(raw, after) {
41
+ if (raw.startsWith("<"))
42
+ return false;
43
+ const remainder = after.slice(0, after.indexOf(")") === -1 ? undefined : after.indexOf(")"));
44
+ const title = remainder.trim();
45
+ return title !== "" && !/^["'(]/.test(title);
46
+ }
47
+ /**
48
+ * Extract every reference in a markdown document, in document order.
49
+ *
50
+ * Frontmatter is left alone: it is metadata for the renderer, and a path in it
51
+ * means whatever the consuming site decides it means.
52
+ */
53
+ export function extractReferences(markdown) {
54
+ const references = [];
55
+ const lines = markdown.split(/\r?\n/);
56
+ let fence;
57
+ let inFrontmatter = lines[0]?.trim() === "---";
58
+ lines.forEach((rawLine, i) => {
59
+ const line = i + 1;
60
+ if (inFrontmatter) {
61
+ if (i > 0 && rawLine.trim() === "---")
62
+ inFrontmatter = false;
63
+ return;
64
+ }
65
+ const fenceMatch = FENCE.exec(rawLine);
66
+ if (fence !== undefined) {
67
+ // A fence closes on a marker of the same kind and at least the same length,
68
+ // so a longer fence can quote a shorter one.
69
+ if (fenceMatch?.[1]?.startsWith(fence[0]) && fenceMatch[1].length >= fence.length) {
70
+ fence = undefined;
71
+ }
72
+ return;
73
+ }
74
+ if (fenceMatch?.[1] !== undefined) {
75
+ fence = fenceMatch[1];
76
+ return;
77
+ }
78
+ const text = rawLine.replace(INLINE_CODE, "");
79
+ const definition = LINK_DEFINITION.exec(text);
80
+ if (definition?.[1] !== undefined) {
81
+ references.push({ target: unwrap(definition[1]), kind: "link", line });
82
+ return;
83
+ }
84
+ for (const match of text.matchAll(INLINE_LINK)) {
85
+ const raw = match[2] ?? "";
86
+ const target = unwrap(raw);
87
+ if (target === "")
88
+ continue;
89
+ references.push({
90
+ target,
91
+ kind: match[1] === "!" ? "image" : "link",
92
+ line,
93
+ ...(cutAtSpace(raw, text.slice(match.index + match[0].length)) ? { cutAtSpace: true } : {}),
94
+ });
95
+ }
96
+ for (const match of text.matchAll(HTML_IMG)) {
97
+ const target = match[1] ?? "";
98
+ if (target !== "")
99
+ references.push({ target, kind: "image", line });
100
+ }
101
+ for (const match of text.matchAll(WIKILINK)) {
102
+ const target = (match[1] ?? "").trim();
103
+ if (target !== "")
104
+ references.push({ target, kind: "wikilink", line });
105
+ }
106
+ });
107
+ return references;
108
+ }
109
+ /**
110
+ * True when a URL addresses something outside the site and must not be checked.
111
+ *
112
+ * These are the cases canopy states it leaves exactly as written: a scheme
113
+ * (`https:`, `mailto:`), a protocol-relative URL, a root-absolute path — a
114
+ * deployment path whose meaning depends on where the site is mounted — and a
115
+ * bare fragment, which addresses the page it is on.
116
+ */
117
+ export function isExternalUrl(url) {
118
+ return (url === "" || url.startsWith("#") || url.startsWith("/") || /^[a-z][a-z0-9+.-]*:/i.test(url));
119
+ }
120
+ /** Strip a query string or fragment, which address a place *within* a target. */
121
+ export function targetPath(url) {
122
+ const cut = url.search(/[?#]/);
123
+ return cut === -1 ? url : url.slice(0, cut);
124
+ }
125
+ /**
126
+ * Turn a URL path into the site path it addresses, undoing percent-encoding.
127
+ *
128
+ * A reference is a URL and the site is files, so the two spellings of one path —
129
+ * `a%20b/note.md` and `a b/note.md` — have to meet before anything is compared.
130
+ * Editors write the encoded form on their own for any path containing a space,
131
+ * and this checker's own advice for a destination that stops at a space is to
132
+ * "write the space as %20", so without this it rejects what it just recommended.
133
+ *
134
+ * Decoded per segment, never across the whole path: `%2F` is a slash inside one
135
+ * name rather than a directory boundary. A malformed escape comes back
136
+ * undefined, and the reference is left alone — which is what the renderer does
137
+ * with it, and the checker's job is to agree with the renderer.
138
+ *
139
+ * TODO(upstream: claudedocs/upstream-issues/ISSUE-canopy-20260807-link-target-resolution.md)
140
+ * — canopy applies this same rule when it rewrites links, and does not expose
141
+ * it, so the rule is spelled out twice and can drift. It just did: canopy
142
+ * learned to read this encoding one release before this file did.
143
+ */
144
+ export function decodeTarget(path) {
145
+ const segments = [];
146
+ for (const segment of path.split("/")) {
147
+ let decoded;
148
+ try {
149
+ decoded = decodeURIComponent(segment);
150
+ }
151
+ catch {
152
+ return undefined;
153
+ }
154
+ if (decoded.includes("/"))
155
+ return undefined;
156
+ segments.push(decoded);
157
+ }
158
+ return segments.join("/");
159
+ }
160
+ /**
161
+ * Resolve a site-relative target against the document holding it.
162
+ *
163
+ * `..` is honoured so a page can point at a sibling folder. A target that walks
164
+ * above the site root addresses something the site was never given, so it comes
165
+ * back undefined and is left alone rather than reported.
166
+ */
167
+ export function resolveFrom(documentPath, target) {
168
+ const segments = documentPath.split("/").slice(0, -1);
169
+ for (const part of target.replace(/\\/g, "/").split("/")) {
170
+ if (part === "" || part === ".")
171
+ continue;
172
+ if (part === "..") {
173
+ if (segments.length === 0)
174
+ return undefined;
175
+ segments.pop();
176
+ continue;
177
+ }
178
+ segments.push(part);
179
+ }
180
+ return segments.join("/");
181
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `settings.json` — the one file a documentation set hands to canopy-page.
3
+ *
4
+ * Everything a site needs to build lives here, next to the markdown it
5
+ * describes, so the build is reproducible from the source tree alone: no build
6
+ * script holds half the configuration, and the file can be read by a person
7
+ * deciding what the site is supposed to look like.
8
+ *
9
+ * The whole file is optional in the sense that every field is: a directory of
10
+ * markdown with an empty `{}` builds, with navigation derived from the folder
11
+ * tree. Settings exist to override what a source tree cannot express by itself —
12
+ * the display order of a release log, a label that is not a directory name, a
13
+ * draft folder that must stay unpublished.
14
+ *
15
+ * ## One site, not several builds
16
+ *
17
+ * A settings file describes a single site, built in one pass from the directory
18
+ * that holds it. `sections` name ordered regions *within* that site — a guide, a
19
+ * release log — rather than separate builds of their own.
20
+ *
21
+ * The alternative, building each region separately, would break the thing the
22
+ * site is for: links and backlinks resolve across one build, so splitting a
23
+ * guide from the release notes it refers to would leave those cross-references
24
+ * dangling — the fragmentation this tool exists to remove. A favicon or an
25
+ * excluded path is likewise stated once for the site, which is only meaningful
26
+ * if there is one. Two genuinely independent sites are two settings files.
27
+ */
28
+ /** Why a settings file was rejected, phrased for someone editing it. */
29
+ export declare class SettingsError extends Error {
30
+ }
31
+ /** One ordered region of the site: a guide, a release log, a reference section. */
32
+ export interface SettingsSection {
33
+ /** Directory this section covers, relative to the settings file. */
34
+ path: string;
35
+ /** Heading shown for the section. Defaults to the directory name. */
36
+ label?: string;
37
+ /**
38
+ * Order for the pages inside, when they are not listed one by one.
39
+ * `desc` is what a release log wants: newest first, which no folder tree says.
40
+ */
41
+ order?: "asc" | "desc";
42
+ /** Explicit contents, in display order. Overrides `order`. */
43
+ items?: SettingsNavItem[];
44
+ }
45
+ /**
46
+ * One entry in a section's contents: a page, or a group of entries.
47
+ *
48
+ * The common case is a bare path, so a string is accepted as shorthand for
49
+ * `{ path }` and normalized away here — the rest of the code sees one shape.
50
+ */
51
+ export interface SettingsNavItem {
52
+ /** Display text. Defaults to the page's title, then its filename. */
53
+ label?: string;
54
+ /** Path of the page, relative to the settings file, with or without `.md`. */
55
+ path?: string;
56
+ /** Nested entries, in display order. */
57
+ items?: SettingsNavItem[];
58
+ }
59
+ /** A validated settings file. Absent fields mean "use canopy's default". */
60
+ export interface Settings {
61
+ /** Site name. Defaults to the directory name. */
62
+ title?: string;
63
+ /** Fills `<meta name="description">`, which is what link previews show. */
64
+ description?: string;
65
+ /** BCP 47 language tag for `<html lang>`. Worth setting for any non-English site. */
66
+ lang?: string;
67
+ /** Favicon, relative to the settings file. Must be a published file. */
68
+ icon?: string;
69
+ /** Paths to leave unpublished: a directory, an extension (`*.tmp`), or one exact path. */
70
+ exclude?: string[];
71
+ /** Ordered regions of the site. Without them, navigation follows the folder tree. */
72
+ sections?: SettingsSection[];
73
+ }
74
+ /**
75
+ * Parse and validate a settings file from JSON text.
76
+ *
77
+ * Validation is strict and every message names the position it is about, since
78
+ * this file is written by hand: a setting that is half-applied looks like a tool
79
+ * that ignores its configuration.
80
+ */
81
+ export declare function parseSettings(json: string): Settings;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * `settings.json` — the one file a documentation set hands to canopy-page.
3
+ *
4
+ * Everything a site needs to build lives here, next to the markdown it
5
+ * describes, so the build is reproducible from the source tree alone: no build
6
+ * script holds half the configuration, and the file can be read by a person
7
+ * deciding what the site is supposed to look like.
8
+ *
9
+ * The whole file is optional in the sense that every field is: a directory of
10
+ * markdown with an empty `{}` builds, with navigation derived from the folder
11
+ * tree. Settings exist to override what a source tree cannot express by itself —
12
+ * the display order of a release log, a label that is not a directory name, a
13
+ * draft folder that must stay unpublished.
14
+ *
15
+ * ## One site, not several builds
16
+ *
17
+ * A settings file describes a single site, built in one pass from the directory
18
+ * that holds it. `sections` name ordered regions *within* that site — a guide, a
19
+ * release log — rather than separate builds of their own.
20
+ *
21
+ * The alternative, building each region separately, would break the thing the
22
+ * site is for: links and backlinks resolve across one build, so splitting a
23
+ * guide from the release notes it refers to would leave those cross-references
24
+ * dangling — the fragmentation this tool exists to remove. A favicon or an
25
+ * excluded path is likewise stated once for the site, which is only meaningful
26
+ * if there is one. Two genuinely independent sites are two settings files.
27
+ */
28
+ /** Why a settings file was rejected, phrased for someone editing it. */
29
+ export class SettingsError extends Error {
30
+ }
31
+ function fail(message) {
32
+ throw new SettingsError(message);
33
+ }
34
+ /**
35
+ * Keys that are allowed but carry no meaning for the build.
36
+ *
37
+ * `$schema` is how an editor knows to offer completion and inline validation,
38
+ * so it has to survive a strict key check.
39
+ */
40
+ const IGNORED_KEYS = new Set(["$schema"]);
41
+ const SETTINGS_KEYS = new Set([
42
+ "title",
43
+ "description",
44
+ "lang",
45
+ "icon",
46
+ "exclude",
47
+ "sections",
48
+ ]);
49
+ const SECTION_KEYS = new Set(["path", "label", "order", "items"]);
50
+ const NAV_ITEM_KEYS = new Set(["label", "path", "items"]);
51
+ /**
52
+ * Unknown keys are rejected rather than ignored.
53
+ *
54
+ * A settings file is hand-edited, and a typo in a key (`titel`, `execlude`) that
55
+ * is quietly dropped presents as canopy-page ignoring an instruction it was
56
+ * given — the hardest kind of problem to see, because the file looks right.
57
+ */
58
+ function rejectUnknownKeys(value, allowed, where) {
59
+ for (const key of Object.keys(value)) {
60
+ if (allowed.has(key) || IGNORED_KEYS.has(key))
61
+ continue;
62
+ fail(`${where}: unknown key "${key}"`);
63
+ }
64
+ }
65
+ function asObject(value, where, expectation) {
66
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
67
+ fail(`${where}: ${expectation}`);
68
+ }
69
+ return value;
70
+ }
71
+ function asString(value, where) {
72
+ if (typeof value !== "string")
73
+ fail(`${where}: must be a string`);
74
+ if (value.trim() === "")
75
+ fail(`${where}: must not be empty`);
76
+ return value;
77
+ }
78
+ /**
79
+ * Normalize a path written in a settings file to the form the rest of the code
80
+ * uses: forward slashes, no leading slash, no trailing slash.
81
+ *
82
+ * Authors on Windows write backslashes, and both `guide` and `guide/` mean the
83
+ * same directory. Paths that leave the site are refused here rather than at the
84
+ * point they fail to resolve, where the message would be about a missing file
85
+ * instead of about the setting that named it.
86
+ */
87
+ function asRelativePath(value, where) {
88
+ const raw = asString(value, where);
89
+ const normalized = raw.replace(/\\/g, "/").replace(/\/+$/, "");
90
+ if (normalized.startsWith("/")) {
91
+ fail(`${where}: must be relative to the settings file, not "${raw}"`);
92
+ }
93
+ if (/^[A-Za-z]:/.test(normalized)) {
94
+ fail(`${where}: must be relative to the settings file, not an absolute path`);
95
+ }
96
+ if (normalized.split("/").includes("..")) {
97
+ fail(`${where}: must stay inside the site, so it cannot contain ".."`);
98
+ }
99
+ if (normalized === "" || normalized === ".") {
100
+ fail(`${where}: must name a path inside the site`);
101
+ }
102
+ return normalized;
103
+ }
104
+ /**
105
+ * Check an exclusion pattern against the dialect that is actually implemented.
106
+ *
107
+ * Four shapes, and nothing else: `drafts`, `drafts/**`, `*.tmp`, and one exact
108
+ * path. A wildcard anywhere else — `images/*.md`, `guide/*` — matches nothing,
109
+ * and a pattern that quietly excludes nothing is the failure strict validation
110
+ * exists to prevent: the file reads as if it said something, and the folder
111
+ * ships anyway. Refusing it here is the same answer an unknown key gets.
112
+ */
113
+ function asExclusionPattern(value, where) {
114
+ const pattern = asString(value, where);
115
+ const normalized = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
116
+ // `*.tmp` is the extension form; `drafts/**` is the whole-tree form. Strip
117
+ // whichever applies and nothing else may hold a wildcard.
118
+ const rest = normalized.startsWith("*.")
119
+ ? normalized.slice(2)
120
+ : normalized.replace(/\/\*\*$/, "");
121
+ if (rest.includes("*")) {
122
+ fail(`${where}: "${pattern}" is not a pattern canopy-page understands. ` +
123
+ 'Use a directory ("drafts"), a whole tree ("drafts/**"), ' +
124
+ 'an extension ("*.tmp"), or one exact path ("notes/scratch.md")');
125
+ }
126
+ return pattern;
127
+ }
128
+ function parseNavItem(value, where) {
129
+ // A bare string is the common case — a page in the order it should appear.
130
+ if (typeof value === "string") {
131
+ return { path: asRelativePath(value, where) };
132
+ }
133
+ const item = asObject(value, where, 'expected a page path or an object with "label", "path", or "items"');
134
+ rejectUnknownKeys(item, NAV_ITEM_KEYS, where);
135
+ const { label, path, items } = item;
136
+ if (label !== undefined)
137
+ asString(label, `${where}.label`);
138
+ if (items !== undefined && !Array.isArray(items))
139
+ fail(`${where}.items: must be an array`);
140
+ if (path === undefined && items === undefined) {
141
+ fail(`${where}: needs a "path" (a page) or "items" (a group)`);
142
+ }
143
+ // A group with no label renders as an unnamed heading, which reads as a bug in
144
+ // the site rather than as the omission in the file that it is.
145
+ if (path === undefined && label === undefined) {
146
+ fail(`${where}: a group needs a "label"`);
147
+ }
148
+ const children = items?.map((child, i) => parseNavItem(child, `${where}.items[${i}]`));
149
+ return {
150
+ ...(label === undefined ? {} : { label: label }),
151
+ ...(path === undefined ? {} : { path: asRelativePath(path, `${where}.path`) }),
152
+ ...(children === undefined ? {} : { items: children }),
153
+ };
154
+ }
155
+ function parseSection(value, where) {
156
+ const section = asObject(value, where, 'expected an object with a "path"');
157
+ rejectUnknownKeys(section, SECTION_KEYS, where);
158
+ const { path, label, order, items } = section;
159
+ if (path === undefined)
160
+ fail(`${where}: needs a "path" naming the directory it covers`);
161
+ if (label !== undefined)
162
+ asString(label, `${where}.label`);
163
+ if (order !== undefined && order !== "asc" && order !== "desc") {
164
+ fail(`${where}.order: must be "asc" or "desc"`);
165
+ }
166
+ if (items !== undefined && !Array.isArray(items))
167
+ fail(`${where}.items: must be an array`);
168
+ // Listing the contents *is* the order. Accepting both would mean silently
169
+ // honouring one and dropping the other, and either choice surprises someone.
170
+ if (items !== undefined && order !== undefined) {
171
+ fail(`${where}: "items" already gives the order, so "order" cannot be set too`);
172
+ }
173
+ return {
174
+ path: asRelativePath(path, `${where}.path`),
175
+ ...(label === undefined ? {} : { label: label }),
176
+ ...(order === undefined ? {} : { order: order }),
177
+ ...(items === undefined
178
+ ? {}
179
+ : { items: items.map((item, i) => parseNavItem(item, `${where}.items[${i}]`)) }),
180
+ };
181
+ }
182
+ /**
183
+ * Parse and validate a settings file from JSON text.
184
+ *
185
+ * Validation is strict and every message names the position it is about, since
186
+ * this file is written by hand: a setting that is half-applied looks like a tool
187
+ * that ignores its configuration.
188
+ */
189
+ export function parseSettings(json) {
190
+ let raw;
191
+ try {
192
+ raw = JSON.parse(json);
193
+ }
194
+ catch (error) {
195
+ fail(`not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
196
+ }
197
+ const value = asObject(raw, "settings", "expected a JSON object");
198
+ rejectUnknownKeys(value, SETTINGS_KEYS, "settings");
199
+ const { title, description, lang, icon, exclude, sections } = value;
200
+ if (title !== undefined)
201
+ asString(title, "settings.title");
202
+ if (description !== undefined)
203
+ asString(description, "settings.description");
204
+ if (lang !== undefined) {
205
+ const tag = asString(lang, "settings.lang");
206
+ // A language tag is subtags of letters and digits joined by hyphens. This
207
+ // catches the shapes people actually mistype — "ko KR", "ko_KR" — without
208
+ // pretending to be a registry of valid tags.
209
+ if (!/^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/.test(tag)) {
210
+ fail(`settings.lang: "${tag}" is not a language tag like "en" or "ko-KR"`);
211
+ }
212
+ }
213
+ if (exclude !== undefined && !Array.isArray(exclude))
214
+ fail("settings.exclude: must be an array");
215
+ if (sections !== undefined && !Array.isArray(sections))
216
+ fail("settings.sections: must be an array");
217
+ return {
218
+ ...(title === undefined ? {} : { title: title }),
219
+ ...(description === undefined ? {} : { description: description }),
220
+ ...(lang === undefined ? {} : { lang: lang }),
221
+ ...(icon === undefined ? {} : { icon: asRelativePath(icon, "settings.icon") }),
222
+ ...(exclude === undefined
223
+ ? {}
224
+ : {
225
+ // Exclusions are patterns, not paths: `*.tmp` and `drafts/**` are both
226
+ // valid to canopy, so they are passed through rather than normalized
227
+ // into a path shape they do not have.
228
+ exclude: exclude.map((pattern, i) => asExclusionPattern(pattern, `settings.exclude[${i}]`)),
229
+ }),
230
+ ...(sections === undefined
231
+ ? {}
232
+ : {
233
+ sections: sections.map((section, i) => parseSection(section, `settings.sections[${i}]`)),
234
+ }),
235
+ };
236
+ }
package/dist/site.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { type NavTranslation } from "./nav.js";
2
+ import { type Settings } from "./settings.js";
3
+ import { type PageIndex } from "./vault.js";
4
+ /**
5
+ * Loading a site: settings, the files they describe, and the navigation that
6
+ * falls out of putting the two together.
7
+ *
8
+ * Every command works from this one view, so `build` and a check cannot disagree
9
+ * about what the site contains — a checker that inspects something other than
10
+ * what the build ships is worse than no checker, because it reports confidence
11
+ * it has not earned.
12
+ */
13
+ /** Why a site could not be loaded, phrased for whoever runs the command. */
14
+ export declare class SiteError extends Error {
15
+ }
16
+ /** A site read from disk, ready to be checked or built. */
17
+ export interface LoadedSite {
18
+ /** Absolute path of the directory holding the settings file. */
19
+ root: string;
20
+ settings: Settings;
21
+ index: PageIndex;
22
+ nav: NavTranslation;
23
+ /** Exclusion patterns that left the site exactly as they found it. */
24
+ unusedExclusions: string[];
25
+ }
26
+ /** Something worth telling the author about their site. */
27
+ export interface Finding {
28
+ /** `error` stops a build; `warning` is reported and the build continues. */
29
+ level: "error" | "warning";
30
+ message: string;
31
+ }
32
+ /** Read a site directory into settings, files, and a navigation translation. */
33
+ export declare function loadSite(dir: string): Promise<LoadedSite>;
34
+ /**
35
+ * What the settings themselves got wrong, beyond what they say about navigation.
36
+ *
37
+ * An exclusion that excluded nothing is a warning rather than an error: the site
38
+ * is publishable and every page in it is sound. What is wrong is that the file
39
+ * claims to hold something back and does not, which the author can only find out
40
+ * by being told.
41
+ */
42
+ export declare function settingsFindings(site: LoadedSite): Finding[];
43
+ /**
44
+ * What the navigation translation found, as findings.
45
+ *
46
+ * A reference to a page that does not exist and a page placed twice are both
47
+ * mistakes *in the settings file*: nothing in the site can make them right, so
48
+ * they stop a build. A page no section mentions is different — the site is
49
+ * complete, it is the description that is behind, and the page is placed anyway
50
+ * — so it is said out loud and the build continues.
51
+ */
52
+ export declare function navFindings(nav: NavTranslation): Finding[];
53
+ /** Print findings in the order given, and report whether any of them stops a build. */
54
+ export declare function reportFindings(findings: readonly Finding[]): boolean;
package/dist/site.js ADDED
@@ -0,0 +1,108 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { translateNav } from "./nav.js";
4
+ import { parseSettings, SettingsError } from "./settings.js";
5
+ import { indexSite, listSite, SETTINGS_FILENAME } from "./vault.js";
6
+ /**
7
+ * Loading a site: settings, the files they describe, and the navigation that
8
+ * falls out of putting the two together.
9
+ *
10
+ * Every command works from this one view, so `build` and a check cannot disagree
11
+ * about what the site contains — a checker that inspects something other than
12
+ * what the build ships is worse than no checker, because it reports confidence
13
+ * it has not earned.
14
+ */
15
+ /** Why a site could not be loaded, phrased for whoever runs the command. */
16
+ export class SiteError extends Error {
17
+ }
18
+ /** Read a site directory into settings, files, and a navigation translation. */
19
+ export async function loadSite(dir) {
20
+ const root = path.resolve(dir);
21
+ const settingsPath = path.join(root, SETTINGS_FILENAME);
22
+ let raw;
23
+ try {
24
+ raw = await readFile(settingsPath, "utf8");
25
+ }
26
+ catch (error) {
27
+ if (error.code === "ENOENT") {
28
+ throw new SiteError(`no ${SETTINGS_FILENAME} in ${root}\n` +
29
+ "A site is configured by that file; run canopy-page in the folder that holds it.");
30
+ }
31
+ throw error;
32
+ }
33
+ let settings;
34
+ try {
35
+ settings = parseSettings(raw);
36
+ }
37
+ catch (error) {
38
+ if (error instanceof SettingsError) {
39
+ // Name the file: a message about `sections[0]` is only actionable if the
40
+ // reader knows which file to open.
41
+ throw new SiteError(`${settingsPath}: ${error.message}`);
42
+ }
43
+ throw error;
44
+ }
45
+ const listing = await listSite(root, settings.exclude);
46
+ const index = indexSite(listing.files);
47
+ return {
48
+ root,
49
+ settings,
50
+ index,
51
+ nav: translateNav(settings, index),
52
+ unusedExclusions: listing.unusedExclusions,
53
+ };
54
+ }
55
+ /**
56
+ * What the settings themselves got wrong, beyond what they say about navigation.
57
+ *
58
+ * An exclusion that excluded nothing is a warning rather than an error: the site
59
+ * is publishable and every page in it is sound. What is wrong is that the file
60
+ * claims to hold something back and does not, which the author can only find out
61
+ * by being told.
62
+ */
63
+ export function settingsFindings(site) {
64
+ return site.unusedExclusions.map((pattern) => ({
65
+ level: "warning",
66
+ message: `settings: exclude "${pattern}" matched nothing, so everything it names is published. ` +
67
+ "Patterns are relative to the settings file",
68
+ }));
69
+ }
70
+ /**
71
+ * What the navigation translation found, as findings.
72
+ *
73
+ * A reference to a page that does not exist and a page placed twice are both
74
+ * mistakes *in the settings file*: nothing in the site can make them right, so
75
+ * they stop a build. A page no section mentions is different — the site is
76
+ * complete, it is the description that is behind, and the page is placed anyway
77
+ * — so it is said out loud and the build continues.
78
+ */
79
+ export function navFindings(nav) {
80
+ const findings = [];
81
+ for (const reference of nav.missing) {
82
+ findings.push({ level: "error", message: `settings: "${reference}" matches no page` });
83
+ }
84
+ for (const page of nav.duplicates) {
85
+ findings.push({ level: "error", message: `settings: "${page}" is placed more than once` });
86
+ }
87
+ if (nav.orphans.length > 0) {
88
+ // One page per line. A real site's uncovered pages run to dozens, and a list
89
+ // joined onto one line is a wall nobody reads to the end of — which loses
90
+ // the whole point of naming them.
91
+ findings.push({
92
+ level: "warning",
93
+ message: `${nav.orphans.length} page(s) no section covers, placed at the end of their section:\n` +
94
+ nav.orphans.map((page) => ` ${page}`).join("\n"),
95
+ });
96
+ }
97
+ return findings;
98
+ }
99
+ /** Print findings in the order given, and report whether any of them stops a build. */
100
+ export function reportFindings(findings) {
101
+ for (const finding of findings) {
102
+ if (finding.level === "error")
103
+ console.error(`error: ${finding.message}`);
104
+ else
105
+ console.warn(`warning: ${finding.message}`);
106
+ }
107
+ return findings.some((finding) => finding.level === "error");
108
+ }