Add sitemap generation and bunext sitemap command

- Add generateSitemap util (exported as bunext.bunextGenerateSitemap)
  that builds sitemap.xml from static pages, with exclude patterns,
  additional paths (array or sync/async function), lastmod from file
  mtime, trailing slash, and default changefreq/priority options
- Let page .server.ts files export a `sitemap` function to list URLs
  for dynamic routes, or override a static page's entry
- Add `bunext sitemap` command reading `sitemap` from bunext.config.ts,
  writing public/sitemap.xml by default, with --output/--origin flags
- Fix handle-routes test leaking a grab-router module mock into
  bunext-req-handler tests; set global.BUNEXT_ROUTER instead
- Ignore /.tmp
- Document sitemap usage in README and CLAUDE.md

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 17:41:21 +01:00
co-authored by Claude Opus 5.5
parent 3025b1681d
commit d9de858439
11 changed files with 917 additions and 11 deletions
+224
View File
@@ -0,0 +1,224 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
import path from "path";
import fs from "fs";
import os from "os";
const cliPath = path.resolve(import.meta.dir, "../../commands/index.ts");
let projectDir: string;
function writeProjectFile(file: string, content: string) {
const file_path = path.join(projectDir, file);
fs.mkdirSync(path.dirname(file_path), { recursive: true });
fs.writeFileSync(file_path, content);
}
function writeConfig(config: string) {
writeProjectFile(
"bunext.config.ts",
`const config = ${config};\nexport default config;\n`,
);
}
async function runSitemap(...args: string[]) {
const proc = Bun.spawn(["bun", cliPath, "sitemap", ...args], {
cwd: projectDir,
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, NODE_ENV: "production" },
});
const [stdout, stderr, exit_code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
proc.exited,
]);
return { stdout, stderr, output: stdout + stderr, exit_code };
}
function readOutput(file = "public/sitemap.xml") {
return fs.readFileSync(path.join(projectDir, file), "utf-8");
}
function locs(xml: string) {
return [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map((m) => m[1]).sort();
}
beforeEach(() => {
// Outside __tests__, which the page scanner skips
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "bunext-sitemap-cmd-"));
for (const page of ["index", "about", "blog/index", "blog/[slug]", "404"]) {
writeProjectFile(`src/pages/${page}.tsx`, "export default () => null");
}
writeProjectFile(
"src/pages/api/hello.ts",
`export default () => new Response("hi")`,
);
});
afterEach(() => {
fs.rmSync(projectDir, { recursive: true, force: true });
});
describe("bunext sitemap", () => {
it("writes public/sitemap.xml using config.origin", async () => {
writeConfig(`{ origin: "https://example.com" }`);
const res = await runSitemap();
expect(res.exit_code).toBe(0);
expect(res.output).toContain(
"Sitemap with 3 URLs written to public/sitemap.xml",
);
expect(locs(readOutput())).toEqual([
"https://example.com/",
"https://example.com/about",
"https://example.com/blog",
]);
});
it("applies the sitemap config options", async () => {
writeConfig(`{
origin: "https://example.com",
sitemap: {
exclude: ["/about"],
default_changefreq: "daily",
use_file_lastmod: false,
trailing_slash: true,
additional_paths: async () => [
"/blog/from-config",
{ path: "/blog/priority", priority: 0.9 },
],
},
}`);
const res = await runSitemap();
const xml = readOutput();
expect(res.exit_code).toBe(0);
expect(locs(xml)).toEqual([
"https://example.com/",
"https://example.com/blog/",
"https://example.com/blog/from-config/",
"https://example.com/blog/priority/",
]);
expect(xml).toContain("<changefreq>daily</changefreq>");
expect(xml).toContain("<priority>0.9</priority>");
expect(xml).not.toContain("<lastmod>");
});
it("includes dynamic route URLs from a page server sitemap export", async () => {
writeConfig(`{ origin: "https://example.com" }`);
writeProjectFile(
"src/pages/blog/[slug].server.ts",
`export default async () => ({ props: {} });
export const sitemap = async ({ route }) => [
"/blog/first",
{ path: "/blog/" + (route == "/blog/[slug]" ? "second" : "bad") },
];`,
);
const res = await runSitemap();
expect(res.exit_code).toBe(0);
expect(locs(readOutput())).toEqual([
"https://example.com/",
"https://example.com/about",
"https://example.com/blog",
"https://example.com/blog/first",
"https://example.com/blog/second",
]);
});
it("writes to config.sitemap.output", async () => {
writeConfig(`{
origin: "https://example.com",
sitemap: { output: "out/site/sitemap.xml" },
}`);
const res = await runSitemap();
expect(res.exit_code).toBe(0);
expect(fs.existsSync(path.join(projectDir, "public/sitemap.xml"))).toBe(
false,
);
expect(locs(readOutput("out/site/sitemap.xml"))).toContain(
"https://example.com/about",
);
});
it("lets --output and --origin override the config", async () => {
writeConfig(`{
origin: "https://example.com",
sitemap: { origin: "https://config.dev", output: "out/config.xml" },
}`);
const res = await runSitemap(
"-o",
"cli/map.xml",
"--origin",
"https://cli.dev",
);
expect(res.exit_code).toBe(0);
expect(fs.existsSync(path.join(projectDir, "out/config.xml"))).toBe(
false,
);
expect(locs(readOutput("cli/map.xml"))).toContain(
"https://cli.dev/about",
);
});
it("falls back to sitemap.origin over config.origin", async () => {
writeConfig(`{
origin: "https://example.com",
sitemap: { origin: "https://sitemap.dev" },
}`);
await runSitemap();
expect(locs(readOutput())).toContain("https://sitemap.dev/about");
});
it("works without a sitemap config key, defaulting to localhost", async () => {
writeConfig(`{}`);
const res = await runSitemap();
expect(res.exit_code).toBe(0);
expect(locs(readOutput())).toContain("http://localhost:7000/about");
});
it("exits with code 1 when a page sitemap export throws", async () => {
writeConfig(`{ origin: "https://example.com" }`);
writeProjectFile(
"src/pages/about.server.ts",
`export const sitemap = () => { throw new Error("db down"); };`,
);
const res = await runSitemap();
expect(res.exit_code).toBe(1);
expect(res.output).toContain("src/pages/about.server.ts");
expect(res.output).toContain("db down");
expect(fs.existsSync(path.join(projectDir, "public/sitemap.xml"))).toBe(
false,
);
});
it("exits with code 1 when config additional_paths throws", async () => {
writeConfig(`{
sitemap: {
additional_paths: async () => { throw new Error("api down"); },
},
}`);
const res = await runSitemap();
expect(res.exit_code).toBe(1);
expect(res.output).toContain("api down");
});
});
@@ -1,4 +1,4 @@
import { describe, expect, test, mock, afterAll } from "bun:test";
import { describe, expect, test, mock, afterAll, beforeAll } from "bun:test";
import handleRoutes from "../../../../src/functions/server/handle-routes";
mock.module("../../../../src/utils/is-development", () => ({
@@ -9,15 +9,6 @@ mock.module("../../../../src/utils/grab-constants", () => ({
default: () => ({ MBInBytes: 1048576, ServerDefaultRequestBodyLimitBytes: 5242880 })
}));
mock.module("../../../../src/utils/grab-router", () => ({
default: () => ({
match: (path: string) => {
if (path === "/api/test") return { filePath: "/test-path" };
if (path === "/api/large") return { filePath: "/large-path" };
return null;
}
})
}));
mock.module("../../../../src/utils/grab-route-params", () => ({
default: async () => ({ params: {}, searchParams: {} })
@@ -36,7 +27,24 @@ mock.module("/large-path", () => ({
* Tests for routing logic within `handle-routes`.
*/
describe("handle-routes", () => {
let originalRouter: typeof global.BUNEXT_ROUTER;
// grabRouter() reads this global. Mocking the grab-router module
// instead leaks into other test files (mock.restore() does not undo
// mock.module), breaking route matching in bunext-req-handler tests.
beforeAll(() => {
originalRouter = global.BUNEXT_ROUTER;
global.BUNEXT_ROUTER = {
match: (path: string) => {
if (path === "/api/test") return { filePath: "/test-path" };
if (path === "/api/large") return { filePath: "/large-path" };
return null;
},
} as any;
});
afterAll(() => {
global.BUNEXT_ROUTER = originalRouter;
mock.restore();
});
@@ -0,0 +1,180 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import path from "path";
import fs from "fs";
import os from "os";
import generateSitemap, {
grabSitemapEntries,
} from "../../utils/generate-sitemap";
// grabAllPages skips anything under __tests__, so fixtures live in the OS tmp dir
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "bunext-sitemap-"));
const pagesDir = path.join(tmpDir, "pages");
const pageFiles = [
"index.tsx",
"about.tsx",
"404.tsx",
"__root.tsx",
"blog/index.tsx",
"blog/[slug].tsx",
"admin/index.tsx",
"admin/users.tsx",
"docs/a&b.tsx",
"api/hello.ts",
"(components)/button.tsx",
];
const serverFiles: Record<string, string> = {
// Dynamic route listing its own URLs
"products/[id].server.ts": `
export default async () => ({ props: {} });
export const sitemap = async ({ route }: { route: string }) => [
"/products/1",
{ path: "/products/2", priority: 0.8 },
route == "/products/[id]" ? "/products/route-ok" : "/products/route-bad",
];
`,
// Static page overriding its own entry
"contact.server.ts": `
export const sitemap = () => [{ path: "/contact", priority: 0.3 }];
`,
// Server file without a sitemap export keeps the default entry
"about.server.ts": "export default async () => ({ props: {} });",
};
beforeAll(() => {
for (const file of [...pageFiles, "products/[id].tsx", "contact.tsx"]) {
const file_path = path.join(pagesDir, file);
fs.mkdirSync(path.dirname(file_path), { recursive: true });
fs.writeFileSync(file_path, "export default () => null");
}
for (const [file, content] of Object.entries(serverFiles)) {
fs.writeFileSync(path.join(pagesDir, file), content);
}
(global as any).BUNEXT_DIR_NAMES = { PAGES_DIR: pagesDir };
(global as any).BUNEXT_CONSTANTS = { RouteIgnorePatterns: [/\/\(/] };
(global as any).BUNEXT_CONFIG = { origin: "https://example.com" };
});
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
(global as any).BUNEXT_DIR_NAMES = undefined;
(global as any).BUNEXT_CONSTANTS = undefined;
(global as any).BUNEXT_CONFIG = undefined;
});
const paths = async (params?: Parameters<typeof grabSitemapEntries>[0]) =>
(await grabSitemapEntries(params)).map((e) => e.path).sort();
describe("grabSitemapEntries", () => {
it("lists static pages, skipping api, dynamic, error and ignored routes", async () => {
expect(await paths()).toEqual(
[
"/",
"/about",
"/admin",
"/admin/users",
"/blog",
"/contact",
"/docs/a&b",
"/products/1",
"/products/2",
"/products/route-ok",
].sort(),
);
});
it("excludes exact paths, subtrees and regex matches", async () => {
expect(
await paths({
exclude: ["/about", "/admin/*", /^\/docs/, "/products/*"],
}),
).toEqual(["/", "/blog", "/contact"]);
});
it("adds and dedupes additional paths, applying exclusions", async () => {
const result = await paths({
additional_paths: [
"/blog/first-post",
{ path: "blog/second-post/", priority: 0.4 },
"/about",
"/admin/secret",
],
exclude: ["/admin/*"],
});
expect(result).toContain("/blog/first-post");
expect(result).toContain("/blog/second-post");
expect(result.filter((p) => p == "/about")).toHaveLength(1);
expect(result).not.toContain("/admin/secret");
});
it("uses a page's server sitemap export for its entries", async () => {
const entries = await grabSitemapEntries();
const find = (p: string) => entries.find((e) => e.path == p);
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 () => {
expect(
await paths({ additional_paths: () => ["/blog/sync-post"] }),
).toContain("/blog/sync-post");
const result = await paths({
additional_paths: async () => [
"/blog/async-post",
{ path: "/admin/hidden" },
],
exclude: ["/admin/*"],
});
expect(result).toContain("/blog/async-post");
expect(result).not.toContain("/admin/hidden");
});
it("applies trailing slashes except on root", async () => {
expect(await paths({ trailing_slash: true })).toContain("/about/");
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("generateSitemap", () => {
it("renders valid sitemap xml with escaped absolute urls", async () => {
const xml = await generateSitemap({
default_changefreq: "weekly",
default_priority: 0.5,
use_file_lastmod: false,
additional_paths: [{ path: "/blog/post", priority: 2 }],
});
expect(xml).toStartWith(`<?xml version="1.0" encoding="UTF-8"?>`);
expect(xml).toContain(
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`,
);
expect(xml).toContain("<loc>https://example.com/</loc>");
expect(xml).toContain("<loc>https://example.com/docs/a&amp;b</loc>");
expect(xml).toContain("<changefreq>weekly</changefreq>");
expect(xml).toContain("<priority>0.5</priority>");
expect(xml).toContain("<priority>1.0</priority>");
expect(xml).not.toContain("<lastmod>");
});
it("uses the origin param over config and strips its trailing slash", async () => {
const xml = await generateSitemap({ origin: "https://other.dev/" });
expect(xml).toContain("<loc>https://other.dev/about</loc>");
});
});