@iyulab/canopy-page 0.4.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 +39 -0
- package/README.md +30 -0
- package/dist/assets/scrollspy.css +10 -0
- package/dist/assets/scrollspy.js +98 -0
- package/dist/assets/search.css +53 -0
- package/dist/assets/search.js +220 -0
- package/dist/assets/theme-toggle.js +87 -0
- package/dist/assets-bundle.d.ts +8 -0
- package/dist/assets-bundle.js +42 -0
- package/dist/build.d.ts +10 -3
- package/dist/build.js +35 -17
- package/dist/settings.d.ts +12 -0
- package/dist/settings.js +35 -1
- package/package.json +5 -3
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,45 @@ 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
|
+
|
|
27
|
+
## [0.5.0] — 2026-08-09
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Search, wired up.** Every build now carries a search box in the top bar, a `Ctrl+K` /
|
|
32
|
+
`Cmd+K` shortcut to jump to it, and an on-page outline that highlights the section currently
|
|
33
|
+
in view while scrolling — all with no `settings.json` field to turn on, and all inert if a
|
|
34
|
+
reader's browser has scripts disabled.
|
|
35
|
+
- **A dark/light toggle**, riding in the same script bundle. Remembers a reader's choice across
|
|
36
|
+
visits; without a stored choice, follows the system preference exactly as before.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Upgraded to canopy 0.6.0.** The current page's sidebar entry is now highlighted, a reader
|
|
41
|
+
landing partway down a page can flip `data-theme` by hand, mobile navigation opens as a
|
|
42
|
+
full-screen panel instead of pushing the page down, an unlabeled or unrecognized code fence
|
|
43
|
+
highlights as plain text instead of falling back unstyled, and every page gains previous/next
|
|
44
|
+
links to its neighbors in the sidebar order — all on the next build, no `settings.json` field
|
|
45
|
+
changed. See
|
|
46
|
+
[canopy's changelog](https://github.com/iyulab/canopy/blob/main/CHANGELOG.md#060--2026-08-09)
|
|
47
|
+
for the underlying markup and CSS selector changes.
|
|
48
|
+
|
|
10
49
|
## [0.4.0] — 2026-08-08
|
|
11
50
|
|
|
12
51
|
### Changed
|
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
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/* Scrollspy — pairs with assets/scrollspy.js. Styles the `.canopy-outline`
|
|
2
|
+
link whose section is currently in view. Reuses canopy's own tokens
|
|
3
|
+
(--accent, --font-weight-semibold) rather than introducing new ones, and
|
|
4
|
+
targets the standard `aria-current` attribute rather than a new class so
|
|
5
|
+
the same rule serves any script that sets it, not just this one. */
|
|
6
|
+
|
|
7
|
+
.canopy-outline a[aria-current="location"] {
|
|
8
|
+
color: var(--accent);
|
|
9
|
+
font-weight: var(--font-weight-semibold);
|
|
10
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canopy-page's on-page outline scrollspy — vanilla JS, no dependencies.
|
|
3
|
+
* Marks whichever `.canopy-outline` link points at the heading currently in
|
|
4
|
+
* view with `aria-current="location"`, the same attribute browsers already
|
|
5
|
+
* use for "current place in a set" (MDN: aria-current). No new class name,
|
|
6
|
+
* so a caller who wants to restyle it only needs `[aria-current="location"]`.
|
|
7
|
+
*
|
|
8
|
+
* `pickActive` is exposed for tests: it is the one piece of this file with
|
|
9
|
+
* real logic (choosing among several simultaneously-visible headings), and
|
|
10
|
+
* it needs no DOM to run.
|
|
11
|
+
*/
|
|
12
|
+
var CanopyScrollspy = (function () {
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Given the outline's heading ids in document order and the subset
|
|
17
|
+
* currently intersecting the viewport, pick the one to mark current.
|
|
18
|
+
*
|
|
19
|
+
* The topmost visible heading wins — not the one with the largest
|
|
20
|
+
* intersection ratio, since a short section near the top of the viewport
|
|
21
|
+
* can be fully visible while a long section just below it is only
|
|
22
|
+
* fractionally visible, and a reader who just scrolled to that short
|
|
23
|
+
* section expects it, not its taller neighbor, to light up.
|
|
24
|
+
*/
|
|
25
|
+
function pickActive(orderedIds, visibleIds) {
|
|
26
|
+
if (visibleIds.length === 0) return null;
|
|
27
|
+
var visible = new Set(visibleIds);
|
|
28
|
+
for (var i = 0; i < orderedIds.length; i++) {
|
|
29
|
+
if (visible.has(orderedIds[i])) return orderedIds[i];
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function main() {
|
|
35
|
+
var outline = document.querySelector(".canopy-outline");
|
|
36
|
+
if (!outline) return;
|
|
37
|
+
var links = outline.querySelectorAll("a[href^='#']");
|
|
38
|
+
if (links.length === 0) return;
|
|
39
|
+
|
|
40
|
+
var orderedIds = [];
|
|
41
|
+
var linkById = {};
|
|
42
|
+
links.forEach(function (link) {
|
|
43
|
+
var id = link.getAttribute("href").slice(1);
|
|
44
|
+
orderedIds.push(id);
|
|
45
|
+
linkById[id] = link;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
var headings = orderedIds
|
|
49
|
+
.map(function (id) {
|
|
50
|
+
return document.getElementById(id);
|
|
51
|
+
})
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
if (headings.length === 0) return;
|
|
54
|
+
|
|
55
|
+
if (typeof IntersectionObserver === "undefined") return;
|
|
56
|
+
|
|
57
|
+
var current = null;
|
|
58
|
+
function setCurrent(id) {
|
|
59
|
+
if (id === current) return;
|
|
60
|
+
if (current !== null && linkById[current]) {
|
|
61
|
+
linkById[current].removeAttribute("aria-current");
|
|
62
|
+
}
|
|
63
|
+
current = id;
|
|
64
|
+
if (current !== null && linkById[current]) {
|
|
65
|
+
linkById[current].setAttribute("aria-current", "location");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
var visible = [];
|
|
70
|
+
var observer = new IntersectionObserver(
|
|
71
|
+
function (entries) {
|
|
72
|
+
entries.forEach(function (entry) {
|
|
73
|
+
var id = entry.target.id;
|
|
74
|
+
var at = visible.indexOf(id);
|
|
75
|
+
if (entry.isIntersecting) {
|
|
76
|
+
if (at === -1) visible.push(id);
|
|
77
|
+
} else if (at !== -1) {
|
|
78
|
+
visible.splice(at, 1);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
setCurrent(pickActive(orderedIds, visible));
|
|
82
|
+
},
|
|
83
|
+
// A heading counts as "in view" while it sits in a band from the
|
|
84
|
+
// upper tenth to the upper third of the viewport — narrow enough that
|
|
85
|
+
// a reader scrolling past a short section still sees it light up, but
|
|
86
|
+
// wide enough to actually be a non-empty band (top + bottom margins
|
|
87
|
+
// here must sum to under 100%, or the observed strip is zero-height).
|
|
88
|
+
{ rootMargin: "-10% 0px -70% 0px" },
|
|
89
|
+
);
|
|
90
|
+
headings.forEach(function (heading) {
|
|
91
|
+
observer.observe(heading);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof document !== "undefined") main();
|
|
96
|
+
|
|
97
|
+
return { pickActive: pickActive };
|
|
98
|
+
})();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* Search UI — pairs with assets/search.js and the `.canopy-search` form
|
|
2
|
+
canopy's shell emits. Loaded only when a build carries both, so a site
|
|
3
|
+
with no search never pays for this either. Reuses canopy's own tokens
|
|
4
|
+
(--sp-*, --border, --radius-m, --bg-primary, --text-*) rather than
|
|
5
|
+
introducing new ones. */
|
|
6
|
+
|
|
7
|
+
.canopy-search {
|
|
8
|
+
position: relative;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.canopy-search-results {
|
|
12
|
+
position: absolute;
|
|
13
|
+
top: 100%;
|
|
14
|
+
right: 0;
|
|
15
|
+
z-index: 10;
|
|
16
|
+
margin: var(--sp-2) 0 0;
|
|
17
|
+
padding: var(--sp-2) 0;
|
|
18
|
+
width: min(24rem, 90vw);
|
|
19
|
+
max-height: 60vh;
|
|
20
|
+
overflow-y: auto;
|
|
21
|
+
list-style: none;
|
|
22
|
+
background: var(--bg-primary);
|
|
23
|
+
border: 1px solid var(--border);
|
|
24
|
+
border-radius: var(--radius-m);
|
|
25
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.canopy-search-results li {
|
|
29
|
+
padding: var(--sp-2) var(--sp-3);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.canopy-search-results li.is-active,
|
|
33
|
+
.canopy-search-results li:hover {
|
|
34
|
+
background: var(--bg-secondary);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.canopy-search-results a {
|
|
38
|
+
display: block;
|
|
39
|
+
font-weight: var(--font-weight-semibold);
|
|
40
|
+
color: var(--text-normal);
|
|
41
|
+
text-decoration: none;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.canopy-search-snippet {
|
|
45
|
+
margin: var(--sp-1, 4px) 0 0;
|
|
46
|
+
font-size: 0.85em;
|
|
47
|
+
color: var(--text-muted);
|
|
48
|
+
overflow: hidden;
|
|
49
|
+
text-overflow: ellipsis;
|
|
50
|
+
display: -webkit-box;
|
|
51
|
+
-webkit-line-clamp: 2;
|
|
52
|
+
-webkit-box-orient: vertical;
|
|
53
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canopy-page's client search — vanilla JS, no dependencies. Wires the
|
|
3
|
+
* `.canopy-search` form canopy's shell emits when `--search-index` and
|
|
4
|
+
* `--script` are both given.
|
|
5
|
+
*
|
|
6
|
+
* Substring matching, not stemming: Korean attaches particles to a word stem
|
|
7
|
+
* (주문 -> 주문을/주문이), so a substring search already finds the stem
|
|
8
|
+
* inside the inflected form — English queries use the same code path.
|
|
9
|
+
*
|
|
10
|
+
* One global (`CanopySearch`), so this adds exactly one name to whatever
|
|
11
|
+
* page carries it. `searchIndex` is exposed for tests, which load and
|
|
12
|
+
* evaluate this exact file rather than reimplementing it (search-ui.test.ts).
|
|
13
|
+
*/
|
|
14
|
+
var CanopySearch = (function () {
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
function normalize(s) {
|
|
18
|
+
return s.normalize("NFC").toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Score one entry against a query's terms. Every term has to appear
|
|
23
|
+
* somewhere (title, a heading, or the body) or the entry is dropped — an
|
|
24
|
+
* OR across terms would surface pages matching none of what was typed.
|
|
25
|
+
*/
|
|
26
|
+
function scoreEntry(entry, terms) {
|
|
27
|
+
var title = normalize(entry.t);
|
|
28
|
+
var headings = entry.h.map(normalize);
|
|
29
|
+
var body = normalize(entry.b);
|
|
30
|
+
var score = 0;
|
|
31
|
+
for (var i = 0; i < terms.length; i++) {
|
|
32
|
+
var term = terms[i];
|
|
33
|
+
var inTitle = title.indexOf(term) !== -1;
|
|
34
|
+
var inHeading = headings.some(function (h) {
|
|
35
|
+
return h.indexOf(term) !== -1;
|
|
36
|
+
});
|
|
37
|
+
var inBody = body.indexOf(term) !== -1;
|
|
38
|
+
if (!inTitle && !inHeading && !inBody) return 0;
|
|
39
|
+
if (inTitle) score += 3;
|
|
40
|
+
if (inHeading) score += 2;
|
|
41
|
+
if (inBody) score += 1;
|
|
42
|
+
}
|
|
43
|
+
return score;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A short excerpt around the first matched term, for the result list. */
|
|
47
|
+
function snippet(entry, terms) {
|
|
48
|
+
var body = entry.b;
|
|
49
|
+
var lower = normalize(body);
|
|
50
|
+
var at = -1;
|
|
51
|
+
for (var i = 0; i < terms.length; i++) {
|
|
52
|
+
at = lower.indexOf(terms[i]);
|
|
53
|
+
if (at !== -1) break;
|
|
54
|
+
}
|
|
55
|
+
if (at === -1) return body.slice(0, 100);
|
|
56
|
+
var start = Math.max(0, at - 40);
|
|
57
|
+
var end = Math.min(body.length, at + 60);
|
|
58
|
+
return (start > 0 ? "…" : "") + body.slice(start, end) + (end < body.length ? "…" : "");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Search `entries` (the parsed search-index.json) for `query`. */
|
|
62
|
+
function searchIndex(entries, query, limit) {
|
|
63
|
+
var terms = normalize(query).split(/\s+/).filter(Boolean);
|
|
64
|
+
if (terms.length === 0) return [];
|
|
65
|
+
var results = [];
|
|
66
|
+
for (var i = 0; i < entries.length; i++) {
|
|
67
|
+
var score = scoreEntry(entries[i], terms);
|
|
68
|
+
if (score > 0) {
|
|
69
|
+
results.push({ entry: entries[i], score: score, snippet: snippet(entries[i], terms) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
results.sort(function (a, b) {
|
|
73
|
+
return b.score - a.score;
|
|
74
|
+
});
|
|
75
|
+
return results.slice(0, limit || 10);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// DOM wiring below is skipped outside a browser, so this file stays
|
|
79
|
+
// loadable in a plain Node sandbox for the test above.
|
|
80
|
+
|
|
81
|
+
var SCRIPT_SRC =
|
|
82
|
+
typeof document !== "undefined" && document.currentScript ? document.currentScript.src : "";
|
|
83
|
+
|
|
84
|
+
function main() {
|
|
85
|
+
var form = document.querySelector(".canopy-search");
|
|
86
|
+
var input = form ? form.querySelector("input[type=search]") : null;
|
|
87
|
+
if (!form || !input) return;
|
|
88
|
+
|
|
89
|
+
// canopy always writes this script to assets/script.js at the site root
|
|
90
|
+
// and links it relatively, so the resolved URL minus that suffix is the
|
|
91
|
+
// site root — the same root the index and every result link resolve
|
|
92
|
+
// against.
|
|
93
|
+
var root = SCRIPT_SRC.replace(/assets\/script\.js(\?.*)?$/, "");
|
|
94
|
+
var indexUrl = root + "search-index.json";
|
|
95
|
+
|
|
96
|
+
var list = document.createElement("ul");
|
|
97
|
+
list.className = "canopy-search-results";
|
|
98
|
+
list.hidden = true;
|
|
99
|
+
list.setAttribute("role", "listbox");
|
|
100
|
+
form.appendChild(list);
|
|
101
|
+
|
|
102
|
+
var indexPromise = null;
|
|
103
|
+
function loadIndex() {
|
|
104
|
+
if (!indexPromise) {
|
|
105
|
+
indexPromise = fetch(indexUrl).then(function (response) {
|
|
106
|
+
if (!response.ok) throw new Error("search index request failed");
|
|
107
|
+
return response.json();
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return indexPromise;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
var active = -1;
|
|
114
|
+
|
|
115
|
+
function render(results) {
|
|
116
|
+
active = -1;
|
|
117
|
+
list.textContent = "";
|
|
118
|
+
if (results.length === 0) {
|
|
119
|
+
list.hidden = true;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
results.forEach(function (result) {
|
|
123
|
+
var item = document.createElement("li");
|
|
124
|
+
item.setAttribute("role", "option");
|
|
125
|
+
var link = document.createElement("a");
|
|
126
|
+
link.href = root + result.entry.p;
|
|
127
|
+
link.textContent = result.entry.t;
|
|
128
|
+
var preview = document.createElement("p");
|
|
129
|
+
preview.className = "canopy-search-snippet";
|
|
130
|
+
preview.textContent = result.snippet;
|
|
131
|
+
item.appendChild(link);
|
|
132
|
+
item.appendChild(preview);
|
|
133
|
+
list.appendChild(item);
|
|
134
|
+
});
|
|
135
|
+
list.hidden = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
var pending;
|
|
139
|
+
function handleInput() {
|
|
140
|
+
var query = input.value;
|
|
141
|
+
clearTimeout(pending);
|
|
142
|
+
pending = setTimeout(function () {
|
|
143
|
+
if (query.trim() === "") {
|
|
144
|
+
render([]);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
loadIndex()
|
|
148
|
+
.then(function (entries) {
|
|
149
|
+
render(searchIndex(entries, query, 10));
|
|
150
|
+
})
|
|
151
|
+
.catch(function () {
|
|
152
|
+
list.textContent = "";
|
|
153
|
+
var item = document.createElement("li");
|
|
154
|
+
item.textContent = "Search failed to load.";
|
|
155
|
+
list.appendChild(item);
|
|
156
|
+
list.hidden = false;
|
|
157
|
+
});
|
|
158
|
+
}, 120);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function setActive(index) {
|
|
162
|
+
var items = list.children;
|
|
163
|
+
if (active >= 0 && items[active]) items[active].classList.remove("is-active");
|
|
164
|
+
active = index;
|
|
165
|
+
if (active >= 0 && items[active]) {
|
|
166
|
+
items[active].classList.add("is-active");
|
|
167
|
+
items[active].scrollIntoView({ block: "nearest" });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleKeydown(event) {
|
|
172
|
+
if (list.hidden || list.children.length === 0) return;
|
|
173
|
+
if (event.key === "ArrowDown") {
|
|
174
|
+
event.preventDefault();
|
|
175
|
+
setActive((active + 1) % list.children.length);
|
|
176
|
+
} else if (event.key === "ArrowUp") {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
setActive((active - 1 + list.children.length) % list.children.length);
|
|
179
|
+
} else if (event.key === "Enter") {
|
|
180
|
+
var target = active >= 0 ? list.children[active] : list.children[0];
|
|
181
|
+
var link = target && target.querySelector("a");
|
|
182
|
+
if (link) {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
location.href = link.href;
|
|
185
|
+
}
|
|
186
|
+
} else if (event.key === "Escape") {
|
|
187
|
+
render([]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
input.addEventListener("focus", loadIndex, { once: true });
|
|
192
|
+
input.addEventListener("input", handleInput);
|
|
193
|
+
input.addEventListener("keydown", handleKeydown);
|
|
194
|
+
document.addEventListener("click", function (event) {
|
|
195
|
+
if (!form.contains(event.target)) list.hidden = true;
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Ctrl+K / Cmd+K jumps to search from anywhere on the page — the
|
|
199
|
+
// convention readers already know from editors and other doc sites.
|
|
200
|
+
// Global rather than scoped to the form, since the point is not having
|
|
201
|
+
// to click the form first.
|
|
202
|
+
document.addEventListener("keydown", function (event) {
|
|
203
|
+
var key = (event.key || "").toLowerCase();
|
|
204
|
+
if ((event.ctrlKey || event.metaKey) && key === "k") {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
input.focus();
|
|
207
|
+
input.select();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// A script that ran this far is a script that can wire the form up —
|
|
212
|
+
// reveal it now, and not before, so a build with no script attached
|
|
213
|
+
// (or one that throws before this point) never shows a dead control.
|
|
214
|
+
form.hidden = false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (typeof document !== "undefined") main();
|
|
218
|
+
|
|
219
|
+
return { searchIndex: searchIndex };
|
|
220
|
+
})();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canopy-page's dark/light toggle — vanilla JS, no dependencies. Wires the
|
|
3
|
+
* `.canopy-theme-toggle` hidden button canopy's shell always carries when a
|
|
4
|
+
* top bar exists, and flips `data-theme` on `<html>`, the attribute canopy's
|
|
5
|
+
* own stylesheet reads to override `prefers-color-scheme` (see canopy's
|
|
6
|
+
* tokens.ts).
|
|
7
|
+
*
|
|
8
|
+
* `effectiveTheme` and `nextTheme` are exposed for tests, which is also why
|
|
9
|
+
* the resolution logic is split out as pure functions taking primitives
|
|
10
|
+
* rather than reading `matchMedia`/`data-theme` inline — the same shape
|
|
11
|
+
* search.js's `searchIndex` and scrollspy.js's `pickActive` already use.
|
|
12
|
+
*/
|
|
13
|
+
var CanopyThemeToggle = (function () {
|
|
14
|
+
"use strict";
|
|
15
|
+
|
|
16
|
+
var STORAGE_KEY = "canopy-theme";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The theme actually in effect right now: an explicit data-theme
|
|
20
|
+
* attribute if one is set, otherwise the system preference. This is the
|
|
21
|
+
* "current" a click toggles away from — computing it fresh (rather than
|
|
22
|
+
* trusting a stored value) is what makes a first click always flip the
|
|
23
|
+
* theme the reader is actually looking at, even if that reader never
|
|
24
|
+
* clicked before and nothing was ever stored.
|
|
25
|
+
*/
|
|
26
|
+
function effectiveTheme(dataThemeAttr, systemPrefersDark) {
|
|
27
|
+
if (dataThemeAttr === "dark" || dataThemeAttr === "light") return dataThemeAttr;
|
|
28
|
+
return systemPrefersDark ? "dark" : "light";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The theme a click moves to: simply the other one. */
|
|
32
|
+
function nextTheme(current) {
|
|
33
|
+
return current === "dark" ? "light" : "dark";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function systemPrefersDark() {
|
|
37
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Storage access can throw (private browsing with storage disabled, a
|
|
41
|
+
// strict cookie/storage policy) — every call here is wrapped so that a
|
|
42
|
+
// reader who cannot persist a preference still gets a fully working
|
|
43
|
+
// toggle for the rest of the session, just not a remembered one.
|
|
44
|
+
function readStored() {
|
|
45
|
+
try {
|
|
46
|
+
return localStorage.getItem(STORAGE_KEY);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeStored(theme) {
|
|
53
|
+
try {
|
|
54
|
+
localStorage.setItem(STORAGE_KEY, theme);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Nothing to recover: the theme is still applied for this page view.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function main() {
|
|
61
|
+
var button = document.querySelector(".canopy-theme-toggle");
|
|
62
|
+
if (!button) return;
|
|
63
|
+
|
|
64
|
+
var stored = readStored();
|
|
65
|
+
if (stored === "dark" || stored === "light") {
|
|
66
|
+
document.documentElement.setAttribute("data-theme", stored);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
button.addEventListener("click", function () {
|
|
70
|
+
var current = effectiveTheme(
|
|
71
|
+
document.documentElement.getAttribute("data-theme"),
|
|
72
|
+
systemPrefersDark(),
|
|
73
|
+
);
|
|
74
|
+
var theme = nextTheme(current);
|
|
75
|
+
document.documentElement.setAttribute("data-theme", theme);
|
|
76
|
+
writeStored(theme);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// A script that ran this far can wire the button up — reveal it now,
|
|
80
|
+
// and not before, the same reasoning search.js's form reveal uses.
|
|
81
|
+
button.hidden = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof document !== "undefined") main();
|
|
85
|
+
|
|
86
|
+
return { effectiveTheme: effectiveTheme, nextTheme: nextTheme };
|
|
87
|
+
})();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The single script every canopy-page site carries via canopy's `--script`. */
|
|
2
|
+
export declare function assembleScript(): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* CSS canopy-page contributes on top of a site's own tokens, carried via
|
|
5
|
+
* canopy's `--tokens-css` — the same channel a site's own `settings.tokens`
|
|
6
|
+
* already uses, so no new canopy surface is needed for this either.
|
|
7
|
+
*/
|
|
8
|
+
export declare function assembleTokensCss(userTokensCss: string | undefined): Promise<string>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Assembling canopy-page's own client-side surface for a build.
|
|
6
|
+
*
|
|
7
|
+
* canopy carries at most one `--script` and one `--tokens-css`, so every UI
|
|
8
|
+
* feature canopy-page ships (search, the outline scrollspy, and whatever
|
|
9
|
+
* follows) has to land in those two files rather than one each. Reading the
|
|
10
|
+
* pieces here — rather than at each call site — keeps the list of what
|
|
11
|
+
* ships in one place: adding a feature means adding one line below, not
|
|
12
|
+
* hunting for every place a script or stylesheet gets assembled.
|
|
13
|
+
*
|
|
14
|
+
* Resolved relative to this module rather than `process.cwd()`, so it finds
|
|
15
|
+
* `assets/` next to itself whether it is running as `src/assets-bundle.ts`
|
|
16
|
+
* (tests, dev) or the compiled `dist/assets-bundle.js` (published package) —
|
|
17
|
+
* `copy-assets.mjs` copies `src/assets` to `dist/assets` in the same
|
|
18
|
+
* position relative to the compiled output.
|
|
19
|
+
*/
|
|
20
|
+
const ASSETS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "assets");
|
|
21
|
+
async function readAsset(name) {
|
|
22
|
+
return readFile(path.join(ASSETS_DIR, name), "utf8");
|
|
23
|
+
}
|
|
24
|
+
/** The single script every canopy-page site carries via canopy's `--script`. */
|
|
25
|
+
export async function assembleScript() {
|
|
26
|
+
const [search, scrollspy, themeToggle] = await Promise.all([
|
|
27
|
+
readAsset("search.js"),
|
|
28
|
+
readAsset("scrollspy.js"),
|
|
29
|
+
readAsset("theme-toggle.js"),
|
|
30
|
+
]);
|
|
31
|
+
return `${search}\n${scrollspy}\n${themeToggle}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* CSS canopy-page contributes on top of a site's own tokens, carried via
|
|
35
|
+
* canopy's `--tokens-css` — the same channel a site's own `settings.tokens`
|
|
36
|
+
* already uses, so no new canopy surface is needed for this either.
|
|
37
|
+
*/
|
|
38
|
+
export async function assembleTokensCss(userTokensCss) {
|
|
39
|
+
const [search, scrollspy] = await Promise.all([readAsset("search.css"), readAsset("scrollspy.css")]);
|
|
40
|
+
const own = `${search}\n${scrollspy}`;
|
|
41
|
+
return userTokensCss === undefined ? own : `${userTokensCss}\n${own}`;
|
|
42
|
+
}
|
package/dist/build.d.ts
CHANGED
|
@@ -14,14 +14,21 @@ export interface BuildOptions {
|
|
|
14
14
|
/** Directory to write the site into. */
|
|
15
15
|
out: string;
|
|
16
16
|
}
|
|
17
|
+
/** Assembled search/UI assets, written to real files so canopy's CLI can read them. */
|
|
18
|
+
export interface SearchAssets {
|
|
19
|
+
/** Absolute path to the assembled tokens CSS (a site's own tokens, plus canopy-page's). */
|
|
20
|
+
tokensCssPath: string;
|
|
21
|
+
/** Absolute path to the assembled client script (search, scrollspy, ...). */
|
|
22
|
+
scriptPath: string;
|
|
23
|
+
}
|
|
17
24
|
/**
|
|
18
25
|
* Translate settings into canopy's arguments.
|
|
19
26
|
*
|
|
20
27
|
* Everything a settings file says about the site itself is already something
|
|
21
28
|
* canopy takes: this is a translation, not a layer of behaviour of its own. The
|
|
22
|
-
* navigation spec
|
|
23
|
-
* reads
|
|
29
|
+
* navigation spec and `searchAssets` are the things that have to be materialized
|
|
30
|
+
* first, since canopy reads all three from files.
|
|
24
31
|
*/
|
|
25
|
-
export declare function canopyArgs(site: Awaited<ReturnType<typeof loadSite>>, out: string, navPath: string | undefined): string[];
|
|
32
|
+
export declare function canopyArgs(site: Awaited<ReturnType<typeof loadSite>>, out: string, navPath: string | undefined, searchAssets: SearchAssets): string[];
|
|
26
33
|
/** Build the site in `dir` into `out`, returning the exit code to leave with. */
|
|
27
34
|
export declare function buildSite({ dir, out }: BuildOptions): Promise<number>;
|
package/dist/build.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assembleScript, assembleTokensCss } from "./assets-bundle.js";
|
|
4
5
|
import { runCanopy } from "./canopy.js";
|
|
5
6
|
import { siteFindings } from "./check.js";
|
|
6
7
|
import { listHtmlFiles, robotsTxt, sitemapXml } from "./sitemap.js";
|
|
@@ -10,10 +11,10 @@ import { loadSite, reportFindings } from "./site.js";
|
|
|
10
11
|
*
|
|
11
12
|
* Everything a settings file says about the site itself is already something
|
|
12
13
|
* canopy takes: this is a translation, not a layer of behaviour of its own. The
|
|
13
|
-
* navigation spec
|
|
14
|
-
* reads
|
|
14
|
+
* navigation spec and `searchAssets` are the things that have to be materialized
|
|
15
|
+
* first, since canopy reads all three from files.
|
|
15
16
|
*/
|
|
16
|
-
export function canopyArgs(site, out, navPath) {
|
|
17
|
+
export function canopyArgs(site, out, navPath, searchAssets) {
|
|
17
18
|
const { settings } = site;
|
|
18
19
|
return [
|
|
19
20
|
"build",
|
|
@@ -23,23 +24,33 @@ export function canopyArgs(site, out, navPath) {
|
|
|
23
24
|
...(settings.description === undefined ? [] : ["--site-description", settings.description]),
|
|
24
25
|
...(settings.lang === undefined ? [] : ["--lang", settings.lang]),
|
|
25
26
|
...(settings.icon === undefined ? [] : ["--site-icon", settings.icon]),
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Always present: canopy-page's own CSS (search, scrollspy) rides here
|
|
28
|
+
// whether or not the site names a tokens file of its own (assembleTokensCss
|
|
29
|
+
// folds one into the other before this ever runs) — no settings field for
|
|
30
|
+
// this, matching the minimal-configuration principle Wave 2 already set.
|
|
31
|
+
"--tokens-css",
|
|
32
|
+
searchAssets.tokensCssPath,
|
|
31
33
|
...(settings.logo === undefined ? [] : ["--site-logo", settings.logo]),
|
|
32
34
|
...(settings.home === undefined
|
|
33
35
|
? []
|
|
34
36
|
: ["--home-url", settings.home.url, "--home-label", settings.home.label]),
|
|
35
37
|
...(navPath === undefined ? [] : ["--nav", navPath]),
|
|
38
|
+
// Always on, same reasoning as --tokens-css above: a search index and the
|
|
39
|
+
// script that searches it are canopy-page's own contribution, not a site
|
|
40
|
+
// author's choice to make.
|
|
41
|
+
"--search-index",
|
|
42
|
+
"search-index.json",
|
|
43
|
+
"--script",
|
|
44
|
+
searchAssets.scriptPath,
|
|
36
45
|
// The settings file is configuration rather than content, and canopy has no
|
|
37
46
|
// reason to know it exists; excluding it keeps it off the published site.
|
|
38
47
|
...["--exclude", "settings.json"],
|
|
39
48
|
// Configuration, not content — the same reason settings.json is excluded.
|
|
40
|
-
// Without this the same CSS ships twice: once
|
|
49
|
+
// Without this the same CSS ships twice: once folded into tokens.css by
|
|
50
|
+
// assembleTokensCss, once copied as a plain asset.
|
|
41
51
|
...(settings.tokens === undefined ? [] : ["--exclude", settings.tokens]),
|
|
42
52
|
...(settings.exclude ?? []).flatMap((pattern) => ["--exclude", pattern]),
|
|
53
|
+
...(settings.rehypePlugins ?? []).flatMap((specifier) => ["--rehype-plugin", specifier]),
|
|
43
54
|
];
|
|
44
55
|
}
|
|
45
56
|
/** Build the site in `dir` into `out`, returning the exit code to leave with. */
|
|
@@ -52,16 +63,24 @@ export async function buildSite({ dir, out }) {
|
|
|
52
63
|
// The spec is derived from settings and means nothing on its own, so it lives
|
|
53
64
|
// in a temporary file rather than in the site or its output: writing it beside
|
|
54
65
|
// the source would leave a generated file for someone to edit by hand, and
|
|
55
|
-
// writing it into the output would ship it.
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// writing it into the output would ship it. The assembled script/CSS are
|
|
67
|
+
// temporary for the same reason — they are canopy-page's own contribution,
|
|
68
|
+
// not something a site author edits or that belongs in the output tree.
|
|
69
|
+
const workDir = await mkdtemp(path.join(tmpdir(), "canopy-page-"));
|
|
58
70
|
try {
|
|
71
|
+
let navPath;
|
|
59
72
|
if (site.nav.spec !== undefined) {
|
|
60
|
-
workDir = await mkdtemp(path.join(tmpdir(), "canopy-page-"));
|
|
61
73
|
navPath = path.join(workDir, "nav.json");
|
|
62
74
|
await writeFile(navPath, JSON.stringify(site.nav.spec, null, 2), "utf8");
|
|
63
75
|
}
|
|
64
|
-
const
|
|
76
|
+
const userTokensCss = site.settings.tokens === undefined
|
|
77
|
+
? undefined
|
|
78
|
+
: await readFile(path.join(site.root, site.settings.tokens), "utf8");
|
|
79
|
+
const tokensCssPath = path.join(workDir, "tokens.css");
|
|
80
|
+
await writeFile(tokensCssPath, await assembleTokensCss(userTokensCss), "utf8");
|
|
81
|
+
const scriptPath = path.join(workDir, "script.js");
|
|
82
|
+
await writeFile(scriptPath, await assembleScript(), "utf8");
|
|
83
|
+
const code = await runCanopy(canopyArgs(site, path.resolve(out), navPath, { tokensCssPath, scriptPath }));
|
|
65
84
|
// Only after canopy succeeded, and only over what it actually wrote: a
|
|
66
85
|
// sitemap listing pages a failed build never produced would be a lie a
|
|
67
86
|
// crawler acts on.
|
|
@@ -75,7 +94,6 @@ export async function buildSite({ dir, out }) {
|
|
|
75
94
|
return code;
|
|
76
95
|
}
|
|
77
96
|
finally {
|
|
78
|
-
|
|
79
|
-
await rm(workDir, { recursive: true, force: true });
|
|
97
|
+
await rm(workDir, { recursive: true, force: true });
|
|
80
98
|
}
|
|
81
99
|
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -39,16 +39,18 @@
|
|
|
39
39
|
"check": "tsc --noEmit",
|
|
40
40
|
"lint": "biome lint ./src",
|
|
41
41
|
"test": "vitest run",
|
|
42
|
-
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"build": "tsc -p tsconfig.build.json && node scripts/copy-assets.mjs",
|
|
43
43
|
"prepublishOnly": "npm run build"
|
|
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.
|
|
54
|
+
"@iyulab/canopy": "^0.7.0"
|
|
53
55
|
}
|
|
54
56
|
}
|