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 = { 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 ` ${escapeHtml(title)}
${body}
`; } export function repoListPage(repos: Repo[], isOwner: boolean): string { if (repos.length === 0) { return `

Repositories

Er zijn nog geen ${isOwner ? "" : "publieke "}repositories.

`; } const items = repos .map( (repo) => `
  • ${escapeHtml(repo.name)} ${repo.public ? "" : 'privé'}
    ${repo.description ? `

    ${escapeHtml(repo.description)}

    ` : ""} ${escapeHtml(repo.cloneUrl)}
  • `, ) .join("\n"); return `

    Repositories

    `; } function breadcrumbs(repo: Repo, branch: string, filePath: string, isBlob: boolean): string { const parts = filePath ? filePath.split("/") : []; const crumbs: string[] = [ `${escapeHtml(repo.name)}`, ]; let accumulated = ""; parts.forEach((part, index) => { accumulated = accumulated ? `${accumulated}/${part}` : part; const isLast = index === parts.length - 1; if (isLast && isBlob) { crumbs.push(`${escapeHtml(part)}`); } else { crumbs.push( `${escapeHtml(part)}`, ); } }); return ``; } function branchPicker( repo: Repo, branch: string, branches: string[], view: string, filePath: string, ): string { if (branches.length <= 1) { return `${escapeHtml(branch)}`; } const suffix = filePath ? `/${encodePath(filePath)}` : ""; const options = branches .map( (name) => `
  • ${escapeHtml(name)}branch
  • `, ) .join(""); return `
    ${escapeHtml(branch)}
    `; } 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 ` ${icon}${escapeHtml(entry.name)} ${entry.type === "blob" ? formatBytes(entry.size) : ""} `; }) .join("\n"); const parentRow = filePath ? ` .. ` : ""; return `${repoHeader(repo, branch, branches, "tree", filePath, snapshot, isOwner)} ${breadcrumbs(repo, branch, filePath, false)} ${parentRow} ${rows || ''}
    Lege map.
    ${readmeHtml ? `
    ${readmeHtml}
    ` : ""}`; } 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 ? `

    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.

    ` : `

    Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

    `; return `

    ${escapeHtml(repo.name)}

    ${repo.public ? "" : 'privé'}
    ${repo.description ? `

    ${escapeHtml(repo.description)}

    ` : ""}
    ${branchPicker(repo, branch, branches, view, filePath)} ${snapshot ? `bijgewerkt ${escapeHtml(formatDate(snapshot.date))}` : ""}
    ${clone}
    `; } 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 = `
    ${content.html}
    `; break; case "text": body = `
    ${content.html}
    `; break; case "binary": body = `

    Binair bestand (${formatBytes(size)}). Downloaden.

    `; break; case "toolarge": body = `

    Bestand te groot om te tonen (${formatBytes(size)}). Downloaden.

    `; break; } return `${repoHeader(repo, branch, branches, "blob", filePath, null, isOwner)} ${breadcrumbs(repo, branch, filePath, true)}
    ${formatBytes(size)} Ruw bestand
    ${body}`; } export function emptyRepoPage(repo: Repo): string { return `

    ${escapeHtml(repo.name)}

    ${repo.public ? "" : 'privé'}
    ${repo.description ? `

    ${escapeHtml(repo.description)}

    ` : ""}

    Deze repository is nog leeg.

    git remote add origin ${escapeHtml(repo.fullCloneUrl)}
    git push -u origin main

    Dat is je eigen remote, met geschiedenis. Bezoekers clonen straks ${escapeHtml(repo.cloneUrl)} en krijgen daar een momentopname.

    `; } export function loginPage(next: string, error: string | null): string { return `

    Inloggen

    ${error ? `

    ${escapeHtml(error)}

    ` : ""}
    `; } export function adminPage(repos: Repo[], message: string | null, error: string | null): string { const rows = repos .map( (repo) => ` ${escapeHtml(repo.name)} ${escapeHtml(repo.fullCloneUrl)}
    `, ) .join("\n"); // Bewust een sjabloon met 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(""); return `

    Beheer

    ${message ? `

    ${escapeHtml(message)}

    ` : ""} ${error ? `

    ${escapeHtml(error)}

    ` : ""}

    Jouw remote

    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.

    Nieuwe repository

    Kleine letters, cijfers, punt, streepje of underscore.

    Repositories

    ${ repos.length === 0 ? '

    Nog geen repositories.

    ' : `${rows}
    NaamBeschrijvingZichtbaarheidVerwijderen
    ` }
    `; } export function errorPage(status: number, message: string): string { return `

    ${status}

    ${escapeHtml(message)}

    Terug naar de repositories

    `; }