@iyulab/canopy-page 0.14.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 +11 -0
- package/dist/build.js +8 -1
- package/dist/lastmod.d.ts +27 -0
- package/dist/lastmod.js +98 -0
- package/dist/sitemap.d.ts +6 -1
- package/dist/sitemap.js +9 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,17 @@ 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
|
+
|
|
10
21
|
## [0.14.0] — 2026-09-17
|
|
11
22
|
|
|
12
23
|
### Added
|
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
|
/**
|
|
@@ -96,10 +97,16 @@ export async function buildSite({ dir, out }) {
|
|
|
96
97
|
if (code === 0 && site.settings.siteUrl !== undefined) {
|
|
97
98
|
const outDir = path.resolve(out);
|
|
98
99
|
const pages = await listHtmlFiles(outDir);
|
|
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
|
+
}
|
|
99
106
|
await writeFile(path.join(outDir, "sitemap.xml"), sitemapXml(site.settings.siteUrl, pages, {
|
|
100
107
|
...(site.settings.lang === undefined ? {} : { lang: site.settings.lang }),
|
|
101
108
|
...(site.settings.alternates === undefined ? {} : { alternates: site.settings.alternates }),
|
|
102
|
-
}), "utf8");
|
|
109
|
+
}, lastmodByPath), "utf8");
|
|
103
110
|
await writeFile(path.join(outDir, "robots.txt"), robotsTxt(site.settings.siteUrl), "utf8");
|
|
104
111
|
console.log(`canopy-page: sitemap.xml with ${pages.length} page(s)`);
|
|
105
112
|
}
|
|
@@ -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>;
|
package/dist/lastmod.js
ADDED
|
@@ -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
|
+
}
|
package/dist/sitemap.d.ts
CHANGED
|
@@ -12,8 +12,13 @@ export interface SitemapEditions {
|
|
|
12
12
|
* edition, this one included — the sitemap form of the `hreflang` links the
|
|
13
13
|
* pages themselves carry, and the same rule: this edition leads unless the map
|
|
14
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.
|
|
15
20
|
*/
|
|
16
|
-
export declare function sitemapXml(siteUrl: string, htmlPaths: readonly string[], editions?: SitemapEditions): string;
|
|
21
|
+
export declare function sitemapXml(siteUrl: string, htmlPaths: readonly string[], editions?: SitemapEditions, lastmodByPath?: Readonly<Record<string, string>>): string;
|
|
17
22
|
/**
|
|
18
23
|
* A robots file whose only job is to point at the sitemap.
|
|
19
24
|
*
|
package/dist/sitemap.js
CHANGED
|
@@ -37,8 +37,13 @@ function escapeXml(value) {
|
|
|
37
37
|
* edition, this one included — the sitemap form of the `hreflang` links the
|
|
38
38
|
* pages themselves carry, and the same rule: this edition leads unless the map
|
|
39
39
|
* already places its language explicitly.
|
|
40
|
+
*
|
|
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.
|
|
40
45
|
*/
|
|
41
|
-
export function sitemapXml(siteUrl, htmlPaths, editions = {}) {
|
|
46
|
+
export function sitemapXml(siteUrl, htmlPaths, editions = {}, lastmodByPath = {}) {
|
|
42
47
|
const editionList = [];
|
|
43
48
|
if (editions.alternates !== undefined) {
|
|
44
49
|
const lang = editions.lang ?? "en";
|
|
@@ -49,10 +54,12 @@ export function sitemapXml(siteUrl, htmlPaths, editions = {}) {
|
|
|
49
54
|
const entries = [...htmlPaths]
|
|
50
55
|
.sort()
|
|
51
56
|
.map((htmlPath) => {
|
|
57
|
+
const lastmod = lastmodByPath[htmlPath];
|
|
58
|
+
const lastmodTag = lastmod === undefined ? "" : `<lastmod>${escapeXml(lastmod)}</lastmod>`;
|
|
52
59
|
const alternates = editionList
|
|
53
60
|
.map(([hreflang, base]) => `<xhtml:link rel="alternate" hreflang="${escapeXml(hreflang)}" href="${escapeXml(pageUrl(base, htmlPath))}"/>`)
|
|
54
61
|
.join("");
|
|
55
|
-
return ` <url><loc>${escapeXml(pageUrl(siteUrl, htmlPath))}</loc>${alternates}</url>`;
|
|
62
|
+
return ` <url><loc>${escapeXml(pageUrl(siteUrl, htmlPath))}</loc>${lastmodTag}${alternates}</url>`;
|
|
56
63
|
})
|
|
57
64
|
.join("\n");
|
|
58
65
|
const xhtmlNamespace = editionList.length > 0 ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : "";
|