@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,70 @@
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
+ export const USAGE = [
10
+ "Usage: canopy-page <command> [site-dir] [options]",
11
+ "",
12
+ " init [site-dir] Start a site: write a settings file",
13
+ " check [site-dir] Check the site without publishing it",
14
+ " build [site-dir] Check the site, then publish it",
15
+ "",
16
+ " [site-dir] Folder holding settings.json (defaults to .)",
17
+ " -o, --out <dir> Where build writes the site (defaults to ./site)",
18
+ ].join("\n");
19
+ const OUT_FLAGS = new Set(["-o", "--out"]);
20
+ export function parseArgs(argv) {
21
+ const [command, ...rest] = argv;
22
+ if (command === undefined)
23
+ return { ok: false, error: USAGE };
24
+ if (command === "build" || command === "check" || command === "init") {
25
+ return parseCommand(command, rest);
26
+ }
27
+ if (command.startsWith("-")) {
28
+ // A flag where a command belongs usually means the command was forgotten,
29
+ // and "unknown command --out" would send someone looking for the wrong bug.
30
+ return { ok: false, error: `${USAGE}\n\nExpected a command before "${command}".` };
31
+ }
32
+ return { ok: false, error: `${USAGE}\n\nUnknown command "${command}".` };
33
+ }
34
+ function parseCommand(command, argv) {
35
+ const positional = [];
36
+ let out;
37
+ for (let i = 0; i < argv.length; i += 1) {
38
+ const arg = argv[i];
39
+ if (OUT_FLAGS.has(arg)) {
40
+ if (command !== "build") {
41
+ // Only build writes a site, so an output directory elsewhere is not a
42
+ // harmless extra: whoever passed it expects a site to appear somewhere.
43
+ return {
44
+ ok: false,
45
+ error: `${arg} is for build, which writes a site; ${command} does not.`,
46
+ };
47
+ }
48
+ const value = argv[i + 1];
49
+ if (value === undefined || value.startsWith("-")) {
50
+ return { ok: false, error: `${arg} needs a directory.` };
51
+ }
52
+ out = value;
53
+ i += 1;
54
+ continue;
55
+ }
56
+ if (arg.startsWith("-")) {
57
+ return { ok: false, error: `${USAGE}\n\nUnknown option "${arg}".` };
58
+ }
59
+ positional.push(arg);
60
+ }
61
+ if (positional.length > 1) {
62
+ // The output directory is a flag precisely so it cannot be confused with the
63
+ // input one; a second bare path is more likely a typo than an intention.
64
+ return { ok: false, error: `${USAGE}\n\nUnexpected argument "${positional[1]}".` };
65
+ }
66
+ const dir = positional[0] ?? ".";
67
+ if (command === "build")
68
+ return { ok: true, command, dir, out: out ?? "site" };
69
+ return { ok: true, command, dir };
70
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { buildSite } from "./build.js";
3
+ import { checkSite } from "./check.js";
4
+ import { initSite, InitError } from "./init.js";
5
+ import { parseArgs } from "./cli-args.js";
6
+ import { SiteError } from "./site.js";
7
+ async function main() {
8
+ const args = parseArgs(process.argv.slice(2));
9
+ if (!args.ok) {
10
+ console.error(args.error);
11
+ process.exitCode = 1;
12
+ return;
13
+ }
14
+ if (args.command === "init") {
15
+ const { settingsPath, pagePath } = await initSite(args.dir);
16
+ console.log(`canopy-page: wrote ${settingsPath}`);
17
+ if (pagePath !== undefined)
18
+ console.log(`canopy-page: wrote ${pagePath}`);
19
+ console.log("canopy-page: run `canopy-page build` to publish it");
20
+ return;
21
+ }
22
+ process.exitCode =
23
+ args.command === "build"
24
+ ? await buildSite({ dir: args.dir, out: args.out })
25
+ : await checkSite(args.dir);
26
+ }
27
+ main().catch((error) => {
28
+ // A site error is about the site, not about canopy-page: the message is the
29
+ // whole of what a reader needs, and a stack trace on top of it only buries it.
30
+ if (error instanceof SiteError || error instanceof InitError) {
31
+ console.error(`error: ${error.message}`);
32
+ }
33
+ else
34
+ console.error(error);
35
+ process.exitCode = 1;
36
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * canopy-page — the authoring pipeline around a documentation site.
3
+ *
4
+ * Rendering markdown into a site is canopy's job; this package owns what
5
+ * surrounds it: the `settings.json` contract a documentation set is configured
6
+ * with, the integrity checks that keep a published site from shipping broken
7
+ * references, and the build that ties them together.
8
+ *
9
+ * The command line is the intended way in. This entry point exposes the same
10
+ * pieces for a caller that wants to run a check inside its own tooling.
11
+ */
12
+ export { buildSite, type BuildOptions } from "./build.js";
13
+ export { checkSite, referenceFindings, siteFindings } from "./check.js";
14
+ export { initSite, InitError, type InitResult } from "./init.js";
15
+ export { extractReferences, isExternalUrl, type Reference, } from "./references.js";
16
+ export { loadSite, navFindings, reportFindings, SiteError, type Finding, type LoadedSite, } from "./site.js";
17
+ export { parseSettings, SettingsError, type Settings, type SettingsNavItem, type SettingsSection, } from "./settings.js";
package/dist/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * canopy-page — the authoring pipeline around a documentation site.
3
+ *
4
+ * Rendering markdown into a site is canopy's job; this package owns what
5
+ * surrounds it: the `settings.json` contract a documentation set is configured
6
+ * with, the integrity checks that keep a published site from shipping broken
7
+ * references, and the build that ties them together.
8
+ *
9
+ * The command line is the intended way in. This entry point exposes the same
10
+ * pieces for a caller that wants to run a check inside its own tooling.
11
+ */
12
+ export { buildSite } from "./build.js";
13
+ export { checkSite, referenceFindings, siteFindings } from "./check.js";
14
+ export { initSite, InitError } from "./init.js";
15
+ export { extractReferences, isExternalUrl, } from "./references.js";
16
+ export { loadSite, navFindings, reportFindings, SiteError, } from "./site.js";
17
+ export { parseSettings, SettingsError, } from "./settings.js";
package/dist/init.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Starting a site.
3
+ *
4
+ * `init` exists because the first minute is the one where a tool is judged, and
5
+ * a blank folder plus a file format is not a start. What it writes is
6
+ * deliberately small: a settings file naming the site, and a page to build if
7
+ * there is nothing to build yet.
8
+ *
9
+ * It writes no example sections. A preset that referenced folders the author
10
+ * does not have would fail the very first check, which teaches that the tool
11
+ * complains before it has been used for anything. Every setting is an override,
12
+ * so the honest starting point is the one with nothing overridden.
13
+ *
14
+ * ## About `$schema`
15
+ *
16
+ * The preset does not carry one. An editor resolves `$schema` by fetching it,
17
+ * and a URL that answers 404 is worse than no URL at all: it puts a permanent
18
+ * error in the author's editor and teaches them to ignore the one place
19
+ * mistakes in this file would be shown. Settings are validated strictly when
20
+ * they are read, naming the exact position of anything wrong, so nothing is
21
+ * unguarded in the meantime — and adding `$schema` later is a line in a file,
22
+ * for which the parser already makes room by accepting and ignoring the key.
23
+ */
24
+ /** What `init` created, so the caller can say so. */
25
+ export interface InitResult {
26
+ /** Absolute path of the settings file written. */
27
+ settingsPath: string;
28
+ /** Absolute path of the starter page, when one was needed. */
29
+ pagePath?: string;
30
+ }
31
+ /** Why a site could not be started here. */
32
+ export declare class InitError extends Error {
33
+ }
34
+ /** A site title from a folder name: `product-help` reads as `Product help`. */
35
+ export declare function titleFromDirectory(dir: string): string;
36
+ /**
37
+ * Write a starting settings file into `dir`, creating the folder if needed.
38
+ *
39
+ * An existing settings file is never overwritten. Running `init` twice is
40
+ * usually a mistake about which folder one is in, and the cost of guessing
41
+ * wrong — a settings file replaced by a blank one — is far higher than the cost
42
+ * of saying so.
43
+ */
44
+ export declare function initSite(dir: string): Promise<InitResult>;
package/dist/init.js ADDED
@@ -0,0 +1,54 @@
1
+ import { access, mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { listSiteFiles, isPage, SETTINGS_FILENAME } from "./vault.js";
4
+ /** Why a site could not be started here. */
5
+ export class InitError extends Error {
6
+ }
7
+ async function exists(file) {
8
+ try {
9
+ await access(file);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ /** A site title from a folder name: `product-help` reads as `Product help`. */
17
+ export function titleFromDirectory(dir) {
18
+ const name = path.basename(path.resolve(dir)).replace(/[-_]+/g, " ").trim();
19
+ if (name === "")
20
+ return "Site";
21
+ return name.charAt(0).toUpperCase() + name.slice(1);
22
+ }
23
+ const STARTER_PAGE = `# {title}
24
+
25
+ This is the home page of the site. Everything in this folder is published with
26
+ it: sub-folders become sections, and links between pages keep working.
27
+ `;
28
+ /**
29
+ * Write a starting settings file into `dir`, creating the folder if needed.
30
+ *
31
+ * An existing settings file is never overwritten. Running `init` twice is
32
+ * usually a mistake about which folder one is in, and the cost of guessing
33
+ * wrong — a settings file replaced by a blank one — is far higher than the cost
34
+ * of saying so.
35
+ */
36
+ export async function initSite(dir) {
37
+ const root = path.resolve(dir);
38
+ await mkdir(root, { recursive: true });
39
+ const settingsPath = path.join(root, SETTINGS_FILENAME);
40
+ if (await exists(settingsPath)) {
41
+ throw new InitError(`${settingsPath} already exists, and init will not replace it.`);
42
+ }
43
+ const existing = await listSiteFiles(root);
44
+ const title = titleFromDirectory(root);
45
+ await writeFile(settingsPath, `${JSON.stringify({ title }, null, 2)}\n`, "utf8");
46
+ // A folder that already holds markdown is an existing set of documents being
47
+ // adopted, not a new site; writing a home page into it would be an opinion
48
+ // about content, which is not this tool's to have.
49
+ if (existing.some(isPage))
50
+ return { settingsPath };
51
+ const pagePath = path.join(root, "index.md");
52
+ await writeFile(pagePath, STARTER_PAGE.replace("{title}", title), "utf8");
53
+ return { settingsPath, pagePath };
54
+ }
package/dist/nav.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { Settings } from "./settings.js";
2
+ import { type PageIndex } from "./vault.js";
3
+ /**
4
+ * Translate a settings file into the navigation spec canopy consumes.
5
+ *
6
+ * Settings speak in the author's terms — sections of a site, a release log that
7
+ * reads newest-first, a folder listed page by page. Canopy consumes one flat
8
+ * spec of ordered items and knows nothing about where it came from. This module
9
+ * is the whole of the translation between the two, which is what keeps the
10
+ * author's vocabulary out of canopy and canopy's out of the settings file.
11
+ *
12
+ * ## Why a spec has to describe the whole site
13
+ *
14
+ * Canopy applies a spec instead of deriving navigation, not alongside it: pages
15
+ * a spec omits are left out and reported. So ordering one section means naming
16
+ * every other page too, and the pages an author did not order are derived here
17
+ * — a second derivation next to canopy's own, which is duplication with a drift
18
+ * risk rather than a design.
19
+ * TODO(upstream: claudedocs/issues/ISSUE-canopy-20260806-partial-nav-spec.md)
20
+ * proposes the partial spec that would retire it.
21
+ *
22
+ * When settings ask for no ordering at all, no spec is emitted and canopy
23
+ * derives navigation itself — the case where the duplication costs nothing.
24
+ */
25
+ /** The navigation spec canopy reads from `--nav`: items in display order. */
26
+ export interface NavSpec {
27
+ items: NavSpecItem[];
28
+ }
29
+ /** One spec entry: a page (`path`), a group (`items`), or a group with its own page. */
30
+ export interface NavSpecItem {
31
+ label?: string;
32
+ path?: string;
33
+ items?: NavSpecItem[];
34
+ }
35
+ /** What the translation produced, and what it could not place. */
36
+ export interface NavTranslation {
37
+ /** The spec to pass to canopy, or `undefined` to let canopy derive navigation. */
38
+ spec?: NavSpec;
39
+ /** Settings references that match no page in the site. */
40
+ missing: string[];
41
+ /** Published pages no section covers. */
42
+ orphans: string[];
43
+ /** Pages the settings place more than once. */
44
+ duplicates: string[];
45
+ }
46
+ /**
47
+ * Turn settings plus the site's pages into the spec canopy builds from.
48
+ *
49
+ * Pages no section mentions are appended rather than dropped — inside their own
50
+ * section when they have one, after the sections when they do not. A page that
51
+ * exists but cannot be reached is a worse outcome than one shown in an order
52
+ * nobody chose, and an author who lists three pages of a folder and forgets the
53
+ * fourth is describing an oversight, not a decision to hide it. They are
54
+ * reported as orphans either way, so the author can say which they meant.
55
+ */
56
+ export declare function translateNav(settings: Settings, index: PageIndex): NavTranslation;
package/dist/nav.js ADDED
@@ -0,0 +1,235 @@
1
+ import { toPageKey } from "./vault.js";
2
+ /** The page a directory is entered by, if it has one. */
3
+ function indexOf(dir, index) {
4
+ return index.resolve(dir === "" ? "index" : `${dir}/index`);
5
+ }
6
+ function stemOf(pagePath) {
7
+ return (pagePath.split("/").pop() ?? pagePath).replace(/\.md$/i, "");
8
+ }
9
+ function lastSegment(dir) {
10
+ return dir.split("/").pop() ?? dir;
11
+ }
12
+ /** Pages directly inside a directory, excluding nested ones. */
13
+ function pagesDirectlyIn(dir, pages) {
14
+ const prefix = dir === "" ? "" : `${dir}/`;
15
+ return pages.filter((page) => {
16
+ if (!page.toLowerCase().startsWith(prefix.toLowerCase()))
17
+ return false;
18
+ return !page.slice(prefix.length).includes("/");
19
+ });
20
+ }
21
+ /** Pages anywhere beneath a directory. */
22
+ function pagesUnder(dir, pages) {
23
+ const prefix = `${dir.toLowerCase()}/`;
24
+ return pages.filter((page) => page.toLowerCase().startsWith(prefix));
25
+ }
26
+ /** Immediate subdirectory names of a directory, in the order pages first named them. */
27
+ function subdirectoriesOf(dir, pages) {
28
+ const prefix = dir === "" ? "" : `${dir}/`;
29
+ const names = new Set();
30
+ for (const page of pages) {
31
+ if (!page.toLowerCase().startsWith(prefix.toLowerCase()))
32
+ continue;
33
+ const rest = page.slice(prefix.length);
34
+ const slash = rest.indexOf("/");
35
+ if (slash > 0)
36
+ names.add(rest.slice(0, slash));
37
+ }
38
+ return [...names];
39
+ }
40
+ function byStem(order) {
41
+ const direction = order === "desc" ? -1 : 1;
42
+ return (a, b) => direction * stemOf(a).localeCompare(stemOf(b), undefined, { sensitivity: "base" });
43
+ }
44
+ function byName(order) {
45
+ const direction = order === "desc" ? -1 : 1;
46
+ return (a, b) => direction * a.localeCompare(b, undefined, { sensitivity: "base" });
47
+ }
48
+ /**
49
+ * Derive the contents of a directory the author did not list.
50
+ *
51
+ * Ordering follows file names rather than frontmatter titles. Canopy's own
52
+ * derivation prefers a title when a page has one, but reading titles means
53
+ * parsing every document, which is canopy's work and not worth duplicating for
54
+ * a sort key. File names are what an author sees in the folder they are
55
+ * ordering, and `desc` on a log of dated files is exactly the case this serves.
56
+ */
57
+ function deriveItems(dir, index, order, place) {
58
+ const items = [];
59
+ // Folders before pages, mirroring how canopy derives a tree, so a site that
60
+ // orders one section does not reshuffle the others.
61
+ for (const name of subdirectoriesOf(dir, index.pages).sort(byName(order))) {
62
+ const childDir = dir === "" ? name : `${dir}/${name}`;
63
+ const childIndex = indexOf(childDir, index);
64
+ if (childIndex !== undefined)
65
+ place(childIndex);
66
+ items.push({
67
+ // A directory with an index page is named by that page — canopy asks the
68
+ // document first and falls back to this same directory name when it has
69
+ // no name of its own. Writing the label here would win over the document
70
+ // every time, which is this file answering a question it already delegates.
71
+ ...(childIndex === undefined ? { label: name } : { path: childIndex }),
72
+ items: deriveItems(childDir, index, order, place),
73
+ });
74
+ }
75
+ const ownIndex = indexOf(dir, index);
76
+ for (const page of pagesDirectlyIn(dir, index.pages).sort(byStem(order))) {
77
+ // A directory's index page is entered through the directory itself, so
78
+ // listing it again would show the same page twice under two names.
79
+ if (page === ownIndex)
80
+ continue;
81
+ place(page);
82
+ items.push({ path: page });
83
+ }
84
+ return items;
85
+ }
86
+ /** Expand one settings entry, which may be a page, a glob, or a group. */
87
+ function expandItem(item, index, report) {
88
+ const children = item.items?.flatMap((child) => expandItem(child, index, report));
89
+ if (item.path === undefined) {
90
+ return [{ ...(item.label === undefined ? {} : { label: item.label }), items: children ?? [] }];
91
+ }
92
+ if (item.path.includes("*")) {
93
+ // A glob stands for the pages it matches, so it expands in place rather
94
+ // than becoming a node of its own — `guide/settings/*` means those pages,
95
+ // not a group containing them.
96
+ //
97
+ // It means the pages there that are not placed already, which is what makes
98
+ // `["guide/install", "guide/*"]` read the way it looks: this page first,
99
+ // then the rest. It also keeps a section from listing its own index page a
100
+ // second time, since the section's label already links it.
101
+ const matched = expandGlob(item.path, index).filter((page) => !report.isPlaced(page));
102
+ if (matched.length === 0)
103
+ report.missing.push(item.path);
104
+ for (const page of matched)
105
+ report.place(page);
106
+ return matched.map((page) => ({ path: page }));
107
+ }
108
+ // The section heading is already a link to its index page, so listing it again
109
+ // would show one page twice under two names. A glob reaches this by filtering
110
+ // what is placed; an explicit mention is the same request spelled out, and
111
+ // used to come back as "placed more than once" — a contradiction to an author
112
+ // who named it once.
113
+ if (report.sectionIndex !== undefined && index.resolve(item.path) === report.sectionIndex) {
114
+ return children === undefined ? [] : children;
115
+ }
116
+ const resolved = index.resolve(item.path);
117
+ if (resolved === undefined) {
118
+ report.missing.push(item.path);
119
+ // A group survives losing its own page; a leaf has nothing left to show.
120
+ if (children === undefined)
121
+ return [];
122
+ return [{ ...(item.label === undefined ? {} : { label: item.label }), items: children }];
123
+ }
124
+ report.place(resolved);
125
+ return [
126
+ {
127
+ ...(item.label === undefined ? {} : { label: item.label }),
128
+ path: resolved,
129
+ ...(children === undefined ? {} : { items: children }),
130
+ },
131
+ ];
132
+ }
133
+ /**
134
+ * Expand a glob into pages, in file-name order.
135
+ *
136
+ * Two shapes, matching how small the exclusion dialect is kept: `dir/*` is the
137
+ * pages directly in a directory, `dir/**` is every page beneath it.
138
+ */
139
+ function expandGlob(pattern, index) {
140
+ const normalized = pattern.replace(/\\/g, "/");
141
+ if (normalized.endsWith("/**")) {
142
+ return pagesUnder(normalized.slice(0, -3), index.pages).sort(byName("asc"));
143
+ }
144
+ if (normalized.endsWith("/*")) {
145
+ return pagesDirectlyIn(normalized.slice(0, -2), index.pages).sort(byStem("asc"));
146
+ }
147
+ // Any other use of `*` is a pattern this does not implement, and matching
148
+ // nothing would look like a site with missing pages rather than a settings
149
+ // file using a shape that was never supported.
150
+ return [];
151
+ }
152
+ function translateSection(section, index, report) {
153
+ const sectionIndex = indexOf(section.path, index);
154
+ if (sectionIndex !== undefined)
155
+ report.place(sectionIndex);
156
+ const items = section.items === undefined
157
+ ? deriveItems(section.path, index, section.order ?? "asc", report.place)
158
+ : section.items.flatMap((item) => expandItem(item, index, { ...report, sectionIndex }));
159
+ if (sectionIndex === undefined && items.length === 0) {
160
+ report.missing.push(section.path);
161
+ }
162
+ // Same rule as a derived directory: the page fronting a section names it, and
163
+ // only a section with no index page needs a name written for it here. A label
164
+ // in the settings file still wins — that is what writing one is for.
165
+ const label = section.label ?? (sectionIndex === undefined ? lastSegment(section.path) : undefined);
166
+ return {
167
+ ...(label === undefined ? {} : { label }),
168
+ ...(sectionIndex === undefined ? {} : { path: sectionIndex }),
169
+ items,
170
+ };
171
+ }
172
+ /** A view of the same site narrowed to a subset of its pages. */
173
+ function narrowTo(pages, index) {
174
+ const keys = new Set(pages.map(toPageKey));
175
+ return {
176
+ pages,
177
+ assets: index.assets,
178
+ resolve: (reference) => (keys.has(toPageKey(reference)) ? index.resolve(reference) : undefined),
179
+ };
180
+ }
181
+ /**
182
+ * Turn settings plus the site's pages into the spec canopy builds from.
183
+ *
184
+ * Pages no section mentions are appended rather than dropped — inside their own
185
+ * section when they have one, after the sections when they do not. A page that
186
+ * exists but cannot be reached is a worse outcome than one shown in an order
187
+ * nobody chose, and an author who lists three pages of a folder and forgets the
188
+ * fourth is describing an oversight, not a decision to hide it. They are
189
+ * reported as orphans either way, so the author can say which they meant.
190
+ */
191
+ export function translateNav(settings, index) {
192
+ const sections = settings.sections ?? [];
193
+ if (sections.length === 0) {
194
+ return { missing: [], orphans: [], duplicates: [] };
195
+ }
196
+ const placements = new Map();
197
+ const missing = [];
198
+ const place = (page) => {
199
+ placements.set(page, (placements.get(page) ?? 0) + 1);
200
+ };
201
+ const report = { missing, place, isPlaced: (page) => placements.has(page) };
202
+ const items = sections.map((section) => translateSection(section, index, report));
203
+ // The root index is the site's home page: it is reached without navigation, so
204
+ // it is neither placed by a section nor counted as something nobody placed.
205
+ const homePage = indexOf("", index);
206
+ const orphans = index.pages.filter((page) => !placements.has(page) && page !== homePage);
207
+ // Leftovers land in the section that covers them, so a partly-listed folder
208
+ // stays one folder in the sidebar instead of appearing a second time at the end.
209
+ let stray = orphans;
210
+ sections.forEach((section, i) => {
211
+ const mine = stray.filter((page) => page.toLowerCase().startsWith(`${section.path.toLowerCase()}/`));
212
+ if (mine.length === 0)
213
+ return;
214
+ stray = stray.filter((page) => !mine.includes(page));
215
+ const target = items[i];
216
+ if (target === undefined)
217
+ return;
218
+ target.items = [
219
+ ...(target.items ?? []),
220
+ ...deriveItems(section.path, narrowTo(mine, index), section.order ?? "asc", place),
221
+ ];
222
+ });
223
+ if (stray.length > 0) {
224
+ items.push(...deriveItems("", narrowTo(stray, index), "asc", place));
225
+ }
226
+ if (homePage !== undefined) {
227
+ // Home first: it is where a reader lands, so it reads oddly anywhere else.
228
+ items.unshift({ path: homePage });
229
+ }
230
+ const duplicates = [...placements.entries()]
231
+ .filter(([, count]) => count > 1)
232
+ .map(([page]) => page)
233
+ .sort();
234
+ return { spec: { items }, missing, orphans, duplicates };
235
+ }
@@ -0,0 +1,79 @@
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
+ /** Something a document points at. */
20
+ export interface Reference {
21
+ /** The target exactly as written. */
22
+ target: string;
23
+ kind: "link" | "image" | "wikilink";
24
+ /** 1-based line in the document, so a finding can name where to look. */
25
+ line: number;
26
+ /**
27
+ * The destination ran into a space and stopped there, leaving the rest of the
28
+ * line outside the link. `target` is what the renderer will use, which is not
29
+ * what the author wrote — the one case where reporting the target alone
30
+ * describes something nobody typed.
31
+ */
32
+ cutAtSpace?: true;
33
+ }
34
+ /**
35
+ * Extract every reference in a markdown document, in document order.
36
+ *
37
+ * Frontmatter is left alone: it is metadata for the renderer, and a path in it
38
+ * means whatever the consuming site decides it means.
39
+ */
40
+ export declare function extractReferences(markdown: string): Reference[];
41
+ /**
42
+ * True when a URL addresses something outside the site and must not be checked.
43
+ *
44
+ * These are the cases canopy states it leaves exactly as written: a scheme
45
+ * (`https:`, `mailto:`), a protocol-relative URL, a root-absolute path — a
46
+ * deployment path whose meaning depends on where the site is mounted — and a
47
+ * bare fragment, which addresses the page it is on.
48
+ */
49
+ export declare function isExternalUrl(url: string): boolean;
50
+ /** Strip a query string or fragment, which address a place *within* a target. */
51
+ export declare function targetPath(url: string): string;
52
+ /**
53
+ * Turn a URL path into the site path it addresses, undoing percent-encoding.
54
+ *
55
+ * A reference is a URL and the site is files, so the two spellings of one path —
56
+ * `a%20b/note.md` and `a b/note.md` — have to meet before anything is compared.
57
+ * Editors write the encoded form on their own for any path containing a space,
58
+ * and this checker's own advice for a destination that stops at a space is to
59
+ * "write the space as %20", so without this it rejects what it just recommended.
60
+ *
61
+ * Decoded per segment, never across the whole path: `%2F` is a slash inside one
62
+ * name rather than a directory boundary. A malformed escape comes back
63
+ * undefined, and the reference is left alone — which is what the renderer does
64
+ * with it, and the checker's job is to agree with the renderer.
65
+ *
66
+ * TODO(upstream: claudedocs/upstream-issues/ISSUE-canopy-20260807-link-target-resolution.md)
67
+ * — canopy applies this same rule when it rewrites links, and does not expose
68
+ * it, so the rule is spelled out twice and can drift. It just did: canopy
69
+ * learned to read this encoding one release before this file did.
70
+ */
71
+ export declare function decodeTarget(path: string): string | undefined;
72
+ /**
73
+ * Resolve a site-relative target against the document holding it.
74
+ *
75
+ * `..` is honoured so a page can point at a sibling folder. A target that walks
76
+ * above the site root addresses something the site was never given, so it comes
77
+ * back undefined and is left alone rather than reported.
78
+ */
79
+ export declare function resolveFrom(documentPath: string, target: string): string | undefined;