@iyulab/canopy-page 0.7.0 → 0.9.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,41 @@ 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.9.0] — 2026-08-11
11
+
12
+ ### Added
13
+
14
+ - **`check` warns when a section's sidebar heading falls back to a raw directory name.** A
15
+ section with no `label` and no index page was already published this way — the fallback
16
+ itself is sound — but silently: the directory name it falls back to is a filesystem detail,
17
+ not a name an author chose, and there was previously no way to find out a sidebar's top-level
18
+ heading was about to read that way instead. `check`/`build` now report it as a warning (the
19
+ build still succeeds) naming the section and the label it fell back to.
20
+
21
+ ### Changed
22
+
23
+ - **Upgraded to canopy 0.10.0**, the release the heading custom-id syntax, the sidebar
24
+ active-page tint, and page-content typography build on.
25
+
26
+ ## [0.8.0] — 2026-08-11
27
+
28
+ ### Added
29
+
30
+ - **`strings` gains `indexTitle`, `backlinks`, and `searchFailed`.** The `strings` field
31
+ covered the reader chrome's search/theme-toggle/navigation text, but three more reader-facing
32
+ literals stayed hardcoded English regardless of `lang` and `strings`: the auto-generated
33
+ contents page's title/heading (`indexTitle`), a page's "Linked references" section heading
34
+ (`backlinks`), and the client search's failure message (`searchFailed`). All three follow the
35
+ same pattern as the existing five keys — optional override, English default when unset.
36
+ - **`$schema` support.** A settings file can now name
37
+ [`settings.schema.json`](https://iyulab.github.io/canopy-page/settings.schema.json), hosted
38
+ at that fixed URL, so an editor offers completion and inline validation for every field —
39
+ canopy-page itself already read and ignored this key, unvalidated.
40
+
41
+ ### Changed
42
+
43
+ - **Upgraded to canopy 0.9.0**, the release `indexTitle` and `backlinks` build on.
44
+
10
45
  ## [0.7.0] — 2026-08-09
11
46
 
12
47
  ### Added
@@ -29,6 +64,13 @@ to it — its fields, its validation, and what the checks reject — are what th
29
64
  absolute origin differs between environments.
30
65
  - **Upgraded to canopy 0.8.0**, the release the relative `home.url` and `strings` support build on.
31
66
 
67
+ ### Fixed
68
+
69
+ - **A `siteUrl` missing its host (`"http://"` alone, for instance) no longer crashes `check`.**
70
+ Only the scheme was validated, so `new URL()` threw on the rest and took the whole check run
71
+ down with it; a `siteUrl` that fails to parse is now treated as absent for the sub-path warning,
72
+ leaving `sitemapXml` to report the malformed value on its own terms.
73
+
32
74
  ## [0.6.0] — 2026-08-09
33
75
 
34
76
  ### Added
package/README.md CHANGED
@@ -87,6 +87,7 @@ unpublished.
87
87
 
88
88
  ```json
89
89
  {
90
+ "$schema": "https://iyulab.github.io/canopy-page/settings.schema.json",
90
91
  "title": "Product Help",
91
92
  "description": "How to use it",
92
93
  "lang": "en-GB",
@@ -105,10 +106,11 @@ unpublished.
105
106
 
106
107
  | Field | Meaning |
107
108
  |---|---|
109
+ | `$schema` | Optional. Points an editor (VS Code, JetBrains) at [`settings.schema.json`](https://iyulab.github.io/canopy-page/settings.schema.json) for completion and inline validation. Read and ignored by canopy-page itself |
108
110
  | `title` | Site name. Defaults to the folder's name |
109
111
  | `description` | Fills `<meta name="description">`, which is what link previews show |
110
112
  | `lang` | BCP 47 tag for `<html lang>`. Worth setting for any non-English site: assistive technology reads pronunciation from it |
111
- | `strings` | Overrides for the reader chrome's own text — `search`, `toggleTheme`, `siteNav`, `pageNav`, `onThisPage`. `lang` only changes what `<html lang>` declares; this text is canopy's own UI, not vault content, so it stays English otherwise. No built-in translation table — the same reasoning `home.label` already follows: link text has to be written in the site's own language. Keys left out keep their English default |
113
+ | `strings` | Overrides for the reader chrome's own text — `search`, `toggleTheme`, `siteNav`, `pageNav`, `onThisPage`, `indexTitle` (the auto-generated contents page's title/heading), `backlinks` (a page's "linked references" heading), `searchFailed` (the client search's failure message). `lang` only changes what `<html lang>` declares; this text is canopy's own UI or canopy-page's own search script, not vault content, so it stays English otherwise. No built-in translation table — the same reasoning `home.label` already follows: link text has to be written in the site's own language. Keys left out keep their English default |
112
114
  | `icon` | Favicon, relative to the settings file. Must be a published file |
113
115
  | `tokens` | CSS of design-token overrides, relative to the settings file. Appended *after* canopy's own tokens, so a file naming one value keeps the rest. It is configuration rather than content, so — unlike `icon` and `logo` — it is excluded from the published site automatically. Absent: canopy's default palette |
114
116
  | `logo` | Image shown beside the site title, relative to the settings file. Must be a published file — the opposite direction from `tokens`, because this one is content. Rendered with an empty `alt`, deliberately: the site title beside it already names the site, so there is no separate text to give it. Absent: the sidebar header shows the title text alone |
@@ -215,6 +217,9 @@ Warnings — reported, and the build continues:
215
217
  - An `exclude` pattern that matched nothing, which usually means a path written from the wrong
216
218
  place. Extension patterns are left alone: `*.tmp` in a site with no scratch files is a rule
217
219
  about what may never ship, not a claim that something is there
220
+ - A section with no `label` and no index page, whose sidebar heading falls back to its own
221
+ directory name — a filesystem detail, not a name anyone chose. Add a `label`, or an index page
222
+ for the section to name itself
218
223
 
219
224
  Checking reads the settings and each page. It never renders, so it is fast enough to sit at the
220
225
  front of a pipeline, at the scale a product manual reaches. References inside fenced
@@ -1,5 +1,14 @@
1
- /** The single script every canopy-page site carries via canopy's `--script`. */
2
- export declare function assembleScript(): Promise<string>;
1
+ /**
2
+ * The single script every canopy-page site carries via canopy's `--script`.
3
+ *
4
+ * `searchFailed` overrides the message `search.js` shows when its fetch of the
5
+ * search index fails — the one reader-facing string in canopy-page's own
6
+ * assets, `settings.strings.searchFailed` in the settings surface. The other
7
+ * two files carry no site-specific text, so only `search.js` takes this
8
+ * substitution; a source literal, not a template placeholder, so the shipped
9
+ * asset stays valid, readable JavaScript on its own.
10
+ */
11
+ export declare function assembleScript(searchFailed?: string): Promise<string>;
3
12
  /**
4
13
  * CSS canopy-page contributes on top of a site's own tokens, carried via
5
14
  * canopy's `--tokens-css` — the same channel a site's own `settings.tokens`
@@ -21,14 +21,31 @@ const ASSETS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "asse
21
21
  async function readAsset(name) {
22
22
  return readFile(path.join(ASSETS_DIR, name), "utf8");
23
23
  }
24
- /** The single script every canopy-page site carries via canopy's `--script`. */
25
- export async function assembleScript() {
24
+ /** The literal `search.js` falls back to when no override is given — the substitution target. */
25
+ const SEARCH_FAILED_DEFAULT = "Search failed to load.";
26
+ /**
27
+ * The single script every canopy-page site carries via canopy's `--script`.
28
+ *
29
+ * `searchFailed` overrides the message `search.js` shows when its fetch of the
30
+ * search index fails — the one reader-facing string in canopy-page's own
31
+ * assets, `settings.strings.searchFailed` in the settings surface. The other
32
+ * two files carry no site-specific text, so only `search.js` takes this
33
+ * substitution; a source literal, not a template placeholder, so the shipped
34
+ * asset stays valid, readable JavaScript on its own.
35
+ */
36
+ export async function assembleScript(searchFailed) {
26
37
  const [search, scrollspy, themeToggle] = await Promise.all([
27
38
  readAsset("search.js"),
28
39
  readAsset("scrollspy.js"),
29
40
  readAsset("theme-toggle.js"),
30
41
  ]);
31
- return `${search}\n${scrollspy}\n${themeToggle}`;
42
+ // A function replacer, not a replacement string: String.replace treats
43
+ // "$&"/"$'"/"$$" etc. in a replacement string as patterns, and a site
44
+ // author's searchFailed text is free to contain a literal "$".
45
+ const searchWithStrings = searchFailed === undefined
46
+ ? search
47
+ : search.replace(JSON.stringify(SEARCH_FAILED_DEFAULT), () => JSON.stringify(searchFailed));
48
+ return `${searchWithStrings}\n${scrollspy}\n${themeToggle}`;
32
49
  }
33
50
  /**
34
51
  * CSS canopy-page contributes on top of a site's own tokens, carried via
package/dist/build.js CHANGED
@@ -80,7 +80,7 @@ export async function buildSite({ dir, out }) {
80
80
  const tokensCssPath = path.join(workDir, "tokens.css");
81
81
  await writeFile(tokensCssPath, await assembleTokensCss(userTokensCss), "utf8");
82
82
  const scriptPath = path.join(workDir, "script.js");
83
- await writeFile(scriptPath, await assembleScript(), "utf8");
83
+ await writeFile(scriptPath, await assembleScript(site.settings.strings?.searchFailed), "utf8");
84
84
  const code = await runCanopy(canopyArgs(site, path.resolve(out), navPath, { tokensCssPath, scriptPath }));
85
85
  // Only after canopy succeeded, and only over what it actually wrote: a
86
86
  // sitemap listing pages a failed build never produced would be a lie a
package/dist/nav.d.ts CHANGED
@@ -42,6 +42,13 @@ export interface NavTranslation {
42
42
  orphans: string[];
43
43
  /** Pages the settings place more than once. */
44
44
  duplicates: string[];
45
+ /**
46
+ * Top-level section paths labeled by their own directory name because the
47
+ * settings file wrote no `label` and the section has no index page to name
48
+ * it instead — see `NavReport.rawSlugLabels` for why this is scoped to
49
+ * sections rather than every subdirectory that falls back the same way.
50
+ */
51
+ rawSlugLabels: string[];
45
52
  }
46
53
  /**
47
54
  * Turn settings plus the site's pages into the spec canopy builds from.
package/dist/nav.js CHANGED
@@ -162,7 +162,10 @@ function translateSection(section, index, report) {
162
162
  // Same rule as a derived directory: the page fronting a section names it, and
163
163
  // only a section with no index page needs a name written for it here. A label
164
164
  // in the settings file still wins — that is what writing one is for.
165
- const label = section.label ?? (sectionIndex === undefined ? lastSegment(section.path) : undefined);
165
+ const usesRawSlugLabel = section.label === undefined && sectionIndex === undefined;
166
+ if (usesRawSlugLabel)
167
+ report.rawSlugLabels.push(section.path);
168
+ const label = section.label ?? (usesRawSlugLabel ? lastSegment(section.path) : undefined);
166
169
  return {
167
170
  ...(label === undefined ? {} : { label }),
168
171
  ...(sectionIndex === undefined ? {} : { path: sectionIndex }),
@@ -191,14 +194,20 @@ function narrowTo(pages, index) {
191
194
  export function translateNav(settings, index) {
192
195
  const sections = settings.sections ?? [];
193
196
  if (sections.length === 0) {
194
- return { missing: [], orphans: [], duplicates: [] };
197
+ return { missing: [], orphans: [], duplicates: [], rawSlugLabels: [] };
195
198
  }
196
199
  const placements = new Map();
197
200
  const missing = [];
201
+ const rawSlugLabels = [];
198
202
  const place = (page) => {
199
203
  placements.set(page, (placements.get(page) ?? 0) + 1);
200
204
  };
201
- const report = { missing, place, isPlaced: (page) => placements.has(page) };
205
+ const report = {
206
+ missing,
207
+ place,
208
+ isPlaced: (page) => placements.has(page),
209
+ rawSlugLabels,
210
+ };
202
211
  const items = sections.map((section) => translateSection(section, index, report));
203
212
  // The root index is the site's home page: it is reached without navigation, so
204
213
  // it is neither placed by a section nor counted as something nobody placed.
@@ -231,5 +240,5 @@ export function translateNav(settings, index) {
231
240
  .filter(([, count]) => count > 1)
232
241
  .map(([page]) => page)
233
242
  .sort();
234
- return { spec: { items }, missing, orphans, duplicates };
243
+ return { spec: { items }, missing, orphans, duplicates, rawSlugLabels };
235
244
  }
@@ -127,8 +127,31 @@ export interface Settings {
127
127
  siteNav?: string;
128
128
  pageNav?: string;
129
129
  onThisPage?: string;
130
+ /** Title and heading of the auto-generated contents page at the site root. */
131
+ indexTitle?: string;
132
+ /** Heading over a page's list of pages that link to it. */
133
+ backlinks?: string;
134
+ /**
135
+ * Message shown in place of results when the client search index fails to
136
+ * load. This key rides the same JSON `--strings` flag as every other one
137
+ * here (canopy just never reads it), but it is consumed by canopy-page's
138
+ * own script assembly (`assembleScript`), not by canopy — every other key
139
+ * styles canopy's own shell markup, this one styles canopy-page's own
140
+ * search UI (see `assets/search.js`).
141
+ */
142
+ searchFailed?: string;
130
143
  };
131
144
  }
145
+ /**
146
+ * Exported so `settings.schema.json` (`examples/site/`) can be tested against
147
+ * the parser's own allowlists rather than a hand-copied duplicate — the two
148
+ * are otherwise free to drift silently apart as fields are added.
149
+ */
150
+ export declare const SETTINGS_KEYS: Set<string>;
151
+ export declare const SECTION_KEYS: Set<string>;
152
+ export declare const HOME_KEYS: Set<string>;
153
+ export declare const STRINGS_KEYS: Set<string>;
154
+ export declare const NAV_ITEM_KEYS: Set<string>;
132
155
  /**
133
156
  * Parse and validate a settings file from JSON text.
134
157
  *
package/dist/settings.js CHANGED
@@ -38,7 +38,12 @@ function fail(message) {
38
38
  * so it has to survive a strict key check.
39
39
  */
40
40
  const IGNORED_KEYS = new Set(["$schema"]);
41
- const SETTINGS_KEYS = new Set([
41
+ /**
42
+ * Exported so `settings.schema.json` (`examples/site/`) can be tested against
43
+ * the parser's own allowlists rather than a hand-copied duplicate — the two
44
+ * are otherwise free to drift silently apart as fields are added.
45
+ */
46
+ export const SETTINGS_KEYS = new Set([
42
47
  "title",
43
48
  "description",
44
49
  "lang",
@@ -52,10 +57,19 @@ const SETTINGS_KEYS = new Set([
52
57
  "rehypePlugins",
53
58
  "strings",
54
59
  ]);
55
- const SECTION_KEYS = new Set(["path", "label", "order", "items"]);
56
- const HOME_KEYS = new Set(["url", "label"]);
57
- const STRINGS_KEYS = new Set(["search", "toggleTheme", "siteNav", "pageNav", "onThisPage"]);
58
- const NAV_ITEM_KEYS = new Set(["label", "path", "items"]);
60
+ export const SECTION_KEYS = new Set(["path", "label", "order", "items"]);
61
+ export const HOME_KEYS = new Set(["url", "label"]);
62
+ export const STRINGS_KEYS = new Set([
63
+ "search",
64
+ "toggleTheme",
65
+ "siteNav",
66
+ "pageNav",
67
+ "onThisPage",
68
+ "indexTitle",
69
+ "backlinks",
70
+ "searchFailed",
71
+ ]);
72
+ export const NAV_ITEM_KEYS = new Set(["label", "path", "items"]);
59
73
  /**
60
74
  * Unknown keys are rejected rather than ignored.
61
75
  *
package/dist/site.js CHANGED
@@ -94,6 +94,20 @@ export function navFindings(nav) {
94
94
  nav.orphans.map((page) => ` ${page}`).join("\n"),
95
95
  });
96
96
  }
97
+ for (const path of nav.rawSlugLabels) {
98
+ // Publishable either way — canopy-page's own fallback is sound, and the
99
+ // build is not wrong to use it. What is wrong is doing so silently: the
100
+ // directory name it falls back to is written for a filesystem, not a
101
+ // reader, and the author has no other way to find out their sidebar's
102
+ // top-level heading is about to read that way instead of a name they chose.
103
+ const slug = path.split("/").pop() ?? path;
104
+ findings.push({
105
+ level: "warning",
106
+ message: `settings: section "${path}" has no "label" and no index page, ` +
107
+ `so its sidebar heading falls back to the directory name "${slug}". ` +
108
+ 'Add a "label", or an index page for the section to name itself',
109
+ });
110
+ }
97
111
  return findings;
98
112
  }
99
113
  /** Print findings in the order given, and report whether any of them stops a build. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/canopy-page",
3
- "version": "0.7.0",
3
+ "version": "0.9.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,6 +53,6 @@
53
53
  "vitest": "^4.1.9"
54
54
  },
55
55
  "dependencies": {
56
- "@iyulab/canopy": "^0.8.0"
56
+ "@iyulab/canopy": "^0.10.0"
57
57
  }
58
58
  }