@iyulab/canopy-page 0.1.0 → 0.2.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 CHANGED
@@ -7,6 +7,24 @@ Notable changes to canopy-page. The format follows
7
7
  The `settings.json` contract is what consuming projects plan their upgrades around, so changes
8
8
  to it — its fields, its validation, and what the checks reject — are what this file is about.
9
9
 
10
+ ## [0.2.0] — 2026-08-07
11
+
12
+ ### Added
13
+
14
+ - `tokens`: a CSS file of design-token overrides, relative to the settings file. It is appended
15
+ after the renderer's own tokens rather than replacing them, so a file naming one custom
16
+ property keeps every other default. It is configuration rather than content, so it is excluded
17
+ from the published site automatically — the same file does not ship twice
18
+ - `logo` and `home`: a site can now say what makes a documentation set read as part of the
19
+ product it belongs to. `logo` is an image shown beside the site title and must be a published
20
+ file, the opposite direction from `tokens`. `home` is a link back to the surrounding site —
21
+ `{ url, label }`, both required together, since naming half of it is not a valid setting. There
22
+ is no default `label`, because link text has to be written in the site's own language
23
+ - `siteUrl`: an absolute URL naming where the built site will stand. Every link the renderer
24
+ writes is relative, which is what lets a site be served from any sub-path — and exactly why a
25
+ sitemap, whose entries must be absolute, needs this stated separately. Setting it makes `build`
26
+ write `sitemap.xml` and a `robots.txt` pointing at it; leaving it unset writes neither
27
+
10
28
  ## [0.1.0] — 2026-08-07
11
29
 
12
30
  First release. Development before it is recorded here in one block rather than reconstructed as
package/README.md CHANGED
@@ -85,9 +85,36 @@ unpublished.
85
85
  | `description` | Fills `<meta name="description">`, which is what link previews show |
86
86
  | `lang` | BCP 47 tag for `<html lang>`. Worth setting for any non-English site: assistive technology reads pronunciation from it |
87
87
  | `icon` | Favicon, relative to the settings file. Must be a published file |
88
+ | `tokens` | CSS of design-token overrides, relative to the settings file. Appended *after* canopy's own tokens, so a file naming one value keeps the rest. It is configuration rather than content, so — unlike `icon` and `logo` — it is excluded from the published site automatically. Absent: canopy's default palette |
89
+ | `logo` | Image shown beside the site title, relative to the settings file. Must be a published file — the opposite direction from `tokens`, because this one is content. Rendered with an empty `alt`, deliberately: the site title beside it already names the site, so there is no separate text to give it. Absent: the sidebar header shows the title text alone |
90
+ | `home` | A link back to the site this documentation sits beside: `{ url, label }`. Both are required together — naming half of it is not a valid setting. `url` must be an absolute http(s) URL; there is no default `label`, because link text has to be written in the site's own language. Absent: no link back to a surrounding site is rendered |
91
+ | `siteUrl` | Absolute URL naming where the built site will stand. Every link canopy writes is relative, which is what lets a site be served from any sub-path — and exactly why a sitemap, whose entries must be absolute, needs this separately. **Only** when it is set does `build` write `sitemap.xml` and a `robots.txt` pointing at it. Absent: neither file is written |
88
92
  | `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
93
  | `sections` | Ordered regions of the site — see below |
90
94
 
95
+ `tokens` is two blocks in practice, not one — a bare `:root` and a `prefers-color-scheme: dark`
96
+ override:
97
+
98
+ ```css
99
+ /* brand.css */
100
+ :root {
101
+ --accent: #0a7c5a;
102
+ --accent-hover: #096a4d;
103
+ }
104
+
105
+ @media (prefers-color-scheme: dark) {
106
+ :root {
107
+ --accent: #4ecfa2;
108
+ --accent-hover: #6fdcb5;
109
+ }
110
+ }
111
+ ```
112
+
113
+ canopy's own tokens end with a `prefers-color-scheme: dark` block, and a media query adds no
114
+ specificity over a bare selector — so a bare `:root` appended after that block wins in *both*
115
+ schemes. A one-block file naming only a light-mode colour would ship that colour onto a dark
116
+ sidebar too.
117
+
91
118
  The settings file itself is never published, and neither is anything `exclude` names. A file
92
119
  named `settings.json` deeper in the site is content, and ships.
93
120
 
@@ -168,6 +195,10 @@ What canopy states it leaves alone is left alone here too — absolute URLs, pro
168
195
  bare fragments, and paths above the site root. A target ending in `/` names a directory, and is
169
196
  answered by the index page that directory is entered by.
170
197
 
198
+ `build` writes two files `check` never sees: with `siteUrl` set, a `sitemap.xml` listing every
199
+ published page and a `robots.txt` pointing at it. Neither is checked, because neither exists until
200
+ the build has already succeeded.
201
+
171
202
  ## What belongs where
172
203
 
173
204
  canopy-page owns the authoring pipeline; canopy owns the rendering.
package/dist/build.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadSite } from "./site.js";
1
2
  /**
2
3
  * Building a site: check first, then hand the whole of it to canopy in one pass.
3
4
  *
@@ -13,5 +14,14 @@ export interface BuildOptions {
13
14
  /** Directory to write the site into. */
14
15
  out: string;
15
16
  }
17
+ /**
18
+ * Translate settings into canopy's arguments.
19
+ *
20
+ * Everything a settings file says about the site itself is already something
21
+ * canopy takes: this is a translation, not a layer of behaviour of its own. The
22
+ * navigation spec is the one thing that has to be materialized, since canopy
23
+ * reads it from a file.
24
+ */
25
+ export declare function canopyArgs(site: Awaited<ReturnType<typeof loadSite>>, out: string, navPath: string | undefined): string[];
16
26
  /** Build the site in `dir` into `out`, returning the exit code to leave with. */
17
27
  export declare function buildSite({ dir, out }: BuildOptions): Promise<number>;
package/dist/build.js CHANGED
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { runCanopy } from "./canopy.js";
5
5
  import { siteFindings } from "./check.js";
6
+ import { listHtmlFiles, robotsTxt, sitemapXml } from "./sitemap.js";
6
7
  import { loadSite, reportFindings } from "./site.js";
7
8
  /**
8
9
  * Translate settings into canopy's arguments.
@@ -12,7 +13,7 @@ import { loadSite, reportFindings } from "./site.js";
12
13
  * navigation spec is the one thing that has to be materialized, since canopy
13
14
  * reads it from a file.
14
15
  */
15
- function canopyArgs(site, out, navPath) {
16
+ export function canopyArgs(site, out, navPath) {
16
17
  const { settings } = site;
17
18
  return [
18
19
  "build",
@@ -22,10 +23,22 @@ function canopyArgs(site, out, navPath) {
22
23
  ...(settings.description === undefined ? [] : ["--site-description", settings.description]),
23
24
  ...(settings.lang === undefined ? [] : ["--lang", settings.lang]),
24
25
  ...(settings.icon === undefined ? [] : ["--site-icon", settings.icon]),
26
+ // canopy resolves --tokens-css against the working directory rather than the
27
+ // vault, so it gets an absolute path — unlike --site-icon just above.
28
+ ...(settings.tokens === undefined
29
+ ? []
30
+ : ["--tokens-css", path.join(site.root, settings.tokens)]),
31
+ ...(settings.logo === undefined ? [] : ["--site-logo", settings.logo]),
32
+ ...(settings.home === undefined
33
+ ? []
34
+ : ["--home-url", settings.home.url, "--home-label", settings.home.label]),
25
35
  ...(navPath === undefined ? [] : ["--nav", navPath]),
26
36
  // The settings file is configuration rather than content, and canopy has no
27
37
  // reason to know it exists; excluding it keeps it off the published site.
28
38
  ...["--exclude", "settings.json"],
39
+ // Configuration, not content — the same reason settings.json is excluded.
40
+ // Without this the same CSS ships twice: once as tokens.css, once copied.
41
+ ...(settings.tokens === undefined ? [] : ["--exclude", settings.tokens]),
29
42
  ...(settings.exclude ?? []).flatMap((pattern) => ["--exclude", pattern]),
30
43
  ];
31
44
  }
@@ -48,7 +61,18 @@ export async function buildSite({ dir, out }) {
48
61
  navPath = path.join(workDir, "nav.json");
49
62
  await writeFile(navPath, JSON.stringify(site.nav.spec, null, 2), "utf8");
50
63
  }
51
- return await runCanopy(canopyArgs(site, path.resolve(out), navPath));
64
+ const code = await runCanopy(canopyArgs(site, path.resolve(out), navPath));
65
+ // Only after canopy succeeded, and only over what it actually wrote: a
66
+ // sitemap listing pages a failed build never produced would be a lie a
67
+ // crawler acts on.
68
+ if (code === 0 && site.settings.siteUrl !== undefined) {
69
+ const outDir = path.resolve(out);
70
+ const pages = await listHtmlFiles(outDir);
71
+ await writeFile(path.join(outDir, "sitemap.xml"), sitemapXml(site.settings.siteUrl, pages), "utf8");
72
+ await writeFile(path.join(outDir, "robots.txt"), robotsTxt(site.settings.siteUrl), "utf8");
73
+ console.log(`canopy-page: sitemap.xml with ${pages.length} page(s)`);
74
+ }
75
+ return code;
52
76
  }
53
77
  finally {
54
78
  if (workDir !== undefined)
@@ -63,7 +63,7 @@ export declare function targetPath(url: string): string;
63
63
  * undefined, and the reference is left alone — which is what the renderer does
64
64
  * with it, and the checker's job is to agree with the renderer.
65
65
  *
66
- * TODO(upstream: claudedocs/upstream-issues/ISSUE-canopy-20260807-link-target-resolution.md)
66
+ * TODO(upstream: claudedocs/issues/ISSUE-canopy-20260807-link-target-resolution.md)
67
67
  * — canopy applies this same rule when it rewrites links, and does not expose
68
68
  * it, so the rule is spelled out twice and can drift. It just did: canopy
69
69
  * learned to read this encoding one release before this file did.
@@ -136,7 +136,7 @@ export function targetPath(url) {
136
136
  * undefined, and the reference is left alone — which is what the renderer does
137
137
  * with it, and the checker's job is to agree with the renderer.
138
138
  *
139
- * TODO(upstream: claudedocs/upstream-issues/ISSUE-canopy-20260807-link-target-resolution.md)
139
+ * TODO(upstream: claudedocs/issues/ISSUE-canopy-20260807-link-target-resolution.md)
140
140
  * — canopy applies this same rule when it rewrites links, and does not expose
141
141
  * it, so the rule is spelled out twice and can drift. It just did: canopy
142
142
  * learned to read this encoding one release before this file did.
@@ -66,10 +66,39 @@ export interface Settings {
66
66
  lang?: string;
67
67
  /** Favicon, relative to the settings file. Must be a published file. */
68
68
  icon?: string;
69
+ /**
70
+ * CSS of design-token overrides, relative to the settings file.
71
+ *
72
+ * Appended after canopy's own tokens, so a file naming one value keeps the
73
+ * rest. It is configuration rather than content: it is read at build time and
74
+ * left off the published site.
75
+ */
76
+ tokens?: string;
69
77
  /** Paths to leave unpublished: a directory, an extension (`*.tmp`), or one exact path. */
70
78
  exclude?: string[];
71
79
  /** Ordered regions of the site. Without them, navigation follows the folder tree. */
72
80
  sections?: SettingsSection[];
81
+ /** Logo shown beside the site title, relative to the settings file. Must be a published file. */
82
+ logo?: string;
83
+ /**
84
+ * A link back to the site this documentation sits beside.
85
+ *
86
+ * Both halves or neither: a URL with no text renders an empty link, and text
87
+ * with no URL links nowhere. Stating them as one object makes the half-filled
88
+ * state unrepresentable rather than merely rejected.
89
+ */
90
+ home?: {
91
+ url: string;
92
+ label: string;
93
+ };
94
+ /**
95
+ * Where the built site will stand, as an absolute URL.
96
+ *
97
+ * Every link canopy writes is relative, so a site needs this for nothing except
98
+ * the things that must be absolute: `sitemap.xml` and the robots file that
99
+ * points at it. Absent, neither is written.
100
+ */
101
+ siteUrl?: string;
73
102
  }
74
103
  /**
75
104
  * Parse and validate a settings file from JSON text.
package/dist/settings.js CHANGED
@@ -43,10 +43,15 @@ const SETTINGS_KEYS = new Set([
43
43
  "description",
44
44
  "lang",
45
45
  "icon",
46
+ "tokens",
46
47
  "exclude",
47
48
  "sections",
49
+ "logo",
50
+ "home",
51
+ "siteUrl",
48
52
  ]);
49
53
  const SECTION_KEYS = new Set(["path", "label", "order", "items"]);
54
+ const HOME_KEYS = new Set(["url", "label"]);
50
55
  const NAV_ITEM_KEYS = new Set(["label", "path", "items"]);
51
56
  /**
52
57
  * Unknown keys are rejected rather than ignored.
@@ -196,7 +201,7 @@ export function parseSettings(json) {
196
201
  }
197
202
  const value = asObject(raw, "settings", "expected a JSON object");
198
203
  rejectUnknownKeys(value, SETTINGS_KEYS, "settings");
199
- const { title, description, lang, icon, exclude, sections } = value;
204
+ const { title, description, lang, icon, tokens, exclude, sections, logo, home, siteUrl } = value;
200
205
  if (title !== undefined)
201
206
  asString(title, "settings.title");
202
207
  if (description !== undefined)
@@ -214,11 +219,34 @@ export function parseSettings(json) {
214
219
  fail("settings.exclude: must be an array");
215
220
  if (sections !== undefined && !Array.isArray(sections))
216
221
  fail("settings.sections: must be an array");
222
+ let parsedHome;
223
+ if (home !== undefined) {
224
+ const object = asObject(home, "settings.home", 'expected an object with "url" and "label"');
225
+ rejectUnknownKeys(object, HOME_KEYS, "settings.home");
226
+ if (object.url === undefined)
227
+ fail('settings.home.url: needed alongside "label"');
228
+ if (object.label === undefined)
229
+ fail('settings.home.label: needed alongside "url"');
230
+ const url = asString(object.url, "settings.home.url");
231
+ // The target is normally outside the published site, so a relative path has
232
+ // nothing here to resolve against.
233
+ if (!/^https?:\/\//i.test(url)) {
234
+ fail(`settings.home.url: "${url}" must be an absolute http(s) URL`);
235
+ }
236
+ parsedHome = { url, label: asString(object.label, "settings.home.label") };
237
+ }
238
+ if (siteUrl !== undefined) {
239
+ const url = asString(siteUrl, "settings.siteUrl");
240
+ if (!/^https?:\/\//i.test(url)) {
241
+ fail(`settings.siteUrl: "${url}" must be an absolute http(s) URL`);
242
+ }
243
+ }
217
244
  return {
218
245
  ...(title === undefined ? {} : { title: title }),
219
246
  ...(description === undefined ? {} : { description: description }),
220
247
  ...(lang === undefined ? {} : { lang: lang }),
221
248
  ...(icon === undefined ? {} : { icon: asRelativePath(icon, "settings.icon") }),
249
+ ...(tokens === undefined ? {} : { tokens: asRelativePath(tokens, "settings.tokens") }),
222
250
  ...(exclude === undefined
223
251
  ? {}
224
252
  : {
@@ -232,5 +260,8 @@ export function parseSettings(json) {
232
260
  : {
233
261
  sections: sections.map((section, i) => parseSection(section, `settings.sections[${i}]`)),
234
262
  }),
263
+ ...(logo === undefined ? {} : { logo: asRelativePath(logo, "settings.logo") }),
264
+ ...(parsedHome === undefined ? {} : { home: parsedHome }),
265
+ ...(siteUrl === undefined ? {} : { siteUrl: siteUrl }),
235
266
  };
236
267
  }
package/dist/site.js CHANGED
@@ -42,7 +42,7 @@ export async function loadSite(dir) {
42
42
  }
43
43
  throw error;
44
44
  }
45
- const listing = await listSite(root, settings.exclude);
45
+ const listing = await listSite(root, settings.exclude, settings.tokens === undefined ? [] : [settings.tokens]);
46
46
  const index = indexSite(listing.files);
47
47
  return {
48
48
  root,
@@ -0,0 +1,11 @@
1
+ /** A sitemap naming every published page, newline-terminated. */
2
+ export declare function sitemapXml(siteUrl: string, htmlPaths: readonly string[]): string;
3
+ /**
4
+ * A robots file whose only job is to point at the sitemap.
5
+ *
6
+ * Written only alongside one. A robots.txt with nothing to say would be canopy-page
7
+ * asserting a crawl policy nobody stated.
8
+ */
9
+ export declare function robotsTxt(siteUrl: string): string;
10
+ /** Every `.html` file under `outDir`, as sorted POSIX paths relative to it. */
11
+ export declare function listHtmlFiles(outDir: string): Promise<string[]>;
@@ -0,0 +1,66 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ /**
4
+ * The two files a site is found by.
5
+ *
6
+ * Both need one thing canopy deliberately does not know: where the site
7
+ * actually stands. Every link canopy writes is relative, which is what lets a
8
+ * site be served from any sub-path — and exactly why a sitemap, whose entries
9
+ * must be absolute, cannot be derived from the output alone. `siteUrl` supplies
10
+ * it, and without it neither file is written.
11
+ *
12
+ * The page list comes from the *output* rather than from the source: canopy adds
13
+ * a synthetic `index.html` to a site whose root has no index page, and a sitemap
14
+ * that omitted it would omit the site's front door. Listing files is not reading
15
+ * them — nothing here parses the HTML canopy produced.
16
+ */
17
+ function escapeXml(value) {
18
+ return value
19
+ .replace(/&/g, "&amp;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;")
23
+ .replace(/'/g, "&apos;");
24
+ }
25
+ /**
26
+ * The URL a page is canonically reached by.
27
+ *
28
+ * A directory's index page is the directory: `guide/index.html` and `guide/` are
29
+ * one page, and listing both would ask a crawler to treat it as two.
30
+ */
31
+ function pageUrl(base, htmlPath) {
32
+ const canonical = htmlPath.replace(/(^|\/)index\.html$/, "$1");
33
+ // encodeURI leaves the separators alone and fixes what a URL cannot carry raw.
34
+ return `${base}/${encodeURI(canonical)}`;
35
+ }
36
+ /** A sitemap naming every published page, newline-terminated. */
37
+ export function sitemapXml(siteUrl, htmlPaths) {
38
+ const base = siteUrl.replace(/\/+$/, "");
39
+ const entries = [...htmlPaths]
40
+ .sort()
41
+ .map((htmlPath) => ` <url><loc>${escapeXml(pageUrl(base, htmlPath))}</loc></url>`)
42
+ .join("\n");
43
+ return `<?xml version="1.0" encoding="UTF-8"?>
44
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
45
+ ${entries}
46
+ </urlset>
47
+ `;
48
+ }
49
+ /**
50
+ * A robots file whose only job is to point at the sitemap.
51
+ *
52
+ * Written only alongside one. A robots.txt with nothing to say would be canopy-page
53
+ * asserting a crawl policy nobody stated.
54
+ */
55
+ export function robotsTxt(siteUrl) {
56
+ const base = siteUrl.replace(/\/+$/, "");
57
+ return `User-agent: *\nAllow: /\nSitemap: ${base}/sitemap.xml\n`;
58
+ }
59
+ /** Every `.html` file under `outDir`, as sorted POSIX paths relative to it. */
60
+ export async function listHtmlFiles(outDir) {
61
+ const entries = await readdir(outDir, { recursive: true, withFileTypes: true });
62
+ return entries
63
+ .filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".html"))
64
+ .map((entry) => path.relative(outDir, path.join(entry.parentPath, entry.name)).replace(/\\/g, "/"))
65
+ .sort();
66
+ }
package/dist/vault.d.ts CHANGED
@@ -25,7 +25,9 @@ export interface SiteListing {
25
25
  * The settings file itself is not content and never ships: it is configuration
26
26
  * that happens to live next to what it configures.
27
27
  */
28
- export declare function listSite(root: string, exclude?: readonly string[]): Promise<SiteListing>;
28
+ export declare function listSite(root: string, exclude?: readonly string[],
29
+ /** Files that configure the site rather than belong to it — never published. */
30
+ configFiles?: readonly string[]): Promise<SiteListing>;
29
31
  /** The published files alone, for callers with nothing to say about exclusions. */
30
32
  export declare function listSiteFiles(root: string, exclude?: readonly string[]): Promise<string[]>;
31
33
  /** The settings file a site is configured by, found at the root of the site. */
package/dist/vault.js CHANGED
@@ -137,12 +137,15 @@ async function walk(root, rel, found, use) {
137
137
  * The settings file itself is not content and never ships: it is configuration
138
138
  * that happens to live next to what it configures.
139
139
  */
140
- export async function listSite(root, exclude = []) {
140
+ export async function listSite(root, exclude = [],
141
+ /** Files that configure the site rather than belong to it — never published. */
142
+ configFiles = []) {
141
143
  const found = [];
142
144
  const use = new ExclusionUse(exclude.filter((pattern) => pattern.trim() !== ""));
143
145
  await walk(root, "", found, use);
146
+ const config = new Set([SETTINGS_FILENAME, ...configFiles]);
144
147
  return {
145
- files: found.filter((file) => file !== SETTINGS_FILENAME).sort(),
148
+ files: found.filter((file) => !config.has(file)).sort(),
146
149
  unusedExclusions: use.unused(),
147
150
  };
148
151
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/canopy-page",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Authoring pipeline for documentation sites: one settings file, integrity checks, and a build.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,6 +49,6 @@
49
49
  "vitest": "^4.1.9"
50
50
  },
51
51
  "dependencies": {
52
- "@iyulab/canopy": "^0.1.2"
52
+ "@iyulab/canopy": "^0.2.0"
53
53
  }
54
54
  }