1import type { Hono } from "hono";2import { isSafePath } from "../bucket/repo.ts";3import { describeFile } from "../describe/describe.ts";4import {5 fileAt,6 filesAt,7 headId,8 history,9 objectBytes,10 repoStats,11 type Save,12 saveByPrefix,13 timeline,14} from "../mirror/queries.ts";15import { mirrorState } from "../mirror/store.ts";16import { visibleReleases } from "../releases/releases.ts";17import { setting } from "../server/db.ts";18import { atlasPanel, atlasPickerRows, atlasSection } from "./atlas.ts";19import { type Html, html } from "./html.ts";20import {21 cacheFor,22 notFound,23 openPublicRepo,24 type PageContext,25 type PublicRepo,26 page,27 plural,28 relativeTime,29} from "./layout.ts";30import { renderMarkdown, renderParagraph } from "./markdown.ts";31import { timelineSaves, timelineSvg } from "./timeline.ts";3233const PAGE_SIZE = 50;3435const PANELS = [36 ["issues", "issues"],37 ["builds", "releases"],38 ["archives", "archives"],39];40const HTTP_URL = /^https?:\/\//i;4142export function registerRepoPage(app: Hono, ctx: PageContext): void {43 app.get("/:repo", (c) => {44 const repo = openPublicRepo(ctx, c.req.param("repo"));45 if (!repo) return notFound(c, ctx);46 47 const at = c.req.query("at");48 const atSave = at === undefined ? null : saveByPrefix(repo.mirror, at);49 if (at !== undefined && !atSave) return notFound(c, ctx);50 const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);51 const name = repo.settings.name;52 const intro = repoIntro(repo);53 cacheFor(c, false);54 return c.html(55 page(ctx, {56 title: name,57 description: intro?.text ?? "",58 path: `/${name}`,59 body: html`${header(ctx, repo, intro?.html ?? null)}60${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}61${details(repoDetails(repo))}`,62 }),63 );64 });65 66 app.get("/:repo/timeline", (c) => {67 const repo = openPublicRepo(ctx, c.req.param("repo"));68 if (!repo) return notFound(c, ctx);69 cacheFor(c, false);70 return c.json(timelineSaves(timeline(repo.mirror)));71 });72 73 app.get("/:repo/atlas/saves", (c) => {74 const repo = openPublicRepo(ctx, c.req.param("repo"));75 const at = c.req.query("at");76 const save = repo && at !== undefined ? saveByPrefix(repo.mirror, at) : null;77 if (!repo || (at !== undefined && !save)) return notFound(c, ctx);78 cacheFor(c, false);79 const rows = atlasPickerRows(repo.settings.name, timeline(repo.mirror), save?.id ?? null);80 return c.html(rows.map(String).join(""));81 });82 83 app.get("/:repo/atlas/panel", (c) => {84 const repo = openPublicRepo(ctx, c.req.param("repo"));85 const at = c.req.query("at");86 const path = c.req.query("path") ?? "";87 const save = repo && (at === undefined ? null : saveByPrefix(repo.mirror, at));88 const saveId = save?.id ?? (repo && headId(repo.mirror));89 const panel =90 repo &&91 saveId &&92 (at === undefined || save) &&93 isSafePath(path.replace(/\/$/, "")) &&94 atlasPanel(repo, saveId, save?.id ?? null, path);95 if (!panel) return notFound(c, ctx);96 cacheFor(c, save !== null && save !== undefined);97 return c.html(String(panel));98 });99}100101102103104105export function repoIntro(repo: PublicRepo): { html: Html; text: string } | null {106 const { name, description } = repo.settings;107 if (description)108 return renderParagraph(description, { repo: name, dir: "", at: null, own: true });109 const readme = readmeAtHead(repo.mirror);110 const paragraph = readme === null ? null : describeFile("README.md", readme);111 return paragraph === null ? null : renderParagraph(paragraph, { repo: name, dir: "", at: null });112}113114115export function repoDetails(repo: PublicRepo): Html | null {116 const { name, details } = repo.settings;117 if (details) return renderMarkdown(details, { repo: name, dir: "", at: null, own: true });118 const readme = readmeAtHead(repo.mirror);119 return readme === null ? null : renderMarkdown(readme, { repo: name, dir: "", at: null });120}121122export function readmeAtHead(mirror: PublicRepo["mirror"]): string | null {123 const head = headId(mirror);124 const path =125 head && filesAt(mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;126 const file = head && path ? fileAt(mirror, head, path) : null;127 const bytes = file && objectBytes(mirror, file.hash);128 return bytes ? new TextDecoder().decode(bytes) : null;129}130131function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {132 const { name, homepage, languages } = repo.settings;133 const steal = `koh steal ${ctx.domain}/${name}`;134 const stats = repoStats(repo.mirror);135 const handle = setting(ctx.kepr, "handle");136 137 138 const version =139 visibleReleases(ctx.kepr, repo.mirror, name)[0]?.version ||140 mirrorState(repo.mirror, "declared_version");141 const meta = [142 version && `v${version}`,143 plural(stats.saves, "save"),144 stats.lines > 0 && plural(stats.lines, "line"),145 stats.updated !== null && `updated ${relativeTime(stats.updated)}`,146 ].filter(Boolean);147 return html`<header class="repo-head">148<h1 class="repo-name">${name}</h1>149${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}150${html`<div class="repo-desc"${!intro && " hidden"}><p>${intro}</p></div>`}151${152 HTTP_URL.test(homepage) &&153 html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`154}155${156 (languages.length > 0 || stats.language) &&157 html`<div class="lang-badges">${(languages.length > 0 ? languages : [stats.language]).map(158 (language) => html`<span class="lang-badge">${language}</span>`,159 )}</div>`160}161<p class="repo-meta">${meta.join(" · ")}</p>162<div class="repo-tabbar">163<nav class="repo-tabs" aria-label="Repository panels">${PANELS.map(164 ([label, path]) =>165 html`<a class="repo-tab" href="/${name}/${path}" data-panel="${path}">${label}</a>`,166 )}</nav>167<div class="install-lines">168<button type="button" class="repo-steal-btn" data-copy="${steal}">${steal}</button>169</div>170</div>171<div class="repo-panel" hidden></div>172</header>`;173}174175function sections(repo: PublicRepo, requestedPage: number, at: string | null): Html {176 const name = repo.settings.name;177 const saves = timeline(repo.mirror);178 179 const pageNumber = (requestedPage - 1) * PAGE_SIZE < saves.length ? requestedPage : 1;180 const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);181 const pages: Pages = {182 at,183 newer: pageNumber > 1 ? pageNumber - 1 : null,184 older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null,185 };186 const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at);187 return html`<section class="repo-section">188<details class="repo-section-toggle" open>189<summary class="section-head"><span class="section-label">timeline</span></summary>190${timelineSvg(name, saves)}191</details>192</section>193${atlas}194<section class="repo-section" id="history">195<details class="repo-section-toggle" open>196<summary class="section-head"><span class="section-label">history</span></summary>197${saveList(name, rows, pages)}198</details>199</section>`;200}201202type Pages = { at: string | null; newer: number | null; older: number | null };203204205206207208function saveList(repo: string, rows: Save[], pages: Pages): Html {209 const pageHref = (n: number) => {210 const query = new URLSearchParams(pages.at ? { at: pages.at } : {});211 query.set("page", String(n));212 return `?${query}#history`;213 };214 return html`<div class="save-list" data-list="history">215${rows.map(216 (save) =>217 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>`,218)}219${220 (pages.newer || pages.older) &&221 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>`222}223</div>`;224}225226227function details(body: Html | null): Html {228 return html`<section class="repo-details"${!body && " hidden"}>229<details class="repo-details-toggle" open>230<summary class="repo-details-head">details</summary>231<div class="repo-details-body markdown">${body}</div>232</details>233</section>`;234}