1import type { Hono } from "hono";2import { describeFile } from "../describe/describe.ts";3import {4 fileAt,5 filesAt,6 headId,7 history,8 objectBytes,9 repoStats,10 type Save,11 saveByPrefix,12 timeline,13} from "../mirror/queries.ts";14import { visibleReleases } from "../releases/releases.ts";15import { setting } from "../server/db.ts";16import { atlasSection } from "./atlas.ts";17import { type Html, html } from "./html.ts";18import {19 cacheFor,20 notFound,21 openPublicRepo,22 type PageContext,23 type PublicRepo,24 page,25 plural,26 relativeTime,27} from "./layout.ts";28import { renderMarkdown, renderParagraph } from "./markdown.ts";29import { timelineSvg } from "./timeline.ts";3031const PAGE_SIZE = 50;3233const PANELS = [34 ["issues", "issues"],35 ["builds", "releases"],36 ["archives", "archives"],37];38const HTTP_URL = /^https?:\/\//i;3940export function registerRepoPage(app: Hono, ctx: PageContext): void {41 app.get("/:repo", (c) => {42 const repo = openPublicRepo(ctx, c.req.param("repo"));43 if (!repo) return notFound(c, ctx);44 45 const at = c.req.query("at");46 const atSave = at === undefined ? null : saveByPrefix(repo.mirror, at);47 if (at !== undefined && !atSave) return notFound(c, ctx);48 const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);49 const name = repo.settings.name;50 const intro = repoIntro(repo);51 cacheFor(c, false);52 return c.html(53 page(ctx, {54 title: name,55 description: intro?.text ?? "",56 path: `/${name}`,57 body: html`${header(ctx, repo, intro?.html ?? null)}58${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}59${details(repoDetails(repo))}`,60 }),61 );62 });63}646566676869export function repoIntro(repo: PublicRepo): { html: Html; text: string } | null {70 const { name, description } = repo.settings;71 if (description)72 return renderParagraph(description, { repo: name, dir: "", at: null, own: true });73 const readme = readmeAtHead(repo.mirror);74 const paragraph = readme === null ? null : describeFile("README.md", readme);75 return paragraph === null ? null : renderParagraph(paragraph, { repo: name, dir: "", at: null });76}777879export function repoDetails(repo: PublicRepo): Html | null {80 const { name, details } = repo.settings;81 if (details) return renderMarkdown(details, { repo: name, dir: "", at: null, own: true });82 const readme = readmeAtHead(repo.mirror);83 return readme === null ? null : renderMarkdown(readme, { repo: name, dir: "", at: null });84}8586export function readmeAtHead(mirror: PublicRepo["mirror"]): string | null {87 const head = headId(mirror);88 const path =89 head && filesAt(mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;90 const file = head && path ? fileAt(mirror, head, path) : null;91 const bytes = file && objectBytes(mirror, file.hash);92 return bytes ? new TextDecoder().decode(bytes) : null;93}9495function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {96 const { name, homepage, installLines } = repo.settings;97 const steal = `koh steal ${ctx.domain}/${name}`;98 const stats = repoStats(repo.mirror);99 const handle = setting(ctx.kepr, "handle");100 101 const newest = visibleReleases(ctx.kepr, repo.mirror, name)[0];102 const meta = [103 newest && `v${newest.version}`,104 plural(stats.saves, "save"),105 stats.lines > 0 && plural(stats.lines, "line"),106 stats.updated !== null && `updated ${relativeTime(stats.updated)}`,107 ].filter(Boolean);108 return html`<header class="repo-head">109<h1 class="repo-name">${name}</h1>110${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}111${html`<div class="repo-desc"${!intro && " hidden"}><p>${intro}</p></div>`}112${113 HTTP_URL.test(homepage) &&114 html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`115}116${117 stats.language &&118 html`<div class="lang-badges"><span class="lang-badge">${stats.language}</span></div>`119}120<p class="repo-meta">${meta.join(" · ")}</p>121<div class="repo-tabbar">122<nav class="repo-tabs" aria-label="Repository panels">${PANELS.map(123 ([label, path]) =>124 html`<a class="repo-tab" href="/${name}/${path}" data-panel="${path}">${label}</a>`,125 )}</nav>126<div class="install-lines">127<button type="button" class="repo-steal-btn" data-copy="${steal}">${steal}</button>128${installLines.map(129 (line) =>130 html`<div class="install-line"><code>${line}</code>${html`<button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button>`}</div>`,131)}132</div>133</div>134<div class="repo-panel" hidden></div>135</header>`;136}137138function sections(repo: PublicRepo, requestedPage: number, at: string | null): Html {139 const name = repo.settings.name;140 const saves = timeline(repo.mirror);141 142 const pageNumber = (requestedPage - 1) * PAGE_SIZE < saves.length ? requestedPage : 1;143 const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);144 const pages: Pages = {145 at,146 newer: pageNumber > 1 ? pageNumber - 1 : null,147 older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null,148 };149 const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at, saves);150 return html`<section class="repo-section">151<details class="repo-section-toggle" open>152<summary class="section-head"><span class="section-label">timeline</span></summary>153${timelineSvg(name, saves)}154</details>155</section>156${atlas}157<section class="repo-section" id="history">158<details class="repo-section-toggle" open>159<summary class="section-head"><span class="section-label">history</span></summary>160${saveList(name, rows, pages)}161</details>162</section>`;163}164165type Pages = { at: string | null; newer: number | null; older: number | null };166167168169170171function saveList(repo: string, rows: Save[], pages: Pages): Html {172 const pageHref = (n: number) => {173 const query = new URLSearchParams(pages.at ? { at: pages.at } : {});174 query.set("page", String(n));175 return `?${query}#history`;176 };177 return html`<div class="save-list" data-list="history">178${rows.map(179 (save) =>180 html`<a class="history-row" href="/${repo}/saves/${save.id}"><span class="history-id">${save.id}</span><span class="history-msg">${save.message.split("\n")[0]}</span><span class="history-when">${relativeTime(save.created_at)}</span></a>`,181)}182${183 (pages.newer || pages.older) &&184 html`<p class="pager mono">${pages.newer && html`<a href="${pageHref(pages.newer)}">← newer</a>`}${pages.older && html`<a class="pager-older" href="${pageHref(pages.older)}">older →</a>`}</p>`185}186</div>`;187}188189190function details(body: Html | null): Html {191 return html`<section class="repo-details"${!body && " hidden"}>192<details class="repo-details-toggle" open>193<summary class="repo-details-head">details</summary>194<div class="repo-details-body markdown">${body}</div>195</details>196</section>`;197}