v0.2.0 · 1e731ad

prose publish

prose build, committed to a branch a host deploys from: prose by default, an orphan branch holding only the site. It never checks the branch out or touches the working tree, the index or HEAD: the pages go through a temporary index into git write-tree, git commit-tree puts the tree on the branch’s last commit, and git update-ref moves the branch, only if it’s still where it was read. It doesn’t push; git push origin prose is a separate step, so nothing leaves the machine unasked. A build is byte-identical for the same commit, so a publish commit holds only the pages that changed, and a publish that changes nothing makes no commit.

import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { build, type Built } from "./build.js";

export interface PublishOptions {
  /** The branch to commit the site to; `prose` by default. */
  branch?: string;

#The site’s own domain, as a CNAME file at the branch’s root: the one file GitHub Pages reads it from, so there’s no config file to keep in step. Given once, it’s carried forward: left out, a publish keeps the branch’s CNAME; a new domain replaces it; null drops it.

  domain?: string | null;
}

export interface Published {
  branch: string;
  /** The branch's commit after publishing; the previous one when nothing changed. */
  commit: string;
  changed: boolean;
  domain: string | null;
  built: Built;
}

function git(cwd: string, args: string[], env?: NodeJS.ProcessEnv): string {
  return execFileSync("git", args, {
    cwd,
    encoding: "utf-8",
    env: env ? { ...process.env, ...env } : undefined,
    stdio: ["ignore", "pipe", "pipe"],
  }).trim();
}

function tryGit(cwd: string, args: string[]): string | null {
  try {
    return git(cwd, args);
  } catch {
    return null;
  }
}

/** A host name: dot-separated labels of letters, digits and inner hyphens. */
const HOST = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9]$/i;

export async function publish(dir: string, options: PublishOptions = {}): Promise<Published> {
  const root = resolve(dir);
  const branch = options.branch ?? "prose";
  if (tryGit(root, ["check-ref-format", "--branch", branch]) === null) {
    throw new Error(`${branch} isn't a valid branch name`);
  }
  if (tryGit(root, ["symbolic-ref", "--short", "-q", "HEAD"]) === branch) {
    throw new Error(`${branch} is checked out; publish to it from another branch`);
  }
  if (options.domain != null && !HOST.test(options.domain)) {
    throw new Error(`${options.domain} isn't a domain name, like docs.example.com`);
  }

  const ref = `refs/heads/${branch}`;
  const parent = tryGit(root, ["rev-parse", "--verify", "-q", `${ref}^{commit}`]);
  const domain =
    options.domain !== undefined
      ? options.domain
      : parent
        ? (tryGit(root, ["show", `${parent}:CNAME`]) ?? null)
        : null;

  const temp = mkdtempSync(join(tmpdir(), "prose-publish-"));
  try {
    const built = await build(root, { out: join(temp, "site") });
    // The build's marker only guards a local folder; Jekyll would drop every `.` and `_` path.
    rmSync(join(built.out, ".prose-build"));
    writeFileSync(join(built.out, ".nojekyll"), "");
    if (domain) writeFileSync(join(built.out, "CNAME"), `${domain}\n`);

    const gitDir = git(root, ["rev-parse", "--absolute-git-dir"]);
    const env = { GIT_INDEX_FILE: join(temp, "index") };
    // `-f`: a page named like an ignored file (`.env.html` under a global ignore) is still a page.
    git(built.out, [`--git-dir=${gitDir}`, `--work-tree=${built.out}`, "add", "-A", "-f"], env);
    const tree = git(built.out, [`--git-dir=${gitDir}`, "write-tree"], env);

    if (parent && git(root, ["rev-parse", `${parent}^{tree}`]) === tree) {
      return { branch, commit: parent, changed: false, domain, built };
    }
    const source = git(root, ["rev-parse", "HEAD"]);
    const message = `prose ${built.version}\n\nBuilt from ${source}.\n`;
    const commit = git(root, [
      "commit-tree",
      tree,
      ...(parent ? ["-p", parent] : []),
      "-m",
      message,
    ]);
    // The old value makes the move conditional: if the branch moved meanwhile, this fails.
    git(root, ["update-ref", "-m", "prose publish", ref, commit, parent ?? ""]);
    return { branch, commit, changed: true, domain, built };
  } finally {
    rmSync(temp, { recursive: true, force: true });
  }
}