@iyulab/canopy-page 0.6.0 → 0.7.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,28 @@ 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.7.0] — 2026-08-09
11
+
12
+ ### Added
13
+
14
+ - **A `strings` field** overriding the reader chrome's own built-in text — search, the theme
15
+ toggle, and the navigation landmarks. `lang` only ever changed what `<html lang>` declares;
16
+ this text is canopy's own UI, not vault content, so it stayed English regardless. No built-in
17
+ translation table, the same reasoning `home.label` already follows. Threaded to canopy as a
18
+ JSON `--strings` flag.
19
+ - **`check` warns about a root-absolute reference that resolves today but would only be correct
20
+ if the site is served from the domain root**, whenever `siteUrl` already declares a non-root
21
+ mount path — reusing the one signal a settings file already carries about where the site is
22
+ served from, rather than requiring a new field.
23
+
24
+ ### Changed
25
+
26
+ - **`home.url` no longer requires an absolute http(s) URL.** A relative one (a sibling of the
27
+ published site, at the same origin) is now resolved against each page's depth, the same as
28
+ every other internal link canopy writes — useful when the site is mounted at a sub-path whose
29
+ absolute origin differs between environments.
30
+ - **Upgraded to canopy 0.8.0**, the release the relative `home.url` and `strings` support build on.
31
+
10
32
  ## [0.6.0] — 2026-08-09
11
33
 
12
34
  ### Added
package/README.md CHANGED
@@ -108,10 +108,11 @@ unpublished.
108
108
  | `title` | Site name. Defaults to the folder's name |
109
109
  | `description` | Fills `<meta name="description">`, which is what link previews show |
110
110
  | `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 |
111
112
  | `icon` | Favicon, relative to the settings file. Must be a published file |
112
113
  | `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 |
113
114
  | `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 |
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 |
115
+ | `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` is absolute when the target is a different origin, relative when it is a sibling of the published site (each page resolves it against its own depth, the same as every other internal link); 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 |
115
116
  | `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 |
116
117
  | `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
118
  | `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 |
@@ -208,7 +209,9 @@ Warnings — reported, and the build continues:
208
209
  path resolves depends on what the site is served from, which is not a checker's to know — but a
209
210
  site served from its own root is the ordinary case, and a `public/`-style folder that other
210
211
  generators map onto the root does not exist here, so these silently 404. A warning rather than
211
- an error, because mounting the site elsewhere would make it right
212
+ an error, because mounting the site elsewhere would make it right. When `siteUrl` already
213
+ declares a sub-path mount, a root-absolute reference warns even if it resolves today, since that
214
+ is the one case the checker can actually judge
212
215
  - An `exclude` pattern that matched nothing, which usually means a path written from the wrong
213
216
  place. Extension patterns are left alone: `*.tmp` in a site with no scratch files is a rule
214
217
  about what may never ship, not a claim that something is there
package/dist/build.js CHANGED
@@ -34,6 +34,7 @@ export function canopyArgs(site, out, navPath, searchAssets) {
34
34
  ...(settings.home === undefined
35
35
  ? []
36
36
  : ["--home-url", settings.home.url, "--home-label", settings.home.label]),
37
+ ...(settings.strings === undefined ? [] : ["--strings", JSON.stringify(settings.strings)]),
37
38
  ...(navPath === undefined ? [] : ["--nav", navPath]),
38
39
  // Always on, same reasoning as --tokens-css above: a search index and the
39
40
  // script that searches it are canopy-page's own contribution, not a site
package/dist/check.js CHANGED
@@ -28,6 +28,31 @@ import { toPageKey } from "./vault.js";
28
28
  function isRootAbsolute(url) {
29
29
  return url.startsWith("/") && !url.startsWith("//");
30
30
  }
31
+ /**
32
+ * The mount path `siteUrl` declares, when it says the site stands under a
33
+ * sub-path rather than a domain root.
34
+ *
35
+ * `siteUrl` is the one place settings already say where the site is served
36
+ * from — set today only to address a sitemap, but its path component answers
37
+ * exactly the question a root-absolute reference otherwise leaves open.
38
+ */
39
+ function siteBasePath(site) {
40
+ const { siteUrl } = site.settings;
41
+ if (siteUrl === undefined)
42
+ return undefined;
43
+ // settings.ts only checks the "http(s)://" prefix, which "http://" itself
44
+ // satisfies without naming a host — malformed enough that `new URL` throws.
45
+ // That is a settings mistake for `sitemapXml` to report, not a reason for
46
+ // this unrelated check to crash the whole run.
47
+ let pathname;
48
+ try {
49
+ ({ pathname } = new URL(siteUrl));
50
+ }
51
+ catch {
52
+ return undefined;
53
+ }
54
+ return pathname === "/" ? undefined : pathname;
55
+ }
31
56
  /**
32
57
  * Does anything published sit at this path — a page, a copied file, or the index
33
58
  * page a directory is entered by?
@@ -81,14 +106,20 @@ export async function referenceFindings(site) {
81
106
  const url = targetPath(reference.target);
82
107
  if (isRootAbsolute(url)) {
83
108
  const atRoot = decodeTarget(url.replace(/^\/+/, "")) ?? url.replace(/^\/+/, "");
84
- if (atRoot === "" || existsInSite(site, atRoot))
109
+ const resolves = atRoot === "" || existsInSite(site, atRoot);
110
+ const basePath = siteBasePath(site);
111
+ if (resolves && basePath === undefined)
85
112
  continue;
86
113
  findings.push({
87
114
  level: "warning",
88
- message: `${where}: ${reference.kind} "${reference.target}" — ` +
89
- `nothing is published at "${atRoot}". A root-absolute path resolves ` +
90
- "against wherever the site is served from, so this is right only if " +
91
- "something else answers it there",
115
+ message: resolves
116
+ ? `${where}: ${reference.kind} "${reference.target}" resolves only when the ` +
117
+ `site is served from the domain root, but settings.siteUrl declares it is ` +
118
+ `mounted under "${basePath}"`
119
+ : `${where}: ${reference.kind} "${reference.target}" — ` +
120
+ `nothing is published at "${atRoot}". A root-absolute path resolves ` +
121
+ "against wherever the site is served from, so this is right only if " +
122
+ "something else answers it there",
92
123
  });
93
124
  continue;
94
125
  }
@@ -111,6 +111,23 @@ export interface Settings {
111
111
  * relative path against.
112
112
  */
113
113
  rehypePlugins?: string[];
114
+ /**
115
+ * Overrides for the reader chrome's own text — search, the theme toggle,
116
+ * and the navigation landmarks.
117
+ *
118
+ * `lang` changes what `<html lang>` declares, but that text is canopy's own
119
+ * UI, not vault content, so `lang` alone leaves it English. There is no
120
+ * built-in translation table: like `home.label`, link text has to be
121
+ * written in the site's own language, and canopy cannot know what that
122
+ * language calls "Search". Keys left out keep their English default.
123
+ */
124
+ strings?: {
125
+ search?: string;
126
+ toggleTheme?: string;
127
+ siteNav?: string;
128
+ pageNav?: string;
129
+ onThisPage?: string;
130
+ };
114
131
  }
115
132
  /**
116
133
  * Parse and validate a settings file from JSON text.
package/dist/settings.js CHANGED
@@ -50,9 +50,11 @@ const SETTINGS_KEYS = new Set([
50
50
  "home",
51
51
  "siteUrl",
52
52
  "rehypePlugins",
53
+ "strings",
53
54
  ]);
54
55
  const SECTION_KEYS = new Set(["path", "label", "order", "items"]);
55
56
  const HOME_KEYS = new Set(["url", "label"]);
57
+ const STRINGS_KEYS = new Set(["search", "toggleTheme", "siteNav", "pageNav", "onThisPage"]);
56
58
  const NAV_ITEM_KEYS = new Set(["label", "path", "items"]);
57
59
  /**
58
60
  * Unknown keys are rejected rather than ignored.
@@ -227,7 +229,7 @@ export function parseSettings(json) {
227
229
  }
228
230
  const value = asObject(raw, "settings", "expected a JSON object");
229
231
  rejectUnknownKeys(value, SETTINGS_KEYS, "settings");
230
- const { title, description, lang, icon, tokens, exclude, sections, logo, home, siteUrl, rehypePlugins } = value;
232
+ const { title, description, lang, icon, tokens, exclude, sections, logo, home, siteUrl, rehypePlugins, strings, } = value;
231
233
  if (title !== undefined)
232
234
  asString(title, "settings.title");
233
235
  if (description !== undefined)
@@ -256,12 +258,11 @@ export function parseSettings(json) {
256
258
  fail('settings.home.url: needed alongside "label"');
257
259
  if (object.label === undefined)
258
260
  fail('settings.home.label: needed alongside "url"');
261
+ // Absolute when the target is a different origin, relative when it is a
262
+ // sibling of the published site (a product this documentation is mounted
263
+ // beside) — canopy resolves a relative one against each page's depth, the
264
+ // same way it resolves every other internal link.
259
265
  const url = asString(object.url, "settings.home.url");
260
- // The target is normally outside the published site, so a relative path has
261
- // nothing here to resolve against.
262
- if (!/^https?:\/\//i.test(url)) {
263
- fail(`settings.home.url: "${url}" must be an absolute http(s) URL`);
264
- }
265
266
  parsedHome = { url, label: asString(object.label, "settings.home.label") };
266
267
  }
267
268
  if (siteUrl !== undefined) {
@@ -270,6 +271,15 @@ export function parseSettings(json) {
270
271
  fail(`settings.siteUrl: "${url}" must be an absolute http(s) URL`);
271
272
  }
272
273
  }
274
+ let parsedStrings;
275
+ if (strings !== undefined) {
276
+ const object = asObject(strings, "settings.strings", "expected an object");
277
+ rejectUnknownKeys(object, STRINGS_KEYS, "settings.strings");
278
+ parsedStrings = {};
279
+ for (const key of Object.keys(object)) {
280
+ parsedStrings[key] = asString(object[key], `settings.strings.${key}`);
281
+ }
282
+ }
273
283
  return {
274
284
  ...(title === undefined ? {} : { title: title }),
275
285
  ...(description === undefined ? {} : { description: description }),
@@ -292,6 +302,7 @@ export function parseSettings(json) {
292
302
  ...(logo === undefined ? {} : { logo: asRelativePath(logo, "settings.logo") }),
293
303
  ...(parsedHome === undefined ? {} : { home: parsedHome }),
294
304
  ...(siteUrl === undefined ? {} : { siteUrl: siteUrl }),
305
+ ...(parsedStrings === undefined ? {} : { strings: parsedStrings }),
295
306
  ...(rehypePlugins === undefined
296
307
  ? {}
297
308
  : {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/canopy-page",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",
@@ -46,11 +46,13 @@
46
46
  "@biomejs/biome": "^2.5.0",
47
47
  "@iyulab/declart": "^0.20.0",
48
48
  "@types/node": "^25.9.3",
49
+ "playwright": "^1.62.1",
49
50
  "rehype-declart": "^0.20.0",
51
+ "rehype-mermaid": "^3.0.0",
50
52
  "typescript": "^6.0.3",
51
53
  "vitest": "^4.1.9"
52
54
  },
53
55
  "dependencies": {
54
- "@iyulab/canopy": "^0.7.0"
56
+ "@iyulab/canopy": "^0.8.0"
55
57
  }
56
58
  }