Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<string, string> = 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,
})),
Expand Down
94 changes: 94 additions & 0 deletions lib/__tests__/sitemap.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
2 changes: 1 addition & 1 deletion lib/jsonLd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
76 changes: 74 additions & 2 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
/** @type {import('next').NextConfig} */
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
Expand All @@ -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 [
{
Expand Down
Loading