Date sitemap lastmod from the build pipeline; warn on skipped pages

- bunext build records when each page's client bundle hash last changed
  in .bunext/page-lastmod.json (sitemap.lastmod_file to relocate). The
  hash covers the page's whole import graph, so component edits re-date
  every page that uses them. The sitemap uses these dates as lastmod
- Add sitemap.default_lastmod
- BREAKING: use_file_lastmod now defaults to false (file mtimes are reset
  by checkouts); it is a fallback after the build record
- Warn when a page or __root.tsx is skipped because its default export
  is not at the start of a line (e.g. export { Page as default })
- Replace leaking module mocks in tests (log, grab-root-file-path) with
  spies and fixtures

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 18:25:38 +01:00
co-authored by Claude Opus 5.5
parent be4bebaa22
commit 3a75880ae4
26 changed files with 877 additions and 54 deletions
+61
View File
@@ -525,4 +525,65 @@ describe("bunext sitemap", () => {
expect(exists("public/sitemap.xml")).toBe(false);
});
});
describe("lastmod", () => {
const writeRecord = (file: string, pages: Record<string, string>) =>
writeProjectFile(
file,
JSON.stringify({
pages: Object.fromEntries(
Object.entries(pages).map(([page, lastmod]) => [
page,
{ fingerprint: "x", lastmod },
]),
),
}),
);
it("warns and omits lastmod when there is no build record", async () => {
writeConfig(`{ origin: "https://example.com" }`);
const res = await runSitemap();
expect(res.exit_code).toBe(0);
expect(res.output).toContain("Run `bunext build` first");
expect(readOutput()).not.toContain("<lastmod>");
});
it("uses dates from .bunext/page-lastmod.json", async () => {
writeConfig(`{ origin: "https://example.com" }`);
writeRecord(".bunext/page-lastmod.json", {
"src/pages/about.tsx": "2026-05-01T00:00:00.000Z",
});
const res = await runSitemap();
const xml = readOutput();
expect(res.output).not.toContain("Run `bunext build` first");
expect(xml).toContain(
"<loc>https://example.com/about</loc>\n <lastmod>2026-05-01T00:00:00.000Z</lastmod>",
);
expect(xml).toContain(
"<loc>https://example.com/</loc>\n </url>",
);
});
it("reads the record from sitemap.lastmod_file", async () => {
writeConfig(`{
origin: "https://example.com",
sitemap: { lastmod_file: "seo/lastmod.json", default_lastmod: "2026-01-01" },
}`);
writeRecord("seo/lastmod.json", {
"src/pages/about.tsx": "2026-05-01T00:00:00.000Z",
});
await runSitemap();
const xml = readOutput();
expect(xml).toContain("<lastmod>2026-05-01T00:00:00.000Z</lastmod>");
expect(xml).toContain(
"<loc>https://example.com/</loc>\n <lastmod>2026-01-01</lastmod>",
);
});
});
});
@@ -0,0 +1,137 @@
import {
describe,
it,
expect,
beforeAll,
beforeEach,
afterAll,
afterEach,
spyOn,
} from "bun:test";
import path from "path";
import fs from "fs";
import os from "os";
import grabClientHydrationScript from "../../../functions/bundler/grab-client-hydration-script";
import grabDirNames from "../../../utils/grab-dir-names";
import { log } from "../../../utils/log";
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bunext-hydration-"));
const pagesDir = path.join(tmpDir, "pages");
let warnSpy: ReturnType<typeof spyOn>;
let fileCounter = 0;
/** Unique file per test so the once-per-file dedupe never leaks between tests */
function writePage(content: string, name = `page-${++fileCounter}.tsx`) {
const file_path = path.join(pagesDir, name);
fs.writeFileSync(file_path, content);
return file_path;
}
const warnings = () => warnSpy.mock.calls.map((c) => String(c[0]));
beforeAll(() => {
fs.mkdirSync(pagesDir, { recursive: true });
(global as any).BUNEXT_CONFIG = {};
(global as any).BUNEXT_DIR_NAMES = {
...grabDirNames(),
PAGES_DIR: pagesDir,
};
});
beforeEach(() => {
warnSpy = spyOn(log, "warn").mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
fs.rmSync(path.join(pagesDir, "__root.tsx"), { force: true });
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
(global as any).BUNEXT_DIR_NAMES = undefined;
});
describe("grabClientHydrationScript default export warning", () => {
it("bundles a page with export default at the start of a line, without warning", async () => {
const page = writePage(
`import X from "x";\n\nexport default function Page() {\n return null;\n}\n`,
);
expect(await grabClientHydrationScript({ page_local_path: page })).toBeString();
expect(warnings()).toEqual([]);
});
it("warns when export default is not at the start of a line", async () => {
const page = writePage(
`import X from "x"; export default function Page() { return null; }\n`,
);
expect(await grabClientHydrationScript({ page_local_path: page })).toBeUndefined();
expect(warnings()).toHaveLength(1);
expect(warnings()[0]).toContain(path.basename(page));
expect(warnings()[0]).toContain("must be at the start of a line");
expect(warnings()[0]).toContain("This page will not be bundled");
});
it("warns for `export { Page as default }`", async () => {
const page = writePage(
`function Page() {\n return null;\n}\n\nexport { Page as default };\n`,
);
expect(await grabClientHydrationScript({ page_local_path: page })).toBeUndefined();
expect(warnings()).toHaveLength(1);
});
it("skips files with no default export silently", async () => {
const empty = writePage("");
const named = writePage(`export function Page() {\n return null;\n}\n`);
expect(await grabClientHydrationScript({ page_local_path: empty })).toBeUndefined();
expect(await grabClientHydrationScript({ page_local_path: named })).toBeUndefined();
expect(warnings()).toEqual([]);
});
it("warns once per file until its content changes", async () => {
const name = `page-repeat.tsx`;
const page = writePage(`const P = () => null; export default P;\n`, name);
await grabClientHydrationScript({ page_local_path: page });
await grabClientHydrationScript({ page_local_path: page });
expect(warnings()).toHaveLength(1);
writePage(`const P = () => <div />; export default P;\n`, name);
await grabClientHydrationScript({ page_local_path: page });
expect(warnings()).toHaveLength(2);
});
it("warns again when a fixed file is broken the same way again", async () => {
const name = `page-flip.tsx`;
const broken = `const P = () => null; export default P;\n`;
const page = writePage(broken, name);
await grabClientHydrationScript({ page_local_path: page });
writePage(`const P = () => null;\nexport default P;\n`, name);
expect(await grabClientHydrationScript({ page_local_path: page })).toBeString();
writePage(broken, name);
await grabClientHydrationScript({ page_local_path: page });
expect(warnings()).toHaveLength(2);
});
it("warns about a root layout with an unrecognized default export", async () => {
writePage(
`import type { PropsWithChildren } from "react"; export default function Root({ children }: PropsWithChildren) { return children; }\n`,
"__root.tsx",
);
const page = writePage(`export default function Page() {\n return null;\n}\n`);
expect(await grabClientHydrationScript({ page_local_path: page })).toBeUndefined();
expect(warnings()).toHaveLength(1);
expect(warnings()[0]).toContain("__root.tsx");
expect(warnings()[0]).toContain("No pages can be bundled");
});
});
@@ -9,6 +9,7 @@ const { BUNX_TMP_DIR } = grabDirNames();
describe("grabPageBundledReactComponent", () => {
const originalConfig = (globalThis as any).CONFIG;
const originalDirNames = global.BUNEXT_DIR_NAMES;
const fixtureDirs: string[] = [];
(globalThis as any).CONFIG = { development: true } as any;
@@ -20,6 +21,7 @@ describe("grabPageBundledReactComponent", () => {
}
(globalThis as any).CONFIG = { development: true } as any;
global.BUNEXT_DIR_NAMES = originalDirNames;
mock.restore();
});
@@ -33,9 +35,16 @@ describe("grabPageBundledReactComponent", () => {
const rootFilePath = path.join(fixtureDir, "__root.tsx");
const pageFilePath = path.join(fixtureDir, "page.tsx");
mock.module("../../../../src/functions/server/web-pages/grab-root-file-path", () => ({
default: () => ({ root_file_path: rootFilePath })
}));
// Point the pages dir at the fixture instead of mocking
// grab-root-file-path: module mocks leak into other test files
// (mock.restore() does not undo mock.module).
const fs = require("fs");
fs.mkdirSync(fixtureDir, { recursive: true });
fs.writeFileSync(rootFilePath, "export default function Root() {}\n");
global.BUNEXT_DIR_NAMES = {
...grabDirNames(),
PAGES_DIR: fixtureDir,
};
mock.module("../../../../src/functions/server/web-pages/grab-tsx-string-module", () => ({
default: async () => {
@@ -1,14 +1,7 @@
import { afterEach, describe, expect, mock, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import path from "path";
import type { GrabPageComponentRes } from "../../../types";
mock.module("../../../../src/utils/log", () => ({
log: {
info: mock((msg: string) => {}),
error: mock((msg: string) => {}),
},
}));
mock.module("../../../../src/utils/is-development", () => ({
default: () => false
}));
@@ -27,6 +20,13 @@ import grabPageComponent from "../../../../src/functions/server/web-pages/grab-p
describe("grabPageComponent", () => {
const originalRouter = global.BUNEXT_ROUTER;
// Spy on the real logger rather than mocking the log module: module
// mocks leak into other test files and the mock lacked `warn`.
beforeEach(() => {
spyOn(log, "info").mockImplementation(() => {});
spyOn(log, "error").mockImplementation(() => {});
});
afterEach(() => {
global.BUNEXT_ROUTER = originalRouter;
mock.restore();
@@ -1,16 +1,7 @@
import { describe, expect, test, mock, afterEach, afterAll } from "bun:test";
import { describe, expect, test, mock, afterEach, afterAll, beforeEach, spyOn } from "bun:test";
import startServer from "../../../../src/functions/server/start-server";
import { log } from "../../../../src/utils/log";
// Mock log so we don't spam terminal during tests
mock.module("../../../../src/utils/log", () => ({
log: {
server: mock((msg: string) => {}),
info: mock((msg: string) => {}),
error: mock((msg: string) => {}),
}
}));
// Mock grabConfig so it doesn't try to look for bunext.config.ts and exit process
mock.module("../../../../src/functions/grab-config", () => ({
default: async () => ({})
@@ -22,6 +13,14 @@ mock.module("../../../../src/utils/grab-app-port", () => ({
}));
describe("startServer", () => {
// Silence the real logger with spies so we don't spam the terminal.
// Mocking the log module instead leaks into other test files.
beforeEach(() => {
spyOn(log, "server").mockImplementation(() => {});
spyOn(log, "info").mockImplementation(() => {});
spyOn(log, "error").mockImplementation(() => {});
});
afterEach(() => {
if (global.BUNEXT_SERVER) {
global.BUNEXT_SERVER.stop(true);
@@ -30,8 +29,8 @@ describe("startServer", () => {
});
afterAll(() => {
// Prevent the module mocks (log, grab-config, grab-app-port) from
// leaking into other test files sharing this process.
// Restore the log spies. Note this does not undo the grab-config and
// grab-app-port module mocks; mock.restore() only restores spies.
mock.restore();
});
+116 -7
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { describe, it, expect, beforeAll, afterAll, afterEach } from "bun:test";
import path from "path";
import fs from "fs";
import os from "os";
@@ -7,6 +7,7 @@ import generateSitemap, {
grabSitemapEntries,
SitemapMaxUrlsPerFile,
} from "../../utils/generate-sitemap";
import grabRelativePath from "../../utils/grab-relative-path";
// grabAllPages skips anything under __tests__, so fixtures live in the OS tmp dir
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bunext-sitemap-"));
@@ -120,7 +121,6 @@ describe("grabSitemapEntries", () => {
expect(find("/products/2")?.priority).toBe(0.8);
expect(find("/contact")?.priority).toBe(0.3);
expect(find("/contact")?.lastmod).toBeUndefined();
expect(find("/about")?.lastmod).toBeInstanceOf(Date);
});
it("accepts a sync or async function for additional paths", async () => {
@@ -145,12 +145,121 @@ describe("grabSitemapEntries", () => {
expect(await paths({ trailing_slash: true })).toContain("/");
});
it("uses file mtime as lastmod unless disabled", async () => {
const [entry] = await grabSitemapEntries();
expect(entry.lastmod).toBeInstanceOf(Date);
});
const [no_lastmod] = await grabSitemapEntries({ use_file_lastmod: false });
expect(no_lastmod.lastmod).toBeUndefined();
describe("lastmod", () => {
const recordFile = path.join(tmpDir, "page-lastmod.json");
const pageKey = (file: string) =>
grabRelativePath(path.join(pagesDir, file));
const writeRecord = (pages: Record<string, string>) =>
fs.writeFileSync(
recordFile,
JSON.stringify({
pages: Object.fromEntries(
Object.entries(pages).map(([file, lastmod]) => [
pageKey(file),
{ fingerprint: "x", lastmod },
]),
),
}),
);
const lastmods = async (
params?: Parameters<typeof grabSitemapEntries>[0],
) =>
Object.fromEntries(
(
await grabSitemapEntries({ lastmod_file: recordFile, ...params })
).map((e) => [e.path, e.lastmod]),
);
afterEach(() => {
fs.rmSync(recordFile, { force: true });
});
it("omits lastmod with no build record by default", async () => {
const result = await lastmods();
expect(result["/about"]).toBeUndefined();
expect(result["/"]).toBeUndefined();
});
it("uses the build record date for static pages", async () => {
writeRecord({
"about.tsx": "2026-05-01T00:00:00.000Z",
"blog/index.tsx": "2026-06-01T00:00:00.000Z",
});
const result = await lastmods();
expect(result["/about"]).toBe("2026-05-01T00:00:00.000Z");
expect(result["/blog"]).toBe("2026-06-01T00:00:00.000Z");
expect(result["/"]).toBeUndefined();
});
it("falls back to the page's build date for its sitemap export entries", async () => {
writeRecord({ "products/[id].tsx": "2026-04-01T00:00:00.000Z" });
const result = await lastmods({
additional_paths: [{ path: "/extra", lastmod: "2026-01-01" }],
});
expect(result["/products/1"]).toBe("2026-04-01T00:00:00.000Z");
expect(result["/extra"]).toBe("2026-01-01");
});
it("prefers an entry's own lastmod over the build record", async () => {
const datedDir = path.join(pagesDir, "dated");
fs.mkdirSync(datedDir, { recursive: true });
fs.writeFileSync(path.join(datedDir, "[x].tsx"), "export default () => null");
fs.writeFileSync(
path.join(datedDir, "[x].server.ts"),
`export const sitemap = () => [
{ path: "/dated/own", lastmod: "2020-01-01" },
"/dated/none",
];`,
);
try {
writeRecord({ "dated/[x].tsx": "2026-04-01T00:00:00.000Z" });
const result = await lastmods();
expect(result["/dated/own"]).toBe("2020-01-01");
expect(result["/dated/none"]).toBe("2026-04-01T00:00:00.000Z");
} finally {
fs.rmSync(datedDir, { recursive: true, force: true });
}
});
it("uses default_lastmod for entries with no other date", async () => {
writeRecord({ "about.tsx": "2026-05-01T00:00:00.000Z" });
const result = await lastmods({
default_lastmod: "2026-09-09",
additional_paths: ["/extra"],
});
expect(result["/about"]).toBe("2026-05-01T00:00:00.000Z");
expect(result["/"]).toBe("2026-09-09");
expect(result["/extra"]).toBe("2026-09-09");
});
it("falls back to file mtime only when use_file_lastmod is set", async () => {
writeRecord({ "about.tsx": "2026-05-01T00:00:00.000Z" });
const result = await lastmods({ use_file_lastmod: true });
expect(result["/about"]).toBe("2026-05-01T00:00:00.000Z");
expect(result["/"]).toBeInstanceOf(Date);
});
it("ignores an invalid record", async () => {
fs.writeFileSync(recordFile, "{ not json");
const result = await lastmods();
expect(result["/about"]).toBeUndefined();
});
});
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach, afterAll } from "bun:test";
import path from "path";
import fs from "fs";
import os from "os";
import type { BundlerCTXMap } from "../../types";
import {
grabPageLastmodFile,
readPageLastmodRecord,
updatePageLastmodRecord,
} from "../../utils/page-lastmod-record";
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bunext-lastmod-"));
const recordFile = path.join(tmpDir, "nested", "page-lastmod.json");
const artifact = (hash: string, css_path?: string) =>
({ hash, css_path, path: `${hash}.js` }) as BundlerCTXMap;
const build = (
ctx_map: Record<string, BundlerCTXMap>,
build_date: string,
) =>
updatePageLastmodRecord({
ctx_map,
build_date: new Date(build_date),
lastmod_file: recordFile,
});
const day1 = "2026-01-01T00:00:00.000Z";
const day2 = "2026-02-01T00:00:00.000Z";
beforeEach(() => {
fs.rmSync(recordFile, { force: true });
(global as any).BUNEXT_CONFIG = {};
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe("updatePageLastmodRecord", () => {
it("dates every page with the build date on the first build", () => {
const record = build(
{ "src/pages/a.tsx": artifact("h1"), "src/pages/b.tsx": artifact("h2") },
day1,
);
expect(record.pages["src/pages/a.tsx"].lastmod).toBe(day1);
expect(record.pages["src/pages/b.tsx"].lastmod).toBe(day1);
expect(readPageLastmodRecord(recordFile)).toEqual(record);
});
it("keeps the date of unchanged pages and re-dates changed ones", () => {
build(
{ "src/pages/a.tsx": artifact("h1"), "src/pages/b.tsx": artifact("h2") },
day1,
);
const record = build(
{ "src/pages/a.tsx": artifact("h1"), "src/pages/b.tsx": artifact("h2-new") },
day2,
);
expect(record.pages["src/pages/a.tsx"].lastmod).toBe(day1);
expect(record.pages["src/pages/b.tsx"].lastmod).toBe(day2);
});
it("re-dates a page when only its CSS bundle changes", () => {
build({ "src/pages/a.tsx": artifact("h1", "a-1.css") }, day1);
const record = build({ "src/pages/a.tsx": artifact("h1", "a-2.css") }, day2);
expect(record.pages["src/pages/a.tsx"].lastmod).toBe(day2);
});
it("drops removed pages and dates new ones", () => {
build({ "src/pages/a.tsx": artifact("h1") }, day1);
const record = build({ "src/pages/new.tsx": artifact("h3") }, day2);
expect(Object.keys(record.pages)).toEqual(["src/pages/new.tsx"]);
expect(record.pages["src/pages/new.tsx"].lastmod).toBe(day2);
});
it("starts over from an invalid record", () => {
fs.mkdirSync(path.dirname(recordFile), { recursive: true });
fs.writeFileSync(recordFile, "nope");
const record = build({ "src/pages/a.tsx": artifact("h1") }, day2);
expect(record.pages["src/pages/a.tsx"].lastmod).toBe(day2);
});
it("writes pages in sorted order", () => {
build(
{ "src/pages/z.tsx": artifact("1"), "src/pages/a.tsx": artifact("2") },
day1,
);
const written = JSON.parse(fs.readFileSync(recordFile, "utf-8"));
expect(Object.keys(written.pages)).toEqual([
"src/pages/a.tsx",
"src/pages/z.tsx",
]);
});
});
describe("grabPageLastmodFile", () => {
it("defaults to .bunext/page-lastmod.json", () => {
expect(grabPageLastmodFile()).toBe(
path.join(process.cwd(), ".bunext", "page-lastmod.json"),
);
});
it("resolves sitemap.lastmod_file from config against the project root", () => {
(global as any).BUNEXT_CONFIG = {
sitemap: { lastmod_file: "seo/lastmod.json" },
};
expect(grabPageLastmodFile()).toBe(
path.join(process.cwd(), "seo", "lastmod.json"),
);
});
});