From 6bf6dc3877c2ad4aaf57af7a32ba1e82d46f9190 Mon Sep 17 00:00:00 2001 From: bhushan4work Date: Mon, 19 Oct 2026 12:35:37 +0530 Subject: [PATCH] fix(sitemap): derive per-url lastModified from build metadata (#46) --- app/sitemap.ts | 34 +++++++++++-- lib/__tests__/sitemap.test.ts | 94 +++++++++++++++++++++++++++++++++++ lib/jsonLd.ts | 2 +- lib/tools.ts | 2 + next.config.js | 76 +++++++++++++++++++++++++++- 5 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 lib/__tests__/sitemap.test.ts diff --git a/app/sitemap.ts b/app/sitemap.ts index 26368c0..033bb72 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,13 +1,39 @@ import type { MetadataRoute } from "next"; -import { SITE_URL, TOOLS } from "@/lib/tools"; +import { SITE_URL, TOOLS, type Tool } from "@/lib/tools"; +import { SITE_PUBLISHED } from "@/lib/jsonLd"; + +export function getSitemapDates(): Record { + if (process.env.SITEMAP_DATES) { + try { + return JSON.parse(process.env.SITEMAP_DATES); + } catch { + /* ignore malformed json */ + } + } + return {}; +} + +export function getUrlLastModified( + path: string, + dates: Record = getSitemapDates(), + tool?: Tool +): Date { + const dateStr = tool?.lastModified ?? dates[path] ?? process.env.BUILD_DATE ?? SITE_PUBLISHED; + return new Date(dateStr); +} export default function sitemap(): MetadataRoute.Sitemap { - const lastModified = new Date(); + const dates = getSitemapDates(); return [ - { url: `${SITE_URL}/`, lastModified, changeFrequency: "weekly", priority: 1 }, + { + url: `${SITE_URL}/`, + lastModified: getUrlLastModified("/", dates), + changeFrequency: "weekly", + priority: 1, + }, ...TOOLS.map((t) => ({ url: `${SITE_URL}${t.path}`, - lastModified, + lastModified: getUrlLastModified(t.path, dates, t), changeFrequency: "weekly" as const, priority: t.sitemapPriority, })), diff --git a/lib/__tests__/sitemap.test.ts b/lib/__tests__/sitemap.test.ts new file mode 100644 index 0000000..d0bb30d --- /dev/null +++ b/lib/__tests__/sitemap.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, afterEach } from "vitest"; +import sitemap, { getSitemapDates, getUrlLastModified } from "@/app/sitemap"; +import { SITE_URL, TOOLS, type Tool } from "@/lib/tools"; +import { SITE_PUBLISHED } from "@/lib/jsonLd"; + +describe("sitemap", () => { + const originalSitemapDates = process.env.SITEMAP_DATES; + const originalBuildDate = process.env.BUILD_DATE; + + afterEach(() => { + if (originalSitemapDates !== undefined) { + process.env.SITEMAP_DATES = originalSitemapDates; + } else { + delete process.env.SITEMAP_DATES; + } + + if (originalBuildDate !== undefined) { + process.env.BUILD_DATE = originalBuildDate; + } else { + delete process.env.BUILD_DATE; + } + }); + + it("includes the homepage and all registered tools", () => { + const entries = sitemap(); + const urls = entries.map((e) => e.url); + + expect(urls).toContain(`${SITE_URL}/`); + for (const tool of TOOLS) { + expect(urls).toContain(`${SITE_URL}${tool.path}`); + } + expect(entries).toHaveLength(TOOLS.length + 1); + }); + + it("assigns distinct lastModified dates to URLs from build metadata", () => { + process.env.SITEMAP_DATES = JSON.stringify({ + "/": "2026-07-08", + "/base64": "2026-10-15", + "/color-converter": "2026-10-17", + "/json-formatter": "2026-10-18", + }); + + const entries = sitemap(); + const home = entries.find((e) => e.url === `${SITE_URL}/`); + const base64 = entries.find((e) => e.url === `${SITE_URL}/base64`); + const color = entries.find((e) => e.url === `${SITE_URL}/color-converter`); + const json = entries.find((e) => e.url === `${SITE_URL}/json-formatter`); + + expect(home?.lastModified).toEqual(new Date("2026-07-08")); + expect(base64?.lastModified).toEqual(new Date("2026-10-15")); + expect(color?.lastModified).toEqual(new Date("2026-10-17")); + expect(json?.lastModified).toEqual(new Date("2026-10-18")); + + // Ensure dates are not identical + expect(base64?.lastModified).not.toEqual(home?.lastModified); + expect(color?.lastModified).not.toEqual(base64?.lastModified); + }); + + it("prioritizes content lastModified when defined on a tool", () => { + const customTool: Tool = { + ...TOOLS[0], + path: "/custom-tool", + lastModified: "2026-09-01", + }; + + const date = getUrlLastModified("/custom-tool", { "/custom-tool": "2026-10-01" }, customTool); + expect(date).toEqual(new Date("2026-09-01")); + }); + + it("falls back to BUILD_DATE when route is absent from SITEMAP_DATES", () => { + delete process.env.SITEMAP_DATES; + process.env.BUILD_DATE = "2026-10-19"; + + const date = getUrlLastModified("/unmapped-tool"); + expect(date).toEqual(new Date("2026-10-19")); + }); + + it("falls back to SITE_PUBLISHED when neither SITEMAP_DATES nor BUILD_DATE is set", () => { + delete process.env.SITEMAP_DATES; + delete process.env.BUILD_DATE; + + const date = getUrlLastModified("/unmapped-tool"); + expect(date).toEqual(new Date(SITE_PUBLISHED)); + }); + + it("safely handles invalid JSON in SITEMAP_DATES", () => { + process.env.SITEMAP_DATES = "{invalid-json"; + delete process.env.BUILD_DATE; + + expect(getSitemapDates()).toEqual({}); + const date = getUrlLastModified("/"); + expect(date).toEqual(new Date(SITE_PUBLISHED)); + }); +}); diff --git a/lib/jsonLd.ts b/lib/jsonLd.ts index 1595f98..ddda461 100644 --- a/lib/jsonLd.ts +++ b/lib/jsonLd.ts @@ -10,7 +10,7 @@ const WEBSITE_ID = `${SITE_URL}/#website`; // The initial public release. dateModified is the build date (BUILD_DATE is // set in next.config.js), not the request date, since pages render per request. -const SITE_PUBLISHED = "2026-07-08"; +export const SITE_PUBLISHED = "2026-07-08"; // The publisher is CODERCOPS (the agency). Keyed by an @id on this domain so the // WebSite node can reference it. sameAs lists only identities we can honestly diff --git a/lib/tools.ts b/lib/tools.ts index 7967222..67ae5be 100644 --- a/lib/tools.ts +++ b/lib/tools.ts @@ -47,6 +47,8 @@ export interface Tool { faqs: ToolFaq[]; cta: ToolCta; sitemapPriority: number; + /** ISO date string (YYYY-MM-DD) overriding build-time git history for the sitemap. */ + lastModified?: string; } const CTA_PROJECTS = "https://www.codercops.com/projects"; diff --git a/next.config.js b/next.config.js index b310796..0b82402 100644 --- a/next.config.js +++ b/next.config.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ /** @type {import('next').NextConfig} */ const securityHeaders = [ { key: "X-Content-Type-Options", value: "nosniff" }, @@ -14,10 +15,81 @@ const securityHeaders = [ }, ]; +const fs = require("node:fs"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); + +function getGitDate(...filePaths) { + try { + const out = execFileSync("git", ["log", "-1", "--format=%cs", "--", ...filePaths], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return out || null; + } catch { + return null; + } +} + +function getSitemapDates() { + const dates = {}; + try { + let isShallow = false; + try { + isShallow = + execFileSync("git", ["rev-parse", "--is-shallow-repository"], { + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim() === "true"; + if (isShallow) { + try { + execFileSync("git", ["fetch", "--unshallow"], { + stdio: ["ignore", "ignore", "ignore"], + }); + isShallow = + execFileSync("git", ["rev-parse", "--is-shallow-repository"], { + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + }).trim() === "true"; + } catch { + // Unshallow failed (e.g. offline) + } + } + } catch { + return dates; + } + + if (isShallow) { + // In a shallow clone that cannot be unshallowed, do not query git log + // because every file would falsely inherit the latest commit date. + return dates; + } + + const homeDate = getGitDate("app/page.tsx", "lib/tools.ts"); + if (homeDate) dates["/"] = homeDate; + + const toolsDir = "app/(tools)"; + if (fs.existsSync(toolsDir)) { + for (const entry of fs.readdirSync(toolsDir, { withFileTypes: true })) { + if (entry.isDirectory() && fs.existsSync(path.join(toolsDir, entry.name, "page.tsx"))) { + const date = getGitDate(path.join(toolsDir, entry.name)); + if (date) dates[`/${entry.name}`] = date; + } + } + } + } catch { + // Filesystem or unexpected error; return dates found so far + } + return dates; +} + const nextConfig = { reactStrictMode: true, - // Inlined at build time for the WebSite JSON-LD dateModified. - env: { BUILD_DATE: new Date().toISOString().slice(0, 10) }, + // Inlined at build time for the WebSite JSON-LD dateModified and sitemap lastModified. + env: { + BUILD_DATE: new Date().toISOString().slice(0, 10), + SITEMAP_DATES: JSON.stringify(getSitemapDates()), + }, async headers() { return [ {