Minimale git-remote met publiek/prive-toggle
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import hljs from "highlight.js";
import MarkdownIt from "markdown-it";
import { config } from "./config.ts";
import type { TreeEntry, CommitSummary } from "./git.ts";
import { fullCloneUrl, type Repo } from "./repos.ts";
export function escapeHtml(input: string): string {
return input
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function segment(part: string): string {
return encodeURIComponent(part);
}
function encodePath(filePath: string): string {
return filePath.split("/").map(segment).join("/");
}
const md: MarkdownIt = new MarkdownIt({
html: false, // Ruwe HTML uit README's van derden vertrouwen we niet.
linkify: true,
breaks: false,
highlight(code, language) {
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
} catch {
/* val terug op onopgemaakte code */
}
}
return escapeHtml(code);
},
});
export function renderMarkdown(source: string): string {
return md.render(source);
}
const EXTENSION_LANGUAGES: Record<string, string> = {
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
rs: "rust",
py: "python",
rb: "ruby",
go: "go",
java: "java",
kt: "kotlin",
c: "c",
h: "c",
cpp: "cpp",
hpp: "cpp",
cs: "csharp",
php: "php",
swift: "swift",
sh: "bash",
bash: "bash",
zsh: "bash",
ps1: "powershell",
sql: "sql",
html: "xml",
xml: "xml",
svg: "xml",
css: "css",
scss: "scss",
json: "json",
yml: "yaml",
yaml: "yaml",
toml: "ini",
ini: "ini",
md: "markdown",
dockerfile: "dockerfile",
};
export function highlightSource(code: string, fileName: string): string {
const lower = fileName.toLowerCase();
const extension = lower.includes(".") ? lower.slice(lower.lastIndexOf(".") + 1) : lower;
const language = EXTENSION_LANGUAGES[extension];
if (language && hljs.getLanguage(language)) {
try {
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
} catch {
/* val terug op onopgemaakte code */
}
}
return escapeHtml(code);
}
export function formatBytes(bytes: number | null): string {
if (bytes === null) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} kB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("nl-NL", { year: "numeric", month: "long", day: "numeric" });
}
interface LayoutOptions {
title: string;
isOwner: boolean;
body: string;
}
export function layout({ title, isOwner, body }: LayoutOptions): string {
return `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)}</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<header class="site-header">
<a class="brand" href="/">${escapeHtml(config.siteName)}</a>
<nav>
${
isOwner
? `<a href="/admin">Beheer</a>
<form method="post" action="/logout"><button type="submit" class="linkish">Uitloggen</button></form>`
: `<a href="/login">Inloggen</a>`
}
</nav>
</header>
<main>
${body}
</main>
<footer class="site-footer">
<span>Broncode gehost met pub-repo</span>
</footer>
</body>
</html>
`;
}
export function repoListPage(repos: Repo[], isOwner: boolean): string {
if (repos.length === 0) {
return `<div class="empty">
<h1>Repositories</h1>
<p>Er zijn nog geen ${isOwner ? "" : "publieke "}repositories.</p>
</div>`;
}
const items = repos
.map(
(repo) => `<li class="repo-card">
<div class="repo-card-head">
<a class="repo-name" href="/r/${segment(repo.name)}">${escapeHtml(repo.name)}</a>
${repo.public ? "" : '<span class="badge badge-private">privé</span>'}
</div>
${repo.description ? `<p class="repo-description">${escapeHtml(repo.description)}</p>` : ""}
<code class="clone-url">${escapeHtml(repo.cloneUrl)}</code>
</li>`,
)
.join("\n");
return `<h1>Repositories</h1>
<ul class="repo-list">
${items}
</ul>`;
}
function breadcrumbs(repo: Repo, branch: string, filePath: string, isBlob: boolean): string {
const parts = filePath ? filePath.split("/") : [];
const crumbs: string[] = [
`<a href="/r/${segment(repo.name)}/tree/${encodePath(branch)}">${escapeHtml(repo.name)}</a>`,
];
let accumulated = "";
parts.forEach((part, index) => {
accumulated = accumulated ? `${accumulated}/${part}` : part;
const isLast = index === parts.length - 1;
if (isLast && isBlob) {
crumbs.push(`<span class="crumb-current">${escapeHtml(part)}</span>`);
} else {
crumbs.push(
`<a href="/r/${segment(repo.name)}/tree/${encodePath(branch)}/${encodePath(accumulated)}">${escapeHtml(part)}</a>`,
);
}
});
return `<nav class="breadcrumbs">${crumbs.join('<span class="sep">/</span>')}</nav>`;
}
function branchPicker(
repo: Repo,
branch: string,
branches: string[],
view: string,
filePath: string,
): string {
if (branches.length <= 1) {
return `<span class="ref-current">${escapeHtml(branch)}</span>`;
}
const suffix = filePath ? `/${encodePath(filePath)}` : "";
const options = branches
.map(
(name) =>
`<li><a href="/r/${segment(repo.name)}/${view}/${encodePath(name)}${suffix}"${
name === branch ? ' class="active"' : ""
}>${escapeHtml(name)}<span class="kind">branch</span></a></li>`,
)
.join("");
return `<details class="ref-picker">
<summary>${escapeHtml(branch)}</summary>
<ul>${options}</ul>
</details>`;
}
interface TreePageOptions {
repo: Repo;
branch: string;
filePath: string;
entries: TreeEntry[];
branches: string[];
readmeHtml: string | null;
snapshot: CommitSummary | null;
isOwner: boolean;
}
export function treePage({
repo,
branch,
filePath,
entries,
branches,
readmeHtml,
snapshot,
isOwner,
}: TreePageOptions): string {
const rows = entries
.map((entry) => {
const childPath = filePath ? `${filePath}/${entry.name}` : entry.name;
const view = entry.type === "tree" ? "tree" : "blob";
const icon = entry.type === "tree" ? "📁" : "📄";
const href = `/r/${segment(repo.name)}/${view}/${encodePath(branch)}/${encodePath(childPath)}`;
return `<tr>
<td class="entry-name"><span class="icon">${icon}</span><a href="${href}">${escapeHtml(entry.name)}</a></td>
<td class="entry-size">${entry.type === "blob" ? formatBytes(entry.size) : ""}</td>
</tr>`;
})
.join("\n");
const parentRow = filePath
? `<tr>
<td class="entry-name"><span class="icon">↩</span><a href="/r/${segment(repo.name)}/tree/${encodePath(branch)}${
filePath.includes("/") ? `/${encodePath(filePath.slice(0, filePath.lastIndexOf("/")))}` : ""
}">..</a></td>
<td></td>
</tr>`
: "";
return `${repoHeader(repo, branch, branches, "tree", filePath, snapshot, isOwner)}
${breadcrumbs(repo, branch, filePath, false)}
<table class="tree">
<tbody>
${parentRow}
${rows || '<tr><td colspan="2" class="muted">Lege map.</td></tr>'}
</tbody>
</table>
${readmeHtml ? `<article class="readme markdown">${readmeHtml}</article>` : ""}`;
}
function repoHeader(
repo: Repo,
branch: string,
branches: string[],
view: string,
filePath: string,
snapshot: CommitSummary | null,
isOwner: boolean,
): string {
// Voor jezelf staat je eigen remote bovenaan en met nadruk. De publieke URL is
// hieronder óók te zien, maar expliciet gelabeld als alleen-lezen: als je die
// per ongeluk als remote instelt, haalt een pull een commit zonder ouders over
// je lokale historie heen.
const clone = isOwner
? `<div class="clone-box">
<label for="clone-own">Jouw remote</label>
<input id="clone-own" type="text" readonly value="git clone ${escapeHtml(repo.fullCloneUrl)}">
</div>
<div class="clone-box">
<label for="clone-pub">Alleen lezen</label>
<input id="clone-pub" type="text" readonly value="git clone ${escapeHtml(repo.cloneUrl)}">
</div>
<p class="muted snapshot-note">
Gebruik de bovenste voor je eigen werk. De onderste is wat bezoekers clonen:
daar staat één momentopname zonder commitgeschiedenis, en die als remote
instellen kost je bij de eerste pull je lokale historie.
</p>`
: `<div class="clone-box">
<label for="clone-pub">Clonen</label>
<input id="clone-pub" type="text" readonly value="git clone ${escapeHtml(repo.cloneUrl)}">
</div>
<p class="muted snapshot-note">
Deze repository wordt als momentopname gepubliceerd: een clone bevat de
volledige inhoud, maar geen commitgeschiedenis.
</p>`;
return `<div class="repo-header">
<div class="repo-title">
<h1><a href="/r/${segment(repo.name)}">${escapeHtml(repo.name)}</a></h1>
${repo.public ? "" : '<span class="badge badge-private">privé</span>'}
</div>
${repo.description ? `<p class="repo-description">${escapeHtml(repo.description)}</p>` : ""}
<div class="repo-meta">
${branchPicker(repo, branch, branches, view, filePath)}
${snapshot ? `<span class="muted">bijgewerkt ${escapeHtml(formatDate(snapshot.date))}</span>` : ""}
</div>
${clone}
</div>`;
}
interface BlobPageOptions {
repo: Repo;
branch: string;
filePath: string;
branches: string[];
size: number;
isOwner: boolean;
content: { kind: "text"; html: string } | { kind: "markdown"; html: string } | { kind: "binary" } | { kind: "toolarge" };
}
export function blobPage({
repo,
branch,
filePath,
branches,
size,
content,
isOwner,
}: BlobPageOptions): string {
const rawHref = `/r/${segment(repo.name)}/raw/${encodePath(branch)}/${encodePath(filePath)}`;
let body: string;
switch (content.kind) {
case "markdown":
body = `<article class="markdown file-markdown">${content.html}</article>`;
break;
case "text":
body = `<div class="file-source"><pre><code>${content.html}</code></pre></div>`;
break;
case "binary":
body = `<p class="muted">Binair bestand (${formatBytes(size)}). <a href="${rawHref}">Downloaden</a>.</p>`;
break;
case "toolarge":
body = `<p class="muted">Bestand te groot om te tonen (${formatBytes(size)}). <a href="${rawHref}">Downloaden</a>.</p>`;
break;
}
return `${repoHeader(repo, branch, branches, "blob", filePath, null, isOwner)}
${breadcrumbs(repo, branch, filePath, true)}
<div class="file-toolbar">
<span class="muted">${formatBytes(size)}</span>
<a href="${rawHref}">Ruw bestand</a>
</div>
${body}`;
}
export function emptyRepoPage(repo: Repo): string {
return `<div class="repo-header">
<div class="repo-title"><h1>${escapeHtml(repo.name)}</h1>
${repo.public ? "" : '<span class="badge badge-private">privé</span>'}</div>
${repo.description ? `<p class="repo-description">${escapeHtml(repo.description)}</p>` : ""}
</div>
<div class="empty">
<p>Deze repository is nog leeg.</p>
<pre><code>git remote add origin ${escapeHtml(repo.fullCloneUrl)}
git push -u origin main</code></pre>
<p>Dat is je eigen remote, met geschiedenis. Bezoekers clonen straks
<code>${escapeHtml(repo.cloneUrl)}</code> en krijgen daar een momentopname.</p>
</div>`;
}
export function loginPage(next: string, error: string | null): string {
return `<div class="narrow">
<h1>Inloggen</h1>
${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
<form method="post" action="/login" class="stack">
<input type="hidden" name="next" value="${escapeHtml(next)}">
<label for="user">Gebruikersnaam</label>
<input id="user" name="user" type="text" autocomplete="username" required autofocus>
<label for="password">Wachtwoord</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Inloggen</button>
</form>
</div>`;
}
export function adminPage(repos: Repo[], message: string | null, error: string | null): string {
const rows = repos
.map(
(repo) => `<tr>
<td>
<a href="/r/${segment(repo.name)}">${escapeHtml(repo.name)}</a>
<code class="clone-url">${escapeHtml(repo.fullCloneUrl)}</code>
</td>
<td>
<form method="post" action="/admin/repos/${segment(repo.name)}/description" class="inline">
<input type="text" name="description" value="${escapeHtml(repo.description)}" placeholder="Korte beschrijving">
<button type="submit">Opslaan</button>
</form>
</td>
<td>
<form method="post" action="/admin/repos/${segment(repo.name)}/visibility" class="inline">
<input type="hidden" name="public" value="${repo.public ? "0" : "1"}">
<button type="submit" class="${repo.public ? "toggle-on" : "toggle-off"}">
${repo.public ? "Publiek" : "Privé"}
</button>
</form>
</td>
<td>
<form method="post" action="/admin/repos/${segment(repo.name)}/delete" class="inline">
<input type="text" name="confirm" placeholder="typ ${escapeHtml(repo.name)}" required>
<button type="submit" class="danger">Verwijderen</button>
</form>
</td>
</tr>`,
)
.join("\n");
// Bewust een sjabloon met <naam> erin, en niet de URL van een willekeurige repo:
// dat laatste leest als een kant-en-klaar commando en is dan voor de verkeerde.
const template = fullCloneUrl("<naam>");
return `<h1>Beheer</h1>
${message ? `<p class="notice">${escapeHtml(message)}</p>` : ""}
${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
<section class="panel">
<h2>Jouw remote</h2>
<p class="muted">
Gebruik voor je eigen werk altijd het volledige transport: dat pusht én pullt je
echte geschiedenis, en vraagt altijd om je token. De publieke URL die op een
repo-pagina staat is alleen om te lezen — daar staat de momentopname, en die
heeft geen commitgeschiedenis. Per repository staat de juiste URL hieronder in
de tabel.
</p>
<input type="text" readonly value="git remote set-url origin ${escapeHtml(template)}">
</section>
<section class="panel">
<h2>Nieuwe repository</h2>
<form method="post" action="/admin/repos" class="inline wrap">
<input type="text" name="name" placeholder="naam" pattern="[a-z0-9][a-z0-9._-]*" required>
<input type="text" name="description" placeholder="beschrijving (optioneel)">
<button type="submit">Aanmaken</button>
</form>
<p class="muted">Kleine letters, cijfers, punt, streepje of underscore.</p>
</section>
<section class="panel">
<h2>Repositories</h2>
${
repos.length === 0
? '<p class="muted">Nog geen repositories.</p>'
: `<table class="admin-table">
<thead><tr><th>Naam</th><th>Beschrijving</th><th>Zichtbaarheid</th><th>Verwijderen</th></tr></thead>
<tbody>${rows}</tbody>
</table>`
}
</section>`;
}
export function errorPage(status: number, message: string): string {
return `<div class="narrow">
<h1>${status}</h1>
<p>${escapeHtml(message)}</p>
<p><a href="/">Terug naar de repositories</a></p>
</div>`;
}