TheBird

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});