- 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
Showing 31 changed files with 1738 additions and 154 deletions.
| @@ -13,6 +13,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 13 | 13 | - **Git over HTTPS**: smart HTTP protocol (v0 and v2). Authenticate with API tokens |
| 14 | 14 | - **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 |
| 15 | 15 | - **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* | |
| 16 | 17 | - **API tokens**: read-only or read & write, for all repositories or selected ones, with expiry. Only a hash is stored |
| 17 | 18 | - **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 |
| 18 | 19 | - **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 | ||
| 95 | 96 | the host to Docker's `insecure-registries`). A reverse proxy in front of |
| 96 | 97 | Vectrum must pass large request bodies through (e.g. nginx `client_max_body_size 0;`). |
| 97 | 98 | |
| 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 | + | |
| 98 | 117 | ### Publishing releases |
| 99 | 118 | |
| 100 | 119 | 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: | ||
| 162 | 181 | | Account and repositories | `get_me`, `list_repositories`, `search_repositories`, `get_repository`, `create_repository`, `update_repository` | |
| 163 | 182 | | 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) | |
| 164 | 183 | | 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) | | |
| 166 | 186 | | Actions | `list_workflows`, `list_workflow_runs`, `get_workflow_run`, `get_job_logs`, `run_workflow`, `cancel_workflow_run`, `rerun_workflow_run` | |
| 167 | 187 | |
| 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`. | |
| 169 | 189 | |
| 170 | 190 | ## Configuration |
| 171 | 191 | |
| @@ -193,6 +213,7 @@ data/ | ||
| 193 | 213 | ssh_host_ed25519_key generated on first start |
| 194 | 214 | hooks/ server-side git hooks for every push, rewritten on start |
| 195 | 215 | repos/<owner>/<repo>.git bare git repositories |
| 216 | + wikis/<repo>.git each repository's wiki, named by its record id | |
| 196 | 217 | registry.db container image manifests, tags and blob records (SQLite) |
| 197 | 218 | registry/blobs/sha256/… container image layers, shared by all repositories |
| 198 | 219 | registry/uploads/ image uploads in progress |
| @@ -224,6 +245,7 @@ kept so pushes in progress don't lose their layers). | ||
| 224 | 245 | - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories |
| 225 | 246 | - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`) |
| 226 | 247 | - `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/` | |
| 227 | 249 | - `lib/releases.ts`, `lib/release-http.ts`: releases and their files, and the download, upload and source archive routes |
| 228 | 250 | - `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 |
| 229 | 251 | - `app/actions/*`: server actions, each re-checking the session and permissions |
| @@ -1,103 +1,12 @@ | ||
| 1 | 1 | import { notFound } from "next/navigation"; |
| 2 | 2 | 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"; | |
| 6 | 7 | import { encodePath, shortSha, timeAgo } from "@/lib/format"; |
| 7 | 8 | import { getRepo } from "@/lib/repo"; |
| 8 | 9 | |
| 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 | - | |
| 101 | 10 | export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/commit/[sha]">) { |
| 102 | 11 | const { owner, repo: repoParam, sha } = await params; |
| 103 | 12 | const repo = await getRepo(owner, repoParam); |
| @@ -107,8 +16,7 @@ export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/ | ||
| 107 | 16 | const additions = files.reduce((n, f) => n + f.additions, 0); |
| 108 | 17 | const deletions = files.reduce((n, f) => n + f.deletions, 0); |
| 109 | 18 | |
| 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); | |
| 112 | 20 | |
| 113 | 21 | return ( |
| 114 | 22 | <div> |
| @@ -158,7 +66,7 @@ export default async function CommitPage({ params }: PageProps<"/[owner]/[repo]/ | ||
| 158 | 66 | )} |
| 159 | 67 | |
| 160 | 68 | {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]} /> | |
| 162 | 70 | ))} |
| 163 | 71 | </div> |
| 164 | 72 | ); |
| @@ -3,8 +3,8 @@ import { RepoTabs } from "@/components/repo-tabs"; | ||
| 3 | 3 | import { Label, RepoAvatar, VisibilityLabel } from "@/components/ui"; |
| 4 | 4 | import { openIssueCount } from "@/lib/issues"; |
| 5 | 5 | import { countImages } from "@/lib/registry"; |
| 6 | - import { countReleases } from "@/lib/releases"; | |
| 7 | 6 | import { getRepo } from "@/lib/repo"; |
| 7 | + import { wikiHead } from "@/lib/wiki"; | |
| 8 | 8 | |
| 9 | 9 | export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]">) { |
| 10 | 10 | const { owner, repo } = await params; |
| @@ -18,6 +18,8 @@ export async function generateMetadata({ params }: LayoutProps<"/[owner]/[repo]" | ||
| 18 | 18 | export default async function RepoLayout({ params, children }: LayoutProps<"/[owner]/[repo]">) { |
| 19 | 19 | const { owner, repo: repoParam } = await params; |
| 20 | 20 | 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); | |
| 21 | 23 | |
| 22 | 24 | return ( |
| 23 | 25 | <> |
| @@ -39,7 +41,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 39 | 41 | <VisibilityLabel visibility={repo.visibility} /> |
| 40 | 42 | {repo.access === "write" && <Label tone="accent">Collaborator</Label>} |
| 41 | 43 | </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} /> | |
| 43 | 45 | </div> |
| 44 | 46 | </div> |
| 45 | 47 | <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div> |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | 3 | import { useActionState, useEffect, useState } from "react"; |
| 4 | + import { Checkbox } from "@unlocalhosted/metalui"; | |
| 4 | 5 | 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"; | |
| 6 | 7 | import { ChoiceCards, TextField } from "@/components/settings-fields"; |
| 7 | 8 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 8 | 9 | import { Alert, Button, buttonClass, RepoAvatar, type RepoImageRef } from "@/components/ui"; |
| @@ -133,6 +134,36 @@ export function GeneralForm({ | ||
| 133 | 134 | ); |
| 134 | 135 | } |
| 135 | 136 | |
| 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 | + | |
| 136 | 167 | /** Deleting the repository: one red cap opens the type-to-confirm form, whose red cap does it. */ |
| 137 | 168 | export function DangerZone({ action, fullName }: { action: Action; fullName: string }) { |
| 138 | 169 | const [state, formAction, pending] = useActionState(action, undefined); |
| @@ -1,9 +1,16 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 10 | import { PlateSection } from "@/components/settings-plate"; |
| 4 | 11 | import { PageTitle } from "@/components/ui"; |
| 5 | 12 | import { getRepo } from "@/lib/repo"; |
| 6 | - import { DangerZone, GeneralForm, ImageForm, RenameForm } from "./forms"; | |
| 13 | + import { DangerZone, FeaturesForm, GeneralForm, ImageForm, RenameForm } from "./forms"; | |
| 7 | 14 | |
| 8 | 15 | export const metadata: Metadata = { title: "Settings" }; |
| 9 | 16 | |
| @@ -31,6 +38,9 @@ export default async function RepoSettingsPage({ params }: PageProps<"/[owner]/[ | ||
| 31 | 38 | visibility={repo.visibility} |
| 32 | 39 | /> |
| 33 | 40 | </PlateSection> |
| 41 | + <PlateSection title="Features"> | |
| 42 | + <FeaturesForm action={updateFeaturesAction.bind(null, repo.owner, repo.name)} wiki={repo.wikiEnabled} /> | |
| 43 | + </PlateSection> | |
| 34 | 44 | <PlateSection title="Danger zone"> |
| 35 | 45 | <DangerZone action={deleteRepoAction.bind(null, repo.owner, repo.name)} fullName={`${repo.owner}/${repo.name}`} /> |
| 36 | 46 | </PlateSection> |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -11,6 +11,7 @@ import { | ||
| 11 | 11 | renameRepo, |
| 12 | 12 | RepoError, |
| 13 | 13 | setRepoImage, |
| 14 | + setWikiEnabled, | |
| 14 | 15 | updateRepo, |
| 15 | 16 | type Visibility, |
| 16 | 17 | } from "@/lib/repos"; |
| @@ -56,6 +57,15 @@ export async function updateRepoAction(owner: string, name: string, _prev: FormS | ||
| 56 | 57 | }); |
| 57 | 58 | } |
| 58 | 59 | |
| 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 | + | |
| 59 | 69 | export async function updateRepoImageAction(owner: string, name: string, _prev: FormState, formData: FormData) { |
| 60 | 70 | return attempt(async () => { |
| 61 | 71 | const { repo } = await repoForAction(owner, name, "admin"); |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -16,7 +16,7 @@ import { Box, Button, ButtonLink, Kbd, RefChip } from "./ui"; | ||
| 16 | 16 | type Tab = "edit" | "preview"; |
| 17 | 17 | |
| 18 | 18 | /** Loads CodeMirror language support based on the file name. */ |
| 19 | - function useLanguage(filename: string) { | |
| 19 | + export function useLanguage(filename: string) { | |
| 20 | 20 | const [lang, setLang] = useState<{ name: string; ext: Extension } | null>(null); |
| 21 | 21 | useEffect(() => { |
| 22 | 22 | let cancelled = false; |
| @@ -1,16 +1,28 @@ | ||
| 1 | 1 | "use client"; |
| 2 | 2 | |
| 3 | - import ReactMarkdown from "react-markdown"; | |
| 3 | + import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; | |
| 4 | 4 | import rehypeRaw from "rehype-raw"; |
| 5 | 5 | import rehypeSanitize from "rehype-sanitize"; |
| 6 | 6 | import remarkGfm from "remark-gfm"; |
| 7 | + import { remarkWikiLinks, wikiUrl } from "@/lib/wiki-links"; | |
| 7 | 8 | |
| 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 }) { | |
| 10 | 14 | return ( |
| 11 | 15 | <div className="markdown"> |
| 12 | 16 | {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 | + > | |
| 14 | 26 | {children} |
| 15 | 27 | </ReactMarkdown> |
| 16 | 28 | ) : ( |
| @@ -4,32 +4,37 @@ import rehypeRaw from "rehype-raw"; | ||
| 4 | 4 | import rehypeSanitize from "rehype-sanitize"; |
| 5 | 5 | import remarkGfm from "remark-gfm"; |
| 6 | 6 | import { encodePath } from "@/lib/format"; |
| 7 | + import { remarkWikiLinks, wikiUrl } from "@/lib/wiki-links"; | |
| 7 | 8 | |
| 8 | 9 | /** |
| 9 | 10 | * Renders GitHub-flavored markdown. Inline HTML is allowed but sanitized with |
| 10 | 11 | * GitHub's own allowlist, so untrusted READMEs and issue bodies are safe. When |
| 11 | 12 | * `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. | |
| 13 | 15 | */ |
| 14 | 16 | export function Markdown({ |
| 15 | 17 | children, |
| 16 | 18 | context, |
| 19 | + wiki, | |
| 17 | 20 | }: { |
| 18 | 21 | children: string; |
| 19 | 22 | context?: { base: string; gitRef: string; dir: string }; |
| 23 | + wiki?: string; | |
| 20 | 24 | }) { |
| 21 | 25 | return ( |
| 22 | 26 | <div className="markdown"> |
| 23 | 27 | <ReactMarkdown |
| 24 | - remarkPlugins={[remarkGfm]} | |
| 28 | + remarkPlugins={wiki ? [remarkGfm, [remarkWikiLinks, { base: wiki }]] : [remarkGfm]} | |
| 25 | 29 | rehypePlugins={[rehypeRaw, rehypeSanitize]} |
| 26 | 30 | urlTransform={(url, key) => { |
| 27 | 31 | 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); | |
| 29 | 34 | 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(/^(\.\.\/)+/, ""); | |
| 31 | 36 | 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}` : ""}`; | |
| 33 | 38 | }} |
| 34 | 39 | > |
| 35 | 40 | {children} |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import Link from "next/link"; |
| 4 | 4 | import { usePathname } from "next/navigation"; |
| 5 | 5 | 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"; | |
| 7 | 7 | import { SlidingIndicator, thumbClass } from "./sliding-indicator"; |
| 8 | 8 | |
| 9 | 9 | /** |
| @@ -13,26 +13,24 @@ import { SlidingIndicator, thumbClass } from "./sliding-indicator"; | ||
| 13 | 13 | export function RepoTabs({ |
| 14 | 14 | base, |
| 15 | 15 | openIssues, |
| 16 | - releases, | |
| 17 | 16 | images, |
| 17 | + showWiki, | |
| 18 | 18 | showSettings, |
| 19 | 19 | }: { |
| 20 | 20 | base: string; |
| 21 | 21 | openIssues: number; |
| 22 | - releases: number; | |
| 23 | 22 | images: number; |
| 23 | + showWiki: boolean; | |
| 24 | 24 | showSettings: boolean; |
| 25 | 25 | }) { |
| 26 | 26 | const pathname = usePathname(); |
| 27 | 27 | const section = pathname.slice(base.length).split("/")[1] ?? ""; |
| 28 | 28 | 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) }, | |
| 30 | 30 | { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" }, |
| 31 | 31 | { 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" }, | |
| 35 | 32 | { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" }, |
| 33 | + ...(showWiki ? [{ href: `${base}/wiki`, label: "Wiki", icon: BookIcon, active: section === "wiki" }] : []), | |
| 36 | 34 | ...(showSettings ? [{ href: `${base}/settings`, label: "Settings", icon: GearIcon, active: section === "settings" }] : []), |
| 37 | 35 | ]; |
| 38 | 36 | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -2,8 +2,8 @@ import "server-only"; | ||
| 2 | 2 | import { Readable } from "node:stream"; |
| 3 | 3 | import { createGunzip } from "node:zlib"; |
| 4 | 4 | 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"; | |
| 7 | 7 | import { authenticateJobToken } from "./runs"; |
| 8 | 8 | import { authenticateToken, tokenCovers } from "./tokens"; |
| 9 | 9 | |
| @@ -42,33 +42,44 @@ async function credentials(request: Request) { | ||
| 42 | 42 | return (await authenticateToken(password)) ?? "invalid"; |
| 43 | 43 | } |
| 44 | 44 | |
| 45 | - type Authorized = { repo: RepoRecord; userId: string | null }; | |
| 45 | + type Authorized = { repo: RepoRecord; dir: string; wiki: boolean; userId: string | null }; | |
| 46 | 46 | |
| 47 | 47 | /** |
| 48 | 48 | * 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. | |
| 50 | 51 | */ |
| 51 | 52 | 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; | |
| 53 | 64 | const pushing = service === "git-receive-pack"; |
| 54 | 65 | const creds = await credentials(request); |
| 55 | 66 | if (creds === "invalid") return unauthorized("Invalid or expired token."); |
| 56 | 67 | |
| 57 | 68 | 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 }; | |
| 59 | 70 | // Ask for credentials; don't reveal whether private repositories exist. |
| 60 | 71 | return unauthorized("Authentication required. Use an API token as the password."); |
| 61 | 72 | } |
| 62 | 73 | |
| 63 | 74 | // A job's token can only fetch the repository the job runs for. |
| 64 | 75 | 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(); | |
| 66 | 77 | if (pushing) return new Response("Workflow job tokens are read-only.\n", { status: 403 }); |
| 67 | - return { repo, userId: null }; | |
| 78 | + return { ...target, userId: null }; | |
| 68 | 79 | } |
| 69 | 80 | |
| 70 | 81 | const access = repo ? await repoAccess(repo, creds.user.id) : null; |
| 71 | - if (!repo || !access) return notFound(); | |
| 82 | + if (!target || !repo || !access) return notFound(); | |
| 72 | 83 | if (pushing && creds.token.permission !== "write") { |
| 73 | 84 | return new Response("This token is read-only. Create a read & write token to push.\n", { status: 403 }); |
| 74 | 85 | } |
| @@ -78,7 +89,7 @@ async function authorize(request: Request, owner: string, repoParam: string, ser | ||
| 78 | 89 | if (pushing && !canWrite(access)) { |
| 79 | 90 | return new Response(`Permission to ${repo.owner}/${repo.name} denied.\n`, { status: 403 }); |
| 80 | 91 | } |
| 81 | - return { repo, userId: creds.user.id }; | |
| 92 | + return { ...target, userId: creds.user.id }; | |
| 82 | 93 | } |
| 83 | 94 | |
| 84 | 95 | /** 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: | ||
| 89 | 100 | } |
| 90 | 101 | const auth = await authorize(request, owner, repoParam, service); |
| 91 | 102 | if (auth instanceof Response) return auth; |
| 92 | - const { repo } = auth; | |
| 93 | 103 | |
| 94 | 104 | 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 }); | |
| 96 | 106 | const stdout = Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>; |
| 97 | 107 | |
| 98 | 108 | // Protocol v2 responses start directly with the capability advertisement. |
| @@ -125,10 +135,11 @@ export async function handleServiceRpc(request: Request, owner: string, repoPara | ||
| 125 | 135 | } |
| 126 | 136 | const auth = await authorize(request, owner, repoParam, service); |
| 127 | 137 | if (auth instanceof Response) return auth; |
| 128 | - const { repo, userId } = auth; | |
| 138 | + const { repo, dir, wiki, userId } = auth; | |
| 129 | 139 | |
| 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") }); | |
| 132 | 143 | child.stderr.on("data", (d) => console.error(`[${service}] ${d}`)); |
| 133 | 144 | if (refsBefore) onPushed(child, repo, refsBefore, userId); |
| 134 | 145 | |
| @@ -66,7 +66,7 @@ export async function commitFiles( | ||
| 66 | 66 | |
| 67 | 67 | const tree = (await git(dir, ["write-tree"], { env })).trim(); |
| 68 | 68 | 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(); | |
| 70 | 70 | if (parentTree === tree) throw new GitError("No changes to commit."); |
| 71 | 71 | } |
| 72 | 72 | const args = ["commit-tree", tree]; |
| @@ -2,7 +2,7 @@ import { execFile, spawn } from "node:child_process"; | ||
| 2 | 2 | import { mkdirSync, writeFileSync } from "node:fs"; |
| 3 | 3 | import { access } from "node:fs/promises"; |
| 4 | 4 | import path from "node:path"; |
| 5 | - import { DATA_ROOT, REPOS_ROOT } from "./paths"; | |
| 5 | + import { DATA_ROOT, REPOS_ROOT, WIKIS_ROOT } from "./paths"; | |
| 6 | 6 | |
| 7 | 7 | // Note: this module is also used by the SSH server (outside of React), so it |
| 8 | 8 | // must not import "server-only" or anything from next/*. |
| @@ -32,6 +32,12 @@ export function repoPath(owner: string, repo: string) { | ||
| 32 | 32 | return path.join(REPOS_ROOT, owner.toLowerCase(), `${repo.toLowerCase()}.git`); |
| 33 | 33 | } |
| 34 | 34 | |
| 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 | + | |
| 35 | 41 | export async function pathExists(p: string) { |
| 36 | 42 | try { |
| 37 | 43 | await access(p); |
| @@ -4,6 +4,7 @@ import { GitError } from "../git"; | ||
| 4 | 4 | import { IssueError, LabelError } from "../issues"; |
| 5 | 5 | import { ReleaseError } from "../releases"; |
| 6 | 6 | import { RepoError } from "../repos"; |
| 7 | + import { WikiError } from "../wiki"; | |
| 7 | 8 | import { ToolError } from "./params"; |
| 8 | 9 | import { TOOLS, type ToolContext } from "./tools"; |
| 9 | 10 | |
| @@ -18,7 +19,7 @@ const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05 | ||
| 18 | 19 | const INSTRUCTIONS = `Vectrum is a self-hosted git forge, like GitHub. Repositories are addressed by owner and repo name. |
| 19 | 20 | - 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. |
| 20 | 21 | - 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. | |
| 22 | 23 | - 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. |
| 23 | 24 | What you can reach depends on what the user approved: read-only or read & write, for all their repositories or selected ones.`; |
| 24 | 25 | |
| @@ -34,7 +35,7 @@ class RpcError extends Error { | ||
| 34 | 35 | type Id = string | number | null; |
| 35 | 36 | |
| 36 | 37 | /** 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]; | |
| 38 | 39 | |
| 39 | 40 | const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v); |
| 40 | 41 | |
| @@ -7,6 +7,7 @@ import { | ||
| 7 | 7 | isBranch, |
| 8 | 8 | getCommit, |
| 9 | 9 | git, |
| 10 | + lastCommit, | |
| 10 | 11 | listBranches, |
| 11 | 12 | listFilesWithSizes, |
| 12 | 13 | listTree, |
| @@ -34,12 +35,16 @@ import { | ||
| 34 | 35 | validateIssuePatch, |
| 35 | 36 | } from "../issues"; |
| 36 | 37 | import { logTail } from "../logs"; |
| 38 | + import { signUpload, UPLOAD_URL_MINUTES } from "../release-upload"; | |
| 37 | 39 | import { |
| 40 | + cleanAssetName, | |
| 38 | 41 | createRelease, |
| 42 | + deleteAsset, | |
| 39 | 43 | deleteRelease, |
| 40 | 44 | findRelease, |
| 41 | 45 | listReleases, |
| 42 | 46 | releaseTitle, |
| 47 | + storeAsset, | |
| 43 | 48 | tagCommit, |
| 44 | 49 | updateRelease, |
| 45 | 50 | type Release, |
| @@ -61,6 +66,8 @@ import { | ||
| 61 | 66 | import { cancelRun, getRun, isFinished, listJobs, listRuns, listSteps, readJobLogs, type Run } from "../runs"; |
| 62 | 67 | import { tokenCovers, type TokenScope } from "../tokens"; |
| 63 | 68 | import { getUserByUsername, type User } from "../users"; |
| 69 | + import { contentPages, findPage, listPages, pageSlug, readPage, savePage, wikiHead } from "../wiki"; | |
| 70 | + import { pageHref } from "../wiki-links"; | |
| 64 | 71 | import { WORKFLOW_DIR } from "../workflows"; |
| 65 | 72 | import { array, boolean, integer, object, objectSchema, oneOf, parseArgs, string, ToolError, type Args, type Props } from "./params"; |
| 66 | 73 | |
| @@ -978,6 +985,26 @@ async function afterRelease(repo: RepoRecord, ctx: ToolContext, tag: string, cre | ||
| 978 | 985 | |
| 979 | 986 | const tag = string("The release's tag, e.g. v1.2.0.", { min: 1, max: 255 }); |
| 980 | 987 | |
| 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 | + | |
| 981 | 1008 | const releaseTools = [ |
| 982 | 1009 | tool({ |
| 983 | 1010 | name: "list_releases", |
| @@ -1011,7 +1038,7 @@ const releaseTools = [ | ||
| 1011 | 1038 | description: [ |
| 1012 | 1039 | "Publishes a release for a tag, with notes in Markdown. If the tag doesn't exist, it's created at target (by default the", |
| 1013 | 1040 | "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).", | |
| 1015 | 1042 | ].join(" "), |
| 1016 | 1043 | access: "write", |
| 1017 | 1044 | params: { |
| @@ -1095,6 +1122,170 @@ const releaseTools = [ | ||
| 1095 | 1122 | return `Deleted the release for ${release.tag}${files ? ` and its ${files} file${files === 1 ? "" : "s"}` : ""}. The tag ${release.tag} is still there.`; |
| 1096 | 1123 | }, |
| 1097 | 1124 | }), |
| 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 | + }), | |
| 1098 | 1289 | ]; |
| 1099 | 1290 | |
| 1100 | - export const TOOLS: Tool[] = [...accountTools, ...codeTools, ...issueTools, ...releaseTools, ...actionTools]; | |
| 1291 | + export const TOOLS: Tool[] = [...accountTools, ...codeTools, ...issueTools, ...releaseTools, ...actionTools, ...wikiTools]; | |
| @@ -2,3 +2,5 @@ import path from "node:path"; | ||
| 2 | 2 | |
| 3 | 3 | export const DATA_ROOT = process.env.DATA_ROOT ?? path.join(process.cwd(), "data"); |
| 4 | 4 | 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"); | |
| @@ -18,13 +18,15 @@ import { | ||
| 18 | 18 | type Release, |
| 19 | 19 | } from "./releases"; |
| 20 | 20 | import { canWrite, findRepo, repoAccess, type Access, type RepoRecord } from "./repos"; |
| 21 | + import { verifyUpload } from "./release-upload"; | |
| 21 | 22 | import { authenticateToken, basicAuthToken, tokenCovers } from "./tokens"; |
| 22 | 23 | |
| 23 | 24 | // Downloading release files and source archives, and uploading release files from scripts: |
| 24 | 25 | // |
| 25 | 26 | // GET /<owner>/<repo>/releases/download/<tag>/<file> |
| 26 | 27 | // 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) | |
| 28 | 30 | // DELETE /<owner>/<repo>/releases/download/<tag>/<file> |
| 29 | 31 | // GET /<owner>/<repo>/archive/<ref>.zip, .tar.gz |
| 30 | 32 | // |
| @@ -159,13 +161,16 @@ export async function downloadLatestAsset(request: Request, owner: string, repoP | ||
| 159 | 161 | export async function uploadAsset(request: Request, owner: string, repoParam: string, segments: string[]) { |
| 160 | 162 | const target = splitTagAndName(segments); |
| 161 | 163 | 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"); | |
| 163 | 166 | if (caller instanceof Response) return caller; |
| 164 | 167 | if (!request.body) return text(400, "Send the file as the request body."); |
| 165 | 168 | if (!cleanAssetName(target.name)) return text(400, `${target.name} is not a valid file name.`); |
| 166 | 169 | |
| 167 | 170 | try { |
| 168 | 171 | 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."); | |
| 169 | 174 | if (!release) { |
| 170 | 175 | if (!(await tagCommit(caller.repo, target.tag))) { |
| 171 | 176 | 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 | ||
| 182 | 187 | } |
| 183 | 188 | } |
| 184 | 189 | |
| 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 | + | |
| 185 | 205 | /** DELETE /<owner>/<repo>/releases/download/<tag>/<file> */ |
| 186 | 206 | export async function removeAsset(request: Request, owner: string, repoParam: string, segments: string[]) { |
| 187 | 207 | const target = splitTagAndName(segments); |
| @@ -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 | + } | |
| @@ -66,6 +66,15 @@ export async function cloneUrls(repo: RepoRecord) { | ||
| 66 | 66 | }; |
| 67 | 67 | } |
| 68 | 68 | |
| 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 | + | |
| 69 | 78 | /** The repository's container image, e.g. vectrum.cc/alice/app. Image names are lowercase. */ |
| 70 | 79 | export async function imageName(repo: RepoRecord) { |
| 71 | 80 | return `${await serverHost()}/${repo.owner}/${repo.name}`.toLowerCase(); |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { rename, rm, mkdir } from "node:fs/promises"; |
| 2 | 2 | import path from "node:path"; |
| 3 | 3 | 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"; | |
| 5 | 5 | import { commitFiles } from "./git-write"; |
| 6 | 6 | import { deleteRepoImages } from "./registry"; |
| 7 | 7 | import { deleteRepoReleaseFiles } from "./releases"; |
| @@ -21,7 +21,10 @@ export type RepoRecord = { | ||
| 21 | 21 | pushedAt: number | null; |
| 22 | 22 | /** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */ |
| 23 | 23 | imageVersion: number | null; |
| 24 | + wikiEnabled: boolean; | |
| 24 | 25 | dir: string; |
| 26 | + /** The wiki's git repository; it only exists once the first page is written. */ | |
| 27 | + wikiDir: string; | |
| 25 | 28 | }; |
| 26 | 29 | |
| 27 | 30 | const EXPAND = "owner"; |
| @@ -38,7 +41,9 @@ function toRepo(r: RecordModel): RepoRecord { | ||
| 38 | 41 | createdAt: r.created_at, |
| 39 | 42 | pushedAt: orNull(r.pushed_at), |
| 40 | 43 | imageVersion: r.image ? orNull(r.image_updated_at) : null, |
| 44 | + wikiEnabled: !r.wiki_disabled, | |
| 41 | 45 | dir: repoPath(owner, r.name), |
| 46 | + wikiDir: wikiPath(r.id), | |
| 42 | 47 | }; |
| 43 | 48 | } |
| 44 | 49 | |
| @@ -60,6 +65,26 @@ export async function findRepoById(id: string) { | ||
| 60 | 65 | return row ? toRepo(row) : null; |
| 61 | 66 | } |
| 62 | 67 | |
| 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 | + | |
| 63 | 88 | export async function repoAccess(repo: RepoRecord, userId: string | null | undefined): Promise<Access> { |
| 64 | 89 | if (userId && repo.ownerId === userId) return "admin"; |
| 65 | 90 | 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 | ||
| 95 | 120 | return list(`${visibleTo(viewerId)} && ${filter("(name ~ {:q} || description ~ {:q} || owner.username ~ {:q})", { q: query })}`, limit); |
| 96 | 121 | } |
| 97 | 122 | |
| 123 | + // <name>.wiki.git is the clone URL of a repository's wiki. | |
| 124 | + const isWikiName = (name: string) => name.toLowerCase().endsWith(".wiki"); | |
| 125 | + | |
| 98 | 126 | export async function createRepo( |
| 99 | 127 | owner: User, |
| 100 | 128 | input: { name: string; description: string; visibility: Visibility; readme: boolean }, |
| @@ -103,6 +131,7 @@ export async function createRepo( | ||
| 103 | 131 | if (!isValidName(name)) { |
| 104 | 132 | throw new RepoError("Repository names may only contain letters, digits, '.', '-' and '_', and may not end in .git."); |
| 105 | 133 | } |
| 134 | + if (isWikiName(name)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from."); | |
| 106 | 135 | if (await findRepo(owner.username, name)) throw new RepoError(`You already have a repository named ${name}.`); |
| 107 | 136 | |
| 108 | 137 | const dir = repoPath(owner.username, name); |
| @@ -150,9 +179,15 @@ export async function updateRepo(repoId: string, data: { description: string; vi | ||
| 150 | 179 | await (await repos()).update(repoId, { description: data.description.trim().slice(0, 350), visibility: data.visibility }); |
| 151 | 180 | } |
| 152 | 181 | |
| 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 | + | |
| 153 | 187 | export async function renameRepo(repo: RepoRecord, newName: string) { |
| 154 | 188 | newName = newName.trim(); |
| 155 | 189 | 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."); | |
| 156 | 191 | if (newName === repo.name) return repo; |
| 157 | 192 | const existing = await findRepo(repo.owner, newName); |
| 158 | 193 | 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) { | ||
| 168 | 203 | return (await findRepoById(repo.id))!; |
| 169 | 204 | } |
| 170 | 205 | |
| 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. */ | |
| 172 | 207 | export async function deleteRepo(repo: RepoRecord) { |
| 173 | 208 | await (await repos()).delete(repo.id); |
| 174 | 209 | // Frees the container images and the blobs no other repository uses. |
| 175 | 210 | deleteRepoImages(repo.id); |
| 176 | 211 | await deleteRepoReleaseFiles(repo.id); |
| 177 | 212 | await rm(repo.dir, { recursive: true, force: true }); |
| 213 | + await rm(repo.wikiDir, { recursive: true, force: true }); | |
| 178 | 214 | } |
| 179 | 215 | |
| 180 | 216 | // --------------------------------------------------------------------------- |
| @@ -2,10 +2,10 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; | ||
| 2 | 2 | import path from "node:path"; |
| 3 | 3 | import { Server, utils, type Connection } from "ssh2"; |
| 4 | 4 | import { onRefsUpdated, startWorkflowsInBackground } from "./ci"; |
| 5 | - import { normalizeRepoName, snapshotRefs, spawnService } from "./git"; | |
| 5 | + import { pathExists, snapshotRefs, spawnService } from "./git"; | |
| 6 | 6 | import { findKeyByBlob, touchSshKey } from "./keys"; |
| 7 | 7 | import { DATA_ROOT } from "./paths"; |
| 8 | - import { canWrite, findRepo, markPushed, repoAccess } from "./repos"; | |
| 8 | + import { canWrite, ensureWikiRepo, findGitTarget, markPushed, repoAccess } from "./repos"; | |
| 9 | 9 | import { SSH_LISTEN_HOST, SSH_PORT } from "./ssh-config"; |
| 10 | 10 | import { getUserById, type User } from "./users"; |
| 11 | 11 | |
| @@ -76,17 +76,24 @@ function handleConnection(client: Connection) { | ||
| 76 | 76 | if (!match || !user) return fail("Only git clone, fetch and push are supported."); |
| 77 | 77 | const [, service, owner, repoName] = match; |
| 78 | 78 | 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; | |
| 80 | 82 | 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."); | |
| 82 | 84 | const pushing = service === "receive-pack"; |
| 83 | 85 | if (pushing && !canWrite(access)) { |
| 84 | 86 | return fail(`Permission to ${repo.owner}/${repo.name} denied to ${user.username}.`); |
| 85 | 87 | } |
| 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 | + } | |
| 86 | 93 | |
| 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, { | |
| 90 | 97 | stateless: false, |
| 91 | 98 | protocol: env.GIT_PROTOCOL, |
| 92 | 99 | }); |
| @@ -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 | + }); | |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + ); | |