diff --git a/_config.ts b/_config.ts index b71da1d5f..61292e569 100644 --- a/_config.ts +++ b/_config.ts @@ -554,6 +554,42 @@ site.scopedUpdates( (path) => path.startsWith("/api/deno/"), ); +// During dev, auto-update last_modified frontmatter when a page is edited +site.addEventListener("beforeUpdate", (event) => { + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + + for (const file of event.files ?? []) { + if (!/\.(md|mdx)$/.test(file)) continue; + + const path = `./${file}`; + let content: string; + try { + content = Deno.readTextFileSync(path); + } catch { + continue; + } + + if (!content.startsWith("---")) continue; + + const endOfFrontmatter = content.indexOf("\n---", 3); + if (endOfFrontmatter === -1) continue; + + const frontmatter = content.slice(0, endOfFrontmatter); + const rest = content.slice(endOfFrontmatter); + + const lastModifiedMatch = frontmatter.match(/^last_modified:\s*.+$/m); + if (lastModifiedMatch) { + const updated = frontmatter.replace( + /^last_modified:\s*.+$/m, + `last_modified: ${today}`, + ); + if (updated !== frontmatter) { + Deno.writeTextFileSync(path, updated + rest); + } + } + } +}); + site.addEventListener("afterStartServer", () => { log.warn( `${cliNow()} Server available at http://localhost:${site.server.options.port}`, diff --git a/frontmatter_test.ts b/frontmatter_test.ts index e8c511981..3be433c6f 100644 --- a/frontmatter_test.ts +++ b/frontmatter_test.ts @@ -23,6 +23,85 @@ Deno.test("Frontmatter titles must not contain backticks", async (t) => { } }); +Deno.test("last_modified dates must be valid and up to date", async (t) => { + // Build a map of file -> last content-change date using git log. + // Uses -G to match only commits that changed non-frontmatter lines, + // skipping commits that only added/updated the last_modified field itself. + const result = new Deno.Command("git", { + args: [ + "log", + "-G", + "^(?!last_modified:)", + "--pretty=format:%aI", + "--name-only", + "--diff-filter=ACMR", + "HEAD", + ], + stdout: "piped", + }).outputSync(); + + const output = new TextDecoder().decode(result.stdout); + const gitDates = new Map(); + let currentDate = ""; + + for (const line of output.split("\n")) { + if (!line) continue; + if (/^\d{4}-/.test(line)) { + currentDate = line.slice(0, 10); // YYYY-MM-DD + } else if (!gitDates.has(line)) { + gitDates.set(line, currentDate); + } + } + + for (const dir of DIRS_TO_CHECK) { + for await (const entry of walk(dir, { exts: [".md", ".mdx"] })) { + const content = await Deno.readTextFile(entry.path); + if (!content.startsWith("---")) continue; + + // Extract the raw last_modified value via regex because YAML parse + // auto-converts YYYY-MM-DD strings to Date objects + const rawMatch = content.match(/^last_modified:\s*(.+)$/m); + if (!rawMatch) continue; + + const dateStr = rawMatch[1].trim(); + + await t.step(`${entry.path} has valid last_modified`, () => { + // Must be YYYY-MM-DD format + assert( + /^\d{4}-\d{2}-\d{2}$/.test(dateStr), + `Invalid date format "${dateStr}" in ${entry.path}. Expected YYYY-MM-DD.`, + ); + + // Must parse to a real date + const parsed = new Date(dateStr + "T00:00:00Z"); + assert( + !isNaN(parsed.getTime()), + `"${dateStr}" is not a valid date in ${entry.path}.`, + ); + + // Must not be in the future + const today = new Date().toISOString().slice(0, 10); + assert( + dateStr <= today, + `last_modified "${dateStr}" is in the future in ${entry.path}.`, + ); + + // Must match the date of the last content change in git history + const relativePath = entry.path.replace(/^\.\//, ""); + const gitDate = gitDates.get(relativePath); + if (gitDate) { + assertEquals( + dateStr, + gitDate, + `last_modified "${dateStr}" does not match last content change "${gitDate}" for ${entry.path}. ` + + `Run the dev server to auto-update, or manually set last_modified: ${gitDate}.`, + ); + } + }); + } + } +}); + Deno.test("CLI command page titles must be just the command name", async (t) => { for await ( const entry of walk("./runtime/reference/cli", { exts: [".md"] })