build.test.ts
8.0 kB · typescript · 188 lines
1import { expect, test } from "bun:test";2import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";3import { join } from "node:path";4import { tmpdir } from "node:os";5import { build, forget, globals, guard, jsonScript, type Output, type Site, type Spec } from "./build.ts";67/* SITE */89const site = (config: Site["config"] = {}) =>10 ({11 config: {12 title: "Demo",13 root: "https://demo.test",14 llms: {15 about: "A demo tree.",16 links: [17 { href: "/raw/README.md", name: "README", note: "the readme" },18 { href: "/git/", name: "Code", note: "the tree" },19 { href: "/faq/", name: "FAQ" },20 { href: "/nowhere/", name: "Nowhere" },21 ],22 },23 ...config,24 },25 routes: [26 { route: "/", name: "Home", at: "2026-01-01" },27 { route: "/404.html", kind: "missing", hidden: true },28 { route: "/cart/", kind: "cart", hidden: true },29 { route: "/faq/", kind: "page", name: "FAQ", at: "2026-04-04" },30 { route: "/git/", kind: "gitdir", name: "demo", sitemap: true, at: "2026-02-02" },31 {32 route: "/git/README.md",33 kind: "gitfile",34 name: "README.md",35 hidden: true,36 sitemap: true,37 at: "2026-03-03",38 urls: [{ route: "/git/README.md" }, { route: "/raw/README.md" }],39 },40 ],41 copies: [],42 }) as unknown as Site;4344const made = async (one = site(), spec = {} as unknown as Spec) => {45 const out = await globals(one, spec);46 const find = (path: string) => out.find((item: Output) => item.path === path)?.bytes as string | undefined;47 return { out, sitemap: find("sitemap.xml")!, robots: find("robots.txt")!, llms: find("llms.txt") };48};4950/* SITEMAP */5152test("the sitemap carries every shown route and every raw path with its own date and no hidden one", async () => {53 const { sitemap } = await made();54 expect(sitemap).toContain("<loc>https://demo.test/</loc><lastmod>2026-01-01</lastmod>");55 expect(sitemap).toContain("<loc>https://demo.test/faq/</loc><lastmod>2026-04-04</lastmod>");56 expect(sitemap).toContain("<loc>https://demo.test/git/</loc><lastmod>2026-02-02</lastmod>");57 expect(sitemap).toContain("<loc>https://demo.test/git/README.md</loc><lastmod>2026-03-03</lastmod>");58 expect(sitemap).toContain("<loc>https://demo.test/raw/README.md</loc><lastmod>2026-03-03</lastmod>");59 expect(sitemap).not.toContain("404.html");60 expect(sitemap).not.toContain("/cart/");61 expect(sitemap.match(/<url>/g)!.length).toBe(5);62});6364/* ROBOTS */6566test("robots names the crawlers it welcomes and points at the sitemap", async () => {67 const { robots } = await made();68 for (const agent of ["GPTBot", "ClaudeBot", "Claude-Web", "CCBot", "Google-Extended", "anthropic-ai", "PerplexityBot"]) {69 expect(robots).toContain(`User-agent: ${agent}\nAllow: /`);70 }71 expect(robots).toContain("User-agent: *\nAllow: /");72 expect(robots).toContain("Sitemap: https://demo.test/sitemap.xml");73});7475/* LLMS */7677test("llms.txt says what the site is and links only what the site publishes", async () => {78 const { llms } = await made();79 expect(llms).toContain("# Demo");80 expect(llms).toContain("A demo tree.");81 expect(llms).toContain("- [README](https://demo.test/raw/README.md): the readme");82 expect(llms).toContain("- [Code](https://demo.test/git/): the tree");83 expect(llms).toContain("- [FAQ](https://demo.test/faq/)");84 expect(llms).not.toContain("Nowhere");85});8687test("no llms block in site.json means no llms.txt", async () => {88 const { llms } = await made(site({ llms: undefined }));89 expect(llms).toBeUndefined();90});9192/* JSON-LD */9394test("a headline that closes a script tag cannot close the ld+json block", () => {95 const out = jsonScript({ headline: "</script><img src=x onerror=alert(1)>" });96 expect(out).toBe('<script type="application/ld+json">{"headline":"\\u003c/script>\\u003cimg src=x onerror=alert(1)>"}</script>');97});9899/* ICONS */100101test("icons come through the spec: a glyph grid draws five files and no grid draws none", async () => {102 const rows = ["10101", "01010", "10101", "01010", "10101"];103 const { out } = await made(site(), { icons: { rows, svg: "<svg/>" } } as unknown as Spec);104 const paths = out.map((one) => one.path);105 for (const path of ["favicon.svg", "favicon.png", "apple-touch-icon.png", "icon-192.png", "icon-512.png"]) expect(paths).toContain(path);106 expect(out.find((one) => one.path === "favicon.svg")!.bytes).toBe("<svg/>");107 const png = out.find((one) => one.path === "favicon.png")!.bytes as Uint8Array;108 expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10]);109 expect((await made()).out.map((one) => one.path)).not.toContain("favicon.png");110});111112/* GUARD */113114test("the guard passes a listed inline script, json data and a raw mirror, and throws on an unlisted one", () => {115 const known = new Set(["const a=1"]);116 expect(() => guard("index.html", "<script data-boot>const a=1</script>", known)).not.toThrow();117 expect(() => guard("index.html", '<script type="application/ld+json">{"a":1}</script>', known)).not.toThrow();118 expect(() => guard("index.html", '<script id="props" type="application/json">{"a":1}</script>', known)).not.toThrow();119 expect(() => guard("raw/site/demos/index.html", "<script>const b=2</script>", known)).not.toThrow();120 expect(() => guard("about/index.html", "<script>const b=2</script>", known)).toThrow(/boot list/);121});122123/* BUILD */124125const fresh = (name: string) => {126 const home = join(tmpdir(), `kitssg-${name}-${process.pid}`);127 rmSync(home, { recursive: true, force: true });128 mkdirSync(home, { recursive: true });129 return home;130};131132test("prepare runs before the scan reads a bundle", async () => {133 const home = fresh("prepare");134 const out = join(home, "dist");135 const done = await build({136 root: home,137 out,138 config: { assets: [{ path: "ui", out: "ui", hash: true, files: ["a.css"] }] },139 prepare: () => {140 mkdirSync(join(home, "ui"), { recursive: true });141 writeFileSync(join(home, "ui", "a.css"), "b{}");142 },143 collect: () => ({ routes: [] }),144 render: () => [],145 });146 expect(done.site.styles).toEqual([done.site.asset("a.css")]);147 expect(existsSync(join(out, done.site.asset("a.css")))).toBe(true);148 rmSync(home, { recursive: true, force: true });149});150151test("a second build drops a dead route's outputs, prunes their empty folders and sweeps a stale asset", async () => {152 const home = fresh("sweep");153 const out = join(home, "dist");154 mkdirSync(join(home, "ui"), { recursive: true });155 writeFileSync(join(home, "ui", "a.css"), "b{}");156 const spec = (routes: string[]): Spec => ({157 root: home,158 out,159 config: { assets: [{ path: "ui", out: "ui", hash: true }] },160 collect: () => ({ routes: routes.map((route) => ({ route })) }),161 render: (_site, route) => [{ path: `${route.route.slice(1)}index.html`, bytes: "<p>x</p>" }],162 });163 const first = await build(spec(["/deep/page/"]), { manifest: "manifest.json" });164 const was = first.site.asset("a.css");165 expect(existsSync(join(out, "deep/page/index.html"))).toBe(true);166 writeFileSync(join(home, "ui", "a.css"), "c{}");167 forget();168 const second = await build(spec([]), { manifest: "manifest.json" });169 expect(existsSync(join(out, was))).toBe(false);170 expect(existsSync(join(out, second.site.asset("a.css")))).toBe(true);171 expect(existsSync(join(out, "deep"))).toBe(false);172 expect(second.removed).toBe(2);173 rmSync(home, { recursive: true, force: true });174});175176/* GIT OFF */177178test("a spec with no git hooks still collects the repo and renders none of it", async () => {179 const home = fresh("gitoff");180 const out = join(home, "dist");181 writeFileSync(join(home, "README.md"), "# off\n");182 const done = await build({ root: home, out, config: { git: { root: "." } }, collect: () => ({ routes: [] }), render: () => [] });183 expect(done.site.routes.map((one) => one.route).sort()).toEqual(["/git/", "/git/README.md"]);184 expect(Object.keys(done.manifest)).toEqual([]);185 expect(existsSync(join(out, "git/README.md"))).toBe(false);186 expect(existsSync(join(out, "raw/README.md"))).toBe(false);187 rmSync(home, { recursive: true, force: true });188});