sitez

Redirects

A page that moved lists the URLs it used to have under redirects, and Sitez writes a small page at each one that sends the browser on (docs/design.md#metadata). A page's URL is still its file's path (rule 2). A redirect only points old addresses at it, so a site can move its files without breaking links from elsewhere.

The page at an old URL is code/index.html rendered for the page it reaches (site.ts). A redirect is checked as a link is. It can't be a URL a page or another redirect has, and a link to it fails, asking for the page it reaches. So nothing on the site goes through one.

An old /x.html can't redirect while a page has /x/. Hosts serve x.html at /x too, so the redirect page would stand in front of that page's /x. When the page is the one listing it, the redirect isn't needed on Cloudflare, which sends /x.html to /x/ by itself. GitHub Pages doesn't, and there the old /x.html is lost (docs/sitez.md#redirects).

import { join, relative } from "node:path";
import { outputFile } from "./discover.ts";
import { SiteError } from "./errors.ts";
import type { PageData } from "./metadata.ts";

The old URL a path names, as the site would serve it, or undefined when no static host could serve a redirect there. A page's URL ends in / (/about is read as /about/, as a link is), and an old page may end in .html. Any other extension is a file, which a page can't stand in for.

export function redirectUrl(path: string): string | undefined {
  if (!path.startsWith("/") || path.startsWith("//") || /[?#\s]/.test(path)) return undefined;
  const last = path.slice(path.lastIndexOf("/") + 1);
  if (last === "" || last.endsWith(".html")) return path;
  return last.includes(".") ? undefined : `${path}/`;
}

/** The file a redirect is written to: `old/index.html` for `/old/`, `old.html` for `/old.html`. */
export function redirectFile(url: string): string {
  return url.endsWith(".html") ? join(...url.split("/").filter(Boolean)) : outputFile(url);
}

Every redirect the site's pages list, from each old URL to the page's URL. all is every page, drafts included, so no redirect can take a draft's URL. listing is the pages this run writes, whose redirects it writes. Each failure names the page that lists the redirect.

export function readRedirects(
  root: string,
  all: { file: string; data: PageData }[],
  listing: { file: string; data: PageData }[],
): Map<string, string> {
  const pages = new Map(
    all.map(({ file, data }) => [outputFile(data.url), { file, url: data.url }]),
  );
  const redirects = new Map<string, string>();
  const listedBy = new Map<string, string>();
  for (const { file, data } of listing) {
    const value = data.redirects;
    const paths = value === undefined ? [] : Array.isArray(value) ? value : [value];
    for (const path of paths as string[]) {
      const url = redirectUrl(path);
      const fail = (problem: string) => new SiteError(file, `redirects lists ${path}: ${problem}`);
      if (url === undefined) {
        throw fail(
          "write each old URL as a path on this site, such as /old-name/ or /old-name.html.",
        );
      }
      const page = pages.get(redirectFile(url));
      if (page?.url === data.url) {
        throw fail(
          "that's this page's own URL. List the URLs that used to reach this page, and it gets a redirect from each.",
        );
      }
      if (page) throw fail(`that's the URL of ${relative(root, page.file)}.`);
      const shadowed = url.endsWith(".html")
        ? pages.get(outputFile(`${url.slice(0, -5)}/`))
        : undefined;
      if (shadowed?.url === data.url) {
        throw fail(
          `a host serves ${url.slice(1)} at ${url.slice(0, -5)} too, so the redirect page would stand in front of this page. Remove it.`,
        );
      }
      if (shadowed) {
        throw fail(
          `a host serves ${url.slice(1)} at ${url.slice(0, -5)} too, which reaches ${relative(root, shadowed.file)}.`,
        );
      }
      const other = listedBy.get(redirectFile(url));
      if (other) throw fail(`${relative(root, other)} lists it too. Keep one.`);
      listedBy.set(redirectFile(url), file);
      redirects.set(url, data.url);
    }
  }
  return redirects;
}