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