@iyulab/canopy-page 0.12.0 → 0.14.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 +47 -0
- package/README.md +21 -2
- package/dist/assets/search.css +38 -0
- package/dist/assets/search.js +13 -0
- package/dist/build.js +12 -1
- package/dist/check.d.ts +30 -0
- package/dist/check.js +92 -0
- package/dist/settings.d.ts +16 -2
- package/dist/settings.js +33 -1
- package/dist/sitemap.d.ts +16 -2
- package/dist/sitemap.js +29 -13
- package/dist/vault.d.ts +18 -0
- package/dist/vault.js +1 -1
- package/dist/watch.js +2 -2
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,53 @@ 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.14.0] — 2026-09-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`siteUrl` now reaches the pages, not just the sitemap.** It is passed to canopy as
|
|
15
|
+
`--site-url`, so every page carries `<link rel="canonical">` and `og:url` naming its one
|
|
16
|
+
address — by exactly the string the sitemap lists it under, since both now come from canopy's
|
|
17
|
+
own `pageUrl()` rule rather than two copies of it. A page's frontmatter `description:` fills
|
|
18
|
+
its own `<meta name="description">` (the site's stays the fallback), and the Open Graph basics
|
|
19
|
+
ride on every page with or without `siteUrl`. Body links stay relative either way.
|
|
20
|
+
- **`previewImage`** — the image link previews show (`og:image`) for any page whose frontmatter
|
|
21
|
+
has no `image:` of its own. Validated like `icon`/`logo` (a published file), and rejected
|
|
22
|
+
without `siteUrl`, since the tag has to be absolute.
|
|
23
|
+
- **`alternates`** — the site's other language editions, `hreflang` → that edition's own site
|
|
24
|
+
URL (`x-default` allowed). Each page lists its counterpart at the same path under every
|
|
25
|
+
edition, its own first, as `hreflang` links in `<head>` and as `xhtml:link` entries in
|
|
26
|
+
`sitemap.xml`. Rejected without `siteUrl`.
|
|
27
|
+
- **`check` warns about pages with no `description:` of their own once `siteUrl` is set** — one
|
|
28
|
+
warning naming them all, never an error. A public site's pages otherwise present one
|
|
29
|
+
identical summary in every search result, and `siteUrl` is the setting that says the site is
|
|
30
|
+
public.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- **Sidebar redesign, via canopy 0.13.0** — rows with padding, a hover surface and a focus
|
|
35
|
+
ring; the group chevron moves to the row's trailing edge so labels at one depth share a left
|
|
36
|
+
edge; nested lists carry a guide line. Two new tokens, `--sidebar-hover-bg` and `--sp-1`. A
|
|
37
|
+
`tokens` file that styled `.canopy-nav-group > summary::before` should target `::after`.
|
|
38
|
+
|
|
39
|
+
## [0.13.0] — 2026-08-22
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **Upgraded to canopy 0.12.0** — an external-link icon on `home` when it points outside the
|
|
44
|
+
site, a scroll-edge shadow on code blocks wider than the viewport, and the on-page outline now
|
|
45
|
+
shows its own label instead of only an `aria-label`. See
|
|
46
|
+
[canopy's own changelog](https://github.com/iyulab/canopy/blob/main/CHANGELOG.md#0120--2026-08-22)
|
|
47
|
+
for details.
|
|
48
|
+
- **A `Ctrl K`/`⌘K` badge on the search box**, so the keyboard shortcut that already worked has
|
|
49
|
+
something telling a reader it exists. Hidden once the box has focus, and on narrow viewports
|
|
50
|
+
where the box collapses to an icon and a shortcut is unlikely to matter anyway.
|
|
51
|
+
- **`check` warns about a filename that needs percent-encoding in its own URL** — a stray space
|
|
52
|
+
or other ASCII character outside a URL's unreserved set, most often. The page still publishes
|
|
53
|
+
correctly (a static host serves the encoded URL fine), so this is a warning, not an error;
|
|
54
|
+
ignore it if the encoding is intended. Blind to non-ASCII on purpose: a Korean, Japanese, or
|
|
55
|
+
any other non-English filename needs encoding too, but that's the language, not a mistake.
|
|
56
|
+
|
|
10
57
|
## [0.12.0] — 2026-08-22
|
|
11
58
|
|
|
12
59
|
### Added
|
package/README.md
CHANGED
|
@@ -10,6 +10,10 @@ itself is [canopy](https://github.com/iyulab/canopy)'s job, and canopy-page driv
|
|
|
10
10
|
**Live docs**: <https://iyulab.github.io/canopy-page> — built with canopy-page itself, from the
|
|
11
11
|
[`examples/site`](examples/site) in this repository, republished on every push to `main`.
|
|
12
12
|
|
|
13
|
+
**Complete reference**: [`docs/USAGE.md`](docs/USAGE.md) — every command, every
|
|
14
|
+
`settings.json` field, every markdown feature, and everything a published site ships with, in
|
|
15
|
+
one document. This README stays the short version.
|
|
16
|
+
|
|
13
17
|
---
|
|
14
18
|
|
|
15
19
|
## Why
|
|
@@ -33,7 +37,8 @@ page never depends on any of the scripted ones: block scripts, or print the page
|
|
|
33
37
|
go away.
|
|
34
38
|
|
|
35
39
|
- **Search**, matching a query against every page's title, headings, and body — `Ctrl+K` /
|
|
36
|
-
`Cmd+K` jumps to it from anywhere
|
|
40
|
+
`Cmd+K` jumps to it from anywhere, badged on the search box itself so the shortcut is
|
|
41
|
+
discoverable without reading the docs
|
|
37
42
|
- **The current page and section, marked** in the sidebar and the on-page outline, updating as
|
|
38
43
|
you scroll
|
|
39
44
|
- **A dark/light toggle** that remembers a reader's choice; without one, pages follow the
|
|
@@ -45,9 +50,18 @@ go away.
|
|
|
45
50
|
- **Sidebar groups collapse**, open exactly along the path to the page you're on and closed
|
|
46
51
|
everywhere else
|
|
47
52
|
- **A breadcrumb trail** in the topbar, when there's a title, a logo, `home`, or search for it to
|
|
48
|
-
sit beside
|
|
53
|
+
sit beside — and an icon on `home` specifically when it points off the site, so a link sitting
|
|
54
|
+
right next to that trail doesn't look like it stays on it
|
|
49
55
|
- **A full-screen menu on narrow screens** that starts closed instead of opening on every page,
|
|
50
56
|
remembering a reader's own choice to leave it open for the rest of that visit
|
|
57
|
+
- **A code block wider than the screen shows a shadow at whichever edge still has more to
|
|
58
|
+
scroll to**, and nothing once you've scrolled there — a cue for a scrollbar that some
|
|
59
|
+
OS/browser combinations hide until hovered
|
|
60
|
+
- **Search and link-preview metadata in every page's `<head>`** — a page's own frontmatter
|
|
61
|
+
`description:` (falling back to the site's), the Open Graph basics and a `twitter:card`; once
|
|
62
|
+
`siteUrl` is set, also a canonical URL, `og:url`, `og:image` (`previewImage`, or a page's own
|
|
63
|
+
`image:`), and `hreflang` links to the language editions `alternates` names. Body links stay
|
|
64
|
+
relative regardless, so the same output still opens from a local folder
|
|
51
65
|
- **Sitemap and `robots.txt`**, once `siteUrl` is set
|
|
52
66
|
|
|
53
67
|
See it live at <https://iyulab.github.io/canopy-page>, or read
|
|
@@ -236,6 +250,11 @@ Warnings — reported, and the build continues:
|
|
|
236
250
|
- A section with no `label` and no index page, whose sidebar heading falls back to its own
|
|
237
251
|
directory name — a filesystem detail, not a name anyone chose. Add a `label`, or an index page
|
|
238
252
|
for the section to name itself
|
|
253
|
+
- A filename whose published URL needs percent-encoding — a stray space or other ASCII
|
|
254
|
+
character outside a URL's unreserved set, most often. The page still publishes and works; this
|
|
255
|
+
is a nudge to check whether the encoding was intended, not a defect. Blind to non-ASCII on
|
|
256
|
+
purpose, so a Korean, Japanese, or any other non-English filename is never flagged for being
|
|
257
|
+
itself
|
|
239
258
|
|
|
240
259
|
Checking reads the settings and each page. It never renders, so it is fast enough to sit at the
|
|
241
260
|
front of a pipeline, at the scale a product manual reaches. References inside fenced
|
package/dist/assets/search.css
CHANGED
|
@@ -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%;
|
package/dist/assets/search.js
CHANGED
|
@@ -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.
|
package/dist/build.js
CHANGED
|
@@ -22,6 +22,14 @@ export function canopyArgs(site, out, navPath, searchAssets) {
|
|
|
22
22
|
out,
|
|
23
23
|
...(settings.title === undefined ? [] : ["--site-title", settings.title]),
|
|
24
24
|
...(settings.description === undefined ? [] : ["--site-description", settings.description]),
|
|
25
|
+
// The same URL the sitemap below is written against, so canopy's canonical
|
|
26
|
+
// tags and the sitemap's entries name each page by one string.
|
|
27
|
+
...(settings.siteUrl === undefined ? [] : ["--site-url", settings.siteUrl]),
|
|
28
|
+
...(settings.previewImage === undefined ? [] : ["--site-image", settings.previewImage]),
|
|
29
|
+
...Object.entries(settings.alternates ?? {}).flatMap(([hreflang, url]) => [
|
|
30
|
+
"--alternate",
|
|
31
|
+
`${hreflang}=${url}`,
|
|
32
|
+
]),
|
|
25
33
|
...(settings.lang === undefined ? [] : ["--lang", settings.lang]),
|
|
26
34
|
...(settings.icon === undefined ? [] : ["--site-icon", settings.icon]),
|
|
27
35
|
// Always present: canopy-page's own CSS (search, scrollspy) rides here
|
|
@@ -88,7 +96,10 @@ export async function buildSite({ dir, out }) {
|
|
|
88
96
|
if (code === 0 && site.settings.siteUrl !== undefined) {
|
|
89
97
|
const outDir = path.resolve(out);
|
|
90
98
|
const pages = await listHtmlFiles(outDir);
|
|
91
|
-
await writeFile(path.join(outDir, "sitemap.xml"), sitemapXml(site.settings.siteUrl, pages
|
|
99
|
+
await writeFile(path.join(outDir, "sitemap.xml"), sitemapXml(site.settings.siteUrl, pages, {
|
|
100
|
+
...(site.settings.lang === undefined ? {} : { lang: site.settings.lang }),
|
|
101
|
+
...(site.settings.alternates === undefined ? {} : { alternates: site.settings.alternates }),
|
|
102
|
+
}), "utf8");
|
|
92
103
|
await writeFile(path.join(outDir, "robots.txt"), robotsTxt(site.settings.siteUrl), "utf8");
|
|
93
104
|
console.log(`canopy-page: sitemap.xml with ${pages.length} page(s)`);
|
|
94
105
|
}
|
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
|
/**
|
|
@@ -6,5 +18,23 @@ export declare function referenceFindings(site: LoadedSite): Promise<Finding[]>;
|
|
|
6
18
|
* the settings got wrong first, then what the pages point at.
|
|
7
19
|
*/
|
|
8
20
|
export declare function siteFindings(site: LoadedSite): Promise<Finding[]>;
|
|
21
|
+
/**
|
|
22
|
+
* Pages with no `description:` of their own, on a site that is going to be
|
|
23
|
+
* found by search.
|
|
24
|
+
*
|
|
25
|
+
* Such a page falls back to the site's one description, which is harmless for
|
|
26
|
+
* a site nobody searches and a duplicate summary on every result for one that
|
|
27
|
+
* is public. `siteUrl` is the setting that says which of the two this is — the
|
|
28
|
+
* same gate the sitemap already uses — so the warning waits for it rather than
|
|
29
|
+
* asking for a field of its own. One warning naming every such page, one per
|
|
30
|
+
* line, for the same reason `navFindings` lists uncovered pages that way: on a
|
|
31
|
+
* real site the list runs to dozens, and a warning per page would be a wall.
|
|
32
|
+
* Never an error: a site published somewhere but not meant to be found that way
|
|
33
|
+
* is entitled to ignore this.
|
|
34
|
+
*
|
|
35
|
+
* Frontmatter is read with canopy's own parser, so what counts as a
|
|
36
|
+
* description here is exactly what canopy will put in the page.
|
|
37
|
+
*/
|
|
38
|
+
export declare function descriptionFindings(site: LoadedSite): Promise<Finding[]>;
|
|
9
39
|
/** Check the site in `dir`, returning the exit code to leave with. */
|
|
10
40
|
export declare function checkSite(dir: string): Promise<number>;
|
package/dist/check.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { parseFrontmatter } from "@iyulab/canopy";
|
|
3
4
|
import { decodeTarget, extractReferences, isExternalUrl, resolveFrom, targetPath, } from "./references.js";
|
|
4
5
|
import { loadSite, navFindings, reportFindings, settingsFindings, } from "./site.js";
|
|
5
6
|
import { toPageKey } from "./vault.js";
|
|
@@ -85,6 +86,57 @@ function wikilinkExists(site, target) {
|
|
|
85
86
|
return site.index.resolve(target) !== undefined;
|
|
86
87
|
return site.index.pages.some((page) => toPageKey(page).split("/").pop() === key);
|
|
87
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* `encodeURIComponent`'s unreserved set — the ASCII characters it leaves
|
|
91
|
+
* alone. Everything else ASCII (a space, `#`, `&`, `?`, …) is the kind of
|
|
92
|
+
* character that lands in a filename by accident — a stray space, a
|
|
93
|
+
* character copied from somewhere that meant it as punctuation, not a path.
|
|
94
|
+
*/
|
|
95
|
+
const ASCII_URI_SAFE = /^[A-Za-z0-9\-_.!~*'()]$/;
|
|
96
|
+
/**
|
|
97
|
+
* True if `segment` contains an ASCII character `encodeURIComponent` would
|
|
98
|
+
* escape. Deliberately blind to non-ASCII: canopy percent-encodes every
|
|
99
|
+
* character outside the unreserved set, which means *any* non-English
|
|
100
|
+
* filename — a Korean directory name, an emoji — would otherwise trip this,
|
|
101
|
+
* and canopy-page's own demo site intentionally ships one (see
|
|
102
|
+
* `examples/site/guide/한국어-예시/`) as a *supported* pattern, not a mistake
|
|
103
|
+
* to flag. An ASCII character in the escaped set, on the other hand, is
|
|
104
|
+
* consistently a slip — nobody names a file "error#messages.md" on purpose.
|
|
105
|
+
*/
|
|
106
|
+
function hasAsciiEncodingIssue(segment) {
|
|
107
|
+
for (const char of segment) {
|
|
108
|
+
const code = char.codePointAt(0);
|
|
109
|
+
if (code !== undefined && code <= 0x7f && !ASCII_URI_SAFE.test(char))
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A published path's segments, exactly where percent-encoding canopy applies
|
|
116
|
+
* to the *link* (never the file, which keeps its raw name) turns out to
|
|
117
|
+
* matter: `relativeHref` (canopy's site-path.ts) encodes each segment with
|
|
118
|
+
* `encodeURIComponent`, so a name with a space or other URL-unsafe character
|
|
119
|
+
* still resolves — a static host serves "error%20messages.html" correctly —
|
|
120
|
+
* but nothing tells the author whether that was intended. Pages become
|
|
121
|
+
* `.html`; every other published file's segments are checked exactly as
|
|
122
|
+
* written, mirroring `toSitePath`'s own "everything but markdown passes
|
|
123
|
+
* through unchanged".
|
|
124
|
+
*/
|
|
125
|
+
export function filenameEncodingFindings(site) {
|
|
126
|
+
const findings = [];
|
|
127
|
+
for (const file of [...site.index.pages, ...site.index.assets]) {
|
|
128
|
+
const published = file.replace(/\.md$/i, ".html");
|
|
129
|
+
const segments = published.split("/");
|
|
130
|
+
if (!segments.some(hasAsciiEncodingIssue))
|
|
131
|
+
continue;
|
|
132
|
+
const href = segments.map((segment) => encodeURIComponent(segment)).join("/");
|
|
133
|
+
findings.push({
|
|
134
|
+
level: "warning",
|
|
135
|
+
message: `${file}: published URL is "${href}" (rename to avoid the encoding, or ignore if intentional)`,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return findings;
|
|
139
|
+
}
|
|
88
140
|
/** Check every page's references, returning one finding per broken reference. */
|
|
89
141
|
export async function referenceFindings(site) {
|
|
90
142
|
const findings = [];
|
|
@@ -169,7 +221,47 @@ export async function siteFindings(site) {
|
|
|
169
221
|
return [
|
|
170
222
|
...settingsFindings(site),
|
|
171
223
|
...navFindings(site.nav),
|
|
224
|
+
...filenameEncodingFindings(site),
|
|
172
225
|
...(await referenceFindings(site)),
|
|
226
|
+
...(await descriptionFindings(site)),
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Pages with no `description:` of their own, on a site that is going to be
|
|
231
|
+
* found by search.
|
|
232
|
+
*
|
|
233
|
+
* Such a page falls back to the site's one description, which is harmless for
|
|
234
|
+
* a site nobody searches and a duplicate summary on every result for one that
|
|
235
|
+
* is public. `siteUrl` is the setting that says which of the two this is — the
|
|
236
|
+
* same gate the sitemap already uses — so the warning waits for it rather than
|
|
237
|
+
* asking for a field of its own. One warning naming every such page, one per
|
|
238
|
+
* line, for the same reason `navFindings` lists uncovered pages that way: on a
|
|
239
|
+
* real site the list runs to dozens, and a warning per page would be a wall.
|
|
240
|
+
* Never an error: a site published somewhere but not meant to be found that way
|
|
241
|
+
* is entitled to ignore this.
|
|
242
|
+
*
|
|
243
|
+
* Frontmatter is read with canopy's own parser, so what counts as a
|
|
244
|
+
* description here is exactly what canopy will put in the page.
|
|
245
|
+
*/
|
|
246
|
+
export async function descriptionFindings(site) {
|
|
247
|
+
if (site.settings.siteUrl === undefined)
|
|
248
|
+
return [];
|
|
249
|
+
const missing = [];
|
|
250
|
+
for (const page of site.index.pages) {
|
|
251
|
+
const { data } = parseFrontmatter(await readFile(path.join(site.root, page), "utf8"));
|
|
252
|
+
const description = data.description;
|
|
253
|
+
if (typeof description !== "string" || description.trim() === "")
|
|
254
|
+
missing.push(page);
|
|
255
|
+
}
|
|
256
|
+
if (missing.length === 0)
|
|
257
|
+
return [];
|
|
258
|
+
return [
|
|
259
|
+
{
|
|
260
|
+
level: "warning",
|
|
261
|
+
message: `${missing.length} page(s) have no "description:" in their frontmatter, so search ` +
|
|
262
|
+
"results and link previews show the site's description for each of them:\n" +
|
|
263
|
+
missing.map((page) => ` ${page}`).join("\n"),
|
|
264
|
+
},
|
|
173
265
|
];
|
|
174
266
|
}
|
|
175
267
|
/** Check the site in `dir`, returning the exit code to leave with. */
|
package/dist/settings.d.ts
CHANGED
|
@@ -95,10 +95,24 @@ export interface Settings {
|
|
|
95
95
|
* Where the built site will stand, as an absolute URL.
|
|
96
96
|
*
|
|
97
97
|
* Every link canopy writes is relative, so a site needs this for nothing except
|
|
98
|
-
* the things that must be absolute: `sitemap.xml
|
|
99
|
-
*
|
|
98
|
+
* the things that must be absolute: `sitemap.xml`, the robots file that points
|
|
99
|
+
* at it, and the `<head>` tags a search engine reads as addresses — canonical,
|
|
100
|
+
* `og:url`, `og:image`, `hreflang`. Absent, none of them is written.
|
|
100
101
|
*/
|
|
101
102
|
siteUrl?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Image a link preview shows (`og:image`) for any page whose frontmatter has
|
|
105
|
+
* no `image` of its own, relative to the settings file. Must be a published
|
|
106
|
+
* file, like `icon` and `logo`. Needs `siteUrl`: the tag has to be absolute.
|
|
107
|
+
*/
|
|
108
|
+
previewImage?: string;
|
|
109
|
+
/**
|
|
110
|
+
* The site's other language editions, `hreflang` tag → that edition's own
|
|
111
|
+
* absolute site URL (`x-default` allowed). Each page then names its
|
|
112
|
+
* counterpart at the same path under every edition, in `<head>` and in the
|
|
113
|
+
* sitemap. Needs `siteUrl`, which is the entry for this edition itself.
|
|
114
|
+
*/
|
|
115
|
+
alternates?: Record<string, string>;
|
|
102
116
|
/**
|
|
103
117
|
* Rehype plugins to run on every page, after canopy's own sanitize step and
|
|
104
118
|
* before syntax highlighting — canopy's fixed extension point for markdown
|
package/dist/settings.js
CHANGED
|
@@ -54,6 +54,8 @@ export const SETTINGS_KEYS = new Set([
|
|
|
54
54
|
"logo",
|
|
55
55
|
"home",
|
|
56
56
|
"siteUrl",
|
|
57
|
+
"previewImage",
|
|
58
|
+
"alternates",
|
|
57
59
|
"rehypePlugins",
|
|
58
60
|
"strings",
|
|
59
61
|
]);
|
|
@@ -244,7 +246,7 @@ export function parseSettings(json) {
|
|
|
244
246
|
}
|
|
245
247
|
const value = asObject(raw, "settings", "expected a JSON object");
|
|
246
248
|
rejectUnknownKeys(value, SETTINGS_KEYS, "settings");
|
|
247
|
-
const { title, description, lang, icon, tokens, exclude, sections, logo, home, siteUrl, rehypePlugins, strings, } = value;
|
|
249
|
+
const { title, description, lang, icon, tokens, exclude, sections, logo, home, siteUrl, previewImage, alternates, rehypePlugins, strings, } = value;
|
|
248
250
|
if (title !== undefined)
|
|
249
251
|
asString(title, "settings.title");
|
|
250
252
|
if (description !== undefined)
|
|
@@ -286,6 +288,32 @@ export function parseSettings(json) {
|
|
|
286
288
|
fail(`settings.siteUrl: "${url}" must be an absolute http(s) URL`);
|
|
287
289
|
}
|
|
288
290
|
}
|
|
291
|
+
// Both turn into absolute URLs, and siteUrl is the only thing they can be
|
|
292
|
+
// absolute against — so naming either without it is rejected here, where the
|
|
293
|
+
// message can say what is missing, rather than passed on for canopy to refuse.
|
|
294
|
+
if (previewImage !== undefined && siteUrl === undefined) {
|
|
295
|
+
fail("settings.previewImage: needs siteUrl, since a preview image has to be an absolute URL");
|
|
296
|
+
}
|
|
297
|
+
let parsedAlternates;
|
|
298
|
+
if (alternates !== undefined) {
|
|
299
|
+
const object = asObject(alternates, "settings.alternates", "expected an object of hreflang → site URL");
|
|
300
|
+
if (siteUrl === undefined) {
|
|
301
|
+
fail("settings.alternates: needs siteUrl, since this edition has to be listed alongside the others");
|
|
302
|
+
}
|
|
303
|
+
parsedAlternates = {};
|
|
304
|
+
for (const key of Object.keys(object)) {
|
|
305
|
+
// The same shape `lang` accepts, plus the one reserved value the
|
|
306
|
+
// protocol defines for "no better match".
|
|
307
|
+
if (key !== "x-default" && !/^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$/.test(key)) {
|
|
308
|
+
fail(`settings.alternates: "${key}" is not a language tag like "en" or "ko-KR", or "x-default"`);
|
|
309
|
+
}
|
|
310
|
+
const url = asString(object[key], `settings.alternates.${key}`);
|
|
311
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
312
|
+
fail(`settings.alternates.${key}: "${url}" must be an absolute http(s) URL`);
|
|
313
|
+
}
|
|
314
|
+
parsedAlternates[key] = url;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
289
317
|
let parsedStrings;
|
|
290
318
|
if (strings !== undefined) {
|
|
291
319
|
const object = asObject(strings, "settings.strings", "expected an object");
|
|
@@ -317,6 +345,10 @@ export function parseSettings(json) {
|
|
|
317
345
|
...(logo === undefined ? {} : { logo: asRelativePath(logo, "settings.logo") }),
|
|
318
346
|
...(parsedHome === undefined ? {} : { home: parsedHome }),
|
|
319
347
|
...(siteUrl === undefined ? {} : { siteUrl: siteUrl }),
|
|
348
|
+
...(previewImage === undefined
|
|
349
|
+
? {}
|
|
350
|
+
: { previewImage: asRelativePath(previewImage, "settings.previewImage") }),
|
|
351
|
+
...(parsedAlternates === undefined ? {} : { alternates: parsedAlternates }),
|
|
320
352
|
...(parsedStrings === undefined ? {} : { strings: parsedStrings }),
|
|
321
353
|
...(rehypePlugins === undefined
|
|
322
354
|
? {}
|
package/dist/sitemap.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
-
/**
|
|
2
|
-
export
|
|
1
|
+
/** The site's other language editions, for the sitemap's `xhtml:link` alternates. */
|
|
2
|
+
export interface SitemapEditions {
|
|
3
|
+
/** This edition's own language tag; defaults to "en", as canopy's shell does. */
|
|
4
|
+
lang?: string;
|
|
5
|
+
/** `hreflang` → that edition's own site URL, `x-default` allowed. */
|
|
6
|
+
alternates?: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* A sitemap naming every published page, newline-terminated.
|
|
10
|
+
*
|
|
11
|
+
* With an edition map, each entry also lists the page's counterpart in every
|
|
12
|
+
* edition, this one included — the sitemap form of the `hreflang` links the
|
|
13
|
+
* pages themselves carry, and the same rule: this edition leads unless the map
|
|
14
|
+
* already places its language explicitly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sitemapXml(siteUrl: string, htmlPaths: readonly string[], editions?: SitemapEditions): string;
|
|
3
17
|
/**
|
|
4
18
|
* A robots file whose only job is to point at the sitemap.
|
|
5
19
|
*
|
package/dist/sitemap.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { pageUrl } from "@iyulab/canopy";
|
|
3
4
|
/**
|
|
4
5
|
* The two files a site is found by.
|
|
5
6
|
*
|
|
@@ -13,6 +14,13 @@ import path from "node:path";
|
|
|
13
14
|
* a synthetic `index.html` to a site whose root has no index page, and a sitemap
|
|
14
15
|
* that omitted it would omit the site's front door. Listing files is not reading
|
|
15
16
|
* them — nothing here parses the HTML canopy produced.
|
|
17
|
+
*
|
|
18
|
+
* `pageUrl` is canopy's own rule for a page's canonical address (an index page
|
|
19
|
+
* is its directory) — the same function the shell uses for `rel="canonical"`,
|
|
20
|
+
* imported rather than restated so the sitemap's `<loc>` and the page's own
|
|
21
|
+
* canonical can never disagree by a character. canopy-page otherwise drives
|
|
22
|
+
* canopy through its command line (see `canopy.ts`); that stance is about the
|
|
23
|
+
* build, and a pure URL rule is not a second door into it.
|
|
16
24
|
*/
|
|
17
25
|
function escapeXml(value) {
|
|
18
26
|
return value
|
|
@@ -23,25 +31,33 @@ function escapeXml(value) {
|
|
|
23
31
|
.replace(/'/g, "'");
|
|
24
32
|
}
|
|
25
33
|
/**
|
|
26
|
-
*
|
|
34
|
+
* A sitemap naming every published page, newline-terminated.
|
|
27
35
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
36
|
+
* With an edition map, each entry also lists the page's counterpart in every
|
|
37
|
+
* edition, this one included — the sitemap form of the `hreflang` links the
|
|
38
|
+
* pages themselves carry, and the same rule: this edition leads unless the map
|
|
39
|
+
* already places its language explicitly.
|
|
30
40
|
*/
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
export function sitemapXml(siteUrl, htmlPaths, editions = {}) {
|
|
42
|
+
const editionList = [];
|
|
43
|
+
if (editions.alternates !== undefined) {
|
|
44
|
+
const lang = editions.lang ?? "en";
|
|
45
|
+
if (!Object.hasOwn(editions.alternates, lang))
|
|
46
|
+
editionList.push([lang, siteUrl]);
|
|
47
|
+
editionList.push(...Object.entries(editions.alternates));
|
|
48
|
+
}
|
|
39
49
|
const entries = [...htmlPaths]
|
|
40
50
|
.sort()
|
|
41
|
-
.map((htmlPath) =>
|
|
51
|
+
.map((htmlPath) => {
|
|
52
|
+
const alternates = editionList
|
|
53
|
+
.map(([hreflang, base]) => `<xhtml:link rel="alternate" hreflang="${escapeXml(hreflang)}" href="${escapeXml(pageUrl(base, htmlPath))}"/>`)
|
|
54
|
+
.join("");
|
|
55
|
+
return ` <url><loc>${escapeXml(pageUrl(siteUrl, htmlPath))}</loc>${alternates}</url>`;
|
|
56
|
+
})
|
|
42
57
|
.join("\n");
|
|
58
|
+
const xhtmlNamespace = editionList.length > 0 ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : "";
|
|
43
59
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
60
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNamespace}>
|
|
45
61
|
${entries}
|
|
46
62
|
</urlset>
|
|
47
63
|
`;
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.14.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.
|
|
56
|
+
"@iyulab/canopy": "^0.13.0",
|
|
57
57
|
"chokidar": "^5.0.0"
|
|
58
58
|
}
|
|
59
59
|
}
|