v0.2.0 · 1e731ad

No prose in this file.

import { execFileSync } from "node:child_process";
import {
  existsSync,
  mkdirSync,
  mkdtempSync,
  readdirSync,
  readFileSync,
  rmSync,
  writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, posix, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
import { build } from "./build.js";
import { renderRoute, type Site } from "./server.js";
import { projectFiles } from "./tree.js";

const temps: string[] = [];
function temp(prefix: string): string {
  const dir = mkdtempSync(join(tmpdir(), prefix));
  temps.push(dir);
  return dir;
}
afterAll(() => {
  for (const dir of temps) rmSync(dir, { recursive: true, force: true });
});

/** Every file under `dir`, relative, with `/` separators. */
function listAll(dir: string, rel = ""): string[] {
  return readdirSync(join(dir, rel), { withFileTypes: true }).flatMap((entry) => {
    const path = rel ? `${rel}/${entry.name}` : entry.name;
    return entry.isDirectory() ? listAll(dir, path) : [path];
  });
}

function git(cwd: string, ...args: string[]): void {
  execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", ...args], {
    cwd,
    stdio: "ignore",
  });
}

describe("build: examples/single", () => {
  let out: string;
  let pages: string[];
  beforeAll(async () => {
    out = join(temp("prose-build-out-"), "site");
    await build(resolve("examples/single"), { out });
    pages = listAll(out).filter((p) => p.endsWith(".html"));
  });

  it("writes a page per folder and per file, a source index.html as index.html.html", () => {
    expect(pages.sort()).toEqual(
      [
        ".gitignore.html",
        "404.html",
        "index.html",
        "index.html.html",
        "main.js.html",
        "package.json.html",
        "pnpm-lock.yaml.html",
        "README.md.html",
        "style.css.html",
      ].sort(),
    );
  });

  it("puts the rail on every page, and a source page's blocks", () => {
    for (const page of pages)
      expect(readFileSync(join(out, page), "utf-8")).toContain('<nav class="rail"');
    const main = readFileSync(join(out, "main.js.html"), "utf-8");
    expect(main).toContain('id="state"');
    expect(main).toContain('class="code-head"');
  });

  it("writes the host's 404 page with the file tree, since it's served at any missing address", () => {
    const missing = readFileSync(join(out, "404.html"), "utf-8");
    expect(missing).toContain('<nav class="rail"');
    expect(missing).toContain("Nothing at this address");
    expect(missing).toContain('href="/main.js"');
  });

  it("leaves the live parts out and says which commit it is", () => {
    for (const page of pages) {
      const html = readFileSync(join(out, page), "utf-8");
      expect(html).not.toContain("new EventSource");
      expect(html).not.toContain("data-rendered=");
      expect(html).not.toContain('href="vscode://');
      expect(html).toMatch(/<span class="version">(?:v[\d.]+ · )?[0-9a-f]{7,}<\/span>/);
    }
  });

  it("resolves every link to a page it wrote", () => {
    for (const page of pages) {
      const html = readFileSync(join(out, page), "utf-8");
      // The page's URL: a folder's `index.html` is served at its folder.
      const url = `/${page.endsWith("/index.html") || page === "index.html" ? page.slice(0, -"index.html".length) : page.slice(0, -".html".length)}`;
      for (const [, href] of html.matchAll(/href="([^"#]+)(?:#[^"]*)?"/g)) {
        if (href!.includes(":")) continue;
        // A link above the project's root (the example's README points into this repo's docs)
        // has no page on any host; it's a link out of the project, not a missing page.
        if (posix.normalize(posix.join(posix.dirname(`${url}x`).slice(1), href!)).startsWith(".."))
          continue;
        const target =
          decodeURIComponent(posix.resolve(posix.dirname(`${url}x`), href!)) +
          (href!.endsWith("/") ? "/" : "");
        // As Pages does: the file at that path, else that path plus `.html`.
        const file = target.endsWith("/")
          ? `${target}index.html`
          : existsSync(join(out, target))
            ? target
            : `${target}.html`;
        expect(existsSync(join(out, file)), `${page} → ${href}`).toBe(true);
      }
    }
  });
});

describe("build: a repository", () => {
  let root: string;
  beforeAll(() => {
    root = temp("prose-build-repo-");
    const files: Record<string, string> = {
      ".gitignore": ".prose/\n",
      "README.md": "# Demo\n\nSee [a](src/a.ts).\n",
      "src/a.ts": "/** @prose\n * Does a.\n */\nexport const a = 1;\n",
    };
    for (const [path, text] of Object.entries(files)) {
      mkdirSync(join(root, path, ".."), { recursive: true });
      writeFileSync(join(root, path), text);
    }
    git(root, "init", "-q");
    git(root, "add", "-A");
    git(root, "commit", "-q", "-m", "first");
  });

  it("publishes the commit: no untracked file, no uncommitted edit, and a warning about them", async () => {
    writeFileSync(join(root, "scratch.ts"), "export const secret = 1;\n");
    writeFileSync(join(root, "src/a.ts"), "/** @prose\n * Edited.\n */\nexport const a = 2;\n");
    try {
      const built = await build(root);
      expect(built.out).toBe(join(root, ".prose", "site"));
      expect(existsSync(join(built.out, "scratch.ts.html"))).toBe(false);
      expect(readFileSync(join(built.out, "src/a.ts.html"), "utf-8")).toContain("Does a.");
      expect(built.warnings.join()).toContain("uncommitted changes");
      expect(built.warnings.join()).not.toContain(".gitignore");
    } finally {
      rmSync(join(root, "scratch.ts"));
      git(root, "checkout", "--", "src/a.ts");
    }
  });

  it("gives the same bytes for the same commit", async () => {
    const one = join(temp("prose-build-a-"), "site");
    const two = join(temp("prose-build-b-"), "site");
    await build(root, { out: one });
    await build(root, { out: two });
    const files = listAll(one).sort();
    expect(listAll(two).sort()).toEqual(files);
    for (const file of files) {
      expect(readFileSync(join(two, file), "utf-8")).toBe(readFileSync(join(one, file), "utf-8"));
    }
  });

  it("refuses an output folder it didn't make, or one holding the repository or tracked files", async () => {
    const other = temp("prose-build-other-");
    writeFileSync(join(other, "keep.txt"), "mine\n");
    await expect(build(root, { out: other })).rejects.toThrow("isn't a previous build");
    expect(existsSync(join(other, "keep.txt"))).toBe(true);
    await expect(build(root, { out: root })).rejects.toThrow("holds the repository");
    await expect(build(root, { out: join(root, "src") })).rejects.toThrow("tracked files");
  });

  it("clears its own previous output, and warns when the output isn't ignored", async () => {
    const built = await build(root, { out: "site" });
    writeFileSync(join(built.out, "stale.html"), "old\n");
    const again = await build(root, { out: "site" });
    expect(existsSync(join(again.out, "stale.html"))).toBe(false);
    expect(again.warnings.join()).toContain("isn't in .gitignore");
    rmSync(again.out, { recursive: true });
  });

  it("renders the same page as the server, less the live parts", async () => {
    const site: Site = { root, project: "demo", files: projectFiles(root), live: true };
    const main = (html: string) => html.slice(html.indexOf("<main>"), html.indexOf("</main>"));
    const live = await renderRoute(site, "src/a.ts");
    const built = await renderRoute({ ...site, live: false, version: "abc1234" }, "src/a.ts");
    if (live.status !== 200 || built.status !== 200) throw new Error("expected pages");
    expect(main(built.html)).toBe(main(live.html));
    expect(live.html).toContain("new EventSource");
    expect(built.html).not.toContain("new EventSource");
  });

  it("names what's ignored on a local folder page, never on a built one", async () => {
    writeFileSync(join(root, ".env"), "SECRET=1\n");
    writeFileSync(join(root, ".gitignore"), ".prose/\n.env\n");
    try {
      const site: Site = { root, project: "demo", files: projectFiles(root), live: true };
      const live = await renderRoute(site, "");
      const built = await renderRoute({ ...site, live: false }, "");
      if (live.status !== 200 || built.status !== 200) throw new Error("expected pages");
      expect(live.html).toContain('<p class="ignored">Ignored here: <code>.env</code>');
      expect(built.html).not.toContain("Ignored here");
    } finally {
      rmSync(join(root, ".env"));
      git(root, "checkout", "--", ".gitignore");
    }
  });
});