@iyulab/canopy-page 0.13.0 → 0.15.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,46 @@ 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.15.0] — 2026-09-17
11
+
12
+ ### Added
13
+
14
+ - **`sitemap.xml` entries now carry `<lastmod>`.** A page's own frontmatter `updated:` date wins
15
+ when it names one; otherwise it is the last git commit date of that page's source markdown.
16
+ A page with no source file (canopy's synthetic root `index.html`) or with an untracked source
17
+ is written without the element rather than with a guessed date. On a shallow clone — where an
18
+ untouched page would falsely report the clone's boundary date, indistinguishable from every
19
+ other untouched page — `<lastmod>` is withheld from the whole sitemap, and the build warns why.
20
+
21
+ ## [0.14.0] — 2026-09-17
22
+
23
+ ### Added
24
+
25
+ - **`siteUrl` now reaches the pages, not just the sitemap.** It is passed to canopy as
26
+ `--site-url`, so every page carries `<link rel="canonical">` and `og:url` naming its one
27
+ address — by exactly the string the sitemap lists it under, since both now come from canopy's
28
+ own `pageUrl()` rule rather than two copies of it. A page's frontmatter `description:` fills
29
+ its own `<meta name="description">` (the site's stays the fallback), and the Open Graph basics
30
+ ride on every page with or without `siteUrl`. Body links stay relative either way.
31
+ - **`previewImage`** — the image link previews show (`og:image`) for any page whose frontmatter
32
+ has no `image:` of its own. Validated like `icon`/`logo` (a published file), and rejected
33
+ without `siteUrl`, since the tag has to be absolute.
34
+ - **`alternates`** — the site's other language editions, `hreflang` → that edition's own site
35
+ URL (`x-default` allowed). Each page lists its counterpart at the same path under every
36
+ edition, its own first, as `hreflang` links in `<head>` and as `xhtml:link` entries in
37
+ `sitemap.xml`. Rejected without `siteUrl`.
38
+ - **`check` warns about pages with no `description:` of their own once `siteUrl` is set** — one
39
+ warning naming them all, never an error. A public site's pages otherwise present one
40
+ identical summary in every search result, and `siteUrl` is the setting that says the site is
41
+ public.
42
+
43
+ ### Changed
44
+
45
+ - **Sidebar redesign, via canopy 0.13.0** — rows with padding, a hover surface and a focus
46
+ ring; the group chevron moves to the row's trailing edge so labels at one depth share a left
47
+ edge; nested lists carry a guide line. Two new tokens, `--sidebar-hover-bg` and `--sp-1`. A
48
+ `tokens` file that styled `.canopy-nav-group > summary::before` should target `::after`.
49
+
10
50
  ## [0.13.0] — 2026-08-22
11
51
 
12
52
  ### 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
@@ -53,6 +57,11 @@ go away.
53
57
  - **A code block wider than the screen shows a shadow at whichever edge still has more to
54
58
  scroll to**, and nothing once you've scrolled there — a cue for a scrollbar that some
55
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
56
65
  - **Sitemap and `robots.txt`**, once `siteUrl` is set
57
66
 
58
67
  See it live at <https://iyulab.github.io/canopy-page>, or read
package/dist/build.js CHANGED
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { assembleScript, assembleTokensCss } from "./assets-bundle.js";
5
5
  import { runCanopy } from "./canopy.js";
6
6
  import { siteFindings } from "./check.js";
7
+ import { resolveLastmods } from "./lastmod.js";
7
8
  import { listHtmlFiles, robotsTxt, sitemapXml } from "./sitemap.js";
8
9
  import { loadSite, reportFindings } from "./site.js";
9
10
  /**
@@ -22,6 +23,14 @@ export function canopyArgs(site, out, navPath, searchAssets) {
22
23
  out,
23
24
  ...(settings.title === undefined ? [] : ["--site-title", settings.title]),
24
25
  ...(settings.description === undefined ? [] : ["--site-description", settings.description]),
26
+ // The same URL the sitemap below is written against, so canopy's canonical
27
+ // tags and the sitemap's entries name each page by one string.
28
+ ...(settings.siteUrl === undefined ? [] : ["--site-url", settings.siteUrl]),
29
+ ...(settings.previewImage === undefined ? [] : ["--site-image", settings.previewImage]),
30
+ ...Object.entries(settings.alternates ?? {}).flatMap(([hreflang, url]) => [
31
+ "--alternate",
32
+ `${hreflang}=${url}`,
33
+ ]),
25
34
  ...(settings.lang === undefined ? [] : ["--lang", settings.lang]),
26
35
  ...(settings.icon === undefined ? [] : ["--site-icon", settings.icon]),
27
36
  // Always present: canopy-page's own CSS (search, scrollspy) rides here
@@ -88,7 +97,16 @@ export async function buildSite({ dir, out }) {
88
97
  if (code === 0 && site.settings.siteUrl !== undefined) {
89
98
  const outDir = path.resolve(out);
90
99
  const pages = await listHtmlFiles(outDir);
91
- await writeFile(path.join(outDir, "sitemap.xml"), sitemapXml(site.settings.siteUrl, pages), "utf8");
100
+ const { byPath: lastmodByPath, shallowClone } = await resolveLastmods(site.root, pages);
101
+ if (shallowClone) {
102
+ console.warn("warning: this checkout is a shallow git clone, so a page's last commit date cannot " +
103
+ "be trusted (every untouched page would report the same boundary date); " +
104
+ "sitemap.xml is written without <lastmod>");
105
+ }
106
+ await writeFile(path.join(outDir, "sitemap.xml"), sitemapXml(site.settings.siteUrl, pages, {
107
+ ...(site.settings.lang === undefined ? {} : { lang: site.settings.lang }),
108
+ ...(site.settings.alternates === undefined ? {} : { alternates: site.settings.alternates }),
109
+ }, lastmodByPath), "utf8");
92
110
  await writeFile(path.join(outDir, "robots.txt"), robotsTxt(site.settings.siteUrl), "utf8");
93
111
  console.log(`canopy-page: sitemap.xml with ${pages.length} page(s)`);
94
112
  }
package/dist/check.d.ts CHANGED
@@ -18,5 +18,23 @@ export declare function referenceFindings(site: LoadedSite): Promise<Finding[]>;
18
18
  * the settings got wrong first, then what the pages point at.
19
19
  */
20
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[]>;
21
39
  /** Check the site in `dir`, returning the exit code to leave with. */
22
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";
@@ -222,6 +223,45 @@ export async function siteFindings(site) {
222
223
  ...navFindings(site.nav),
223
224
  ...filenameEncodingFindings(site),
224
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
+ },
225
265
  ];
226
266
  }
227
267
  /** Check the site in `dir`, returning the exit code to leave with. */
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Whether the repository containing `cwd` is a shallow clone.
3
+ *
4
+ * A shallow clone's history stops at an arbitrary boundary commit. A page last
5
+ * touched before that boundary reports the boundary commit's date instead of
6
+ * its own — so every such page would carry the same `<lastmod>`, which reads
7
+ * to a crawler as "all of these changed together" when in fact none of them
8
+ * did. That false agreement is worse than publishing nothing.
9
+ */
10
+ export declare function isShallowClone(cwd: string): Promise<boolean>;
11
+ /** Every page a `<lastmod>` could be found for, and whether it was suppressed for being unsafe to trust. */
12
+ export interface Lastmods {
13
+ /** htmlPath → date, for pages a date was found for. */
14
+ byPath: Record<string, string>;
15
+ /** True when dates were withheld entirely because the clone is shallow. */
16
+ shallowClone: boolean;
17
+ }
18
+ /**
19
+ * Resolve `<lastmod>` for each published page.
20
+ *
21
+ * `htmlPaths` are site paths as `listHtmlFiles` returns them; each is mapped
22
+ * back to the source markdown `toSitePath` produced it from
23
+ * (`toSitePath`'s only transform is `.md` → `.html`, so the inverse is exact).
24
+ * A path with no such source — canopy's synthetic root `index.html` when a
25
+ * site has none of its own — is left out rather than guessed at.
26
+ */
27
+ export declare function resolveLastmods(siteRoot: string, htmlPaths: readonly string[]): Promise<Lastmods>;
@@ -0,0 +1,98 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { parseFrontmatter } from "@iyulab/canopy";
6
+ const execFileAsync = promisify(execFile);
7
+ /**
8
+ * Resolving each page's `<lastmod>`.
9
+ *
10
+ * A sitemap's `<lastmod>` is a claim about when a page actually changed, and a
11
+ * wrong claim is worse than none: a crawler that trusts a stale or fabricated
12
+ * date has no reason to revisit a page that did change. The only two sources
13
+ * honest enough to publish are a page's own frontmatter, when the author named
14
+ * a date, and its source markdown's last git commit otherwise. Anything else —
15
+ * a file's mtime, the build's own clock — describes the filesystem or the
16
+ * build, not the page.
17
+ */
18
+ /** A YAML scalar that plausibly names a calendar date, in either form the `yaml` parser hands back. */
19
+ function asDateString(value) {
20
+ if (value instanceof Date)
21
+ return value.toISOString().slice(0, 10);
22
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value))
23
+ return value.slice(0, 10);
24
+ return undefined;
25
+ }
26
+ /** A page's own `updated:` frontmatter date, when it names one. */
27
+ async function frontmatterUpdated(absPath) {
28
+ let raw;
29
+ try {
30
+ raw = await readFile(absPath, "utf8");
31
+ }
32
+ catch {
33
+ // No source file at this path — canopy's synthetic root index.html, or a
34
+ // page whose source moved. Nothing to read frontmatter from.
35
+ return undefined;
36
+ }
37
+ return asDateString(parseFrontmatter(raw).data.updated);
38
+ }
39
+ /** The date (YYYY-MM-DD) git last recorded a change to `file`, or undefined when it has none. */
40
+ async function lastCommitDate(file, cwd) {
41
+ try {
42
+ const { stdout } = await execFileAsync("git", ["log", "-1", "--format=%cs", "--", file], {
43
+ cwd,
44
+ });
45
+ const date = stdout.trim();
46
+ return date === "" ? undefined : date;
47
+ }
48
+ catch {
49
+ // Not a git repository, or the file is untracked — indistinguishable from
50
+ // here, and both mean the same thing: no date to publish.
51
+ return undefined;
52
+ }
53
+ }
54
+ /**
55
+ * Whether the repository containing `cwd` is a shallow clone.
56
+ *
57
+ * A shallow clone's history stops at an arbitrary boundary commit. A page last
58
+ * touched before that boundary reports the boundary commit's date instead of
59
+ * its own — so every such page would carry the same `<lastmod>`, which reads
60
+ * to a crawler as "all of these changed together" when in fact none of them
61
+ * did. That false agreement is worse than publishing nothing.
62
+ */
63
+ export async function isShallowClone(cwd) {
64
+ try {
65
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--is-shallow-repository"], {
66
+ cwd,
67
+ });
68
+ return stdout.trim() === "true";
69
+ }
70
+ catch {
71
+ return false;
72
+ }
73
+ }
74
+ /**
75
+ * Resolve `<lastmod>` for each published page.
76
+ *
77
+ * `htmlPaths` are site paths as `listHtmlFiles` returns them; each is mapped
78
+ * back to the source markdown `toSitePath` produced it from
79
+ * (`toSitePath`'s only transform is `.md` → `.html`, so the inverse is exact).
80
+ * A path with no such source — canopy's synthetic root `index.html` when a
81
+ * site has none of its own — is left out rather than guessed at.
82
+ */
83
+ export async function resolveLastmods(siteRoot, htmlPaths) {
84
+ if (await isShallowClone(siteRoot)) {
85
+ return { byPath: {}, shallowClone: true };
86
+ }
87
+ const byPath = {};
88
+ for (const htmlPath of htmlPaths) {
89
+ if (!htmlPath.toLowerCase().endsWith(".html"))
90
+ continue;
91
+ const mdPath = htmlPath.replace(/\.html$/i, ".md");
92
+ const fromFrontmatter = await frontmatterUpdated(path.join(siteRoot, mdPath));
93
+ const date = fromFrontmatter ?? (await lastCommitDate(mdPath, siteRoot));
94
+ if (date !== undefined)
95
+ byPath[htmlPath] = date;
96
+ }
97
+ return { byPath, shallowClone: false };
98
+ }
@@ -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` and the robots file that
99
- * points at it. Absent, neither is written.
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,24 @@
1
- /** A sitemap naming every published page, newline-terminated. */
2
- export declare function sitemapXml(siteUrl: string, htmlPaths: readonly string[]): string;
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
+ * `lastmodByPath` supplies each page's `<lastmod>` by its own htmlPath
17
+ * (`resolveLastmods` builds this map); a page missing from it — no source
18
+ * file, or one git has no record of — is written without the element rather
19
+ * than with a guessed date.
20
+ */
21
+ export declare function sitemapXml(siteUrl: string, htmlPaths: readonly string[], editions?: SitemapEditions, lastmodByPath?: Readonly<Record<string, string>>): string;
3
22
  /**
4
23
  * A robots file whose only job is to point at the sitemap.
5
24
  *
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,40 @@ function escapeXml(value) {
23
31
  .replace(/'/g, "&apos;");
24
32
  }
25
33
  /**
26
- * The URL a page is canonically reached by.
34
+ * A sitemap naming every published page, newline-terminated.
35
+ *
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.
27
40
  *
28
- * A directory's index page is the directory: `guide/index.html` and `guide/` are
29
- * one page, and listing both would ask a crawler to treat it as two.
41
+ * `lastmodByPath` supplies each page's `<lastmod>` by its own htmlPath
42
+ * (`resolveLastmods` builds this map); a page missing from it no source
43
+ * file, or one git has no record of — is written without the element rather
44
+ * than with a guessed date.
30
45
  */
31
- function pageUrl(base, htmlPath) {
32
- const canonical = htmlPath.replace(/(^|\/)index\.html$/, "$1");
33
- // encodeURI leaves the separators alone and fixes what a URL cannot carry raw.
34
- return `${base}/${encodeURI(canonical)}`;
35
- }
36
- /** A sitemap naming every published page, newline-terminated. */
37
- export function sitemapXml(siteUrl, htmlPaths) {
38
- const base = siteUrl.replace(/\/+$/, "");
46
+ export function sitemapXml(siteUrl, htmlPaths, editions = {}, lastmodByPath = {}) {
47
+ const editionList = [];
48
+ if (editions.alternates !== undefined) {
49
+ const lang = editions.lang ?? "en";
50
+ if (!Object.hasOwn(editions.alternates, lang))
51
+ editionList.push([lang, siteUrl]);
52
+ editionList.push(...Object.entries(editions.alternates));
53
+ }
39
54
  const entries = [...htmlPaths]
40
55
  .sort()
41
- .map((htmlPath) => ` <url><loc>${escapeXml(pageUrl(base, htmlPath))}</loc></url>`)
56
+ .map((htmlPath) => {
57
+ const lastmod = lastmodByPath[htmlPath];
58
+ const lastmodTag = lastmod === undefined ? "" : `<lastmod>${escapeXml(lastmod)}</lastmod>`;
59
+ const alternates = editionList
60
+ .map(([hreflang, base]) => `<xhtml:link rel="alternate" hreflang="${escapeXml(hreflang)}" href="${escapeXml(pageUrl(base, htmlPath))}"/>`)
61
+ .join("");
62
+ return ` <url><loc>${escapeXml(pageUrl(siteUrl, htmlPath))}</loc>${lastmodTag}${alternates}</url>`;
63
+ })
42
64
  .join("\n");
65
+ const xhtmlNamespace = editionList.length > 0 ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : "";
43
66
  return `<?xml version="1.0" encoding="UTF-8"?>
44
- <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
67
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${xhtmlNamespace}>
45
68
  ${entries}
46
69
  </urlset>
47
70
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/canopy-page",
3
- "version": "0.13.0",
3
+ "version": "0.15.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.12.0",
56
+ "@iyulab/canopy": "^0.13.0",
57
57
  "chokidar": "^5.0.0"
58
58
  }
59
59
  }