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