@iyulab/canopy-page 0.11.2 → 0.13.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,34 @@ 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.13.0] — 2026-08-22
11
+
12
+ ### Added
13
+
14
+ - **Upgraded to canopy 0.12.0** — an external-link icon on `home` when it points outside the
15
+ site, a scroll-edge shadow on code blocks wider than the viewport, and the on-page outline now
16
+ shows its own label instead of only an `aria-label`. See
17
+ [canopy's own changelog](https://github.com/iyulab/canopy/blob/main/CHANGELOG.md#0120--2026-08-22)
18
+ for details.
19
+ - **A `Ctrl K`/`⌘K` badge on the search box**, so the keyboard shortcut that already worked has
20
+ something telling a reader it exists. Hidden once the box has focus, and on narrow viewports
21
+ where the box collapses to an icon and a shortcut is unlikely to matter anyway.
22
+ - **`check` warns about a filename that needs percent-encoding in its own URL** — a stray space
23
+ or other ASCII character outside a URL's unreserved set, most often. The page still publishes
24
+ correctly (a static host serves the encoded URL fine), so this is a warning, not an error;
25
+ ignore it if the encoding is intended. Blind to non-ASCII on purpose: a Korean, Japanese, or
26
+ any other non-English filename needs encoding too, but that's the language, not a mistake.
27
+
28
+ ## [0.12.0] — 2026-08-22
29
+
30
+ ### Added
31
+
32
+ - **Content images open full-size in a lightbox on click**, closing on a background click,
33
+ <kbd>Esc</kbd>, or its close button. An image already wrapped in a link to its own file — a
34
+ common workaround for the lack of a zoom before this — still opens the lightbox first; a
35
+ middle click or a modifier-held click on it still follows the link, for a reader who wants the
36
+ file itself.
37
+
10
38
  ## [0.11.2] — 2026-08-18
11
39
 
12
40
  ### Changed
package/README.md CHANGED
@@ -33,19 +33,26 @@ page never depends on any of the scripted ones: block scripts, or print the page
33
33
  go away.
34
34
 
35
35
  - **Search**, matching a query against every page's title, headings, and body — `Ctrl+K` /
36
- `Cmd+K` jumps to it from anywhere
36
+ `Cmd+K` jumps to it from anywhere, badged on the search box itself so the shortcut is
37
+ discoverable without reading the docs
37
38
  - **The current page and section, marked** in the sidebar and the on-page outline, updating as
38
39
  you scroll
39
40
  - **A dark/light toggle** that remembers a reader's choice; without one, pages follow the
40
41
  system setting
42
+ - **Content images open full-size in a lightbox** when clicked, closing on a background click,
43
+ <kbd>Esc</kbd>, or its close button
41
44
  - **Prev/next cards** linking to a page's neighbors in the sidebar's own order, and **backlinks**
42
45
  listing every page that points to it
43
46
  - **Sidebar groups collapse**, open exactly along the path to the page you're on and closed
44
47
  everywhere else
45
48
  - **A breadcrumb trail** in the topbar, when there's a title, a logo, `home`, or search for it to
46
- sit beside
49
+ sit beside — and an icon on `home` specifically when it points off the site, so a link sitting
50
+ right next to that trail doesn't look like it stays on it
47
51
  - **A full-screen menu on narrow screens** that starts closed instead of opening on every page,
48
52
  remembering a reader's own choice to leave it open for the rest of that visit
53
+ - **A code block wider than the screen shows a shadow at whichever edge still has more to
54
+ scroll to**, and nothing once you've scrolled there — a cue for a scrollbar that some
55
+ OS/browser combinations hide until hovered
49
56
  - **Sitemap and `robots.txt`**, once `siteUrl` is set
50
57
 
51
58
  See it live at <https://iyulab.github.io/canopy-page>, or read
@@ -234,6 +241,11 @@ Warnings — reported, and the build continues:
234
241
  - A section with no `label` and no index page, whose sidebar heading falls back to its own
235
242
  directory name — a filesystem detail, not a name anyone chose. Add a `label`, or an index page
236
243
  for the section to name itself
244
+ - A filename whose published URL needs percent-encoding — a stray space or other ASCII
245
+ character outside a URL's unreserved set, most often. The page still publishes and works; this
246
+ is a nudge to check whether the encoding was intended, not a defect. Blind to non-ASCII on
247
+ purpose, so a Korean, Japanese, or any other non-English filename is never flagged for being
248
+ itself
237
249
 
238
250
  Checking reads the settings and each page. It never renders, so it is fast enough to sit at the
239
251
  front of a pipeline, at the scale a product manual reaches. References inside fenced
@@ -0,0 +1,61 @@
1
+ /* Image lightbox — pairs with assets/image-lightbox.js. Reuses canopy's own
2
+ tokens (--bg-primary, --text-normal, --radius-m, --sp-*) rather than
3
+ introducing new ones. z-index 30 sits above search's 10 and the mobile
4
+ nav overlay's 20, since the lightbox can open while either is present. */
5
+
6
+ .canopy-content img {
7
+ cursor: zoom-in;
8
+ }
9
+
10
+ body.canopy-lightbox-open {
11
+ overflow: hidden;
12
+ }
13
+
14
+ .canopy-lightbox-overlay {
15
+ position: fixed;
16
+ inset: 0;
17
+ z-index: 30;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ padding: var(--sp-8);
22
+ background: rgba(0, 0, 0, 0.85);
23
+ }
24
+
25
+ .canopy-lightbox-overlay[hidden] {
26
+ display: none;
27
+ }
28
+
29
+ .canopy-lightbox-overlay img {
30
+ max-width: 90vw;
31
+ max-height: 90vh;
32
+ object-fit: contain;
33
+ cursor: default;
34
+ border-radius: var(--radius-m);
35
+ }
36
+
37
+ .canopy-lightbox-close {
38
+ position: fixed;
39
+ top: var(--sp-4);
40
+ right: var(--sp-4);
41
+ width: 2.5rem;
42
+ height: 2.5rem;
43
+ display: flex;
44
+ align-items: center;
45
+ justify-content: center;
46
+ border: 1px solid var(--border);
47
+ border-radius: var(--radius-m);
48
+ background: var(--bg-primary);
49
+ color: var(--text-normal);
50
+ font-size: 1.25rem;
51
+ line-height: 1;
52
+ cursor: pointer;
53
+ }
54
+
55
+ .canopy-lightbox-close::before {
56
+ content: "\00d7";
57
+ }
58
+
59
+ .canopy-lightbox-close:hover {
60
+ background: var(--bg-secondary);
61
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * canopy-page's image lightbox — vanilla JS, no dependencies. Wires every
3
+ * `<img>` inside `.canopy-content` so a click opens it full-size in a
4
+ * dismissible overlay, rather than leaving a reader stuck at whatever width
5
+ * the article column happens to render it at.
6
+ *
7
+ * An image an author already wrapped in `<a href="...">` (a common way to
8
+ * work around the lack of a zoom before this script existed) still opens the
9
+ * lightbox first: the overlay shows the same source either way, so the link
10
+ * added nothing a reader couldn't already get from the image itself. The one
11
+ * case left alone is a click that already carries browser intent to open a
12
+ * new tab/window — middle click, or a modifier-held click — which this
13
+ * script never intercepts, so that intent still reaches the ancestor link.
14
+ *
15
+ * `shouldIntercept` is exposed for tests: it is the one piece of this file
16
+ * with real logic, and it needs no DOM to run.
17
+ */
18
+ var CanopyImageLightbox = (function () {
19
+ "use strict";
20
+
21
+ /** Whether a click should open the lightbox instead of its default action. */
22
+ function shouldIntercept(event) {
23
+ return event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
24
+ }
25
+
26
+ function main() {
27
+ var images = document.querySelectorAll(".canopy-content img");
28
+ if (images.length === 0) return;
29
+
30
+ var overlay = document.createElement("div");
31
+ overlay.className = "canopy-lightbox-overlay";
32
+ overlay.hidden = true;
33
+
34
+ var overlayImg = document.createElement("img");
35
+ overlay.appendChild(overlayImg);
36
+
37
+ var closeButton = document.createElement("button");
38
+ closeButton.type = "button";
39
+ closeButton.className = "canopy-lightbox-close";
40
+ closeButton.setAttribute("aria-label", "Close");
41
+ overlay.appendChild(closeButton);
42
+
43
+ var opener = null;
44
+
45
+ function close() {
46
+ overlay.hidden = true;
47
+ document.body.classList.remove("canopy-lightbox-open");
48
+ if (opener) {
49
+ opener.focus();
50
+ opener = null;
51
+ }
52
+ }
53
+
54
+ function open(img) {
55
+ overlayImg.src = img.currentSrc || img.src;
56
+ overlayImg.alt = img.alt;
57
+ overlay.hidden = false;
58
+ document.body.classList.add("canopy-lightbox-open");
59
+ opener = img;
60
+ closeButton.focus();
61
+ }
62
+
63
+ overlay.addEventListener("click", function (event) {
64
+ if (event.target === overlayImg) return;
65
+ close();
66
+ });
67
+ closeButton.addEventListener("click", close);
68
+ document.addEventListener("keydown", function (event) {
69
+ if (!overlay.hidden && event.key === "Escape") close();
70
+ });
71
+
72
+ images.forEach(function (img) {
73
+ img.addEventListener("click", function (event) {
74
+ if (!shouldIntercept(event)) return;
75
+ event.preventDefault();
76
+ event.stopPropagation();
77
+ open(img);
78
+ });
79
+ });
80
+
81
+ document.body.appendChild(overlay);
82
+ }
83
+
84
+ if (typeof document !== "undefined") main();
85
+
86
+ return { shouldIntercept: shouldIntercept };
87
+ })();
@@ -8,6 +8,44 @@
8
8
  position: relative;
9
9
  }
10
10
 
11
+ /* Reserve room so the shortcut badge below doesn't sit on top of typed
12
+ text — canopy's own input padding only accounts for its search icon on
13
+ the left. */
14
+ .canopy-search input[type="search"] {
15
+ padding-right: 3rem;
16
+ }
17
+
18
+ /* Advertises Ctrl+K/Cmd+K (search.js). An inert glyph, not a button — same
19
+ masked-icon-adjacent technique canopy's own shell uses for its search and
20
+ theme-toggle icons: pointer-events: none lets a click straight through to
21
+ the input underneath it. Hidden once the input has focus, so it doesn't
22
+ crowd a query being typed, and on narrow viewports, where canopy's own
23
+ shell (styles.ts, the `max-width: 40rem` breakpoint) collapses the input
24
+ to an icon-sized 2.25rem with no room for a badge — and where a physical
25
+ keyboard, the whole reason the shortcut exists, is unlikely anyway. */
26
+ .canopy-search-shortcut {
27
+ position: absolute;
28
+ top: 50%;
29
+ right: var(--sp-2);
30
+ transform: translateY(-50%);
31
+ padding: 0.05em 0.4em;
32
+ border: 1px solid var(--border);
33
+ border-radius: var(--radius-m);
34
+ font-size: 0.75em;
35
+ line-height: 1.4;
36
+ color: var(--text-muted);
37
+ background: var(--bg-secondary);
38
+ pointer-events: none;
39
+ }
40
+ .canopy-search input[type="search"]:focus ~ .canopy-search-shortcut {
41
+ display: none;
42
+ }
43
+ @media (max-width: 40rem) {
44
+ .canopy-search-shortcut {
45
+ display: none;
46
+ }
47
+ }
48
+
11
49
  .canopy-search-results {
12
50
  position: absolute;
13
51
  top: 100%;
@@ -208,6 +208,19 @@ var CanopySearch = (function () {
208
208
  }
209
209
  });
210
210
 
211
+ // The shortcut above only works if a reader knows it exists — the badge
212
+ // is what tells them. Built here rather than in canopy's own shell markup
213
+ // because only this script knows the shortcut actually ended up wired
214
+ // (a build with no --script attached never reaches this line at all, so
215
+ // it never advertises a key that would do nothing). CSS hides it once
216
+ // the input has focus (search.css) so it doesn't crowd a query being
217
+ // typed.
218
+ var shortcut = document.createElement("kbd");
219
+ shortcut.className = "canopy-search-shortcut";
220
+ shortcut.setAttribute("aria-hidden", "true");
221
+ shortcut.textContent = /Mac|iPhone|iPad/.test(navigator.platform || "") ? "⌘K" : "Ctrl K";
222
+ form.appendChild(shortcut);
223
+
211
224
  // A script that ran this far is a script that can wire the form up —
212
225
  // reveal it now, and not before, so a build with no script attached
213
226
  // (or one that throws before this point) never shows a dead control.
@@ -34,11 +34,12 @@ const SEARCH_FAILED_DEFAULT = "Search failed to load.";
34
34
  * asset stays valid, readable JavaScript on its own.
35
35
  */
36
36
  export async function assembleScript(searchFailed) {
37
- const [search, scrollspy, themeToggle, mobileNav] = await Promise.all([
37
+ const [search, scrollspy, themeToggle, mobileNav, imageLightbox] = await Promise.all([
38
38
  readAsset("search.js"),
39
39
  readAsset("scrollspy.js"),
40
40
  readAsset("theme-toggle.js"),
41
41
  readAsset("mobile-nav.js"),
42
+ readAsset("image-lightbox.js"),
42
43
  ]);
43
44
  // A function replacer, not a replacement string: String.replace treats
44
45
  // "$&"/"$'"/"$$" etc. in a replacement string as patterns, and a site
@@ -46,7 +47,7 @@ export async function assembleScript(searchFailed) {
46
47
  const searchWithStrings = searchFailed === undefined
47
48
  ? search
48
49
  : search.replace(JSON.stringify(SEARCH_FAILED_DEFAULT), () => JSON.stringify(searchFailed));
49
- return `${searchWithStrings}\n${scrollspy}\n${themeToggle}\n${mobileNav}`;
50
+ return `${searchWithStrings}\n${scrollspy}\n${themeToggle}\n${mobileNav}\n${imageLightbox}`;
50
51
  }
51
52
  /**
52
53
  * CSS canopy-page contributes on top of a site's own tokens, carried via
@@ -54,7 +55,11 @@ export async function assembleScript(searchFailed) {
54
55
  * already uses, so no new canopy surface is needed for this either.
55
56
  */
56
57
  export async function assembleTokensCss(userTokensCss) {
57
- const [search, scrollspy] = await Promise.all([readAsset("search.css"), readAsset("scrollspy.css")]);
58
- const own = `${search}\n${scrollspy}`;
58
+ const [search, scrollspy, imageLightbox] = await Promise.all([
59
+ readAsset("search.css"),
60
+ readAsset("scrollspy.css"),
61
+ readAsset("image-lightbox.css"),
62
+ ]);
63
+ const own = `${search}\n${scrollspy}\n${imageLightbox}`;
59
64
  return userTokensCss === undefined ? own : `${userTokensCss}\n${own}`;
60
65
  }
package/dist/check.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  import { type Finding, type LoadedSite } from "./site.js";
2
+ /**
3
+ * A published path's segments, exactly where percent-encoding canopy applies
4
+ * to the *link* (never the file, which keeps its raw name) turns out to
5
+ * matter: `relativeHref` (canopy's site-path.ts) encodes each segment with
6
+ * `encodeURIComponent`, so a name with a space or other URL-unsafe character
7
+ * still resolves — a static host serves "error%20messages.html" correctly —
8
+ * but nothing tells the author whether that was intended. Pages become
9
+ * `.html`; every other published file's segments are checked exactly as
10
+ * written, mirroring `toSitePath`'s own "everything but markdown passes
11
+ * through unchanged".
12
+ */
13
+ export declare function filenameEncodingFindings(site: LoadedSite): Finding[];
2
14
  /** Check every page's references, returning one finding per broken reference. */
3
15
  export declare function referenceFindings(site: LoadedSite): Promise<Finding[]>;
4
16
  /**
package/dist/check.js CHANGED
@@ -85,6 +85,57 @@ function wikilinkExists(site, target) {
85
85
  return site.index.resolve(target) !== undefined;
86
86
  return site.index.pages.some((page) => toPageKey(page).split("/").pop() === key);
87
87
  }
88
+ /**
89
+ * `encodeURIComponent`'s unreserved set — the ASCII characters it leaves
90
+ * alone. Everything else ASCII (a space, `#`, `&`, `?`, …) is the kind of
91
+ * character that lands in a filename by accident — a stray space, a
92
+ * character copied from somewhere that meant it as punctuation, not a path.
93
+ */
94
+ const ASCII_URI_SAFE = /^[A-Za-z0-9\-_.!~*'()]$/;
95
+ /**
96
+ * True if `segment` contains an ASCII character `encodeURIComponent` would
97
+ * escape. Deliberately blind to non-ASCII: canopy percent-encodes every
98
+ * character outside the unreserved set, which means *any* non-English
99
+ * filename — a Korean directory name, an emoji — would otherwise trip this,
100
+ * and canopy-page's own demo site intentionally ships one (see
101
+ * `examples/site/guide/한국어-예시/`) as a *supported* pattern, not a mistake
102
+ * to flag. An ASCII character in the escaped set, on the other hand, is
103
+ * consistently a slip — nobody names a file "error#messages.md" on purpose.
104
+ */
105
+ function hasAsciiEncodingIssue(segment) {
106
+ for (const char of segment) {
107
+ const code = char.codePointAt(0);
108
+ if (code !== undefined && code <= 0x7f && !ASCII_URI_SAFE.test(char))
109
+ return true;
110
+ }
111
+ return false;
112
+ }
113
+ /**
114
+ * A published path's segments, exactly where percent-encoding canopy applies
115
+ * to the *link* (never the file, which keeps its raw name) turns out to
116
+ * matter: `relativeHref` (canopy's site-path.ts) encodes each segment with
117
+ * `encodeURIComponent`, so a name with a space or other URL-unsafe character
118
+ * still resolves — a static host serves "error%20messages.html" correctly —
119
+ * but nothing tells the author whether that was intended. Pages become
120
+ * `.html`; every other published file's segments are checked exactly as
121
+ * written, mirroring `toSitePath`'s own "everything but markdown passes
122
+ * through unchanged".
123
+ */
124
+ export function filenameEncodingFindings(site) {
125
+ const findings = [];
126
+ for (const file of [...site.index.pages, ...site.index.assets]) {
127
+ const published = file.replace(/\.md$/i, ".html");
128
+ const segments = published.split("/");
129
+ if (!segments.some(hasAsciiEncodingIssue))
130
+ continue;
131
+ const href = segments.map((segment) => encodeURIComponent(segment)).join("/");
132
+ findings.push({
133
+ level: "warning",
134
+ message: `${file}: published URL is "${href}" (rename to avoid the encoding, or ignore if intentional)`,
135
+ });
136
+ }
137
+ return findings;
138
+ }
88
139
  /** Check every page's references, returning one finding per broken reference. */
89
140
  export async function referenceFindings(site) {
90
141
  const findings = [];
@@ -169,6 +220,7 @@ export async function siteFindings(site) {
169
220
  return [
170
221
  ...settingsFindings(site),
171
222
  ...navFindings(site.nav),
223
+ ...filenameEncodingFindings(site),
172
224
  ...(await referenceFindings(site)),
173
225
  ];
174
226
  }
package/dist/vault.d.ts CHANGED
@@ -1,3 +1,21 @@
1
+ /**
2
+ * Reading the source tree a settings file describes.
3
+ *
4
+ * canopy-page has to see the same files canopy will publish, because everything
5
+ * it does is about them: expanding `guide/*` into pages, reporting a link to a
6
+ * page that does not exist, deciding what a section contains. The checks are
7
+ * only worth anything if the file list they run against is the one that ships.
8
+ *
9
+ * That means the exclusion rules here have to agree with canopy's, and they are
10
+ * stated in canopy's README as the interface they are: dot-prefixed directories
11
+ * and `node_modules` are never published, and caller patterns come in three
12
+ * shapes. Agreeing by restating is a seam — if canopy ever widens its dialect,
13
+ * a check here would quietly disagree with the build. See
14
+ * TODO(upstream: claudedocs/issues/ISSUE-canopy-20260806-published-file-listing.md)
15
+ * for the proposal that would let a consumer ask canopy instead of restating it.
16
+ */
17
+ /** Directories whose contents are never published, whatever the settings say. */
18
+ export declare function isSkippedDir(name: string): boolean;
1
19
  /**
2
20
  * Match a site-relative POSIX path against one exclusion pattern.
3
21
  *
package/dist/vault.js CHANGED
@@ -17,7 +17,7 @@ import path from "node:path";
17
17
  * for the proposal that would let a consumer ask canopy instead of restating it.
18
18
  */
19
19
  /** Directories whose contents are never published, whatever the settings say. */
20
- function isSkippedDir(name) {
20
+ export function isSkippedDir(name) {
21
21
  return name.startsWith(".") || name === "node_modules";
22
22
  }
23
23
  /**
package/dist/watch.js CHANGED
@@ -3,6 +3,7 @@ import http from "node:http";
3
3
  import { basename, extname, resolve, sep } from "node:path";
4
4
  import { watch as watchFiles } from "chokidar";
5
5
  import { buildSite } from "./build.js";
6
+ import { isSkippedDir } from "./vault.js";
6
7
  /**
7
8
  * Serving a build's output locally during authoring.
8
9
  *
@@ -240,8 +241,7 @@ export async function watchSite(options) {
240
241
  return false;
241
242
  if (resolved === resolvedOut || resolved.startsWith(outWithSep))
242
243
  return true;
243
- const name = basename(resolved);
244
- return name === "node_modules" || name.startsWith(".");
244
+ return isSkippedDir(basename(resolved));
245
245
  },
246
246
  });
247
247
  // chokidar.watch() returns before its initial directory scan finishes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/canopy-page",
3
- "version": "0.11.2",
3
+ "version": "0.13.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",
@@ -53,7 +53,7 @@
53
53
  "vitest": "^4.1.9"
54
54
  },
55
55
  "dependencies": {
56
- "@iyulab/canopy": "^0.11.2",
56
+ "@iyulab/canopy": "^0.12.0",
57
57
  "chokidar": "^5.0.0"
58
58
  }
59
59
  }