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;
}