links.test.ts
No prose in this file.
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { linkTarget, renderedLinkProblem, type LinkKind, type LinkTargets } from "./links.ts";
const root = mkdtempSync(join(tmpdir(), "sitez-"));
mkdirSync(join(root, ".git"));
for (const file of [
"site.md",
"text/index.md",
"text/404.md",
"text/blog/hello.md",
"text/blog/draft.md",
"public/logo.svg",
"public/_headers",
"text/_ideas.md",
"src/code.ts",
"docs/diagram.png",
]) {
mkdirSync(dirname(join(root, file)), { recursive: true });
writeFileSync(join(root, file), "");
}
const targets: LinkTargets = {
root,
repo: "https://github.com/example/site",
pages: new Map([
["/", { draft: false }],
["/404/", { draft: false }],
["/blog/hello/", { draft: false }],
["/blog/draft/", { draft: true }],
]),
redirects: new Map([["/old-about/", "/about/"]]),
};
const from = join(root, "text/blog/hello.md");
const link = (destination: string, kind: LinkKind = "link", site = targets) =>
linkTarget(site, from, destination, kind);
describe("linkTarget", () => {
test("leaves full URLs and fragments alone, unchecked", () => {
for (const href of [
"https://example.com/x",
"mailto:me@example.com",
"//cdn.example",
"#top",
]) {
expect(link(href)).toEqual({ href });
}
});
test("writes a page's file as its URL, keeping the fragment and query", () => {
expect(link("../index.md")).toEqual({ href: "/" });
expect(link("hello.md#usage")).toEqual({ href: "/blog/hello/#usage" });
expect(link("hello.md?v=1")).toEqual({ href: "/blog/hello/?v=1" });
});
test("checks a site link against the pages and public/, adding the trailing slash", () => {
expect(link("/blog/hello")).toEqual({ href: "/blog/hello/" });
expect(link("/blog/hello/#x")).toEqual({ href: "/blog/hello/#x" });
expect(link("/logo.svg")).toEqual({ href: "/logo.svg" });
expect(link("/stories")).toEqual({
problem:
"/stories isn't a page or a file in public/. Link to one that is, or write a page elsewhere on this domain in full, starting https://",
});
});
test("serves a file in public/ at its path", () => {
expect(link("../../public/logo.svg", "image")).toEqual({ href: "/logo.svg" });
});
test("sends any other repo file to GitHub, and a folder to its tree", () => {
expect(link("../../src/code.ts#L3")).toEqual({
href: "https://github.com/example/site/blob/main/src/code.ts#L3",
});
expect(link("../../src")).toEqual({ href: "https://github.com/example/site/tree/main/src" });
});
test("fails on what the build would ship broken", () => {
expect(link("gone.md")).toEqual({
problem: "gone.md isn't there: no file at text/blog/gone.md.",
});
expect(link("draft.md")).toEqual({
problem:
"/blog/draft/ is a draft, which build leaves out. Publish it (remove draft: true), or remove the link.",
});
expect(link("/404")).toMatchObject({
problem: expect.stringMatching(/^404 is the page a host/),
});
expect(link("../../docs/diagram.png", "image")).toMatchObject({
problem: expect.stringMatching(/is an image outside public\//),
});
expect(link("../../src/code.ts", "link", { ...targets, repo: undefined })).toMatchObject({
problem: expect.stringMatching(/site\.md has no repo/),
});
expect(link("../../../outside.md")).toMatchObject({
problem: expect.stringMatching(/isn't there/),
});
expect(link("../_ideas.md")).toMatchObject({
problem: expect.stringMatching(/is skipped, since a name starting with _/),
});
expect(link("../../public/_headers")).toMatchObject({
problem: expect.stringMatching(/is skipped/),
});
expect(link("/_headers")).toMatchObject({
problem: expect.stringMatching(/isn't a page or a file in public\//),
});
expect(link("/old-about")).toEqual({
problem: "/old-about redirects to /about/. Link to /about/, where the page is now.",
});
});
});
describe("renderedLinkProblem", () => {
const check = (html: string) => renderedLinkProblem(targets, "/blog/hello/", html);
test("passes URLs the site serves, relative ones read from the page", () => {
expect(
check(
'<a href="/">Home</a> <a href="../hello/#top">Me</a> <img src="/logo.svg"> ' +
'<a href="https://example.com">Out</a> <a href="#x">X</a>',
),
).toBeUndefined();
});
test("doesn't expect a sitemap, which Sitez no longer writes", () => {
expect(check('<a href="/sitemap.xml">Map</a>')).toMatch(/^href="\/sitemap.xml"/);
});
test("names the first broken link, whoever wrote it", () => {
expect(check('<nav><a href="/">Home</a> <a href="/posts/">Posts</a></nav>')).toBe(
'href="/posts/": /posts/ isn\'t a page or a file in public/. Link to one that is, or write a page elsewhere on this domain in full, starting https://',
);
expect(check('<a href="/blog/draft/">Soon</a>')).toMatch(/is a draft/);
expect(check('<a href="/old-about/">Old</a>')).toMatch(/redirects to \/about\//);
});
test("reads links from tags only, not from text or code that shows one", () => {
// HTML shown as text, with only `<` and `&` escaped.
expect(check('<p>Write <code><a href="guide"></code>.</p>')).toBeUndefined();
expect(check("<script>const a = '<a href=\"guide\">';</script>")).toBeUndefined();
expect(check('<p title="a > b"><a href="/posts/">Posts</a></p>')).toMatch(/^href="\/posts\/"/);
expect(check('<script src="/app.js"></script>')).toMatch(/^src="\/app\.js"/);
});
test("asks for the URL as served, not one a host redirects", () => {
expect(check('<a href="/blog/hello">Me</a>')).toBe(
'href="/blog/hello": /blog/hello is served at /blog/hello/. Write that, so no host has to redirect it.',
);
});
});