Skip to content

Commit da7bc72

Browse files
TMHSDigitalclaude
andcommitted
sitemap: date guide pages by their doc's last commit
The git log behind lastmod covered catalog/ and stacks/ only, so every guide page carried the build date. docs/ is now included (DATED_PATHS), the log parse moved to exports.mjs (commitDatesFromLog) with tests, and the guide's Article JSON-LD gives the same date as dateModified. Closes #167 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpkeXRUqm7oDedPDk3QWtK
1 parent 7265f43 commit da7bc72

4 files changed

Lines changed: 50 additions & 20 deletions

File tree

‎site/build.mjs‎

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs";
2424
import { listEntryFiles, idMatchesFile } from "./lib/entry-files.mjs";
2525
import { entryPageHtml, scriptJson } from "./lib/entry-page.mjs";
2626
import { LinkError, makeLinkResolver, caseMismatch } from "./lib/links.mjs";
27-
import { atomFeed, catalogCsv, homeJsonLd, sitemapXml } from "./lib/exports.mjs";
27+
import { atomFeed, catalogCsv, commitDatesFromLog, DATED_PATHS, homeJsonLd, sitemapXml } from "./lib/exports.mjs";
2828
import { schemaErrors } from "./lib/schema-check.mjs";
2929
import { llmsFullTxt, llmsTxt } from "./lib/llms.mjs";
3030
import { deprecationReason, MarkdownError, renderBlocks, renderInline, splitEntryBody } from "./lib/markdown.mjs";
@@ -222,33 +222,23 @@ function headMetaHtml(site, stats, generatedAt, hasCard) {
222222

223223
/**
224224
* Repo-relative file => date of its last commit, from one `git log` over the
225-
* catalog and stacks. The sitemap's lastmod must never be earlier than the
225+
* catalog, stacks and docs (DATED_PATHS). The sitemap's lastmod must never be earlier than the
226226
* last content change, and an entry's `verified` date is not that. Empty when
227227
* git is unavailable (a tarball, a shallow CI checkout with no history).
228228
*/
229229
function lastCommitDates() {
230-
const dates = new Map();
231230
try {
232-
const out = execFileSync("git", ["log", "--format=%x00%cs", "--name-only", "--", "catalog", "stacks"], {
231+
const out = execFileSync("git", ["log", "--format=%x00%cs", "--name-only", "--", ...DATED_PATHS], {
233232
cwd: ROOT,
234233
encoding: "utf8",
235234
maxBuffer: 64 * 1024 * 1024,
236235
stdio: ["ignore", "pipe", "ignore"],
237236
});
238-
let date = null;
239-
for (const line of out.split("\n")) {
240-
if (line.startsWith("\0")) {
241-
date = line.slice(1).trim();
242-
continue;
243-
}
244-
const file = line.trim();
245-
// Newest commit first, so the first date seen for a file is its latest.
246-
if (file && date && !dates.has(file)) dates.set(file, date);
247-
}
237+
return commitDatesFromLog(out);
248238
} catch {
249239
// No git: the sitemap falls back to the build date.
240+
return new Map();
250241
}
251-
return dates;
252242
}
253243

254244
function robotsTxt(site) {
@@ -491,7 +481,7 @@ function writeCategoryPages({ entries, config, stats, stamp, hasCard, now }) {
491481
* docs/ guides as guide/<id>/ pages. Links to entries go to their pages,
492482
* links to another guide go to its page, anything else to GitHub.
493483
*/
494-
function writeGuidePages({ entries, config, stats, stamp, hasCard }) {
484+
function writeGuidePages({ entries, config, stats, stamp, hasCard, modified = new Map() }) {
495485
const written = [];
496486
const errors = [];
497487
const resolverFor = linkResolvers(entries, config, "../../entry/");
@@ -516,7 +506,7 @@ function writeGuidePages({ entries, config, stats, stamp, hasCard }) {
516506
};
517507
try {
518508
const rendered = renderGuide(fs.readFileSync(path.join(ROOT, guide.path), "utf8"), { file: guide.path, resolveLink });
519-
writeOut(path.join("guide", guide.id, "index.html"), guidePageHtml({ guide, rendered, site: config.site, stamp, total: stats.total, hasCard }));
509+
writeOut(path.join("guide", guide.id, "index.html"), guidePageHtml({ guide, rendered, site: config.site, stamp, total: stats.total, hasCard, modified: modified.get(guide.path) || null }));
520510
written.push(`guide/${guide.id}/index.html`);
521511
} catch (err) {
522512
errors.push(err.message);
@@ -695,15 +685,16 @@ function main() {
695685
const categoriesInSitemap = Object.keys(config.categories)
696686
.filter((cat) => visible.some((e) => e.category === cat))
697687
.map((id) => ({ id, lastmod: stamp }));
698-
writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified: lastCommitDates(), categories: categoriesInSitemap, guides: config.guides }));
688+
const modified = lastCommitDates();
689+
writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified, categories: categoriesInSitemap, guides: config.guides }));
699690
writeOut("robots.txt", robotsTxt(config.site));
700691
writeOut("404.html", notFoundHtml(config.site));
701692
writeOut(path.join("freshness", "index.html"), freshnessPageHtml({ stats: fresh, site: config.site, stamp, total: stats.total, hasCard }));
702693

703694
const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn });
704695
const stackPages = writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now });
705696
const categoryPages = writeCategoryPages({ entries: visible, config, stats, stamp, hasCard, now });
706-
const guidePages = writeGuidePages({ entries, config, stats, stamp, hasCard });
697+
const guidePages = writeGuidePages({ entries, config, stats, stamp, hasCard, modified });
707698
const pageErrors = [
708699
...stackErrors,
709700
...pages.errors,

‎site/lib/exports.mjs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,31 @@
55
*/
66
import { esc } from "./shared.mjs";
77

8+
/**
9+
* The folders whose files get a page with a lastmod: catalog entries, stacks
10+
* and the docs/ guides. build.mjs runs one `git log` over them.
11+
*/
12+
export const DATED_PATHS = ["catalog", "stacks", "docs"];
13+
14+
/**
15+
* Repo path => YYYY-MM-DD of its last commit, from
16+
* `git log --format=%x00%cs --name-only` output (newest commit first, so
17+
* the first date seen for a file is its latest).
18+
*/
19+
export function commitDatesFromLog(out) {
20+
const dates = new Map();
21+
let date = null;
22+
for (const line of String(out).split("\n")) {
23+
if (line.startsWith("\0")) {
24+
date = line.slice(1).trim();
25+
continue;
26+
}
27+
const file = line.trim();
28+
if (file && date && !dates.has(file)) dates.set(file, date);
29+
}
30+
return dates;
31+
}
32+
833
/**
934
* sitemap.xml. Every URL carries a lastmod that is never earlier than the
1035
* last content change: the build date for the home and freshness pages, which

‎site/lib/guide-page.mjs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export function guidePageUrl(site, id) {
77
return `${String(site.siteUrl).replace(/\/+$/, "")}/guide/${id}/`;
88
}
99

10-
export function guidePageHtml({ guide, rendered, site, stamp, total, hasCard }) {
10+
export function guidePageHtml({ guide, rendered, site, stamp, total, hasCard, modified = null }) {
1111
const url = guidePageUrl(site, guide.id);
1212
const title = `${guide.title} | ${site.title}`;
1313
const description = clipText(plainText(rendered.firstParagraph) || guide.title, SEO_DESCRIPTION_MAX);
@@ -17,6 +17,8 @@ export function guidePageHtml({ guide, rendered, site, stamp, total, hasCard })
1717
"@type": "Article",
1818
headline: plainText(rendered.title),
1919
url,
20+
// The doc's last commit, the same date the sitemap gives (build.mjs).
21+
...(modified ? { dateModified: modified } : {}),
2022
isPartOf: { "@type": "WebSite", name: site.title, url: site.siteUrl },
2123
breadcrumb: breadcrumbLd([
2224
{ name: site.title, url: site.siteUrl },

‎site/lib/lib.test.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,18 @@ eq("seo description fits the limit", seoDescription({ license: "CC-BY-4.0", comm
546546

547547
/* sitemap lastmod (#108) --------------------------------------------------- */
548548
import { sitemapXml } from "./exports.mjs";
549+
{
550+
const { commitDatesFromLog, DATED_PATHS } = await import("./exports.mjs");
551+
const log = "\0" + "2026-10-20\n\ndocs/fonts.md\ncatalog/2d/a.md\n\0" + "2026-09-01\n\ndocs/fonts.md\ndocs/data.md\n";
552+
const dates = commitDatesFromLog(log);
553+
eq("git dates: newest commit wins", dates.get("docs/fonts.md"), "2026-10-20");
554+
eq("git dates: older-only file keeps its date", dates.get("docs/data.md"), "2026-09-01");
555+
const siteCfg = JSON.parse(nodeFs.readFileSync(new URL("../config.json", import.meta.url), "utf8"));
556+
const undated = (siteCfg.guides || []).filter((g) => !DATED_PATHS.some((d) => g.path.startsWith(`${d}/`)));
557+
eq("git dates: every guide lives under a dated path", undated.map((g) => g.path).join(", "), "");
558+
const sm = sitemapXml({ site, stamp: "2026-11-03", entries: [], guides: [{ id: "fonts", path: "docs/fonts.md" }], modified: dates });
559+
eq("sitemap: a guide's lastmod is its doc's last commit", sm.includes("<loc>https://x.test/s/guide/fonts/</loc><lastmod>2026-10-20</lastmod>"), true);
560+
}
549561
{
550562
const sm = sitemapXml({
551563
site,

0 commit comments

Comments
 (0)