@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,54 @@
1
+ /**
2
+ * Match a site-relative POSIX path against one exclusion pattern.
3
+ *
4
+ * Supported, and nothing else — the dialect canopy accepts:
5
+ * - `drafts` or `drafts/**` — that directory and everything beneath it
6
+ * - `*.tmp` — any file with that extension, at any depth
7
+ * - `notes/scratch.md` — one exact path
8
+ *
9
+ * Comparison is case-insensitive, so a site behaves the same on a
10
+ * case-insensitive filesystem as in a checkout on a case-sensitive one.
11
+ */
12
+ export declare function matchesPattern(filePath: string, pattern: string): boolean;
13
+ /** Build the "is this path unpublished?" predicate for a set of patterns. */
14
+ export declare function createExcluder(patterns?: readonly string[]): (filePath: string) => boolean;
15
+ /** The files a site publishes, and what its exclusions did to get there. */
16
+ export interface SiteListing {
17
+ /** Published files, as POSIX paths relative to the site root, sorted. */
18
+ files: string[];
19
+ /** Place-naming exclusions that matched nothing and shadowed nothing. */
20
+ unusedExclusions: string[];
21
+ }
22
+ /**
23
+ * List the files a site publishes, as POSIX paths relative to its root, sorted.
24
+ *
25
+ * The settings file itself is not content and never ships: it is configuration
26
+ * that happens to live next to what it configures.
27
+ */
28
+ export declare function listSite(root: string, exclude?: readonly string[]): Promise<SiteListing>;
29
+ /** The published files alone, for callers with nothing to say about exclusions. */
30
+ export declare function listSiteFiles(root: string, exclude?: readonly string[]): Promise<string[]>;
31
+ /** The settings file a site is configured by, found at the root of the site. */
32
+ export declare const SETTINGS_FILENAME = "settings.json";
33
+ /** Is this a markdown source file — a page rather than an asset? */
34
+ export declare function isPage(filePath: string): boolean;
35
+ /**
36
+ * The key a page is addressed by, from any of the ways one can be written.
37
+ *
38
+ * Settings name pages the way an author thinks of them — `guide/install`,
39
+ * `guide/install.md`, sometimes the published `guide/install.html` — and paths
40
+ * are compared case-insensitively for the same reason exclusions are. Reducing
41
+ * all of those to one key is what lets a reference be resolved once.
42
+ */
43
+ export declare function toPageKey(filePath: string): string;
44
+ /** An index of the pages a site publishes, addressable however they are written. */
45
+ export interface PageIndex {
46
+ /** Page paths, sorted, as they appear in the source tree. */
47
+ readonly pages: readonly string[];
48
+ /** Resolve any spelling of a page reference to its source path. */
49
+ resolve(reference: string): string | undefined;
50
+ /** Non-markdown files, sorted — images and anything else copied alongside. */
51
+ readonly assets: readonly string[];
52
+ }
53
+ /** Index the files of a site into pages, assets, and a resolver over them. */
54
+ export declare function indexSite(files: readonly string[]): PageIndex;
package/dist/vault.js ADDED
@@ -0,0 +1,188 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * Reading the source tree a settings file describes.
5
+ *
6
+ * canopy-page has to see the same files canopy will publish, because everything
7
+ * it does is about them: expanding `guide/*` into pages, reporting a link to a
8
+ * page that does not exist, deciding what a section contains. The checks are
9
+ * only worth anything if the file list they run against is the one that ships.
10
+ *
11
+ * That means the exclusion rules here have to agree with canopy's, and they are
12
+ * stated in canopy's README as the interface they are: dot-prefixed directories
13
+ * and `node_modules` are never published, and caller patterns come in three
14
+ * shapes. Agreeing by restating is a seam — if canopy ever widens its dialect,
15
+ * a check here would quietly disagree with the build. See
16
+ * TODO(upstream: claudedocs/issues/ISSUE-canopy-20260806-published-file-listing.md)
17
+ * for the proposal that would let a consumer ask canopy instead of restating it.
18
+ */
19
+ /** Directories whose contents are never published, whatever the settings say. */
20
+ function isSkippedDir(name) {
21
+ return name.startsWith(".") || name === "node_modules";
22
+ }
23
+ /**
24
+ * Match a site-relative POSIX path against one exclusion pattern.
25
+ *
26
+ * Supported, and nothing else — the dialect canopy accepts:
27
+ * - `drafts` or `drafts/**` — that directory and everything beneath it
28
+ * - `*.tmp` — any file with that extension, at any depth
29
+ * - `notes/scratch.md` — one exact path
30
+ *
31
+ * Comparison is case-insensitive, so a site behaves the same on a
32
+ * case-insensitive filesystem as in a checkout on a case-sensitive one.
33
+ */
34
+ export function matchesPattern(filePath, pattern) {
35
+ const target = filePath.toLowerCase();
36
+ const raw = pattern.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
37
+ if (raw === "")
38
+ return false;
39
+ if (raw.startsWith("*."))
40
+ return target.endsWith(raw.slice(1));
41
+ const dir = raw.replace(/\/\*\*$/, "").replace(/\/+$/, "");
42
+ return target === dir || target.startsWith(`${dir}/`);
43
+ }
44
+ /** Build the "is this path unpublished?" predicate for a set of patterns. */
45
+ export function createExcluder(patterns = []) {
46
+ const active = patterns.filter((pattern) => pattern.trim() !== "");
47
+ if (active.length === 0)
48
+ return () => false;
49
+ return (filePath) => active.some((pattern) => matchesPattern(filePath, pattern));
50
+ }
51
+ /** Does this pattern name a place, rather than a kind of file? */
52
+ function namesAPlace(pattern) {
53
+ return !pattern.replace(/^\.\//, "").startsWith("*.");
54
+ }
55
+ function normalizePattern(pattern) {
56
+ return pattern
57
+ .replace(/\\/g, "/")
58
+ .replace(/^\.\//, "")
59
+ .replace(/\/\*\*$/, "")
60
+ .replace(/\/+$/, "")
61
+ .toLowerCase();
62
+ }
63
+ /**
64
+ * Which exclusion patterns did any work, recorded as the site is walked.
65
+ *
66
+ * Asking this afterwards would mean a second walk of the tree with pruning
67
+ * turned off — the one thing pruning exists to avoid, paid on every check. The
68
+ * walk already tests each pattern against each path, so it can say which of them
69
+ * ever answered yes without doing the work twice.
70
+ */
71
+ class ExclusionUse {
72
+ patterns;
73
+ used = new Set();
74
+ constructor(patterns) {
75
+ this.patterns = patterns;
76
+ }
77
+ /** Note every pattern that claims this path. */
78
+ record(filePath) {
79
+ let excluded = false;
80
+ for (const pattern of this.patterns) {
81
+ if (!matchesPattern(filePath, pattern))
82
+ continue;
83
+ this.used.add(pattern);
84
+ excluded = true;
85
+ }
86
+ return excluded;
87
+ }
88
+ /**
89
+ * A pruned directory is never walked, so a pattern naming something inside it
90
+ * cannot be said to have matched nothing — the tree it spoke about was
91
+ * excluded by a broader rule, which is redundancy rather than a mistake.
92
+ */
93
+ shadow(dirPath) {
94
+ const prefix = `${dirPath.toLowerCase()}/`;
95
+ for (const pattern of this.patterns) {
96
+ if (normalizePattern(pattern).startsWith(prefix))
97
+ this.used.add(pattern);
98
+ }
99
+ }
100
+ /**
101
+ * Patterns that left the site exactly as they found it.
102
+ *
103
+ * One that excludes nothing is usually a path written from the wrong place —
104
+ * `_archive` for what is really `docs/_archive` — and it fails the way a
105
+ * mistyped key would: the file looks right and the folder ships anyway.
106
+ *
107
+ * An extension pattern is left out, because it is a different kind of
108
+ * statement. `*.tmp` in a site with no scratch files is a rule about what may
109
+ * never ship, not a claim that something is there to remove.
110
+ */
111
+ unused() {
112
+ return this.patterns.filter((pattern) => !this.used.has(pattern) && namesAPlace(pattern));
113
+ }
114
+ }
115
+ async function walk(root, rel, found, use) {
116
+ const entries = await readdir(path.join(root, rel), { withFileTypes: true });
117
+ for (const entry of entries) {
118
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name;
119
+ if (entry.isDirectory()) {
120
+ if (isSkippedDir(entry.name))
121
+ continue;
122
+ // Pruning at the directory keeps an excluded tree from being walked at
123
+ // all, so a large archive costs nothing to skip.
124
+ if (use.record(childRel))
125
+ use.shadow(childRel);
126
+ else
127
+ await walk(root, childRel, found, use);
128
+ }
129
+ else if (entry.isFile() && !use.record(childRel)) {
130
+ found.push(childRel);
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * List the files a site publishes, as POSIX paths relative to its root, sorted.
136
+ *
137
+ * The settings file itself is not content and never ships: it is configuration
138
+ * that happens to live next to what it configures.
139
+ */
140
+ export async function listSite(root, exclude = []) {
141
+ const found = [];
142
+ const use = new ExclusionUse(exclude.filter((pattern) => pattern.trim() !== ""));
143
+ await walk(root, "", found, use);
144
+ return {
145
+ files: found.filter((file) => file !== SETTINGS_FILENAME).sort(),
146
+ unusedExclusions: use.unused(),
147
+ };
148
+ }
149
+ /** The published files alone, for callers with nothing to say about exclusions. */
150
+ export async function listSiteFiles(root, exclude = []) {
151
+ return (await listSite(root, exclude)).files;
152
+ }
153
+ /** The settings file a site is configured by, found at the root of the site. */
154
+ export const SETTINGS_FILENAME = "settings.json";
155
+ /** Is this a markdown source file — a page rather than an asset? */
156
+ export function isPage(filePath) {
157
+ return /\.md$/i.test(filePath);
158
+ }
159
+ /**
160
+ * The key a page is addressed by, from any of the ways one can be written.
161
+ *
162
+ * Settings name pages the way an author thinks of them — `guide/install`,
163
+ * `guide/install.md`, sometimes the published `guide/install.html` — and paths
164
+ * are compared case-insensitively for the same reason exclusions are. Reducing
165
+ * all of those to one key is what lets a reference be resolved once.
166
+ */
167
+ export function toPageKey(filePath) {
168
+ return filePath
169
+ .replace(/\\/g, "/")
170
+ .replace(/^\.\//, "")
171
+ .replace(/^\/+/, "")
172
+ .replace(/\.(md|html)$/i, "")
173
+ .toLowerCase();
174
+ }
175
+ /** Index the files of a site into pages, assets, and a resolver over them. */
176
+ export function indexSite(files) {
177
+ const pages = files.filter(isPage);
178
+ const assets = files.filter((file) => !isPage(file));
179
+ const byKey = new Map();
180
+ for (const page of pages) {
181
+ byKey.set(toPageKey(page), page);
182
+ }
183
+ return {
184
+ pages,
185
+ assets,
186
+ resolve: (reference) => byKey.get(toPageKey(reference)),
187
+ };
188
+ }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@iyulab/canopy-page",
3
+ "version": "0.1.0",
4
+ "description": "Authoring pipeline for documentation sites: one settings file, integrity checks, and a build.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "documentation",
9
+ "static-site-generator",
10
+ "markdown",
11
+ "docs",
12
+ "canopy"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/iyulab/canopy-page.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/iyulab/canopy-page/issues"
20
+ },
21
+ "homepage": "https://github.com/iyulab/canopy-page#readme",
22
+ "engines": {
23
+ "node": ">=22"
24
+ },
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js"
29
+ }
30
+ },
31
+ "bin": {
32
+ "canopy-page": "./dist/cli.js"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "CHANGELOG.md"
37
+ ],
38
+ "scripts": {
39
+ "check": "tsc --noEmit",
40
+ "lint": "biome lint ./src",
41
+ "test": "vitest run",
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "prepublishOnly": "npm run build"
44
+ },
45
+ "devDependencies": {
46
+ "@biomejs/biome": "^2.5.0",
47
+ "@types/node": "^25.9.3",
48
+ "typescript": "^6.0.3",
49
+ "vitest": "^4.1.9"
50
+ },
51
+ "dependencies": {
52
+ "@iyulab/canopy": "^0.1.2"
53
+ }
54
+ }