Vectrum
Sign In
JjohannSoAdd wikisHistory
146 lines · 7.0 KB · TypeScript
import "server-only";
import path from "node:path/posix";
import { defaultBranch, git, GitError, listFilesWithSizes, log, pathExists, readBlob, revParse } from "./git";
import { commitFiles, type FileChange } from "./git-write";
import { ensureWikiRepo, type RepoRecord } from "./repos";
import { pageSlug, pageTitle } from "./wiki-links";

// A repository's wiki, like GitHub's: a git repository of its own (cloned as <name>.wiki.git) whose
// Markdown files are the pages. A page's name is its file name without the extension, with dashes
// for spaces ("Getting-Started.md" is "Getting Started"), wherever it is in the tree. _Sidebar and
// _Footer are shown around every page.

export { pageSlug, pageTitle };

const PAGE_EXT = /\.(md|markdown|mdown|mkdn|mkd)$/i;

/** The special pages shown around every page, and the only page names that may start with "_". */
export const SIDEBAR = "_Sidebar";
export const FOOTER = "_Footer";

export type WikiPage = { slug: string; title: string; path: string };

export class WikiError extends Error {}

/** The wiki's branch and its latest commit; both null before the first page is written. */
export async function wikiHead(repo: RepoRecord) {
  if (!(await pathExists(repo.wikiDir))) return { branch: null, head: null };
  const branch = await defaultBranch(repo.wikiDir);
  const head = branch ? await revParse(repo.wikiDir, `refs/heads/${branch}`) : null;
  return { branch, head };
}

/** Every page at a commit: Home first, then by title. Files outside the root count too. */
export async function listPages(dir: string, commit: string): Promise<WikiPage[]> {
  const files = await listFilesWithSizes(dir, commit);
  return files
    .filter((f) => PAGE_EXT.test(f.path))
    .map((f) => {
      const slug = path.basename(f.path).replace(PAGE_EXT, "");
      return { slug, title: pageTitle(slug), path: f.path };
    })
    .sort((a, b) => Number(b.slug === "Home") - Number(a.slug === "Home") || a.title.localeCompare(b.title, "en", { numeric: true }));
}

/** The pages listed in the wiki, without _Sidebar and _Footer. */
export const contentPages = (pages: WikiPage[]) => pages.filter((p) => !p.slug.startsWith("_"));

const fold = (slug: string) => slug.toLowerCase().replace(/[\s-]+/g, "-");

/**
 * The page a link names: the exact name, or else ignoring case and spaces vs. dashes. If files in
 * different folders share a name, the one closest to the root wins.
 */
export function findPage(pages: WikiPage[], slug: string) {
  const byDepth = (list: WikiPage[]) => list.sort((a, b) => a.path.split("/").length - b.path.split("/").length)[0] ?? null;
  return byDepth(pages.filter((p) => p.slug === slug)) ?? byDepth(pages.filter((p) => fold(p.slug) === fold(slug)));
}

/** Why a page title typed by a user can't be used, or null if it can. */
export function titleProblem(title: string) {
  const t = title.trim();
  if (!t) return "Give the page a title.";
  if (t.length > 200) return "Page titles can be at most 200 characters.";
  if (/[/\\\0-\x1f\x7f]/.test(t)) return "Page titles may not contain slashes.";
  if (t.startsWith(".")) return "Page titles may not start with a dot.";
  if (t.startsWith("_") && t !== SIDEBAR && t !== FOOTER) return `Page titles may not start with “_”, except ${SIDEBAR} and ${FOOTER}.`;
  return null;
}

export async function readPage(dir: string, commit: string, file: string) {
  const blob = await readBlob(dir, commit, file);
  if (blob.tooLarge) return { text: null, size: blob.size };
  return { text: blob.content.toString("utf8"), size: blob.size };
}

/** The commits that changed the wiki, or one of its files, newest first. */
export function wikiHistory(dir: string, commit: string, opts: { path?: string; limit: number; skip: number }) {
  return log(dir, commit, opts);
}

/** How many commits changed a file. */
export async function revisionCount(dir: string, commit: string, file: string) {
  return Number((await git(dir, ["rev-list", "--count", "--end-of-options", commit, "--", file])).trim());
}

type Author = { name: string; email: string };

async function blobAt(dir: string, commit: string, file: string) {
  return (await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}:${file}`]).catch(() => "")).trim() || null;
}

/**
 * Commits a page to the wiki, creating the wiki on its first page. `originalPath` is the file of the
 * page being edited (null for a new one); a changed title renames it in place. With `baseSha`, the
 * commit the editor was opened at, the save is refused if someone changed the page since.
 * Returns the page's name.
 */
export async function savePage(
  repo: RepoRecord,
  author: Author,
  input: { originalPath: string | null; title: string; content: string; message?: string; baseSha?: string },
) {
  const problem = titleProblem(input.title);
  if (problem) throw new WikiError(problem);
  await ensureWikiRepo(repo);
  const dir = repo.wikiDir;
  const { branch, head } = await wikiHead(repo);
  const pages = head ? await listPages(dir, head) : [];
  const original = input.originalPath ? pages.find((p) => p.path === input.originalPath) : null;
  if (input.originalPath && !original) throw new WikiError("This page was deleted in the meantime. Copy your text and create it again.");
  if (original && input.baseSha && head && input.baseSha !== head && (await blobAt(dir, input.baseSha, original.path)) !== (await blobAt(dir, head, original.path))) {
    throw new WikiError(`Someone else changed ${original.title} since you started editing. Copy your text, reload and edit again.`);
  }

  const slug = pageSlug(input.title);
  const taken = findPage(pages, slug);
  if (taken && taken.path !== original?.path) throw new WikiError(`A page named ${taken.title} already exists.`);

  // A renamed page keeps its folder and extension.
  const file = !original
    ? `${slug}.md`
    : original.slug === slug
      ? original.path
      : path.join(path.dirname(original.path), slug + path.extname(original.path));
  const changes: FileChange[] = [{ path: file, content: Buffer.from(input.content.replace(/\r\n?/g, "\n")) }];
  if (original && original.path !== file) changes.unshift({ path: original.path, delete: true });

  const title = pageTitle(slug);
  const message = input.message?.trim() || (!original ? `Create ${title}` : original.path !== file ? `Rename ${original.title} to ${title}` : `Update ${title}`);
  try {
    await commitFiles(dir, { branch: branch ?? "main", parent: head, changes, message: `${message}\n`, author });
  } catch (e) {
    if (e instanceof GitError && /No changes/.test(e.message) && original) return slug;
    throw e;
  }
  return slug;
}

export async function deletePage(repo: RepoRecord, author: Author, file: string) {
  const { branch, head } = await wikiHead(repo);
  if (!branch || !head) throw new WikiError("This wiki has no pages.");
  const page = (await listPages(repo.wikiDir, head)).find((p) => p.path === file);
  if (!page) throw new WikiError("This page was deleted already.");
  await commitFiles(repo.wikiDir, { branch, parent: head, changes: [{ path: file, delete: true }], message: `Delete ${page.title}\n`, author });
  return page;
}