v0.2.0 · 1e731ad
#!/usr/bin/env node

prose [dir]

The command: serve a repository and open it in the browser (spec §4); as prose build, write the same pages for a static host (build.ts); as prose publish, commit them to a branch a host deploys from (publish.ts). No config, and only the few flags a person would reach for: a port, not opening the browser, where a build goes, and a published site’s branch and domain.

import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { build } from "./build.js";
import { publish } from "./publish.js";
import { serve } from "./server.js";

const HELP = `Usage: prose [dir] [options]
       prose build [dir] [--out <dir>]
       prose publish [dir] [--branch <name>] [--domain <host> | --no-domain]

Open a repository in the browser as a Markdown-first document. Read-only.
\`prose build\` writes the same pages as a static site, from the last commit.
\`prose publish\` commits that site to a branch for a host to serve; it doesn't push.

Options:
  --port <n>   Port to try first (default 1234; the next free one if taken)
  --no-open    Don't open the browser
  --out <dir>  Where \`prose build\` writes (default .prose/site)
  --branch <name>  The branch \`prose publish\` commits to (default prose)
  --domain <host>  The site's domain, kept on the branch as CNAME for later publishes
  --no-domain      Drop the branch's CNAME
  -h, --help   Show this help
  -v, --version
`;

function version(): string {
  const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")) as {
    version: string;
  };
  return pkg.version;
}

function openBrowser(url: string): void {
  const command =
    process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
  const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
  try {
    spawn(command, args, { stdio: "ignore", detached: true })
      .on("error", () => {})
      .unref();
  } catch {
    // No browser to open is fine: the URL is printed.
  }
}

async function main(argv: string[]): Promise<void> {
  let dir = ".";
  let port = 1234;
  let open = true;
  let out: string | undefined;
  let branch: string | undefined;
  let domain: string | null | undefined;
  const command = argv[0] === "build" || argv[0] === "publish" ? argv[0] : null;
  const building = command === "build";
  const publishing = command === "publish";
  if (command) argv = argv.slice(1);
  for (let i = 0; i < argv.length; i++) {
    const arg = argv[i]!;
    if (arg === "-h" || arg === "--help") return void process.stdout.write(HELP);
    if (arg === "-v" || arg === "--version") return void process.stdout.write(`${version()}\n`);
    const value = () => (arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : argv[++i]);
    if (arg === "--no-open") open = false;
    else if (building && (arg === "--out" || arg.startsWith("--out="))) {
      out = value();
      if (!out) throw new Error("--out needs a folder");
    } else if (publishing && (arg === "--branch" || arg.startsWith("--branch="))) {
      branch = value();
      if (!branch) throw new Error("--branch needs a name");
    } else if (publishing && (arg === "--domain" || arg.startsWith("--domain="))) {
      domain = value();
      if (!domain) throw new Error("--domain needs a host, like docs.example.com");
    } else if (publishing && arg === "--no-domain") domain = null;
    else if (arg === "--port" || arg.startsWith("--port=")) {
      const given = value();
      port = Number(given);
      if (!Number.isInteger(port) || port < 0 || port > 65535) {
        throw new Error(`--port needs a number from 0 to 65535, not ${given ?? "nothing"}`);
      }
    } else if (arg.startsWith("-")) throw new Error(`Unknown option ${arg}\n\n${HELP}`);
    else dir = arg;
  }
  if (building) {
    const built = await build(dir, { out });
    for (const warning of built.warnings) process.stderr.write(`prose: warning: ${warning}\n`);
    process.stdout.write(`prose: ${built.pages} pages from ${built.version}\n  ${built.out}\n`);
    return;
  }
  if (publishing) {
    const done = await publish(dir, { branch, domain });
    for (const warning of done.built.warnings) process.stderr.write(`prose: warning: ${warning}\n`);
    const where = done.domain ? `, at ${done.domain}` : "";
    process.stdout.write(
      done.changed
        ? `prose: published ${done.built.version} to ${done.branch} (${done.commit.slice(0, 7)})${where}\n  git push origin ${done.branch}\n`
        : `prose: ${done.branch} already has these pages (${done.commit.slice(0, 7)})${where}\n`,
    );
    return;
  }
  const served = await serve(resolve(dir), { port });
  process.stdout.write(`prose: ${resolve(dir)}\n  ${served.url}\n`);
  if (open) openBrowser(served.url);
}

main(process.argv.slice(2)).catch((error: unknown) => {
  process.stderr.write(`prose: ${error instanceof Error ? error.message : String(error)}\n`);
  process.exit(1);
});