Vectrum
Sign In

Add wikis

- Markdown pages in the browser or pushed to a `<repo>.wiki.git` repository, with [[links]], _Sidebar/_Footer, page history and revision diffs
- Toggle under repository Settings > Features; readers see the tab once a page exists
- New MCP tools: list_wiki_pages, read_wiki_page, write_wiki_page
- Extract shared FileDiffView/diffBudgets from the commit page for reuse on wiki diffs
JjohannSocommitted 1 parent 488246ccommit592eec5

Showing 31 changed files with 1738 additions and 154 deletions.

+24−2README.mdView File
@@ -13,6 +13,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or
1313 - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens
1414 - **Container registry**: every repository is also a Docker/OCI registry, like ghcr.io. `docker push <host>/<owner>/<repo>` (or `<host>/<owner>/<repo>/<name>` for more images per repository) with the same visibility, collaborators and API tokens as the code. Multi-platform images, cross-repository blob mounts, chunked uploads and the referrers API (signatures, SBOMs) are supported. Images are listed under the repository's *Packages* tab, where writers can delete versions
1515 - **Releases**: publish a version from a tag, with markdown notes and files to download (binaries, installers, checksums) of any size, plus the source code as zip and tar.gz. Drafts and pre-releases; a release made for a new tag creates it and starts the workflows a push of it would. Files can be uploaded from scripts and CI with `curl`, and `/releases/latest/download/<file>` always links the newest version
16+ - **Wikis**, like GitHub's: Markdown pages written in the browser (with preview, page history, old revisions and their diffs) or cloned and pushed as a git repository of their own, `<repo>.wiki.git`. `[[Page Name]]` links, a custom `_Sidebar` and `_Footer`, and images and other files from the wiki's repository. Collaborators edit, readers read; the owner can turn the wiki off under *Settings*
1617 - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored
1718 - **Web editor**: CodeMirror with syntax highlighting and markdown preview. Create, edit, rename, upload and delete files, committing to the current branch or a new one
1819 - **Code browsing**: file tree, highlighting (Shiki), rendered READMEs (sanitized HTML), raw files, image previews
@@ -95,6 +96,24 @@ talks plain HTTP to `localhost`; anywhere else, serve Vectrum over HTTPS (or add
9596 the host to Docker's `insecure-registries`). A reverse proxy in front of
9697 Vectrum must pass large request bodies through (e.g. nginx `client_max_body_size 0;`).
9798
99+ ### Writing the wiki
100+
101+ Start the first page under a repository's **Wiki** tab, or clone the wiki and
102+ push Markdown files to it (the first push creates it):
103+
104+ ```bash
105+ git clone ssh://git@localhost:2222/<you>/<repo>.wiki.git
106+ cd <repo>.wiki && echo "# Hello" > Home.md
107+ git add Home.md && git commit -m "Add Home" && git push
108+ ```
109+
110+ A page is named after its file: `Getting-Started.md` is *Getting Started* at
111+ `/<you>/<repo>/wiki/Getting-Started`, in whichever folder it is. `Home` is the
112+ wiki's front page, `_Sidebar.md` and `_Footer.md` are shown around every page,
113+ and `[[Getting Started]]` or `[[the guide|Getting Started]]` link to a page.
114+ Pushes to a wiki don't start workflows. Repository names can't end in `.wiki`,
115+ since that's where wikis are cloned from.
116+
98117 ### Publishing releases
99118
100119 Draft one under a repository's **Releases** tab, or upload files from a script
@@ -162,10 +181,11 @@ The tools, which check the same permissions as the web UI:
162181 | Account and repositories | `get_me`, `list_repositories`, `search_repositories`, `get_repository`, `create_repository`, `update_repository` |
163182 | Code | `list_branches`, `create_branch`, `list_files`, `read_file`, `commit_files` (several files in one commit, optionally on a new branch, with an optimistic `expected_head` check), `list_commits`, `get_commit` (with its diff) |
164183 | Issues | `list_issues`, `get_issue`, `create_issue`, `update_issue`, `comment_on_issue`, `list_labels`, `create_label` |
165- | Releases | `list_releases`, `get_release`, `create_release` (creates a missing tag at a branch or commit), `update_release` (also publishes drafts), `delete_release` |
184+ | Releases | `list_releases`, `get_release`, `create_release` (creates a missing tag at a branch or commit), `update_release` (also publishes drafts), `delete_release`, `upload_release_asset`, `create_release_upload_url`, `delete_release_asset` |
185+ | Wiki | `list_wiki_pages`, `read_wiki_page`, `write_wiki_page` (creates, replaces or renames a page) |
166186 | Actions | `list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, `run_workflow`, `cancel_workflow_run`, `rerun_workflow_run` |
167187
168- Read-only connections only see the reading tools. Commits made by an agent are authored as you and start workflows like a push would. Nothing deletes repositories or issues; `delete_release` removes a release and its files but keeps the tag. Release files are uploaded with an API token (see [Publishing releases](#publishing-releases)).
188+ Read-only connections only see the reading tools. Commits made by an agent are authored as you and start workflows like a push would. Nothing deletes repositories or issues; `delete_release` removes a release and its files but keeps the tag. Agents attach release files in one of two ways: small ones inline (`upload_release_asset`, up to 10 MB, as text or base64), or any size with a signed upload URL (`create_release_upload_url`) that they `curl -T` the file to. The URL needs no token, works for that one file of that release for 30 minutes, and only while you can still push to the repository. It's signed with a key generated into `data/release-upload-key`.
169189
170190 ## Configuration
171191
@@ -193,6 +213,7 @@ data/
193213 ssh_host_ed25519_key generated on first start
194214 hooks/ server-side git hooks for every push, rewritten on start
195215 repos/<owner>/<repo>.git bare git repositories
216+ wikis/<repo>.git each repository's wiki, named by its record id
196217 registry.db container image manifests, tags and blob records (SQLite)
197218 registry/blobs/sha256/… container image layers, shared by all repositories
198219 registry/uploads/ image uploads in progress
@@ -224,6 +245,7 @@ kept so pushes in progress don't lose their layers).
224245 - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories
225246 - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`)
226247 - `lib/registry.ts`, `lib/registry-http.ts`: container registry storage and the OCI distribution API, served under `/v2`
248+ - `lib/wiki.ts`, `lib/wiki-links.ts`: wiki pages in the wiki's git repository, and page names and `[[links]]`; UI in `app/[owner]/[repo]/wiki/` and `components/wiki/`
227249 - `lib/releases.ts`, `lib/release-http.ts`: releases and their files, and the download, upload and source archive routes
228250 - `lib/workflows.ts`, `lib/ci.ts`, `lib/runs.ts`, `lib/runners.ts`: workflow parsing, starting runs on push, the job queue and runner registration; `app/api/runner/*` is the runner API and `public/vectrum-runner.mjs` the runner
229251 - `app/actions/*`: server actions, each re-checking the session and permissions
+6−98app/[owner]/[repo]/commit/[sha]/page.tsxView File
@@ -1,103 +1,12 @@
11 import { notFound } from "next/navigation";
22 import { ShaChip } from "@/components/file-table";
3- import { CodeIcon, FileIcon } from "@/components/icons";
4- import { Avatar, Box, ButtonLink, Label, RefChip } from "@/components/ui";
5- import { diffCommit, getCommit, type FileDiff } from "@/lib/git";
3+ import { diffBudgets, FileDiffView } from "@/components/file-diff";
4+ import { CodeIcon } from "@/components/icons";
5+ import { Avatar, Box, ButtonLink, RefChip } from "@/components/ui";
6+ import { diffCommit, getCommit } from "@/lib/git";
67 import { encodePath, shortSha, timeAgo } from "@/lib/format";
78 import { getRepo } from "@/lib/repo";
89
9- const MAX_RENDERED_LINES = 3000;
10-
11- const lineStyles = {
12- add: { row: "bg-[var(--diff-add)]", num: "bg-[var(--diff-add-num)]", sign: "+" },
13- del: { row: "bg-[var(--diff-del)]", num: "bg-[var(--diff-del-num)]", sign: "-" },
14- ctx: { row: "", num: "", sign: " " },
15- };
16-
17- /** The change as five lamps: green for the added share, red for the deleted, dark when nothing changed. */
18- function DiffStat({ additions, deletions }: { additions: number; deletions: number }) {
19- const total = additions + deletions;
20- const greens = total ? Math.round((additions / total) * 5) : 0;
21- return (
22- <span className="flex shrink-0 items-center gap-2 type-readout">
23- <span className="text-success-emphasis">+{additions}</span>
24- <span className="text-danger">−{deletions}</span>
25- <span aria-hidden className="flex gap-[2px]">
26- {Array.from({ length: 5 }, (_, i) => (
27- <span
28- key={i}
29- className={`size-2 rounded-[2.5px] ${i < greens ? "bg-success-emphasis" : i < (total ? 5 : 0) ? "bg-danger" : "material-well"}`}
30- />
31- ))}
32- </span>
33- </span>
34- );
35- }
36-
37- function FileDiffView({ file, base, sha, budget }: { file: FileDiff; base: string; sha: string; budget: number }) {
38- const name = file.status === "renamed" ? `${file.oldPath} → ${file.newPath}` : file.newPath;
39- const truncated = file.lines.length > budget;
40- return (
41- <section id={encodeURIComponent(file.newPath)} aria-label={name} className="mb-4 scroll-mt-20">
42- <Box className="overflow-clip">
43- <div className="rule-b sticky top-14 z-10 flex min-h-11 items-center gap-3 bg-[var(--mu-frost-strong)] py-1.5 pr-2 pl-4 backdrop-blur-md">
44- <DiffStat additions={file.additions} deletions={file.deletions} />
45- <span className="min-w-0 truncate font-mono text-[11.5px] font-medium text-ink">{name}</span>
46- {file.status !== "modified" && (
47- <Label tone={file.status === "added" ? "success" : file.status === "deleted" ? "danger" : "default"}>{file.status}</Label>
48- )}
49- {file.status !== "deleted" && (
50- <ButtonLink
51- href={`${base}/blob/${sha}/${encodePath(file.newPath)}`}
52- variant="invisible"
53- icon={<FileIcon width={14} height={14} />}
54- className="ml-auto shrink-0"
55- >
56- View File
57- </ButtonLink>
58- )}
59- </div>
60- {file.binary ? (
61- <p className="p-6 text-center type-ui text-ink3">Binary file not shown.</p>
62- ) : file.lines.length === 0 ? (
63- <p className="p-6 text-center type-ui text-ink3">File mode or name changed only.</p>
64- ) : truncated ? (
65- <p className="p-6 text-center type-ui text-ink3">Large diffs are not rendered by default.</p>
66- ) : (
67- <div className="overflow-x-auto">
68- <table className="w-full border-collapse font-mono text-[11.5px] leading-[22px] text-ink">
69- <tbody>
70- {file.lines.map((line, i) =>
71- line.kind === "hunk" ? (
72- <tr key={i} className="bg-[var(--diff-hunk)] text-ink3">
73- <td colSpan={3} className="px-4 py-1.5">
74- {line.text}
75- </td>
76- </tr>
77- ) : (
78- <tr key={i} className={lineStyles[line.kind].row}>
79- <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}>
80- {line.oldNo}
81- </td>
82- <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}>
83- {line.newNo}
84- </td>
85- <td className="px-2 whitespace-pre">
86- <span className="select-none text-ink3">{lineStyles[line.kind].sign} </span>
87- {line.text}
88- </td>
89- </tr>
90- ),
91- )}
92- </tbody>
93- </table>
94- </div>
95- )}
96- </Box>
97- </section>
98- );
99- }
100-
10110 export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/commit/[sha]">) {
10211 const { owner, repo: repoParam, sha } = await params;
10312 const repo = await getRepo(owner, repoParam);
@@ -107,8 +16,7 @@ export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/
10716 const additions = files.reduce((n, f) => n + f.additions, 0);
10817 const deletions = files.reduce((n, f) => n + f.deletions, 0);
10918
110- // Each file may render only the diff lines left over from the files above it.
111- const budgets = files.map((_, i) => MAX_RENDERED_LINES - files.slice(0, i).reduce((n, f) => n + f.lines.length, 0));
19+ const budgets = diffBudgets(files);
11220
11321 return (
11422 <div>
@@ -158,7 +66,7 @@ export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/
15866 )}
15967
16068 {files.map((file, i) => (
161- <FileDiffView key={file.newPath} file={file} base={repo.base} sha={commit.sha} budget={budgets[i]} />
69+ <FileDiffView key={file.newPath} file={file} viewHref={`${repo.base}/blob/${commit.sha}/${encodePath(file.newPath)}`} budget={budgets[i]} />
16270 ))}
16371 </div>
16472 );
+4−2app/[owner]/[repo]/layout.tsxView File
@@ -3,8 +3,8 @@ import { RepoTabs } from "@/components/repo-tabs";
33 import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui";
44 import { openIssueCount } from "@/lib/issues";
55 import { countImages } from "@/lib/registry";
6- import { countReleases } from "@/lib/releases";
76 import { getRepo } from "@/lib/repo";
7+ import { wikiHead } from "@/lib/wiki";
88
99 export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) {
1010 const { owner, repo } = await params;
@@ -18,6 +18,8 @@ export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]"
1818 export default async function RepoLayout({ params, children }: LayoutProps<"/[owner]/[repo]">) {
1919 const { owner, repo: repoParam } = await params;
2020 const repo = await getRepo(owner, repoParam);
21+ // Like GitHub, readers only see the tab once the wiki has a page.
22+ const showWiki = repo.wikiEnabled && (repo.canWrite || (await wikiHead(repo)).head !== null);
2123
2224 return (
2325 <>
@@ -39,7 +41,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow
3941 <VisibilityLabel visibility={repo.visibility} />
4042 {repo.access === "write" && <Label tone="accent">Collaborator</Label>}
4143 </div>
42- <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} releases={await countReleases(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} />
44+ <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} images={countImages(repo.id)} showWiki={showWiki} showSettings={repo.isAdmin} />
4345 </div>
4446 </div>
4547 <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div>
+32−1app/[owner]/[repo]/settings/forms.tsxView File
@@ -1,8 +1,9 @@
11 "use client";
22
33 import { useActionState, useEffect, useState } from "react";
4+ import { Checkbox } from "@unlocalhosted/metalui";
45 import type { FormState } from "@/app/actions/types";
5- import { AlertIcon, GlobeIcon, LockIcon, PencilIcon, TrashIcon, UndoIcon, UploadIcon } from "@/components/icons";
6+ import { AlertIcon, BookIcon, GlobeIcon, LockIcon, PencilIcon, TrashIcon, UndoIcon, UploadIcon } from "@/components/icons";
67 import { ChoiceCards, TextField } from "@/components/settings-fields";
78 import { PlateBody, PlateFooter } from "@/components/settings-plate";
89 import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui";
@@ -133,6 +134,36 @@ export function GeneralForm({
133134 );
134135 }
135136
137+ /** The repository's optional parts. */
138+ export function FeaturesForm({ action, wiki }: { action: Action; wiki: boolean }) {
139+ const [state, formAction, pending] = useActionState(action, undefined);
140+ return (
141+ <form action={formAction}>
142+ <PlateBody className="max-w-xl">
143+ <label className="flex cursor-pointer items-start gap-3">
144+ <Checkbox name="wiki" defaultChecked={wiki} className="mt-px" />
145+ <span className="flex flex-col gap-0.5">
146+ <span className="flex items-center gap-2 type-title text-ink">
147+ <BookIcon width={14} height={14} className="text-ink2" /> Wiki
148+ </span>
149+ <span className="type-body text-ink2">
150+ Pages of documentation for the repository, written in the browser or pushed to its own git repository. Turning it off hides the
151+ pages but keeps them.
152+ </span>
153+ </span>
154+ </label>
155+ {state?.error && <Alert>{state.error}</Alert>}
156+ {state?.success && <Alert kind="success">{state.success}</Alert>}
157+ </PlateBody>
158+ <PlateFooter>
159+ <Button variant="primary" disabled={pending}>
160+ {pending ? "Saving…" : "Save Changes"}
161+ </Button>
162+ </PlateFooter>
163+ </form>
164+ );
165+ }
166+
136167 /** Deleting the repository: one red cap opens the type-to-confirm form, whose red cap does it. */
137168 export function DangerZone({ action, fullName }: { action: Action; fullName: string }) {
138169 const [state, formAction, pending] = useActionState(action, undefined);
+12−2app/[owner]/[repo]/settings/page.tsxView File
@@ -1,9 +1,16 @@
11 import type { Metadata } from "next";
2- import { deleteRepoAction, removeRepoImageAction, renameRepoAction, updateRepoAction, updateRepoImageAction } from "@/app/actions/repo";
2+ import {
3+ deleteRepoAction,
4+ removeRepoImageAction,
5+ renameRepoAction,
6+ updateFeaturesAction,
7+ updateRepoAction,
8+ updateRepoImageAction,
9+ } from "@/app/actions/repo";
310 import { PlateSection } from "@/components/settings-plate";
411 import { PageTitle } from "@/components/ui";
512 import { getRepo } from "@/lib/repo";
6- import { DangerZone, GeneralForm, ImageForm, RenameForm } from "./forms";
13+ import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm } from "./forms";
714
815 export const metadata: Metadata = { title: "Settings" };
916
@@ -31,6 +38,9 @@ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[
3138 visibility={repo.visibility}
3239 />
3340 </PlateSection>
41+ <PlateSection title="Features">
42+ <FeaturesForm action={updateFeaturesAction.bind(null, repo.owner, repo.name)} wiki={repo.wikiEnabled} />
43+ </PlateSection>
3444 <PlateSection title="Danger zone">
3545 <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} />
3646 </PlateSection>
+37−0app/[owner]/[repo]/wiki/%5Fraw/[...path]/route.tsaddedView File
@@ -0,0 +1,37 @@
1+ import { getViewer } from "@/lib/auth";
2+ import { objectType, readBlob } from "@/lib/git";
3+ import { contentTypeFor } from "@/lib/mime";
4+ import { findRepo, repoAccess } from "@/lib/repos";
5+ import { wikiHead } from "@/lib/wiki";
6+
7+ /** A file in the wiki's repository (images in pages, mostly), served like raw repository files. */
8+ export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/wiki/_raw/[...path]">) {
9+ const { owner, repo: repoParam, path: encoded } = await ctx.params;
10+ // Catch-all segments arrive still percent-encoded.
11+ const segments = encoded.map((s) => {
12+ try {
13+ return decodeURIComponent(s);
14+ } catch {
15+ return s;
16+ }
17+ });
18+ const repo = await findRepo(owner, repoParam);
19+ const viewer = await getViewer();
20+ if (!repo || !repo.wikiEnabled || !(await repoAccess(repo, viewer?.id))) return new Response("Not found", { status: 404 });
21+
22+ const { head } = await wikiHead(repo);
23+ const file = segments.join("/");
24+ if (!head || (await objectType(repo.wikiDir, head, file)) !== "blob") return new Response("Not found", { status: 404 });
25+ const blob = await readBlob(repo.wikiDir, head, file);
26+ if (blob.tooLarge) return new Response("File too large", { status: 413 });
27+
28+ return new Response(new Uint8Array(blob.content), {
29+ headers: {
30+ "Content-Type": contentTypeFor(segments.at(-1)!, blob.binary),
31+ "X-Content-Type-Options": "nosniff",
32+ "Cache-Control": repo.visibility === "private" ? "private, no-store" : "no-cache",
33+ // Sandbox the response so things like scripted SVGs can't run in our origin.
34+ "Content-Security-Policy": "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox",
35+ },
36+ });
37+ }
+482−0app/[owner]/[repo]/wiki/[[...segments]]/page.tsxaddedView File
@@ -0,0 +1,482 @@
1+ import Link from "next/link";
2+ import type { Metadata } from "next";
3+ import type { ReactNode } from "react";
4+ import { forbidden, notFound, redirect } from "next/navigation";
5+ import { deleteWikiPageAction, saveWikiPageAction } from "@/app/actions/wiki";
6+ import { diffBudgets, FileDiffView } from "@/components/file-diff";
7+ import { ShaChip } from "@/components/file-table";
8+ import { BookIcon, ChevronLeftIcon, ChevronRightIcon, CommitIcon, FileIcon, HistoryIcon, PencilIcon, PlusIcon } from "@/components/icons";
9+ import { Markdown } from "@/components/markdown";
10+ import { liftRowClass } from "@/components/repo-rows";
11+ import { Alert, Avatar, Blankslate, Box, ButtonLink, PageTitle } from "@/components/ui";
12+ import { AddSpecialLink, WikiColumns, WikiSidebar } from "@/components/wiki/wiki-shell";
13+ import { WikiEditor } from "@/components/wiki/wiki-editor";
14+ import { encodePath, formatDate, shortSha, timeAgo } from "@/lib/format";
15+ import { diffCommit, getCommit, lastCommit, objectType, revParse, type Commit } from "@/lib/git";
16+ import { getRepo, wikiCloneUrls, type Repo } from "@/lib/repo";
17+ import {
18+ contentPages,
19+ findPage,
20+ FOOTER,
21+ listPages,
22+ readPage,
23+ revisionCount,
24+ SIDEBAR,
25+ wikiHead,
26+ wikiHistory,
27+ type WikiPage,
28+ } from "@/lib/wiki";
29+ import { pageHref, pageSlug, pageTitle } from "@/lib/wiki-links";
30+
31+ // GitHub's wiki URLs: /wiki is Home, /wiki/<Page> a page, /wiki/<Page>/_edit, /_history and
32+ // /<sha> (an old revision), and /wiki/_new, /_pages, /_history and /_compare/<sha> for the
33+ // whole wiki. Other paths are files in the wiki's repository, served from /wiki/_raw/<path>.
34+
35+ const HISTORY_PAGE_SIZE = 30;
36+ const SHA = /^[0-9a-f]{7,40}$/i;
37+
38+ type Wiki = {
39+ repo: Repo;
40+ /** The wiki's base URL. */
41+ wiki: string;
42+ head: string | null;
43+ /** Every page file, _Sidebar and _Footer included. */
44+ all: WikiPage[];
45+ /** The pages to list. */
46+ pages: WikiPage[];
47+ };
48+
49+ async function loadWiki(owner: string, repoParam: string): Promise<Wiki> {
50+ const repo = await getRepo(owner, repoParam);
51+ if (!repo.wikiEnabled) notFound();
52+ const { head } = await wikiHead(repo);
53+ const all = head ? await listPages(repo.wikiDir, head) : [];
54+ return { repo, wiki: `${repo.base}/wiki`, head, all, pages: contentPages(all) };
55+ }
56+
57+ async function specialText(w: Wiki, slug: string) {
58+ const page = w.head && w.all.find((p) => p.slug === slug && !p.path.includes("/"));
59+ return page ? ((await readPage(w.repo.wikiDir, w.head!, page.path)).text ?? "") : null;
60+ }
61+
62+ async function Columns({ w, current, children }: { w: Wiki; current?: string; children: ReactNode }) {
63+ const [sidebar, cloneUrls] = await Promise.all([specialText(w, SIDEBAR), wikiCloneUrls(w.repo)]);
64+ return (
65+ <WikiColumns
66+ sidebar={<WikiSidebar wiki={w.wiki} pages={w.pages} current={current} sidebar={sidebar} canWrite={w.repo.canWrite} cloneUrls={cloneUrls} />}
67+ >
68+ {children}
69+ </WikiColumns>
70+ );
71+ }
72+
73+ const textLinkClass = "text-ink underline decoration-rule underline-offset-2 hover:decoration-current";
74+
75+ // ---------------------------------------------------------------------------
76+ // Views
77+
78+ function EmptyWiki({ w }: { w: Wiki }) {
79+ return (
80+ <Box>
81+ <Blankslate
82+ icon={<BookIcon />}
83+ title={`Welcome to the ${w.repo.name} wiki!`}
84+ action={
85+ w.repo.canWrite && (
86+ <ButtonLink href={`${w.wiki}/_new?title=Home`} variant="primary" icon={<PlusIcon />}>
87+ Create the First Page
88+ </ButtonLink>
89+ )
90+ }
91+ >
92+ {w.repo.canWrite
93+ ? "Wikis are the place for the documentation of your project: how to use it, how it’s built, where it’s going."
94+ : "There’s nothing in this wiki yet."}
95+ </Blankslate>
96+ </Box>
97+ );
98+ }
99+
100+ function PagesList({ w }: { w: Wiki }) {
101+ return (
102+ <Box className="p-1.5">
103+ <ul>
104+ {w.pages.map((p) => (
105+ <li key={p.path} className={`${liftRowClass} flex items-center gap-3 px-3 py-2.5`}>
106+ <FileIcon width={14} height={14} className="shrink-0 text-ink3" />
107+ <Link href={pageHref(w.wiki, p.slug)} className="min-w-0 truncate type-title text-ink decoration-ink3 underline-offset-4 hover:underline">
108+ {p.title}
109+ </Link>
110+ {p.path.includes("/") && <span className="ml-auto truncate font-mono text-[11px] text-ink3">{p.path}</span>}
111+ </li>
112+ ))}
113+ </ul>
114+ </Box>
115+ );
116+ }
117+
118+ function PageActions({ w, page }: { w: Wiki; page?: WikiPage }) {
119+ if (!w.repo.canWrite) return null;
120+ return (
121+ <div className="flex gap-2">
122+ {page && (
123+ <ButtonLink href={`${w.wiki}/${encodeURIComponent(page.slug)}/_edit`} icon={<PencilIcon />}>
124+ Edit
125+ </ButtonLink>
126+ )}
127+ <ButtonLink href={`${w.wiki}/_new`} variant="primary" icon={<PlusIcon />}>
128+ New Page
129+ </ButtonLink>
130+ </div>
131+ );
132+ }
133+
134+ async function PageView({ w, page }: { w: Wiki; page: WikiPage }) {
135+ const dir = w.repo.wikiDir;
136+ const [{ text, size }, edit, revisions, footer] = await Promise.all([
137+ readPage(dir, w.head!, page.path),
138+ lastCommit(dir, w.head!, page.path),
139+ revisionCount(dir, w.head!, page.path),
140+ page.slug.startsWith("_") ? null : specialText(w, FOOTER),
141+ ]);
142+ const historyHref = `${w.wiki}/${encodeURIComponent(page.slug)}/_history`;
143+
144+ return (
145+ <Columns w={w} current={page.slug}>
146+ <PageTitle
147+ title={page.title}
148+ description={
149+ edit && (
150+ <span className="flex flex-wrap items-center gap-1 type-ui">
151+ <span className="font-medium text-ink">{edit.authorName}</span> edited this page{" "}
152+ <time dateTime={new Date(edit.date).toISOString()}>{timeAgo(edit.date)}</time> ·
153+ <Link href={historyHref} className={textLinkClass}>
154+ {revisions} revision{revisions === 1 ? "" : "s"}
155+ </Link>
156+ </span>
157+ )
158+ }
159+ actions={<PageActions w={w} page={page} />}
160+ />
161+ <Box className="px-6 py-8 md:px-10">
162+ {text === null ? (
163+ <p className="text-center type-body text-ink2">This page is too large to display ({Math.round(size / 1024 / 1024)} MB).</p>
164+ ) : text.trim() ? (
165+ <Markdown wiki={w.wiki}>{text}</Markdown>
166+ ) : (
167+ <p className="type-body text-ink3 italic">This page is empty.</p>
168+ )}
169+ </Box>
170+ {footer !== null ? (
171+ <Box className="mt-4 px-6 py-5 md:px-10">
172+ <Markdown wiki={w.wiki}>{footer}</Markdown>
173+ {w.repo.canWrite && (
174+ <Link href={`${w.wiki}/_Footer/_edit`} className="mt-3 inline-flex type-meta text-ink2 hover:text-ink">
175+ Edit footer
176+ </Link>
177+ )}
178+ </Box>
179+ ) : (
180+ w.repo.canWrite &&
181+ !page.slug.startsWith("_") && (
182+ <div className="mt-3 px-1">
183+ <AddSpecialLink wiki={w.wiki} slug={FOOTER}>
184+ Add a custom footer
185+ </AddSpecialLink>
186+ </div>
187+ )
188+ )}
189+ </Columns>
190+ );
191+ }
192+
193+ /** Home without a Home page: the list of pages. */
194+ function IndexView({ w }: { w: Wiki }) {
195+ return (
196+ <Columns w={w}>
197+ <PageTitle
198+ title="Home"
199+ description={
200+ w.repo.canWrite ? (
201+ <>
202+ This wiki has no Home page yet.{" "}
203+ <Link href={`${w.wiki}/_new?title=Home`} className={textLinkClass}>
204+ Create it
205+ </Link>{" "}
206+ to welcome readers here.
207+ </>
208+ ) : (
209+ "The pages of this wiki."
210+ )
211+ }
212+ actions={<PageActions w={w} />}
213+ />
214+ <PagesList w={w} />
215+ </Columns>
216+ );
217+ }
218+
219+ function EditorView({ w, page, initialTitle, content }: { w: Wiki; page?: WikiPage; initialTitle: string; content: string }) {
220+ const { repo } = w;
221+ return (
222+ <div className="mx-auto max-w-5xl">
223+ <PageTitle
224+ title={page ? `Editing ${page.title}` : "New page"}
225+ description={page ? undefined : "Write a page in Markdown. It’s committed to the wiki’s repository."}
226+ />
227+ <WikiEditor
228+ action={saveWikiPageAction.bind(null, repo.owner, repo.name)}
229+ deleteAction={page ? deleteWikiPageAction.bind(null, repo.owner, repo.name, page.path) : undefined}
230+ wiki={w.wiki}
231+ originalPath={page?.path ?? ""}
232+ baseSha={w.head ?? ""}
233+ initialTitle={initialTitle}
234+ initialContent={content}
235+ cancelHref={page ? pageHref(w.wiki, page.slug) : w.wiki}
236+ />
237+ </div>
238+ );
239+ }
240+
241+ function HistoryList({ w, commits, page }: { w: Wiki; commits: Commit[]; page?: WikiPage }) {
242+ return (
243+ <Box className="p-1.5">
244+ <ul>
245+ {commits.map((c) => {
246+ const href = page ? `${w.wiki}/${encodeURIComponent(page.slug)}/${c.sha}` : `${w.wiki}/_compare/${c.sha}`;
247+ return (
248+ <li key={c.sha} className={`${liftRowClass} flex items-center gap-3.5 px-3 py-2.5`}>
249+ <Avatar name={c.authorName} size={32} className="hidden sm:inline-flex" />
250+ <div className="min-w-0 flex-1">
251+ <Link href={href} className="block truncate type-title text-ink decoration-ink3 underline-offset-4 hover:underline">
252+ {c.subject}
253+ </Link>
254+ <div className="mt-0.5 flex items-center gap-1 type-meta text-ink3">
255+ <span className="font-medium text-ink2">{c.authorName}</span> edited{" "}
256+ <time dateTime={new Date(c.date).toISOString()} title={formatDate(c.date)}>
257+ {timeAgo(c.date)}
258+ </time>
259+ </div>
260+ </div>
261+ <ShaChip sha={c.sha} href={`${w.wiki}/_compare/${c.sha}`} />
262+ </li>
263+ );
264+ })}
265+ </ul>
266+ </Box>
267+ );
268+ }
269+
270+ async function HistoryView({ w, page, pageNumber }: { w: Wiki; page?: WikiPage; pageNumber: number }) {
271+ const commits = w.head
272+ ? await wikiHistory(w.repo.wikiDir, w.head, { path: page?.path, limit: HISTORY_PAGE_SIZE + 1, skip: (pageNumber - 1) * HISTORY_PAGE_SIZE })
273+ : [];
274+ const hasNext = commits.length > HISTORY_PAGE_SIZE;
275+ const selfHref = page ? `${w.wiki}/${encodeURIComponent(page.slug)}/_history` : `${w.wiki}/_history`;
276+ return (
277+ <Columns w={w} current={page?.slug}>
278+ <PageTitle
279+ title={page ? `History of ${page.title}` : "Wiki history"}
280+ description={
281+ page ? (
282+ <Link href={pageHref(w.wiki, page.slug)} className={textLinkClass}>
283+ Back to the page
284+ </Link>
285+ ) : (
286+ "Every change to this wiki, newest first."
287+ )
288+ }
289+ />
290+ {commits.length === 0 ? (
291+ <Box>
292+ <Blankslate icon={<HistoryIcon />} title="No changes yet" />
293+ </Box>
294+ ) : (
295+ <HistoryList w={w} commits={commits.slice(0, HISTORY_PAGE_SIZE)} page={page} />
296+ )}
297+ {(pageNumber > 1 || hasNext) && (
298+ <div className="mt-6 flex justify-center gap-2">
299+ {pageNumber > 1 && (
300+ <ButtonLink href={`${selfHref}?page=${pageNumber - 1}`} icon={<ChevronLeftIcon />}>
301+ Newer
302+ </ButtonLink>
303+ )}
304+ {hasNext && (
305+ <ButtonLink href={`${selfHref}?page=${pageNumber + 1}`}>
306+ Older <ChevronRightIcon />
307+ </ButtonLink>
308+ )}
309+ </div>
310+ )}
311+ </Columns>
312+ );
313+ }
314+
315+ /** A page as it was at an older commit. */
316+ async function RevisionView({ w, slug, sha }: { w: Wiki; slug: string; sha: string }) {
317+ const dir = w.repo.wikiDir;
318+ const commit = await revParse(dir, sha);
319+ const old = commit && findPage(await listPages(dir, commit), slug);
320+ if (!commit || !old) notFound();
321+ const [{ text }, edit] = await Promise.all([readPage(dir, commit, old.path), lastCommit(dir, commit, old.path)]);
322+ const current = findPage(w.all, slug);
323+
324+ return (
325+ <Columns w={w} current={current?.slug}>
326+ <PageTitle title={old.title} actions={<PageActions w={w} />} />
327+ <div className="mb-4">
328+ <Alert kind="info">
329+ This is an older version of the page
330+ {edit && (
331+ <>
332+ , from {formatDate(edit.date)} by {edit.authorName}
333+ </>
334+ )}
335+ .{" "}
336+ {current && (
337+ <>
338+ <Link href={pageHref(w.wiki, current.slug)} className={textLinkClass}>
339+ View the latest version
340+ </Link>{" "}
341+ ·{" "}
342+ </>
343+ )}
344+ <Link href={`${w.wiki}/_compare/${edit?.sha ?? commit}`} className={textLinkClass}>
345+ View the changes
346+ </Link>
347+ </Alert>
348+ </div>
349+ <Box className="px-6 py-8 md:px-10">
350+ {text === null ? <p className="text-center type-body text-ink2">This page is too large to display.</p> : <Markdown wiki={w.wiki}>{text}</Markdown>}
351+ </Box>
352+ </Columns>
353+ );
354+ }
355+
356+ /** What one commit changed in the wiki. */
357+ async function CompareView({ w, sha }: { w: Wiki; sha: string }) {
358+ const dir = w.repo.wikiDir;
359+ const commit = w.head ? await getCommit(dir, sha) : null;
360+ if (!commit) notFound();
361+ const files = await diffCommit(dir, commit);
362+ const budgets = diffBudgets(files);
363+ const pageOf = (file: string) => {
364+ const name = file.split("/").pop()!;
365+ return /\.(md|markdown|mdown|mkdn|mkd)$/i.test(name) ? name.replace(/\.[^.]+$/, "") : null;
366+ };
367+
368+ return (
369+ <div>
370+ <Box className="mb-6 overflow-hidden">
371+ <div className="flex flex-wrap items-start gap-4 p-6">
372+ <div className="min-w-0 flex-1">
373+ <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">{commit.subject}</h1>
374+ {commit.body && <pre className="mt-3 font-mono text-[11.5px] leading-relaxed whitespace-pre-wrap text-ink2">{commit.body}</pre>}
375+ </div>
376+ <ButtonLink href={`${w.wiki}/_history`} icon={<HistoryIcon />}>
377+ Wiki History
378+ </ButtonLink>
379+ </div>
380+ <div className="rule-t flex flex-wrap items-center gap-2 px-6 py-3 type-ui">
381+ <Avatar name={commit.authorName} size={22} />
382+ <span className="font-medium text-ink">{commit.authorName}</span>
383+ <span className="text-ink2">
384+ edited <time dateTime={new Date(commit.date).toISOString()}>{timeAgo(commit.date)}</time>
385+ </span>
386+ <span className="ml-auto flex items-center gap-1.5 type-meta text-ink3">
387+ <CommitIcon width={12} height={12} /> {shortSha(commit.sha)}
388+ </span>
389+ </div>
390+ </Box>
391+ {files.map((file, i) => {
392+ const slug = pageOf(file.newPath);
393+ return (
394+ <FileDiffView
395+ key={file.newPath}
396+ file={file}
397+ viewHref={slug ? `${w.wiki}/${encodeURIComponent(slug)}/${commit.sha}` : `${w.wiki}/_raw/${encodePath(file.newPath)}`}
398+ budget={budgets[i]}
399+ />
400+ );
401+ })}
402+ </div>
403+ );
404+ }
405+
406+ // ---------------------------------------------------------------------------
407+ // Routing
408+
409+ /** Catch-all segments arrive still percent-encoded ("C%23-Notes"). */
410+ const decode = (segment: string) => {
411+ try {
412+ return decodeURIComponent(segment);
413+ } catch {
414+ return segment;
415+ }
416+ };
417+
418+ const RESERVED: Record<string, string> = { _new: "New page", _pages: "Pages", _history: "History", _compare: "Changes" };
419+
420+ export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/wiki/[[...segments]]">): Promise<Metadata> {
421+ const [first, second] = ((await params).segments ?? []).map(decode);
422+ const title = !first ? "Home" : (RESERVED[first] ?? pageTitle(first));
423+ const suffix = second === "_edit" ? " (editing)" : second === "_history" ? " (history)" : "";
424+ return { title: `${title}${suffix} · Wiki` };
425+ }
426+
427+ export default async function WikiPage({ params, searchParams }: PageProps<"/[owner]/[repo]/wiki/[[...segments]]">) {
428+ const { owner, repo: repoParam, segments: encoded = [] } = await params;
429+ const segments = encoded.map(decode);
430+ const query = await searchParams;
431+ const w = await loadWiki(owner, repoParam);
432+ const [first, second, ...more] = segments;
433+ const pageNumber = Math.max(1, Number(query.page) || 1);
434+
435+ if (!first) {
436+ if (w.pages.length === 0) return <EmptyWiki w={w} />;
437+ const home = findPage(w.all, "Home");
438+ return home ? <PageView w={w} page={home} /> : <IndexView w={w} />;
439+ }
440+
441+ if (first === "_new" && !second) {
442+ if (!w.repo.canWrite) forbidden();
443+ const title = typeof query.title === "string" ? query.title : "";
444+ // A link to a page that exists already (say, from an old wiki link) edits it.
445+ const existing = title && findPage(w.all, pageSlug(title));
446+ if (existing) redirect(`${w.wiki}/${encodeURIComponent(existing.slug)}/_edit`);
447+ return <EditorView w={w} initialTitle={title} content="" />;
448+ }
449+ if (first === "_pages" && !second) {
450+ return (
451+ <Columns w={w}>
452+ <PageTitle title="Pages" description={`${w.pages.length} page${w.pages.length === 1 ? "" : "s"} in this wiki.`} actions={<PageActions w={w} />} />
453+ {w.pages.length ? <PagesList w={w} /> : <EmptyWiki w={w} />}
454+ </Columns>
455+ );
456+ }
457+ if (first === "_history" && !second) return <HistoryView w={w} pageNumber={pageNumber} />;
458+ if (first === "_compare" && second && SHA.test(second) && !more.length) return <CompareView w={w} sha={second} />;
459+
460+ const page = !more.length ? findPage(w.all, first) : null;
461+ if (page && page.slug !== first) {
462+ // One URL per page: links written as "getting started" land on /Getting-Started.
463+ redirect(`${w.wiki}/${encodeURIComponent(page.slug)}${second ? `/${second}` : ""}`);
464+ }
465+ if (page && !second) return <PageView w={w} page={page} />;
466+ if (page && second === "_edit") {
467+ if (!w.repo.canWrite) forbidden();
468+ const { text } = await readPage(w.repo.wikiDir, w.head!, page.path);
469+ if (text === null) return <Blankslate icon={<FileIcon />} title="This page is too large to edit in the browser" />;
470+ return <EditorView w={w} page={page} initialTitle={page.title} content={text} />;
471+ }
472+ if (page && second === "_history") return <HistoryView w={w} page={page} pageNumber={pageNumber} />;
473+ if (second && SHA.test(second) && !more.length) return <RevisionView w={w} slug={first} sha={second} />;
474+
475+ // Any other path is a file in the wiki's repository: an image, a PDF, a page's source.
476+ const file = segments.join("/");
477+ if (w.head && (await objectType(w.repo.wikiDir, w.head, file)) === "blob") redirect(`${w.wiki}/_raw/${encodePath(file)}`);
478+ // Following a link to a page that doesn't exist yet offers to write it.
479+ if (!second && w.repo.canWrite && !first.startsWith("_")) redirect(`${w.wiki}/_new?title=${encodeURIComponent(pageTitle(first))}`);
480+ if (!second && w.repo.canWrite && (first === SIDEBAR || first === FOOTER)) redirect(`${w.wiki}/_new?title=${first}`);
481+ notFound();
482+ }
+10−0app/actions/repo.tsView File
@@ -11,6 +11,7 @@ import {
1111 renameRepo,
1212 RepoError,
1313 setRepoImage,
14+ setWikiEnabled,
1415 updateRepo,
1516 type Visibility,
1617 } from "@/lib/repos";
@@ -56,6 +57,15 @@ export async function updateRepoAction(owner: string, name: string, _prev: FormS
5657 });
5758 }
5859
60+ export async function updateFeaturesAction(owner: string, name: string, _prev: FormState, formData: FormData) {
61+ return attempt(async () => {
62+ const { repo } = await repoForAction(owner, name, "admin");
63+ await setWikiEnabled(repo.id, formData.get("wiki") === "on");
64+ revalidatePath(`/${repo.owner}/${repo.name}`, "layout");
65+ return { success: "Features saved." };
66+ });
67+ }
68+
5969 export async function updateRepoImageAction(owner: string, name: string, _prev: FormState, formData: FormData) {
6070 return attempt(async () => {
6171 const { repo } = await repoForAction(owner, name, "admin");
+43−0app/actions/wiki.tsaddedView File
@@ -0,0 +1,43 @@
1+ "use server";
2+
3+ import { redirect } from "next/navigation";
4+ import { GitError } from "@/lib/git";
5+ import { deletePage, savePage, WikiError } from "@/lib/wiki";
6+ import { pageHref } from "@/lib/wiki-links";
7+ import { ActionError, repoForAction } from "./guard";
8+ import type { FormState } from "./types";
9+
10+ const MAX_PAGE_BYTES = 1024 * 1024;
11+
12+ async function wikiForAction(owner: string, name: string) {
13+ const { repo, viewer } = await repoForAction(owner, name, "write");
14+ if (!repo.wikiEnabled) throw new ActionError("The wiki is turned off for this repository.");
15+ return { repo, author: { name: viewer.name || viewer.username, email: viewer.email } };
16+ }
17+
18+ export async function saveWikiPageAction(owner: string, name: string, _prev: FormState, formData: FormData): Promise<FormState> {
19+ let destination: string;
20+ try {
21+ const { repo, author } = await wikiForAction(owner, name);
22+ const content = String(formData.get("content") ?? "");
23+ if (Buffer.byteLength(content) > MAX_PAGE_BYTES) throw new ActionError("Pages can be at most 1 MB.");
24+ const slug = await savePage(repo, author, {
25+ originalPath: String(formData.get("originalPath") ?? "") || null,
26+ title: String(formData.get("title") ?? ""),
27+ content,
28+ message: String(formData.get("message") ?? ""),
29+ baseSha: String(formData.get("baseSha") ?? "") || undefined,
30+ });
31+ destination = pageHref(`/${repo.owner}/${repo.name}/wiki`, slug);
32+ } catch (e) {
33+ if (e instanceof ActionError || e instanceof WikiError || e instanceof GitError) return { error: e.message };
34+ throw e;
35+ }
36+ redirect(destination);
37+ }
38+
39+ export async function deleteWikiPageAction(owner: string, name: string, path: string) {
40+ const { repo, author } = await wikiForAction(owner, name);
41+ await deletePage(repo, author, String(path));
42+ redirect(`/${repo.owner}/${repo.name}/wiki`);
43+ }
+104−0components/file-diff.tsxaddedView File
@@ -0,0 +1,104 @@
1+ import { FileIcon } from "@/components/icons";
2+ import { Box, ButtonLink, Label } from "@/components/ui";
3+ import type { FileDiff } from "@/lib/git";
4+
5+ const MAX_RENDERED_LINES = 3000;
6+
7+ /** How many lines each file may render: what is left of the budget after the files above it. */
8+ export function diffBudgets(files: FileDiff[]) {
9+ return files.map((_, i) => MAX_RENDERED_LINES - files.slice(0, i).reduce((n, f) => n + f.lines.length, 0));
10+ }
11+
12+ const lineStyles = {
13+ add: { row: "bg-[var(--diff-add)]", num: "bg-[var(--diff-add-num)]", sign: "+" },
14+ del: { row: "bg-[var(--diff-del)]", num: "bg-[var(--diff-del-num)]", sign: "-" },
15+ ctx: { row: "", num: "", sign: " " },
16+ };
17+
18+ /** The change as five lamps: green for the added share, red for the deleted, dark when nothing changed. */
19+ function DiffStat({ additions, deletions }: { additions: number; deletions: number }) {
20+ const total = additions + deletions;
21+ const greens = total ? Math.round((additions / total) * 5) : 0;
22+ return (
23+ <span className="flex shrink-0 items-center gap-2 type-readout">
24+ <span className="text-success-emphasis">+{additions}</span>
25+ <span className="text-danger">−{deletions}</span>
26+ <span aria-hidden className="flex gap-[2px]">
27+ {Array.from({ length: 5 }, (_, i) => (
28+ <span
29+ key={i}
30+ className={`size-2 rounded-[2.5px] ${i < greens ? "bg-success-emphasis" : i < (total ? 5 : 0) ? "bg-danger" : "material-well"}`}
31+ />
32+ ))}
33+ </span>
34+ </span>
35+ );
36+ }
37+
38+ /**
39+ * One file's changes on a plate: its name and stat in a sticky header, then the lines. `viewHref`
40+ * links the file as it is after the change.
41+ */
42+ export function FileDiffView({ file, viewHref, budget }: { file: FileDiff; viewHref?: string; budget: number }) {
43+ const name = file.status === "renamed" ? `${file.oldPath} → ${file.newPath}` : file.newPath;
44+ const truncated = file.lines.length > budget;
45+ return (
46+ <section id={encodeURIComponent(file.newPath)} aria-label={name} className="mb-4 scroll-mt-20">
47+ <Box className="overflow-clip">
48+ <div className="rule-b sticky top-14 z-10 flex min-h-11 items-center gap-3 bg-[var(--mu-frost-strong)] py-1.5 pr-2 pl-4 backdrop-blur-md">
49+ <DiffStat additions={file.additions} deletions={file.deletions} />
50+ <span className="min-w-0 truncate font-mono text-[11.5px] font-medium text-ink">{name}</span>
51+ {file.status !== "modified" && (
52+ <Label tone={file.status === "added" ? "success" : file.status === "deleted" ? "danger" : "default"}>{file.status}</Label>
53+ )}
54+ {viewHref && file.status !== "deleted" && (
55+ <ButtonLink
56+ href={viewHref}
57+ variant="invisible"
58+ icon={<FileIcon width={14} height={14} />}
59+ className="ml-auto shrink-0"
60+ >
61+ View File
62+ </ButtonLink>
63+ )}
64+ </div>
65+ {file.binary ? (
66+ <p className="p-6 text-center type-ui text-ink3">Binary file not shown.</p>
67+ ) : file.lines.length === 0 ? (
68+ <p className="p-6 text-center type-ui text-ink3">File mode or name changed only.</p>
69+ ) : truncated ? (
70+ <p className="p-6 text-center type-ui text-ink3">Large diffs are not rendered by default.</p>
71+ ) : (
72+ <div className="overflow-x-auto">
73+ <table className="w-full border-collapse font-mono text-[11.5px] leading-[22px] text-ink">
74+ <tbody>
75+ {file.lines.map((line, i) =>
76+ line.kind === "hunk" ? (
77+ <tr key={i} className="bg-[var(--diff-hunk)] text-ink3">
78+ <td colSpan={3} className="px-4 py-1.5">
79+ {line.text}
80+ </td>
81+ </tr>
82+ ) : (
83+ <tr key={i} className={lineStyles[line.kind].row}>
84+ <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}>
85+ {line.oldNo}
86+ </td>
87+ <td className={`w-12 min-w-12 select-none px-2 text-right text-ink3 tabular-nums ${lineStyles[line.kind].num}`}>
88+ {line.newNo}
89+ </td>
90+ <td className="px-2 whitespace-pre">
91+ <span className="select-none text-ink3">{lineStyles[line.kind].sign} </span>
92+ {line.text}
93+ </td>
94+ </tr>
95+ ),
96+ )}
97+ </tbody>
98+ </table>
99+ </div>
100+ )}
101+ </Box>
102+ </section>
103+ );
104+ }
+1−1components/file-editor.tsxView File
@@ -16,7 +16,7 @@ import { Box, Button, ButtonLink, Kbd, RefChip } from "./ui";
1616 type Tab = "edit" | "preview";
1717
1818 /** Loads CodeMirror language support based on the file name. */
19- function useLanguage(filename: string) {
19+ export function useLanguage(filename: string) {
2020 const [lang, setLang] = useState<{ name: string; ext: Extension } | null>(null);
2121 useEffect(() => {
2222 let cancelled = false;
+16−4components/markdown-preview.tsxView File
@@ -1,16 +1,28 @@
11 "use client";
22
3- import ReactMarkdown from "react-markdown";
3+ import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
44 import rehypeRaw from "rehype-raw";
55 import rehypeSanitize from "rehype-sanitize";
66 import remarkGfm from "remark-gfm";
7+ import { remarkWikiLinks, wikiUrl } from "@/lib/wiki-links";
78
8- /** Client-side markdown rendering for editor previews (same pipeline as the server renderer). */
9- export function MarkdownPreview({ children }: { children: string }) {
9+ /**
10+ * Client-side markdown rendering for editor previews (same pipeline as the server renderer). With
11+ * `wiki`, links resolve like on a wiki page.
12+ */
13+ export function MarkdownPreview({ children, wiki }: { children: string; wiki?: string }) {
1014 return (
1115 <div className="markdown">
1216 {children.trim() ? (
13- <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
17+ <ReactMarkdown
18+ remarkPlugins={wiki ? [remarkGfm, [remarkWikiLinks, { base: wiki }]] : [remarkGfm]}
19+ rehypePlugins={[rehypeRaw, rehypeSanitize]}
20+ urlTransform={(url, key) => {
21+ const safe = defaultUrlTransform(url);
22+ if (!wiki || !safe || /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(safe)) return safe;
23+ return wikiUrl(wiki, safe, key);
24+ }}
25+ >
1426 {children}
1527 </ReactMarkdown>
1628 ) : (
+10−5components/markdown.tsxView File
@@ -4,32 +4,37 @@ import rehypeRaw from "rehype-raw";
44 import rehypeSanitize from "rehype-sanitize";
55 import remarkGfm from "remark-gfm";
66 import { encodePath } from "@/lib/format";
7+ import { remarkWikiLinks, wikiUrl } from "@/lib/wiki-links";
78
89 /**
910 * Renders GitHub-flavored markdown. Inline HTML is allowed but sanitized with
1011 * GitHub's own allowlist, so untrusted READMEs and issue bodies are safe. When
1112 * `context` is given, relative links and images resolve against the file's
12- * location in the repository.
13+ * location in the repository. With `wiki` (the wiki's base URL) instead, they
14+ * resolve to the wiki's pages and files, and [[Wiki Links]] work.
1315 */
1416 export function Markdown({
1517 children,
1618 context,
19+ wiki,
1720 }: {
1821 children: string;
1922 context?: { base: string; gitRef: string; dir: string };
23+ wiki?: string;
2024 }) {
2125 return (
2226 <div className="markdown">
2327 <ReactMarkdown
24- remarkPlugins={[remarkGfm]}
28+ remarkPlugins={wiki ? [remarkGfm, [remarkWikiLinks, { base: wiki }]] : [remarkGfm]}
2529 rehypePlugins={[rehypeRaw, rehypeSanitize]}
2630 urlTransform={(url, key) => {
2731 const safe = defaultUrlTransform(url);
28- if (!context || !safe || /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(safe)) return safe;
32+ if ((!context && !wiki) || !safe || /^([a-z][a-z0-9+.-]*:|\/\/|#|\/)/i.test(safe)) return safe;
33+ if (wiki) return wikiUrl(wiki, safe, key);
2934 const [target, hash] = safe.split("#");
30- const resolved = path.normalize(path.join(context.dir, target)).replace(/^(\.\.\/)+/, "");
35+ const resolved = path.normalize(path.join(context!.dir, target)).replace(/^(\.\.\/)+/, "");
3136 const kind = key === "src" ? "raw" : "blob";
32- return `${context.base}/${kind}/${encodePath(context.gitRef)}/${encodePath(resolved)}${hash ? `#${hash}` : ""}`;
37+ return `${context!.base}/${kind}/${encodePath(context!.gitRef)}/${encodePath(resolved)}${hash ? `#${hash}` : ""}`;
3338 }}
3439 >
3540 {children}
+5−7components/repo-tabs.tsxView File
@@ -3,7 +3,7 @@
33 import Link from "next/link";
44 import { usePathname } from "next/navigation";
55 import { useState } from "react";
6- import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon, PlayIcon, TagIcon } from "./icons";
6+ import { BookIcon, CodeIcon, GearIcon, IssueOpenedIcon, PackageIcon, PlayIcon } from "./icons";
77 import { SlidingIndicator, thumbClass } from "./sliding-indicator";
88
99 /**
@@ -13,26 +13,24 @@ import { SlidingIndicator, thumbClass } from "./sliding-indicator";
1313 export function RepoTabs({
1414 base,
1515 openIssues,
16- releases,
1716 images,
17+ showWiki,
1818 showSettings,
1919 }: {
2020 base: string;
2121 openIssues: number;
22- releases: number;
2322 images: number;
23+ showWiki: boolean;
2424 showSettings: boolean;
2525 }) {
2626 const pathname = usePathname();
2727 const section = pathname.slice(base.length).split("/")[1] ?? "";
2828 const tabs = [
29- { href: base, label: "Code", icon: CodeIcon, active: !["issues", "actions", "branches", "commits", "commit", "releases", "packages", "settings"].includes(section) },
29+ { href: base, label: "Code", icon: CodeIcon, active: !["issues", "actions", "packages", "wiki", "settings"].includes(section) },
3030 { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" },
3131 { href: `${base}/actions`, label: "Actions", icon: PlayIcon, active: section === "actions" },
32- { href: `${base}/commits`, label: "Commits", icon: HistoryIcon, active: section === "commits" || section === "commit" },
33- { href: `${base}/branches`, label: "Branches", icon: BranchIcon, active: section === "branches" },
34- { href: `${base}/releases`, label: "Releases", icon: TagIcon, count: releases, active: section === "releases" },
3532 { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" },
33+ ...(showWiki ? [{ href: `${base}/wiki`, label: "Wiki", icon: BookIcon, active: section === "wiki" }] : []),
3634 ...(showSettings ? [{ href: `${base}/settings`, label: "Settings", icon: GearIcon, active: section === "settings" }] : []),
3735 ];
3836
+170−0components/wiki/wiki-editor.tsxaddedView File
@@ -0,0 +1,170 @@
1+ "use client";
2+
3+ import { useActionState, useEffect, useId, useState } from "react";
4+ import CodeMirror, { EditorView } from "@uiw/react-codemirror";
5+ import { Led, Spinner, Switch, TabList, TabPanel, Tabs } from "@unlocalhosted/metalui";
6+ import type { FormState } from "@/app/actions/types";
7+ import { ConfirmButton } from "../confirm-button";
8+ import { vectrumDark, vectrumLight } from "../editor-theme";
9+ import { useLanguage } from "../file-editor";
10+ import { BookIcon, CodeIcon, EyeIcon, TrashIcon } from "../icons";
11+ import { MarkdownPreview } from "../markdown-preview";
12+ import { TextField } from "../settings-fields";
13+ import { useDarkMode } from "../theme-toggle";
14+ import { Alert, Box, Button, ButtonLink, Kbd } from "../ui";
15+
16+ type Tab = "write" | "preview";
17+
18+ /**
19+ * Writing a wiki page, like GitHub's: its title, the Markdown with a preview, and an optional edit
20+ * message; saving commits it to the wiki. Editing an existing page also offers to delete it.
21+ */
22+ export function WikiEditor({
23+ action,
24+ deleteAction,
25+ wiki,
26+ originalPath,
27+ baseSha,
28+ initialTitle,
29+ initialContent,
30+ cancelHref,
31+ }: {
32+ action: (prev: FormState, formData: FormData) => Promise<FormState>;
33+ deleteAction?: () => Promise<void>;
34+ /** The wiki's base URL, for links in the preview. */
35+ wiki: string;
36+ /** The page's file, or "" for a new page. */
37+ originalPath: string;
38+ baseSha: string;
39+ initialTitle: string;
40+ initialContent: string;
41+ cancelHref: string;
42+ }) {
43+ const formId = useId();
44+ const [state, formAction, pending] = useActionState(action, undefined);
45+ const [title, setTitle] = useState(initialTitle);
46+ const [content, setContent] = useState(initialContent);
47+ const [tab, setTab] = useState<Tab>("write");
48+ const [wrap, setWrap] = useState(true);
49+ const dark = useDarkMode();
50+ const lang = useLanguage("page.md");
51+ const dirty = content !== initialContent || title !== initialTitle;
52+ const extensions = [...(lang ? [lang.ext] : []), ...(wrap ? [EditorView.lineWrapping] : [])];
53+
54+ // Ctrl/Cmd+S saves; warn before leaving with unsaved edits.
55+ useEffect(() => {
56+ const onKey = (e: KeyboardEvent) => {
57+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
58+ e.preventDefault();
59+ (document.getElementById(formId) as HTMLFormElement | null)?.requestSubmit();
60+ }
61+ };
62+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
63+ if (dirty && !pending) e.preventDefault();
64+ };
65+ window.addEventListener("keydown", onKey);
66+ window.addEventListener("beforeunload", onBeforeUnload);
67+ return () => {
68+ window.removeEventListener("keydown", onKey);
69+ window.removeEventListener("beforeunload", onBeforeUnload);
70+ };
71+ }, [dirty, pending, formId]);
72+
73+ return (
74+ <div className="flex flex-col gap-4">
75+ <form id={formId} action={formAction} className="flex flex-col gap-4">
76+ <input type="hidden" name="originalPath" value={originalPath} />
77+ <input type="hidden" name="baseSha" value={baseSha} />
78+ <input type="hidden" name="content" value={content} />
79+
80+ <TextField
81+ label="Title"
82+ name="title"
83+ icon={<BookIcon />}
84+ required
85+ maxLength={200}
86+ value={title}
87+ onChange={(e) => setTitle(e.target.value)}
88+ autoFocus={!initialTitle}
89+ placeholder="Getting Started"
90+ />
91+
92+ <Tabs<Tab> value={tab} onValueChange={setTab}>
93+ <Box className="overflow-hidden">
94+ <div className="rule-b flex min-h-12 flex-wrap items-center gap-3 px-3 py-2">
95+ <TabList<Tab>
96+ aria-label="Editor view"
97+ size="compact"
98+ items={[
99+ { value: "write", label: "Write", icon: <CodeIcon /> },
100+ { value: "preview", label: "Preview", icon: <EyeIcon /> },
101+ ]}
102+ />
103+ <span className="hidden type-meta text-ink3 md:inline">
104+ Markdown. Link pages with <code className="font-mono">[[Page Name]]</code> or <code className="font-mono">[[Text|Page Name]]</code>.
105+ </span>
106+ <label className="ml-auto flex cursor-pointer items-center gap-2 type-meta text-ink2">
107+ <Switch size="small" checked={wrap} onCheckedChange={setWrap} /> Soft wrap
108+ </label>
109+ </div>
110+ <TabPanel<Tab> value="write" keepMounted>
111+ <CodeMirror
112+ value={content}
113+ onChange={setContent}
114+ theme={dark ? vectrumDark : vectrumLight}
115+ extensions={extensions}
116+ minHeight="460px"
117+ basicSetup={{ highlightActiveLine: true, foldGutter: false, tabSize: 2 }}
118+ aria-label="Page content"
119+ className="py-2"
120+ />
121+ </TabPanel>
122+ <TabPanel<Tab> value="preview">
123+ <div className="min-h-[460px] px-6 py-8 md:px-10">
124+ <MarkdownPreview wiki={wiki}>{content}</MarkdownPreview>
125+ </div>
126+ </TabPanel>
127+ </Box>
128+ </Tabs>
129+
130+ <TextField
131+ label="Edit message"
132+ name="message"
133+ optional
134+ maxLength={500}
135+ placeholder={!originalPath ? `Create ${title.trim() || "page"}` : `Update ${title.trim() || "page"}`}
136+ description="Describes the change in the page’s history."
137+ />
138+
139+ {state?.error && <Alert>{state.error}</Alert>}
140+ </form>
141+
142+ <div className="flex flex-wrap items-center gap-2">
143+ {deleteAction && (
144+ <ConfirmButton action={deleteAction} message={`Delete ${initialTitle}? Its history stays in the wiki’s repository.`} variant="invisible" confirmLabel="Delete Page">
145+ <TrashIcon /> Delete Page
146+ </ConfirmButton>
147+ )}
148+ <span className="flex items-center gap-1 type-meta text-ink2 max-sm:hidden">
149+ <Kbd>Ctrl</Kbd> <Kbd>S</Kbd> saves.
150+ {dirty && (
151+ <span className="ml-2 inline-flex items-center gap-1.5 text-ink">
152+ <Led kind="waiting" /> Unsaved changes
153+ </span>
154+ )}
155+ </span>
156+ <div className="ml-auto flex gap-2">
157+ <ButtonLink href={cancelHref}>Cancel</ButtonLink>
158+ <Button
159+ form={formId}
160+ variant="primary"
161+ disabled={pending || !title.trim()}
162+ icon={pending ? <Spinner size="small" label="Saving" /> : <BookIcon />}
163+ >
164+ {pending ? "Saving…" : "Save Page"}
165+ </Button>
166+ </div>
167+ </div>
168+ </div>
169+ );
170+ }
+124−0components/wiki/wiki-shell.tsxaddedView File
@@ -0,0 +1,124 @@
1+ import Link from "next/link";
2+ import type { ReactNode } from "react";
3+ import { CloneUrlTabs, type CloneUrls } from "@/components/clone-button";
4+ import { BookIcon, FileIcon, PlusIcon } from "@/components/icons";
5+ import { Markdown } from "@/components/markdown";
6+ import { Box } from "@/components/ui";
7+ import type { WikiPage } from "@/lib/wiki";
8+ import { pageHref } from "@/lib/wiki-links";
9+
10+ /** How many pages the sidebar lists before linking to all of them. */
11+ const SIDEBAR_PAGES = 20;
12+
13+ /** An engraved heading over a sidebar group, with an optional readout at its right. */
14+ function SectionLabel({ children, count }: { children: ReactNode; count?: number }) {
15+ return (
16+ <h2 className="mb-2 flex items-center justify-between type-label engraved">
17+ {children}
18+ {count !== undefined && <span className="type-readout text-ink3 [text-shadow:none]">{count}</span>}
19+ </h2>
20+ );
21+ }
22+
23+ const quietLinkClass = "inline-flex items-center gap-1.5 type-meta text-ink2 transition-colors hover:text-ink [&_svg]:size-3";
24+
25+ /** "+ Add a custom sidebar": where a writer starts one of the special pages. */
26+ export function AddSpecialLink({ wiki, slug, children }: { wiki: string; slug: string; children: ReactNode }) {
27+ return (
28+ <Link href={`${wiki}/_new?title=${encodeURIComponent(slug)}`} className={quietLinkClass}>
29+ <PlusIcon /> {children}
30+ </Link>
31+ );
32+ }
33+
34+ /**
35+ * The wiki's sidebar, like GitHub's: its pages, the custom _Sidebar page if there is one, and the
36+ * clone URL.
37+ */
38+ export function WikiSidebar({
39+ wiki,
40+ pages,
41+ current,
42+ sidebar,
43+ canWrite,
44+ cloneUrls,
45+ }: {
46+ wiki: string;
47+ pages: WikiPage[];
48+ current?: string;
49+ /** The _Sidebar page's Markdown. */
50+ sidebar: string | null;
51+ canWrite: boolean;
52+ cloneUrls: CloneUrls;
53+ }) {
54+ return (
55+ <aside className="flex flex-col gap-7">
56+ <section>
57+ <SectionLabel count={pages.length}>Pages</SectionLabel>
58+ {pages.length === 0 ? (
59+ <p className="type-body text-ink3">No pages yet.</p>
60+ ) : (
61+ <ul>
62+ {pages.slice(0, SIDEBAR_PAGES).map((p) => (
63+ <li key={p.path}>
64+ <Link
65+ href={pageHref(wiki, p.slug)}
66+ aria-current={p.slug === current ? "page" : undefined}
67+ className={`group -mx-2 flex min-h-8 items-center gap-2.5 rounded-row px-2 py-1 type-ui outline-none hover:recipe-row-list-hover focus-visible:focus-ring ${
68+ p.slug === current ? "font-medium text-ink recipe-row-list-hover" : "text-ink2 hover:text-ink"
69+ }`}
70+ >
71+ <FileIcon width={14} height={14} className="shrink-0 text-ink3 transition-colors group-hover:text-ink2" />
72+ <span className="min-w-0 truncate">{p.title}</span>
73+ </Link>
74+ </li>
75+ ))}
76+ </ul>
77+ )}
78+ {pages.length > SIDEBAR_PAGES && (
79+ <Link href={`${wiki}/_pages`} className={`${quietLinkClass} mt-2`}>
80+ Show all {pages.length} pages
81+ </Link>
82+ )}
83+ </section>
84+
85+ {sidebar !== null ? (
86+ <Box className="px-5 py-4 text-[13px]">
87+ <Markdown wiki={wiki}>{sidebar}</Markdown>
88+ {canWrite && (
89+ <Link href={`${wiki}/_Sidebar/_edit`} className={`${quietLinkClass} mt-3`}>
90+ Edit sidebar
91+ </Link>
92+ )}
93+ </Box>
94+ ) : (
95+ canWrite && (
96+ <div>
97+ <AddSpecialLink wiki={wiki} slug="_Sidebar">
98+ Add a custom sidebar
99+ </AddSpecialLink>
100+ </div>
101+ )
102+ )}
103+
104+ <section>
105+ <SectionLabel>
106+ <span className="inline-flex items-center gap-1.5">
107+ <BookIcon width={12} height={12} /> Clone this wiki locally
108+ </span>
109+ </SectionLabel>
110+ <CloneUrlTabs urls={cloneUrls} />
111+ </section>
112+ </aside>
113+ );
114+ }
115+
116+ /** The wiki's two columns: the page, then the sidebar. */
117+ export function WikiColumns({ children, sidebar }: { children: ReactNode; sidebar: ReactNode }) {
118+ return (
119+ <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_280px] xl:gap-10">
120+ <div className="min-w-0">{children}</div>
121+ {sidebar}
122+ </div>
123+ );
124+ }
+26−15lib/git-http.tsView File
@@ -2,8 +2,8 @@ import "server-only";
22 import { Readable } from "node:stream";
33 import { createGunzip } from "node:zlib";
44 import { onRefsUpdated, startWorkflowsInBackground } from "./ci";
5- import { normalizeRepoName, snapshotRefs, spawnService, type GitService } from "./git";
6- import { canWrite, findRepo, markPushed, repoAccess, type RepoRecord } from "./repos";
5+ import { pathExists, snapshotRefs, spawnService, type GitService } from "./git";
6+ import { canWrite, ensureWikiRepo, findGitTarget, markPushed, repoAccess, type RepoRecord } from "./repos";
77 import { authenticateJobToken } from "./runs";
88 import { authenticateToken, tokenCovers } from "./tokens";
99
@@ -42,33 +42,44 @@ async function credentials(request: Request) {
4242 return (await authenticateToken(password)) ?? "invalid";
4343 }
4444
45- type Authorized = { repo: RepoRecord; userId: string | null };
45+ type Authorized = { repo: RepoRecord; dir: string; wiki: boolean; userId: string | null };
4646
4747 /**
4848 * Decides whether a git request may proceed. Anonymous users can clone public
49- * repositories; everything else needs a token that covers the repository.
49+ * repositories; everything else needs a token that covers the repository. A
50+ * repository's wiki (<name>.wiki.git) has the repository's permissions.
5051 */
5152 async function authorize(request: Request, owner: string, repoParam: string, service: GitService): Promise<Authorized | Response> {
52- const repo = await findRepo(owner, normalizeRepoName(repoParam));
53+ const auth = await authorizeRepo(request, owner, repoParam, service);
54+ if (auth instanceof Response || !auth.wiki) return auth;
55+ // The first push to a wiki creates it; until then there is nothing to fetch.
56+ if (service === "git-receive-pack") await ensureWikiRepo(auth.repo);
57+ else if (!(await pathExists(auth.dir))) return notFound();
58+ return auth;
59+ }
60+
61+ async function authorizeRepo(request: Request, owner: string, repoParam: string, service: GitService): Promise<Authorized | Response> {
62+ const target = await findGitTarget(owner, repoParam);
63+ const repo = target?.repo;
5364 const pushing = service === "git-receive-pack";
5465 const creds = await credentials(request);
5566 if (creds === "invalid") return unauthorized("Invalid or expired token.");
5667
5768 if (!creds) {
58- if (repo && !pushing && repo.visibility === "public") return { repo, userId: null };
69+ if (target && !pushing && target.repo.visibility === "public") return { ...target, userId: null };
5970 // Ask for credentials; don't reveal whether private repositories exist.
6071 return unauthorized("Authentication required. Use an API token as the password.");
6172 }
6273
6374 // A job's token can only fetch the repository the job runs for.
6475 if ("job" in creds) {
65- if (!repo || repo.id !== creds.job.repoId) return notFound();
76+ if (!target || target.repo.id !== creds.job.repoId) return notFound();
6677 if (pushing) return new Response("Workflow job tokens are read-only.\n", { status: 403 });
67- return { repo, userId: null };
78+ return { ...target, userId: null };
6879 }
6980
7081 const access = repo ? await repoAccess(repo, creds.user.id) : null;
71- if (!repo || !access) return notFound();
82+ if (!target || !repo || !access) return notFound();
7283 if (pushing && creds.token.permission !== "write") {
7384 return new Response("This token is read-only. Create a read & write token to push.\n", { status: 403 });
7485 }
@@ -78,7 +89,7 @@ async function authorize(request: Request, owner: string, repoParam: string, ser
7889 if (pushing && !canWrite(access)) {
7990 return new Response(`Permission to ${repo.owner}/${repo.name} denied.\n`, { status: 403 });
8091 }
81- return { repo, userId: creds.user.id };
92+ return { ...target, userId: creds.user.id };
8293 }
8394
8495 /** GET /:owner/:repo.git/info/refs?service=git-upload-pack|git-receive-pack */
@@ -89,10 +100,9 @@ export async function handleInfoRefs(request: Request, owner: string, repoParam:
89100 }
90101 const auth = await authorize(request, owner, repoParam, service);
91102 if (auth instanceof Response) return auth;
92- const { repo } = auth;
93103
94104 const protocol = request.headers.get("git-protocol");
95- const child = spawnService(service, repo.dir, { advertise: true, protocol });
105+ const child = spawnService(service, auth.dir, { advertise: true, protocol });
96106 const stdout = Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>;
97107
98108 // Protocol v2 responses start directly with the capability advertisement.
@@ -125,10 +135,11 @@ export async function handleServiceRpc(request: Request, owner: string, repoPara
125135 }
126136 const auth = await authorize(request, owner, repoParam, service);
127137 if (auth instanceof Response) return auth;
128- const { repo, userId } = auth;
138+ const { repo, dir, wiki, userId } = auth;
129139
130- const refsBefore = service === "git-receive-pack" ? await snapshotRefs(repo.dir) : null;
131- const child = spawnService(service, repo.dir, { protocol: request.headers.get("git-protocol") });
140+ // Pushes to the code start workflows; pushes to the wiki don't.
141+ const refsBefore = service === "git-receive-pack" && !wiki ? await snapshotRefs(repo.dir) : null;
142+ const child = spawnService(service, dir, { protocol: request.headers.get("git-protocol") });
132143 child.stderr.on("data", (d) => console.error(`[${service}] ${d}`));
133144 if (refsBefore) onPushed(child, repo, refsBefore, userId);
134145
+1−1lib/git-write.tsView File
@@ -66,7 +66,7 @@ export async function commitFiles(
6666
6767 const tree = (await git(dir, ["write-tree"], { env })).trim();
6868 if (opts.parent) {
69- const parentTree = (await git(dir, ["rev-parse", "--end-of-options", `${opts.parent}^{tree}`])).trim();
69+ const parentTree = (await git(dir, ["rev-parse", "--verify", "--end-of-options", `${opts.parent}^{tree}`])).trim();
7070 if (parentTree === tree) throw new GitError("No changes to commit.");
7171 }
7272 const args = ["commit-tree", tree];
+7−1lib/git.tsView File
@@ -2,7 +2,7 @@ import { execFile, spawn } from "node:child_process";
22 import { mkdirSync, writeFileSync } from "node:fs";
33 import { access } from "node:fs/promises";
44 import path from "node:path";
5- import { DATA_ROOT, REPOS_ROOT } from "./paths";
5+ import { DATA_ROOT, REPOS_ROOT, WIKIS_ROOT } from "./paths";
66
77 // Note: this module is also used by the SSH server (outside of React), so it
88 // must not import "server-only" or anything from next/*.
@@ -32,6 +32,12 @@ export function repoPath(owner: string, repo: string) {
3232 return path.join(REPOS_ROOT, owner.toLowerCase(), `${repo.toLowerCase()}.git`);
3333 }
3434
35+ /** On-disk location of a repository's wiki (PocketBase record ids are lowercase alphanumeric). */
36+ export function wikiPath(repoId: string) {
37+ if (!/^[a-z0-9]+$/.test(repoId)) throw new GitError("Invalid repository id");
38+ return path.join(WIKIS_ROOT, `${repoId}.git`);
39+ }
40+
3541 export async function pathExists(p: string) {
3642 try {
3743 await access(p);
+3−2lib/mcp/server.tsView File
@@ -4,6 +4,7 @@ import { GitError } from "../git";
44 import { IssueError, LabelError } from "../issues";
55 import { ReleaseError } from "../releases";
66 import { RepoError } from "../repos";
7+ import { WikiError } from "../wiki";
78 import { ToolError } from "./params";
89 import { TOOLS, type ToolContext } from "./tools";
910
@@ -18,7 +19,7 @@ const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05
1819 const INSTRUCTIONS = `Vectrum is a self-hosted git forge, like GitHub. Repositories are addressed by owner and repo name.
1920 - Read code with list_files and read_file; change it with commit_files, which makes one commit with any number of files on a branch (creating the branch if needed). Pushing a commit starts the repository's workflows.
2021 - Issues work like Linear: statuses backlog, todo, in_progress, in_review, done, canceled; priorities 0 (none), 1 (urgent) to 4 (low).
21- - Releases belong to a tag; publishing one for a tag that doesn't exist creates the tag. Deleting a release keeps its tag.
22+ - Releases belong to a tag; publishing one for a tag that doesn't exist creates the tag. Deleting a release keeps its tag. To attach a file on disk (a build output), get a URL with create_release_upload_url and run the curl command it returns; upload_release_asset is for small content you already have.
2223 - Workflows live in .vectrum/workflows/*.yml and run on self-hosted runners. Check results with list_workflow_runs, get_workflow_run and get_job_logs.
2324 What you can reach depends on what the user approved: read-only or read & write, for all their repositories or selected ones.`;
2425
@@ -34,7 +35,7 @@ class RpcError extends Error {
3435 type Id = string | number | null;
3536
3637 /** What the lib functions throw about bad input or state, worded for the user. */
37- const USER_ERRORS = [ToolError, GitError, RepoError, CiError, IssueError, LabelError, ReleaseError];
38+ const USER_ERRORS = [ToolError, GitError, RepoError, CiError, IssueError, LabelError, ReleaseError, WikiError];
3839
3940 const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
4041
+193−2lib/mcp/tools.tsView File
@@ -7,6 +7,7 @@ import {
77 isBranch,
88 getCommit,
99 git,
10+ lastCommit,
1011 listBranches,
1112 listFilesWithSizes,
1213 listTree,
@@ -34,12 +35,16 @@ import {
3435 validateIssuePatch,
3536 } from "../issues";
3637 import { logTail } from "../logs";
38+ import { signUpload, UPLOAD_URL_MINUTES } from "../release-upload";
3739 import {
40+ cleanAssetName,
3841 createRelease,
42+ deleteAsset,
3943 deleteRelease,
4044 findRelease,
4145 listReleases,
4246 releaseTitle,
47+ storeAsset,
4348 tagCommit,
4449 updateRelease,
4550 type Release,
@@ -61,6 +66,8 @@ import {
6166 import { cancelRun, getRun, isFinished, listJobs, listRuns, listSteps, readJobLogs, type Run } from "../runs";
6267 import { tokenCovers, type TokenScope } from "../tokens";
6368 import { getUserByUsername, type User } from "../users";
69+ import { contentPages, findPage, listPages, pageSlug, readPage, savePage, wikiHead } from "../wiki";
70+ import { pageHref } from "../wiki-links";
6471 import { WORKFLOW_DIR } from "../workflows";
6572 import { array, boolean, integer, object, objectSchema, oneOf, parseArgs, string, ToolError, type Args, type Props } from "./params";
6673
@@ -978,6 +985,26 @@ async function afterRelease(repo: RepoRecord, ctx: ToolContext, tag: string, cre
978985
979986 const tag = string("The release's tag, e.g. v1.2.0.", { min: 1, max: 255 });
980987
988+ /** Release files sent in a tool call travel through the agent's context, so they stay small. */
989+ const MAX_INLINE_ASSET_MB = 10;
990+
991+ const assetUrl = (url: string, release: Release, name: string) => `${url}/releases/download/${encodePath(release.tag)}/${encodeURIComponent(name)}`;
992+
993+ function assetName(input: string) {
994+ const name = cleanAssetName(input);
995+ if (!name) throw new ToolError(`${input} isn't a valid file name. Use a plain name without slashes.`);
996+ return name;
997+ }
998+
999+ /** Strict base64: Buffer.from would silently skip anything else and store a broken file. */
1000+ function decodeBase64(input: string) {
1001+ const compact = input.replace(/\s+/g, "");
1002+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 === 1) throw new ToolError("content isn't valid base64.");
1003+ return Buffer.from(compact, "base64");
1004+ }
1005+
1006+ const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
1007+
9811008 const releaseTools = [
9821009 tool({
9831010 name: "list_releases",
@@ -1011,7 +1038,7 @@ const releaseTools = [
10111038 description: [
10121039 "Publishes a release for a tag, with notes in Markdown. If the tag doesn't exist, it's created at target (by default the",
10131040 "default branch) and starts the workflows a push of the tag would. With draft, nothing is published and no tag is created",
1014- "until update_release sets draft to false. Upload files with an API token: curl -T <file> -u <you>:vec_… <url>/releases/download/<tag>/<file>.",
1041+ "until update_release sets draft to false. Add files with upload_release_asset (small ones) or create_release_upload_url (anything else).",
10151042 ].join(" "),
10161043 access: "write",
10171044 params: {
@@ -1095,6 +1122,170 @@ const releaseTools = [
10951122 return `Deleted the release for ${release.tag}${files ? ` and its ${files} file${files === 1 ? "" : "s"}` : ""}. The tag ${release.tag} is still there.`;
10961123 },
10971124 }),
1125+
1126+ tool({
1127+ name: "upload_release_asset",
1128+ title: "Upload a release file",
1129+ description: [
1130+ "Adds a file to a release, with its content in the call: text, or base64 for binary files. A file of the same name is replaced.",
1131+ `Only for small files (up to ${MAX_INLINE_ASSET_MB} MB), since the content passes through your context. For build outputs`,
1132+ "on disk, use create_release_upload_url and upload with curl instead.",
1133+ ].join(" "),
1134+ access: "write",
1135+ idempotent: true,
1136+ params: {
1137+ owner,
1138+ repo,
1139+ tag,
1140+ name: string("The file name people download it as, e.g. checksums.txt.", { min: 1, max: 255 }),
1141+ content: string("The file's content.", { max: Math.ceil((MAX_INLINE_ASSET_MB * 1024 * 1024 * 4) / 3) + 4 }),
1142+ encoding: oneOf("How content is encoded.", ["utf8", "base64"] as const).default("utf8"),
1143+ },
1144+ run: async (args, ctx) => {
1145+ const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
1146+ const release = await openRelease(repo, access, args.tag);
1147+ const name = assetName(args.name);
1148+ const data = args.encoding === "base64" ? decodeBase64(args.content) : Buffer.from(args.content, "utf8");
1149+ if (data.length > MAX_INLINE_ASSET_MB * 1024 * 1024) {
1150+ throw new ToolError(`That's more than ${MAX_INLINE_ASSET_MB} MB. Use create_release_upload_url and upload it with curl.`);
1151+ }
1152+ const asset = await storeAsset(release, name, new Response(new Uint8Array(data)).body!, ctx.user.id);
1153+ return { name: asset.name, size: asset.size, sha256: asset.sha256, url: assetUrl(url, release, asset.name) };
1154+ },
1155+ }),
1156+
1157+ tool({
1158+ name: "create_release_upload_url",
1159+ title: "Get a release upload URL",
1160+ description: [
1161+ `A URL to upload one file of a release to with a plain PUT, valid for ${UPLOAD_URL_MINUTES} minutes and needing no credentials:`,
1162+ 'run `curl -fT <local file> "<upload_url>"` in a shell. The file streams straight to the server, so it can be any size.',
1163+ "Uploading again replaces the file.",
1164+ ].join(" "),
1165+ access: "write",
1166+ params: { owner, repo, tag, name: string("The file name people download it as, e.g. app-linux-x64.tar.gz.", { min: 1, max: 255 }) },
1167+ run: async (args, ctx) => {
1168+ const { repo, access, url } = await openRepo(ctx, args.owner, args.repo, "write");
1169+ const release = await openRelease(repo, access, args.tag);
1170+ const name = assetName(args.name);
1171+ const download = assetUrl(url, release, name);
1172+ const upload = `${download}?upload=${signUpload({ repoId: repo.id, releaseId: release.id, name, userId: ctx.user.id })}`;
1173+ return {
1174+ upload_url: upload,
1175+ method: "PUT",
1176+ expires_at: iso(Date.now() + UPLOAD_URL_MINUTES * 60e3),
1177+ command: `curl -fT ${shellQuote(name)} ${shellQuote(upload)}`,
1178+ download_url: download,
1179+ };
1180+ },
1181+ }),
1182+
1183+ tool({
1184+ name: "delete_release_asset",
1185+ title: "Delete a release file",
1186+ description: "Removes a file from a release.",
1187+ access: "write",
1188+ destructive: true,
1189+ idempotent: true,
1190+ params: { owner, repo, tag, name: string("The file's name.", { min: 1, max: 255 }) },
1191+ run: async (args, ctx) => {
1192+ const { repo, access } = await openRepo(ctx, args.owner, args.repo, "write");
1193+ const release = await openRelease(repo, access, args.tag);
1194+ const asset = release.assets.find((a) => a.name === args.name);
1195+ if (!asset) {
1196+ const names = release.assets.map((a) => a.name);
1197+ throw new ToolError(`The release for ${release.tag} has no file ${args.name}.${names.length ? ` Files: ${names.join(", ")}.` : ""}`);
1198+ }
1199+ await deleteAsset(release, asset);
1200+ return `Deleted ${asset.name} from the release for ${release.tag}.`;
1201+ },
1202+ }),
1203+ ];
1204+
1205+ // ---------------------------------------------------------------------------
1206+ // Wiki
1207+
1208+ /** A repository's wiki, its latest commit and pages; the wiki may not have any yet. */
1209+ async function openWiki(ctx: ToolContext, owner: string, name: string, need: Need) {
1210+ const opened = await openRepo(ctx, owner, name, need);
1211+ if (!opened.repo.wikiEnabled) throw new ToolError(`${opened.full} has its wiki turned off.`);
1212+ const { head } = await wikiHead(opened.repo);
1213+ const pages = head ? await listPages(opened.repo.wikiDir, head) : [];
1214+ return { ...opened, head, pages, wiki: `${opened.url}/wiki` };
1215+ }
1216+
1217+ const pageTitleParam = string("The page's title, e.g. Getting Started (or its name, Getting-Started).", { min: 1, max: 200 });
1218+
1219+ const wikiTools = [
1220+ tool({
1221+ name: "list_wiki_pages",
1222+ title: "List wiki pages",
1223+ description: "A repository's wiki pages, Home first. The wiki is a git repository of its own, cloned as <repo>.wiki.git.",
1224+ access: "read",
1225+ params: { owner, repo },
1226+ run: async (args, ctx) => {
1227+ const { repo, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "read");
1228+ return {
1229+ pages: contentPages(pages).map((p) => ({ title: p.title, path: p.path, url: pageHref(wiki, p.slug) })),
1230+ has_sidebar: pages.some((p) => p.slug === "_Sidebar"),
1231+ has_footer: pages.some((p) => p.slug === "_Footer"),
1232+ clone_url: `${ctx.origin}/${repo.owner}/${repo.name}.wiki.git`,
1233+ };
1234+ },
1235+ }),
1236+
1237+ tool({
1238+ name: "read_wiki_page",
1239+ title: "Read a wiki page",
1240+ description: "A wiki page's Markdown, with who changed it last. _Sidebar and _Footer are the pages shown around every page.",
1241+ access: "read",
1242+ params: { owner, repo, title: pageTitleParam.default("Home") },
1243+ run: async (args, ctx) => {
1244+ const { repo, head, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "read");
1245+ const page = findPage(pages, pageSlug(args.title));
1246+ if (!head || !page) throw new ToolError(`The wiki has no page ${args.title}.${pages.length ? " List them with list_wiki_pages." : " It has no pages yet."}`);
1247+ const [{ text, size }, edit] = await Promise.all([readPage(repo.wikiDir, head, page.path), lastCommit(repo.wikiDir, head, page.path)]);
1248+ if (text === null) throw new ToolError(`${page.title} is ${size} bytes, too large to read here. Clone the wiki instead.`);
1249+ return {
1250+ title: page.title,
1251+ path: page.path,
1252+ url: pageHref(wiki, page.slug),
1253+ last_edited_by: edit?.authorName ?? null,
1254+ last_edited_at: iso(edit?.date ?? null),
1255+ content: truncate(text, "Clone the wiki to read all of it."),
1256+ };
1257+ },
1258+ }),
1259+
1260+ tool({
1261+ name: "write_wiki_page",
1262+ title: "Write a wiki page",
1263+ description: [
1264+ "Creates a wiki page, or replaces an existing page's Markdown, in one commit to the wiki as you. The first page creates the wiki.",
1265+ "Link pages with [[Page Title]] or [[Text|Page Title]]. new_title renames the page.",
1266+ ].join(" "),
1267+ access: "write",
1268+ idempotent: true,
1269+ params: {
1270+ owner,
1271+ repo,
1272+ title: pageTitleParam,
1273+ content: string("The page's Markdown. Replaces what the page had.", { max: 1024 * 1024 }),
1274+ new_title: string("A new title for an existing page.", { min: 1, max: 200 }).optional(),
1275+ message: string("A message for the page's history. Defaults to Create/Update <title>.", { max: 500 }).optional(),
1276+ },
1277+ run: async (args, ctx) => {
1278+ const { repo, pages, wiki } = await openWiki(ctx, args.owner, args.repo, "write");
1279+ const existing = findPage(pages, pageSlug(args.title));
1280+ if (args.new_title && !existing) throw new ToolError(`The wiki has no page ${args.title} to rename.`);
1281+ const slug = await savePage(
1282+ repo,
1283+ { name: ctx.user.name || ctx.user.username, email: ctx.user.email },
1284+ { originalPath: existing?.path ?? null, title: args.new_title ?? existing?.title ?? args.title, content: args.content, message: args.message },
1285+ );
1286+ return { created: !existing, url: pageHref(wiki, slug) };
1287+ },
1288+ }),
10981289 ];
10991290
1100- export const TOOLS: Tool[] = [...accountTools, ...codeTools, ...issueTools, ...releaseTools, ...actionTools];
1291+ export const TOOLS: Tool[] = [...accountTools, ...codeTools, ...issueTools, ...releaseTools, ...actionTools, ...wikiTools];
+2−0lib/paths.tsView File
@@ -2,3 +2,5 @@ import path from "node:path";
22
33 export const DATA_ROOT = process.env.DATA_ROOT ?? path.join(process.cwd(), "data");
44 export const REPOS_ROOT = path.join(DATA_ROOT, "repos");
5+ /** Wikis are git repositories of their own, kept by repository id so renames don't move them. */
6+ export const WIKIS_ROOT = path.join(DATA_ROOT, "wikis");
+22−2lib/release-http.tsView File
@@ -18,13 +18,15 @@ import {
1818 type Release,
1919 } from "./releases";
2020 import { canWrite, findRepo, repoAccess, type Access, type RepoRecord } from "./repos";
21+ import { verifyUpload } from "./release-upload";
2122 import { authenticateToken, basicAuthToken, tokenCovers } from "./tokens";
2223
2324 // Downloading release files and source archives, and uploading release files from scripts:
2425 //
2526 // GET /<owner>/<repo>/releases/download/<tag>/<file>
2627 // GET /<owner>/<repo>/releases/latest/download/<file>
27- // PUT /<owner>/<repo>/releases/download/<tag>/<file> (curl -T <file> -u <you>:vec_… <url>)
28+ // PUT /<owner>/<repo>/releases/download/<tag>/<file> (curl -T <file> -u <you>:vec_… <url>,
29+ // or ?upload=… from an agent's signed URL)
2830 // DELETE /<owner>/<repo>/releases/download/<tag>/<file>
2931 // GET /<owner>/<repo>/archive/<ref>.zip, .tar.gz
3032 //
@@ -159,13 +161,16 @@ export async function downloadLatestAsset(request: Request, owner: string, repoP
159161 export async function uploadAsset(request: Request, owner: string, repoParam: string, segments: string[]) {
160162 const target = splitTagAndName(segments);
161163 if (!target) return notFound();
162- const caller = await authorize(request, owner, repoParam, "write");
164+ const signed = new URL(request.url).searchParams.get("upload");
165+ const caller = signed ? await authorizeSigned(signed, owner, repoParam, target) : await authorize(request, owner, repoParam, "write");
163166 if (caller instanceof Response) return caller;
164167 if (!request.body) return text(400, "Send the file as the request body.");
165168 if (!cleanAssetName(target.name)) return text(400, `${target.name} is not a valid file name.`);
166169
167170 try {
168171 let release = await findRelease(caller.repo.id, target.tag);
172+ // A signed URL names its release; it can't create one.
173+ if ("releaseId" in caller && release?.id !== caller.releaseId) return text(403, "This upload URL is for another release.");
169174 if (!release) {
170175 if (!(await tagCommit(caller.repo, target.tag))) {
171176 return text(404, `There is no release or tag named ${target.tag}. Push the tag or create the release first.`);
@@ -182,6 +187,21 @@ export async function uploadAsset(request: Request, owner: string, repoParam: st
182187 }
183188 }
184189
190+ /**
191+ * An upload with a signed URL (lib/release-upload.ts) instead of credentials: allowed for the one
192+ * file it names, while its signer can still push to the repository.
193+ */
194+ async function authorizeSigned(value: string, owner: string, repoParam: string, target: { tag: string; name: string }) {
195+ const upload = verifyUpload(value);
196+ if (upload === "expired") return text(401, "This upload URL has expired. Ask for a new one.");
197+ const repo = await findRepo(owner, normalizeRepoName(repoParam));
198+ if (!upload || !repo || upload.repoId !== repo.id) return text(403, "Invalid upload URL.");
199+ if (upload.name !== target.name) return text(403, `This upload URL is for ${upload.name}, not ${target.name}.`);
200+ const access = await repoAccess(repo, upload.userId);
201+ if (!canWrite(access)) return text(403, `Permission to ${repo.owner}/${repo.name} denied.`);
202+ return { repo, access: access!, userId: upload.userId, releaseId: upload.releaseId };
203+ }
204+
185205 /** DELETE /<owner>/<repo>/releases/download/<tag>/<file> */
186206 export async function removeAsset(request: Request, owner: string, repoParam: string, segments: string[]) {
187207 const target = splitTagAndName(segments);
+53−0lib/release-upload.tsaddedView File
@@ -0,0 +1,53 @@
1+ import "server-only";
2+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4+ import path from "node:path";
5+ import { DATA_ROOT } from "./paths";
6+
7+ /*
8+ * Signed upload URLs for release files, so an agent can stream a file (`curl -T <file> <url>`)
9+ * without holding an API token, and without the file passing through its context. A URL is good for
10+ * one file name of one release, for its signer, for UPLOAD_URL_MINUTES. Uploading still needs the
11+ * signer to have push access then, so removing a collaborator also ends their URLs.
12+ */
13+
14+ export const UPLOAD_URL_MINUTES = 30;
15+
16+ type Payload = { repoId: string; releaseId: string; name: string; userId: string; expiresAt: number };
17+
18+ const g = globalThis as unknown as { __releaseUploadKey?: Buffer };
19+
20+ /** Generated once and kept in the data directory, so URLs survive a restart. */
21+ function key() {
22+ if (g.__releaseUploadKey) return g.__releaseUploadKey;
23+ const file = path.join(DATA_ROOT, "release-upload-key");
24+ if (!existsSync(file)) {
25+ mkdirSync(DATA_ROOT, { recursive: true });
26+ try {
27+ writeFileSync(file, randomBytes(32).toString("hex"), { mode: 0o600, flag: "wx" });
28+ } catch (e) {
29+ // Another request created it first.
30+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
31+ }
32+ }
33+ return (g.__releaseUploadKey = Buffer.from(readFileSync(file, "utf8").trim(), "hex"));
34+ }
35+
36+ const sign = (data: string) => createHmac("sha256", key()).update(data).digest("base64url");
37+
38+ /** The value of an upload URL's `upload` parameter. */
39+ export function signUpload(payload: Omit<Payload, "expiresAt">) {
40+ const data = Buffer.from(JSON.stringify({ ...payload, expiresAt: Date.now() + UPLOAD_URL_MINUTES * 60e3 })).toString("base64url");
41+ return `${data}.${sign(data)}`;
42+ }
43+
44+ /** The upload a signed value allows, "expired", or null if it isn't one this server signed. */
45+ export function verifyUpload(value: string): Payload | "expired" | null {
46+ const [data, signature, extra] = value.split(".");
47+ if (!data || !signature || extra !== undefined) return null;
48+ const expected = Buffer.from(sign(data));
49+ const actual = Buffer.from(signature);
50+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
51+ const payload = JSON.parse(Buffer.from(data, "base64url").toString("utf8")) as Payload;
52+ return payload.expiresAt < Date.now() ? "expired" : payload;
53+ }
+9−0lib/repo.tsView File
@@ -66,6 +66,15 @@ export async function cloneUrls(repo: RepoRecord) {
6666 };
6767 }
6868
69+ /** The wiki is cloned like the repository, as <name>.wiki.git. */
70+ export async function wikiCloneUrls(repo: RepoRecord) {
71+ const [origin, host] = await Promise.all([serverOrigin(), serverHost()]);
72+ return {
73+ https: `${origin}/${repo.owner}/${repo.name}.wiki.git`,
74+ ssh: sshCloneUrl(host, `${repo.owner}/${repo.name}.wiki.git`),
75+ };
76+ }
77+
6978 /** The repository's container image, e.g. vectrum.cc/alice/app. Image names are lowercase. */
7079 export async function imageName(repo: RepoRecord) {
7180 return `${await serverHost()}/${repo.owner}/${repo.name}`.toLowerCase();
+38−2lib/repos.tsView File
@@ -1,7 +1,7 @@
11 import { rename, rm, mkdir } from "node:fs/promises";
22 import path from "node:path";
33 import { db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db";
4- import { GitError, initBareRepo, isValidName, lastActivity, pathExists, repoPath } from "./git";
4+ import { GitError, initBareRepo, isValidName, lastActivity, normalizeRepoName, pathExists, repoPath, wikiPath } from "./git";
55 import { commitFiles } from "./git-write";
66 import { deleteRepoImages } from "./registry";
77 import { deleteRepoReleaseFiles } from "./releases";
@@ -21,7 +21,10 @@ export type RepoRecord = {
2121 pushedAt: number | null;
2222 /** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */
2323 imageVersion: number | null;
24+ wikiEnabled: boolean;
2425 dir: string;
26+ /** The wiki's git repository; it only exists once the first page is written. */
27+ wikiDir: string;
2528 };
2629
2730 const EXPAND = "owner";
@@ -38,7 +41,9 @@ function toRepo(r: RecordModel): RepoRecord {
3841 createdAt: r.created_at,
3942 pushedAt: orNull(r.pushed_at),
4043 imageVersion: r.image ? orNull(r.image_updated_at) : null,
44+ wikiEnabled: !r.wiki_disabled,
4145 dir: repoPath(owner, r.name),
46+ wikiDir: wikiPath(r.id),
4247 };
4348 }
4449
@@ -60,6 +65,26 @@ export async function findRepoById(id: string) {
6065 return row ? toRepo(row) : null;
6166 }
6267
68+ /**
69+ * What a git URL names: a repository, or the wiki of one as <name>.wiki.git (like GitHub). A
70+ * repository that is itself named "<name>.wiki" (from before such names were refused) wins.
71+ */
72+ export async function findGitTarget(owner: string, repoParam: string) {
73+ const name = normalizeRepoName(repoParam);
74+ const repo = await findRepo(owner, name);
75+ if (repo) return { repo, dir: repo.dir, wiki: false };
76+ if (!name.toLowerCase().endsWith(".wiki")) return null;
77+ const parent = await findRepo(owner, name.slice(0, -5));
78+ return parent?.wikiEnabled ? { repo: parent, dir: parent.wikiDir, wiki: true } : null;
79+ }
80+
81+ /** Creates the wiki's git repository if nothing has been written to it yet. */
82+ export async function ensureWikiRepo(repo: RepoRecord) {
83+ if (await pathExists(repo.wikiDir)) return;
84+ await mkdir(path.dirname(repo.wikiDir), { recursive: true });
85+ await initBareRepo(repo.wikiDir);
86+ }
87+
6388 export async function repoAccess(repo: RepoRecord, userId: string | null | undefined): Promise<Access> {
6489 if (userId && repo.ownerId === userId) return "admin";
6590 if (userId && (await findFirst("collaborators", filter("repo = {:repo} && user = {:userId}", { repo: repo.id, userId }), { fields: "id" }))) {
@@ -95,6 +120,9 @@ export async function searchRepos(query: string, viewerId: string | null, limit
95120 return list(`${visibleTo(viewerId)} && ${filter("(name ~ {:q} || description ~ {:q} || owner.username ~ {:q})", { q: query })}`, limit);
96121 }
97122
123+ // <name>.wiki.git is the clone URL of a repository's wiki.
124+ const isWikiName = (name: string) => name.toLowerCase().endsWith(".wiki");
125+
98126 export async function createRepo(
99127 owner: User,
100128 input: { name: string; description: string; visibility: Visibility; readme: boolean },
@@ -103,6 +131,7 @@ export async function createRepo(
103131 if (!isValidName(name)) {
104132 throw new RepoError("Repository names may only contain letters, digits, '.', '-' and '_', and may not end in .git.");
105133 }
134+ if (isWikiName(name)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from.");
106135 if (await findRepo(owner.username, name)) throw new RepoError(`You already have a repository named ${name}.`);
107136
108137 const dir = repoPath(owner.username, name);
@@ -150,9 +179,15 @@ export async function updateRepo(repoId: string, data: { description: string; vi
150179 await (await repos()).update(repoId, { description: data.description.trim().slice(0, 350), visibility: data.visibility });
151180 }
152181
182+ /** Turning the wiki off hides it and refuses git access to it, but keeps its pages. */
183+ export async function setWikiEnabled(repoId: string, enabled: boolean) {
184+ await (await repos()).update(repoId, { wiki_disabled: !enabled });
185+ }
186+
153187 export async function renameRepo(repo: RepoRecord, newName: string) {
154188 newName = newName.trim();
155189 if (!isValidName(newName)) throw new RepoError("That repository name is invalid.");
190+ if (isWikiName(newName)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from.");
156191 if (newName === repo.name) return repo;
157192 const existing = await findRepo(repo.owner, newName);
158193 if (existing && existing.id !== repo.id) throw new RepoError(`You already have a repository named ${newName}.`);
@@ -168,13 +203,14 @@ export async function renameRepo(repo: RepoRecord, newName: string) {
168203 return (await findRepoById(repo.id))!;
169204 }
170205
171- /** Deletes the repository; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */
206+ /** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */
172207 export async function deleteRepo(repo: RepoRecord) {
173208 await (await repos()).delete(repo.id);
174209 // Frees the container images and the blobs no other repository uses.
175210 deleteRepoImages(repo.id);
176211 await deleteRepoReleaseFiles(repo.id);
177212 await rm(repo.dir, { recursive: true, force: true });
213+ await rm(repo.wikiDir, { recursive: true, force: true });
178214 }
179215
180216 // ---------------------------------------------------------------------------
+14−7lib/ssh-server.tsView File
@@ -2,10 +2,10 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
22 import path from "node:path";
33 import { Server, utils, type Connection } from "ssh2";
44 import { onRefsUpdated, startWorkflowsInBackground } from "./ci";
5- import { normalizeRepoName, snapshotRefs, spawnService } from "./git";
5+ import { pathExists, snapshotRefs, spawnService } from "./git";
66 import { findKeyByBlob, touchSshKey } from "./keys";
77 import { DATA_ROOT } from "./paths";
8- import { canWrite, findRepo, markPushed, repoAccess } from "./repos";
8+ import { canWrite, ensureWikiRepo, findGitTarget, markPushed, repoAccess } from "./repos";
99 import { SSH_LISTEN_HOST, SSH_PORT } from "./ssh-config";
1010 import { getUserById, type User } from "./users";
1111
@@ -76,17 +76,24 @@ function handleConnection(client: Connection) {
7676 if (!match || !user) return fail("Only git clone, fetch and push are supported.");
7777 const [, service, owner, repoName] = match;
7878 const userId = user.id;
79- const repo = await findRepo(owner, normalizeRepoName(repoName)).catch(() => null);
79+ // <name>.wiki.git is the repository's wiki, with the repository's permissions.
80+ const target = await findGitTarget(owner, repoName).catch(() => null);
81+ const repo = target?.repo;
8082 const access = repo ? await repoAccess(repo, user.id).catch(() => null) : null;
81- if (!repo || !access) return fail("Repository not found.");
83+ if (!target || !repo || !access) return fail("Repository not found.");
8284 const pushing = service === "receive-pack";
8385 if (pushing && !canWrite(access)) {
8486 return fail(`Permission to ${repo.owner}/${repo.name} denied to ${user.username}.`);
8587 }
88+ if (target.wiki) {
89+ // The first push to a wiki creates it; until then there is nothing to fetch.
90+ if (pushing) await ensureWikiRepo(repo);
91+ else if (!(await pathExists(target.dir))) return fail("Repository not found.");
92+ }
8693
87- // Pushes start workflows for the refs they changed.
88- const refsBefore = pushing ? await snapshotRefs(repo.dir) : null;
89- const child = spawnService(pushing ? "git-receive-pack" : "git-upload-pack", repo.dir, {
94+ // Pushes to the code start workflows for the refs they changed; pushes to the wiki don't.
95+ const refsBefore = pushing && !target.wiki ? await snapshotRefs(repo.dir) : null;
96+ const child = spawnService(pushing ? "git-receive-pack" : "git-upload-pack", target.dir, {
9097 stateless: false,
9198 protocol: env.GIT_PROTOCOL,
9299 });
+48−0lib/wiki-links.test.tsaddedView File
@@ -0,0 +1,48 @@
1+ // Run with `bun test`.
2+ import assert from "node:assert/strict";
3+ import { test } from "node:test";
4+ import type { Root } from "mdast";
5+ import { pageHref, pageSlug, pageTitle, remarkWikiLinks, wikiUrl } from "./wiki-links";
6+
7+ const wiki = "/alice/app/wiki";
8+
9+ test("page titles and names convert both ways", () => {
10+ assert.equal(pageSlug(" Getting Started "), "Getting-Started");
11+ assert.equal(pageTitle("Getting-Started"), "Getting Started");
12+ assert.equal(pageHref(wiki, "Home"), wiki);
13+ assert.equal(pageHref(wiki, "C# & .NET"), `${wiki}/C%23%20%26%20.NET`);
14+ });
15+
16+ test("relative links resolve to pages by name, against the wiki's root", () => {
17+ assert.equal(wikiUrl(wiki, "Other-Page", "href"), `${wiki}/Other-Page`);
18+ assert.equal(wikiUrl(wiki, "docs/Other-Page.md#usage", "href"), `${wiki}/Other-Page#usage`);
19+ assert.equal(wikiUrl(wiki, "v1.0-notes", "href"), `${wiki}/v1.0-notes`);
20+ });
21+
22+ test("images go to the raw files, never above the root", () => {
23+ assert.equal(wikiUrl(wiki, "./images/logo.png", "src"), `${wiki}/_raw/images/logo.png`);
24+ assert.equal(wikiUrl(wiki, "../../etc/passwd", "src"), `${wiki}/_raw/etc/passwd`);
25+ });
26+
27+ function linkify(value: string) {
28+ const tree: Root = { type: "root", children: [{ type: "paragraph", children: [{ type: "text", value }] }] };
29+ remarkWikiLinks({ base: wiki })(tree);
30+ return (tree.children[0] as { children: unknown[] }).children;
31+ }
32+
33+ test("[[Page]] and [[Text|Page]] become links", () => {
34+ assert.deepEqual(linkify("See [[Getting Started]] and [[the FAQ|FAQ#install]]."), [
35+ { type: "text", value: "See " },
36+ { type: "link", url: `${wiki}/Getting-Started`, children: [{ type: "text", value: "Getting Started" }] },
37+ { type: "text", value: " and " },
38+ { type: "link", url: `${wiki}/FAQ#install`, children: [{ type: "text", value: "the FAQ" }] },
39+ { type: "text", value: "." },
40+ ]);
41+ });
42+
43+ test("[[image.png]] embeds the image; external targets stay as they are", () => {
44+ assert.deepEqual(linkify("[[images/arch.png]][[Docs|https://example.com]]"), [
45+ { type: "image", url: `${wiki}/_raw/images/arch.png`, alt: "arch.png" },
46+ { type: "link", url: "https://example.com", children: [{ type: "text", value: "Docs" }] },
47+ ]);
48+ });
+81−0lib/wiki-links.tsaddedView File
@@ -0,0 +1,81 @@
1+ import type { Nodes, PhrasingContent, Root } from "mdast";
2+ import { encodePath } from "./format";
3+
4+ // Wiki page names and links, shared by the server renderer and the editor's preview (so no node:path).
5+
6+ /** "Getting Started" → "Getting-Started", the page's file and URL name. */
7+ export const pageSlug = (title: string) => title.trim().replace(/\s+/g, "-");
8+
9+ /** "Getting-Started" → "Getting Started". */
10+ export const pageTitle = (slug: string) => slug.replace(/-/g, " ");
11+
12+ /** A page's URL, under the wiki's base (e.g. /alice/app/wiki). */
13+ export const pageHref = (wiki: string, slug: string) => (slug === "Home" ? wiki : `${wiki}/${encodeURIComponent(slug)}`);
14+
15+ const PAGE_EXT = /\.(md|markdown|mdown|mkdn|mkd)$/i;
16+ const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i;
17+
18+ /** "a/./b/../c.png" → "a/c.png", never leaving the root. */
19+ function normalize(target: string) {
20+ const parts: string[] = [];
21+ for (const part of target.split("/")) {
22+ if (part === "..") parts.pop();
23+ else if (part && part !== ".") parts.push(part);
24+ }
25+ return parts.join("/");
26+ }
27+
28+ const basename = (file: string) => file.slice(file.lastIndexOf("/") + 1);
29+
30+ /**
31+ * Where a relative URL in a page points. Page URLs are flat (/wiki/<name>, wherever the file is),
32+ * so links resolve against the wiki's root: "Other-Page" and "docs/Other-Page.md" to that page,
33+ * images to their content in the wiki's repository, and other files to /wiki/<path>, which serves
34+ * them when no page has that name.
35+ */
36+ export function wikiUrl(wiki: string, url: string, key: string) {
37+ const [target, hash] = url.split("#");
38+ const resolved = normalize(target);
39+ const suffix = hash ? `#${hash}` : "";
40+ if (key === "src") return `${wiki}/_raw/${encodePath(resolved)}${suffix}`;
41+ if (PAGE_EXT.test(resolved)) return `${pageHref(wiki, pageSlug(basename(resolved).replace(PAGE_EXT, "")))}${suffix}`;
42+ return `${wiki}/${encodePath(resolved)}${suffix}`;
43+ }
44+
45+ /** [[Page]], [[Text|Page]], [[Page#Section]] and [[image.png]]. */
46+ const WIKI_LINK = /\[\[([^[\]\n]+?)\]\]/g;
47+
48+ function linkNode(wiki: string, inner: string): PhrasingContent {
49+ const bar = inner.indexOf("|");
50+ const text = (bar < 0 ? inner : inner.slice(0, bar)).trim();
51+ const target = (bar < 0 ? inner : inner.slice(bar + 1)).trim();
52+ if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return { type: "link", url: target, children: [{ type: "text", value: text }] };
53+ if (IMAGE_EXT.test(target)) return { type: "image", url: wikiUrl(wiki, target, "src"), alt: bar < 0 ? basename(target) : text };
54+ const [page, hash] = target.split("#");
55+ const url = page ? `${pageHref(wiki, pageSlug(page))}${hash ? `#${hash}` : ""}` : `#${hash ?? ""}`;
56+ return { type: "link", url, children: [{ type: "text", value: text }] };
57+ }
58+
59+ function split(wiki: string, value: string): PhrasingContent[] {
60+ const out: PhrasingContent[] = [];
61+ let last = 0;
62+ for (const m of value.matchAll(WIKI_LINK)) {
63+ if (m.index > last) out.push({ type: "text", value: value.slice(last, m.index) });
64+ out.push(linkNode(wiki, m[1]));
65+ last = m.index + m[0].length;
66+ }
67+ if (last < value.length) out.push({ type: "text", value: value.slice(last) });
68+ return out;
69+ }
70+
71+ function visit(wiki: string, node: Nodes) {
72+ // Links can't hold links; code keeps its text as it is (it isn't made of text nodes anyway).
73+ if (!("children" in node) || node.type === "link" || node.type === "linkReference") return;
74+ const children = node.children as Nodes[];
75+ node.children = children.flatMap((child) => (child.type === "text" && child.value.includes("[[") ? split(wiki, child.value) : (visit(wiki, child), [child]))) as never;
76+ }
77+
78+ /** A remark plugin turning wiki links into links to pages under `base`. */
79+ export function remarkWikiLinks({ base }: { base: string }) {
80+ return (tree: Root) => visit(base, tree);
81+ }
+146−0lib/wiki.tsaddedView File
@@ -0,0 +1,146 @@
1+ import "server-only";
2+ import path from "node:path/posix";
3+ import { defaultBranch, git, GitError, listFilesWithSizes, log, pathExists, readBlob, revParse } from "./git";
4+ import { commitFiles, type FileChange } from "./git-write";
5+ import { ensureWikiRepo, type RepoRecord } from "./repos";
6+ import { pageSlug, pageTitle } from "./wiki-links";
7+
8+ // A repository's wiki, like GitHub's: a git repository of its own (cloned as <name>.wiki.git) whose
9+ // Markdown files are the pages. A page's name is its file name without the extension, with dashes
10+ // for spaces ("Getting-Started.md" is "Getting Started"), wherever it is in the tree. _Sidebar and
11+ // _Footer are shown around every page.
12+
13+ export { pageSlug, pageTitle };
14+
15+ const PAGE_EXT = /\.(md|markdown|mdown|mkdn|mkd)$/i;
16+
17+ /** The special pages shown around every page, and the only page names that may start with "_". */
18+ export const SIDEBAR = "_Sidebar";
19+ export const FOOTER = "_Footer";
20+
21+ export type WikiPage = { slug: string; title: string; path: string };
22+
23+ export class WikiError extends Error {}
24+
25+ /** The wiki's branch and its latest commit; both null before the first page is written. */
26+ export async function wikiHead(repo: RepoRecord) {
27+ if (!(await pathExists(repo.wikiDir))) return { branch: null, head: null };
28+ const branch = await defaultBranch(repo.wikiDir);
29+ const head = branch ? await revParse(repo.wikiDir, `refs/heads/${branch}`) : null;
30+ return { branch, head };
31+ }
32+
33+ /** Every page at a commit: Home first, then by title. Files outside the root count too. */
34+ export async function listPages(dir: string, commit: string): Promise<WikiPage[]> {
35+ const files = await listFilesWithSizes(dir, commit);
36+ return files
37+ .filter((f) => PAGE_EXT.test(f.path))
38+ .map((f) => {
39+ const slug = path.basename(f.path).replace(PAGE_EXT, "");
40+ return { slug, title: pageTitle(slug), path: f.path };
41+ })
42+ .sort((a, b) => Number(b.slug === "Home") - Number(a.slug === "Home") || a.title.localeCompare(b.title, "en", { numeric: true }));
43+ }
44+
45+ /** The pages listed in the wiki, without _Sidebar and _Footer. */
46+ export const contentPages = (pages: WikiPage[]) => pages.filter((p) => !p.slug.startsWith("_"));
47+
48+ const fold = (slug: string) => slug.toLowerCase().replace(/[\s-]+/g, "-");
49+
50+ /**
51+ * The page a link names: the exact name, or else ignoring case and spaces vs. dashes. If files in
52+ * different folders share a name, the one closest to the root wins.
53+ */
54+ export function findPage(pages: WikiPage[], slug: string) {
55+ const byDepth = (list: WikiPage[]) => list.sort((a, b) => a.path.split("/").length - b.path.split("/").length)[0] ?? null;
56+ return byDepth(pages.filter((p) => p.slug === slug)) ?? byDepth(pages.filter((p) => fold(p.slug) === fold(slug)));
57+ }
58+
59+ /** Why a page title typed by a user can't be used, or null if it can. */
60+ export function titleProblem(title: string) {
61+ const t = title.trim();
62+ if (!t) return "Give the page a title.";
63+ if (t.length > 200) return "Page titles can be at most 200 characters.";
64+ if (/[/\\\0-\x1f\x7f]/.test(t)) return "Page titles may not contain slashes.";
65+ if (t.startsWith(".")) return "Page titles may not start with a dot.";
66+ if (t.startsWith("_") && t !== SIDEBAR && t !== FOOTER) return `Page titles may not start with “_”, except ${SIDEBAR} and ${FOOTER}.`;
67+ return null;
68+ }
69+
70+ export async function readPage(dir: string, commit: string, file: string) {
71+ const blob = await readBlob(dir, commit, file);
72+ if (blob.tooLarge) return { text: null, size: blob.size };
73+ return { text: blob.content.toString("utf8"), size: blob.size };
74+ }
75+
76+ /** The commits that changed the wiki, or one of its files, newest first. */
77+ export function wikiHistory(dir: string, commit: string, opts: { path?: string; limit: number; skip: number }) {
78+ return log(dir, commit, opts);
79+ }
80+
81+ /** How many commits changed a file. */
82+ export async function revisionCount(dir: string, commit: string, file: string) {
83+ return Number((await git(dir, ["rev-list", "--count", "--end-of-options", commit, "--", file])).trim());
84+ }
85+
86+ type Author = { name: string; email: string };
87+
88+ async function blobAt(dir: string, commit: string, file: string) {
89+ return (await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${commit}:${file}`]).catch(() => "")).trim() || null;
90+ }
91+
92+ /**
93+ * Commits a page to the wiki, creating the wiki on its first page. `originalPath` is the file of the
94+ * page being edited (null for a new one); a changed title renames it in place. With `baseSha`, the
95+ * commit the editor was opened at, the save is refused if someone changed the page since.
96+ * Returns the page's name.
97+ */
98+ export async function savePage(
99+ repo: RepoRecord,
100+ author: Author,
101+ input: { originalPath: string | null; title: string; content: string; message?: string; baseSha?: string },
102+ ) {
103+ const problem = titleProblem(input.title);
104+ if (problem) throw new WikiError(problem);
105+ await ensureWikiRepo(repo);
106+ const dir = repo.wikiDir;
107+ const { branch, head } = await wikiHead(repo);
108+ const pages = head ? await listPages(dir, head) : [];
109+ const original = input.originalPath ? pages.find((p) => p.path === input.originalPath) : null;
110+ if (input.originalPath && !original) throw new WikiError("This page was deleted in the meantime. Copy your text and create it again.");
111+ if (original && input.baseSha && head && input.baseSha !== head && (await blobAt(dir, input.baseSha, original.path)) !== (await blobAt(dir, head, original.path))) {
112+ throw new WikiError(`Someone else changed ${original.title} since you started editing. Copy your text, reload and edit again.`);
113+ }
114+
115+ const slug = pageSlug(input.title);
116+ const taken = findPage(pages, slug);
117+ if (taken && taken.path !== original?.path) throw new WikiError(`A page named ${taken.title} already exists.`);
118+
119+ // A renamed page keeps its folder and extension.
120+ const file = !original
121+ ? `${slug}.md`
122+ : original.slug === slug
123+ ? original.path
124+ : path.join(path.dirname(original.path), slug + path.extname(original.path));
125+ const changes: FileChange[] = [{ path: file, content: Buffer.from(input.content.replace(/\r\n?/g, "\n")) }];
126+ if (original && original.path !== file) changes.unshift({ path: original.path, delete: true });
127+
128+ const title = pageTitle(slug);
129+ const message = input.message?.trim() || (!original ? `Create ${title}` : original.path !== file ? `Rename ${original.title} to ${title}` : `Update ${title}`);
130+ try {
131+ await commitFiles(dir, { branch: branch ?? "main", parent: head, changes, message: `${message}\n`, author });
132+ } catch (e) {
133+ if (e instanceof GitError && /No changes/.test(e.message) && original) return slug;
134+ throw e;
135+ }
136+ return slug;
137+ }
138+
139+ export async function deletePage(repo: RepoRecord, author: Author, file: string) {
140+ const { branch, head } = await wikiHead(repo);
141+ if (!branch || !head) throw new WikiError("This wiki has no pages.");
142+ const page = (await listPages(repo.wikiDir, head)).find((p) => p.path === file);
143+ if (!page) throw new WikiError("This page was deleted already.");
144+ await commitFiles(repo.wikiDir, { branch, parent: head, changes: [{ path: file, delete: true }], message: `Delete ${page.title}\n`, author });
145+ return page;
146+ }
+15−0pb_migrations/1790971005_wiki.jsaddedView File
@@ -0,0 +1,15 @@
1+ // Repositories have a wiki (a git repository of its own, under data/wikis/) unless the owner turns
2+ // it off. Stored as "disabled" so existing repositories get one.
3+
4+ migrate(
5+ (app) => {
6+ const repos = app.findCollectionByNameOrId("repos");
7+ repos.fields.add(new BoolField({ name: "wiki_disabled" }));
8+ app.save(repos);
9+ },
10+ (app) => {
11+ const repos = app.findCollectionByNameOrId("repos");
12+ repos.fields.removeByName("wiki_disabled");
13+ app.save(repos);
14+ },
15+ );