@iyulab/canopy-page 0.5.0 → 0.6.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,23 @@ 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.6.0] — 2026-08-09
11
+
12
+ ### Added
13
+
14
+ - **A `rehypePlugins` field**, naming installed rehype plugin packages to run on every page —
15
+ canopy's fixed extension point (after sanitize, before syntax highlighting) for markdown that
16
+ needs more than CommonMark and GFM, a diagram fence rendered to SVG being the case this exists
17
+ for. This site's own [guide to diagrams](https://iyulab.github.io/canopy-page/guide/writing/diagrams.html)
18
+ demonstrates it end to end, with [declart](https://github.com/iyulab/declart) rendered at build
19
+ time through `rehype-declart`.
20
+
21
+ ### Changed
22
+
23
+ - **Upgraded to canopy 0.7.0**, the release `rehypePlugins` builds on. See
24
+ [canopy's changelog](https://github.com/iyulab/canopy/blob/main/CHANGELOG.md#070--2026-08-09)
25
+ for the plugin's fixed position and how it interacts with sanitizing and highlighting.
26
+
10
27
  ## [0.5.0] — 2026-08-09
11
28
 
12
29
  ### Added
package/README.md CHANGED
@@ -7,6 +7,9 @@ authoring pipeline around the rendering: the settings a site is configured by, t
7
7
  keep broken references from shipping, and the build that ties them together. The rendering
8
8
  itself is [canopy](https://github.com/iyulab/canopy)'s job, and canopy-page drives it.
9
9
 
10
+ **Live docs**: <https://iyulab.github.io/canopy-page> — built with canopy-page itself, from the
11
+ [`examples/site`](examples/site) in this repository, republished on every push to `main`.
12
+
10
13
  ---
11
14
 
12
15
  ## Why
@@ -23,6 +26,26 @@ the ones this package holds.
23
26
  - **One site, built in one pass.** Links and backlinks resolve across the whole of it, so a guide
24
27
  and the release notes it refers to stay connected.
25
28
 
29
+ ## What ships in every site
30
+
31
+ No `settings.json` field turns these on — they are just there, on the next build. Reading a
32
+ page never depends on any of the scripted ones: block scripts, or print the page, and only they
33
+ go away.
34
+
35
+ - **Search**, matching a query against every page's title, headings, and body — `Ctrl+K` /
36
+ `Cmd+K` jumps to it from anywhere
37
+ - **The current page and section, marked** in the sidebar and the on-page outline, updating as
38
+ you scroll
39
+ - **A dark/light toggle** that remembers a reader's choice; without one, pages follow the
40
+ system setting
41
+ - **Prev/next cards** linking to a page's neighbors in the sidebar's own order, and **backlinks**
42
+ listing every page that points to it
43
+ - **A full-screen menu on narrow screens**, rather than one that pushes the page's content down
44
+ - **Sitemap and `robots.txt`**, once `siteUrl` is set
45
+
46
+ See it live at <https://iyulab.github.io/canopy-page>, or read
47
+ [What a reader gets](examples/site/guide/reading.md) for how each one behaves.
48
+
26
49
  ## Install
27
50
 
28
51
  ```sh
@@ -69,6 +92,7 @@ unpublished.
69
92
  "lang": "en-GB",
70
93
  "icon": "assets/favicon.png",
71
94
  "exclude": ["_drafts", "*.tmp"],
95
+ "rehypePlugins": ["rehype-declart"],
72
96
  "sections": [
73
97
  { "path": "guide", "label": "Guide", "items": [
74
98
  { "label": "Orders", "items": ["guide/orders/list", "guide/orders/detail"] },
@@ -90,6 +114,7 @@ unpublished.
90
114
  | `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
115
  | `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 |
92
116
  | `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 |
117
+ | `rehypePlugins` | Package names of rehype plugins to run on every page, after canopy's own sanitize step and before syntax highlighting — canopy's fixed extension point for markdown that needs more than CommonMark and GFM, a diagram fence rendered to SVG being the case this exists for. Each entry is an installed package name (`"rehype-declart"`), never a filesystem path — a relative-looking entry is refused, since the directory it would resolve against is wherever the build happens to run from, not this file |
93
118
  | `sections` | Ordered regions of the site — see below |
94
119
 
95
120
  `tokens` is two blocks in practice, not one — a bare `:root` and a `prefers-color-scheme: dark`
@@ -215,6 +240,11 @@ door every other consumer uses, and a door only stays wide enough if the people
215
240
  gone around it do not. Where the command line cannot express something, that is worth raising
216
241
  with canopy rather than working around here.
217
242
 
243
+ ## Contributing
244
+
245
+ See [CONTRIBUTING.md](CONTRIBUTING.md), including what a release has to update besides the
246
+ version number.
247
+
218
248
  ## License
219
249
 
220
250
  MIT
package/dist/build.js CHANGED
@@ -50,6 +50,7 @@ export function canopyArgs(site, out, navPath, searchAssets) {
50
50
  // assembleTokensCss, once copied as a plain asset.
51
51
  ...(settings.tokens === undefined ? [] : ["--exclude", settings.tokens]),
52
52
  ...(settings.exclude ?? []).flatMap((pattern) => ["--exclude", pattern]),
53
+ ...(settings.rehypePlugins ?? []).flatMap((specifier) => ["--rehype-plugin", specifier]),
53
54
  ];
54
55
  }
55
56
  /** Build the site in `dir` into `out`, returning the exit code to leave with. */
@@ -99,6 +99,18 @@ export interface Settings {
99
99
  * points at it. Absent, neither is written.
100
100
  */
101
101
  siteUrl?: string;
102
+ /**
103
+ * Rehype plugins to run on every page, after canopy's own sanitize step and
104
+ * before syntax highlighting — canopy's fixed extension point for markdown
105
+ * that needs more than CommonMark and GFM, a diagram fence rendered to SVG
106
+ * being the case this exists for.
107
+ *
108
+ * Each entry is an installed package name (`"rehype-declart"`), never a
109
+ * filesystem path: this is a JSON settings file naming a dependency the site
110
+ * author already declared, not a script with a place of its own to resolve a
111
+ * relative path against.
112
+ */
113
+ rehypePlugins?: string[];
102
114
  }
103
115
  /**
104
116
  * Parse and validate a settings file from JSON text.
package/dist/settings.js CHANGED
@@ -49,6 +49,7 @@ const SETTINGS_KEYS = new Set([
49
49
  "logo",
50
50
  "home",
51
51
  "siteUrl",
52
+ "rehypePlugins",
52
53
  ]);
53
54
  const SECTION_KEYS = new Set(["path", "label", "order", "items"]);
54
55
  const HOME_KEYS = new Set(["url", "label"]);
@@ -130,6 +131,31 @@ function asExclusionPattern(value, where) {
130
131
  }
131
132
  return pattern;
132
133
  }
134
+ /**
135
+ * Check a `rehypePlugins` entry names a package rather than a file.
136
+ *
137
+ * canopy itself accepts either shape on `--rehype-plugin`, resolving a
138
+ * filesystem-looking specifier against its own process's working directory.
139
+ * That directory is canopy-page's spawning process, not the settings file —
140
+ * the same ambiguity every other path setting here avoids by resolving
141
+ * relative to the settings file instead. Rather than resolve it a second way
142
+ * here, a path-looking entry is refused with a message saying why, matching
143
+ * every other setting whose value must stay inside a stated shape.
144
+ */
145
+ function asModuleSpecifier(value, where) {
146
+ const specifier = asString(value, where);
147
+ const looksLikeAPath = specifier.startsWith("./") ||
148
+ specifier.startsWith("../") ||
149
+ specifier.startsWith("/") ||
150
+ specifier.includes("\\") ||
151
+ /^[A-Za-z]:/.test(specifier);
152
+ if (looksLikeAPath) {
153
+ fail(`${where}: "${specifier}" looks like a file path, not a package name. ` +
154
+ "Install the plugin as a dependency and name it here the way its package.json does " +
155
+ '(e.g. "rehype-declart"), so resolution does not depend on the directory the build runs from.');
156
+ }
157
+ return specifier;
158
+ }
133
159
  function parseNavItem(value, where) {
134
160
  // A bare string is the common case — a page in the order it should appear.
135
161
  if (typeof value === "string") {
@@ -201,7 +227,7 @@ export function parseSettings(json) {
201
227
  }
202
228
  const value = asObject(raw, "settings", "expected a JSON object");
203
229
  rejectUnknownKeys(value, SETTINGS_KEYS, "settings");
204
- const { title, description, lang, icon, tokens, exclude, sections, logo, home, siteUrl } = value;
230
+ const { title, description, lang, icon, tokens, exclude, sections, logo, home, siteUrl, rehypePlugins } = value;
205
231
  if (title !== undefined)
206
232
  asString(title, "settings.title");
207
233
  if (description !== undefined)
@@ -219,6 +245,9 @@ export function parseSettings(json) {
219
245
  fail("settings.exclude: must be an array");
220
246
  if (sections !== undefined && !Array.isArray(sections))
221
247
  fail("settings.sections: must be an array");
248
+ if (rehypePlugins !== undefined && !Array.isArray(rehypePlugins)) {
249
+ fail("settings.rehypePlugins: must be an array");
250
+ }
222
251
  let parsedHome;
223
252
  if (home !== undefined) {
224
253
  const object = asObject(home, "settings.home", 'expected an object with "url" and "label"');
@@ -263,5 +292,10 @@ export function parseSettings(json) {
263
292
  ...(logo === undefined ? {} : { logo: asRelativePath(logo, "settings.logo") }),
264
293
  ...(parsedHome === undefined ? {} : { home: parsedHome }),
265
294
  ...(siteUrl === undefined ? {} : { siteUrl: siteUrl }),
295
+ ...(rehypePlugins === undefined
296
+ ? {}
297
+ : {
298
+ rehypePlugins: rehypePlugins.map((specifier, i) => asModuleSpecifier(specifier, `settings.rehypePlugins[${i}]`)),
299
+ }),
266
300
  };
267
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/canopy-page",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -44,11 +44,13 @@
44
44
  },
45
45
  "devDependencies": {
46
46
  "@biomejs/biome": "^2.5.0",
47
+ "@iyulab/declart": "^0.20.0",
47
48
  "@types/node": "^25.9.3",
49
+ "rehype-declart": "^0.20.0",
48
50
  "typescript": "^6.0.3",
49
51
  "vitest": "^4.1.9"
50
52
  },
51
53
  "dependencies": {
52
- "@iyulab/canopy": "^0.6.0"
54
+ "@iyulab/canopy": "^0.7.0"
53
55
  }
54
56
  }