sitez

discover.test.ts

No prose in this file.

import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, relative } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { discover, readElements, skipped } from "./discover.ts";
import { SiteError } from "./errors.ts";

/** A site from a list of paths; every file gets the same few bytes. */
function site(...paths: string[]): string {
  const root = mkdtempSync(join(tmpdir(), "sitez-"));
  for (const path of ["site.md", ...paths]) {
    mkdirSync(dirname(join(root, path)), { recursive: true });
    writeFileSync(join(root, path), "# x\n");
  }
  return root;
}

function urls(root: string) {
  return discover(root).map((page) => `${page.url} ${relative(root, page.file)}`);
}

describe("discover", () => {
  test("gives every text file a URL from its path, and nothing in code/ one", () => {
    const root = site(
      "text/index.md",
      "text/about.md",
      "text/blog/index.md",
      "text/blog/hello.md",
      "code/tags.js",
      "code/blog/archive.ts",
    );
    expect(urls(root)).toEqual([
      "/ text/index.md",
      "/about/ text/about.md",
      "/blog/ text/blog/index.md",
      "/blog/hello/ text/blog/hello.md",
    ]);
  });

  test("skips anything under a dot or an underscore", () => {
    const root = site(
      "text/index.md",
      "text/.drafts/design.md",
      "text/.DS_Store",
      "text/_ideas.md",
      "text/_old/about.md",
      "text/blog/_next.md",
      "code/@call-out.html.ts",
      "code/notes.txt",
      "data/talks.json",
    );
    expect(urls(root)).toEqual(["/ text/index.md"]);
  });

  test("knows a skipped path by a name starting with an underscore, wherever it is", () => {
    expect(skipped("/site/public", "/site/public/_headers")).toBe(true);
    expect(skipped("/site/public", "/site/public/a/_b/c.txt")).toBe(true);
    expect(skipped("/site/public", "/site/public/a_b.txt")).toBe(false);
    expect(skipped("/site/_public", "/site/_public/a.txt")).toBe(false);
  });

  test("fails on a file in text/ that isn't Markz, pointing to public/", () => {
    const root = site("text/index.md", "text/blog/photo.png");
    const error = catchError(() => discover(root));
    expect(relative(root, error.file)).toBe("text/blog/photo.png");
    expect(error.message).toBe(
      "text/ holds only Markz (.md), so this file wouldn't reach the site. Move it to public/blog/photo.png, and link to it from there.",
    );
  });

  test("points a JSON file in text/ to data/", () => {
    const error = catchError(() => discover(site("text/talks.json")));
    expect(error.message).toBe(
      "text/ holds only Markz (.md), so this file wouldn't reach the site. Move it to data/talks.json, and read it from an element with data.",
    );
  });

  test("works with no text/", () => {
    expect(urls(site("code/index.js"))).toEqual([]);
    expect(urls(site())).toEqual([]);
  });

  test("fails on two files for one URL, naming the second", () => {
    const root = site("text/blog.md", "text/blog/index.md");
    const error = catchError(() => discover(root));
    expect(relative(root, error.file)).toBe("text/blog/index.md");
    expect(error.message).toBe(
      "/blog/ also comes from text/blog.md. A URL has one file: rename or remove one of them.",
    );
  });

  test("fails on text/site.md, but not on a site.md deeper in text/", () => {
    const error = catchError(() => discover(site("text/site.md")));
    expect(error.message).toMatch(/^site\.md is reserved/);
    expect(urls(site("text/notes/site.md"))).toEqual(["/notes/site/ text/notes/site.md"]);
  });
});

describe("readElements", () => {
  const read = (root: string) =>
    [...readElements(root)]
      .toSorted(([a], [b]) => (a < b ? -1 : 1))
      .map(([tag, file]) => `${tag} ${relative(root, file)}`);

  test("finds each element's file by its name, in any folder", () => {
    const root = site(
      "code/@call-out.html",
      "code/blog/@tag-filter.html",
      "code/blog/archive/@chart-view-2d.html",
    );
    expect(read(root)).toEqual([
      "call-out code/@call-out.html",
      "chart-view-2d code/blog/archive/@chart-view-2d.html",
      "tag-filter code/blog/@tag-filter.html",
    ]);
  });

  test("leaves modules, other files, and anything under a dot or an underscore", () => {
    const root = site(
      "code/index.html",
      "code/style.css",
      "code/format.js",
      "code/Layout.js",
      "code/notes.txt",
      "code/.cache/@call-out.js",
      "code/_old/@call-out.js",
      "code/_old/@call-out.html.js",
      "code/@call-out.html",
    );
    expect(read(root)).toEqual(["call-out code/@call-out.html"]);
  });

  test.each(["@callout.html", "@Call-Out.html", "@-x.html", "@call-out-.html"])(
    "fails on an @ file that has no tag's name: %s",
    (name) => {
      const root = site(`code/${name}`);
      const error = catchError(() => readElements(root));
      expect(relative(root, error.file)).toBe(`code/${name}`);
      expect(error.message).toMatch(/^an @ file is an element's, named for its tag/);
    },
  );

  test("fails on a name HTML keeps", () => {
    expect(catchError(() => readElements(site("code/@font-face.html"))).message).toBe(
      "<font-face> is a name HTML keeps for its own elements. Rename the element.",
    );
  });

  test.each([
    ["@call-out", "An element is one file, @call-out.html. Rename it"],
    ["@call-out.js", "An element is one file, @call-out.html. Rename it"],
    ["@call-out.html.js", "so this goes into its <template>"],
    ["@call-out.html.ts", "so this goes into its <template>"],
    ["@call-out.browser.js", "so this becomes its top-level <script>"],
    ["@call-out.live.js", "so this becomes its top-level <script>"],
    ["@call-out.css", "so this becomes its top-level <style>"],
    ["@call-out.island.js", "An element is one file, @call-out.html. Rename it"],
  ])("fails on a name that isn't @name.html: %s", (name, message) => {
    const root = site(`code/blog/${name}`);
    const error = catchError(() => readElements(root));
    expect(relative(root, error.file)).toBe(`code/blog/${name}`);
    expect(error.message).toContain(message);
  });

  test.each(["+layout.js", "+layout.ts"])("fails on %s, which is the frame now", (name) => {
    const root = site(`code/blog/${name}`);
    const error = catchError(() => readElements(root));
    expect(relative(root, error.file)).toBe(`code/blog/${name}`);
    expect(error.message).toMatch(
      /^Sitez no longer reads a layout. The page's frame is code\/index.html/,
    );
  });

  test("fails on +style.css, which index.html links now", () => {
    const root = site("code/+style.css");
    expect(catchError(() => readElements(root)).message).toMatch(
      /^Sitez no longer reads \+style.css. Rename it style.css, and link it from code\/index.html/,
    );
  });

  test("lets any other + file be a module", () => {
    expect(read(site("code/+head.js", "code/+style.scss"))).toEqual([]);
  });

  test("fails on one element in two files, wherever they sit, naming the second", () => {
    const root = site("code/@call-out.html", "code/blog/@call-out.html");
    const error = catchError(() => readElements(root));
    expect(relative(root, error.file)).toBe("code/blog/@call-out.html");
    expect(error.message).toBe(
      "<call-out> is also in code/@call-out.html. Elements are global, so a name has one file wherever it sits. Remove one, or rename the element.",
    );
  });
});

function catchError(fn: () => unknown): SiteError {
  try {
    fn();
  } catch (error) {
    if (error instanceof SiteError) return error;
    throw error;
  }
  throw new Error("expected a SiteError");
}