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 saveByPrefix,11 timeline,12} from "../mirror/queries.ts";13import { visibleReleases } from "../releases/releases.ts";14import { setting } from "../server/db.ts";15import { atlasSection } from "./atlas.ts";16import { type Html, html } from "./html.ts";17import {18 cacheFor,19 notFound,20 openPublicRepo,21 type PageContext,22 type PublicRepo,23 page,24 plural,25 relativeTime,26} from "./layout.ts";27import { renderMarkdown, renderParagraph } from "./markdown.ts";28import { timelineSvg } from "./timeline.ts";2930const PAGE_SIZE = 50;31const HTTP_URL = /^https?:\/\//i;3233export function registerRepoPage(app: Hono, ctx: PageContext): void {34 app.get("/:repo", (c) => {35 const repo = openPublicRepo(ctx, c.req.param("repo"));36 if (!repo) return notFound(c, ctx);37 38 const at = c.req.query("at");39 const atSave = at === undefined ? null : saveByPrefix(repo.mirror, at);40 if (at !== undefined && !atSave) return notFound(c, ctx);41 const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);42 const readme = readmeAtHead(repo);43 const name = repo.settings.name;44 const paragraph = readme === null ? null : describeFile("README.md", readme);45 const intro =46 paragraph === null ? null : renderParagraph(paragraph, { repo: name, dir: "", at: null });47 cacheFor(c, false);48 return c.html(49 page(ctx, {50 title: name,51 description: intro?.text ?? "",52 path: `/${name}`,53 body: html`${header(ctx, repo, intro?.html ?? null)}54${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}55${readme && details(renderMarkdown(readme, { repo: name, dir: "", at: null }))}`,56 }),57 );58 });59}6061function readmeAtHead(repo: PublicRepo): string | null {62 const head = headId(repo.mirror);63 const path =64 head && filesAt(repo.mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;65 const file = head && path ? fileAt(repo.mirror, head, path) : null;66 const bytes = file && objectBytes(repo.mirror, file.hash);67 return bytes ? new TextDecoder().decode(bytes) : null;68}6970function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {71 const { name, homepage, installLines } = repo.settings;72 const stats = repoStats(repo.mirror);73 const handle = setting(ctx.kepr, "handle");74 75 const newest = visibleReleases(ctx.kepr, repo.mirror, name)[0];76 const meta = [77 newest && `v${newest.version}`,78 plural(stats.saves, "save"),79 stats.lines > 0 && plural(stats.lines, "line"),80 stats.updated !== null && `updated ${relativeTime(stats.updated)}`,81 ].filter(Boolean);82 return html`<header class="repo-head">83<h1 class="repo-name">${name}</h1>84${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}85${intro && html`<div class="repo-desc"><p>${intro}</p></div>`}86${HTTP_URL.test(homepage) && html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`}87${stats.language && html`<div class="lang-badges"><span class="lang-badge">${stats.language}</span></div>`}88<p class="repo-meta">${meta.join(" · ")}</p>89<div class="repo-tabbar">90<nav class="repo-tabs" aria-label="Repository panels"><a class="repo-tab" href="/${name}/issues">issues</a><a class="repo-tab" href="/${name}/releases">builds</a><a class="repo-tab" href="/${name}/archives">archives</a></nav>91${installLines.length > 0 && html`<div class="install-lines">${installLines.map((line) => html`<div class="install-line"><code>${line}</code><button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button></div>`)}</div>`}92</div>93</header>`;94}9596function sections(repo: PublicRepo, pageNumber: number, at: string | null): Html {97 const name = repo.settings.name;98 const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at);99 const saves = timeline(repo.mirror);100 const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);101 const count = plural(saves.length, "save");102 const older = pageNumber * PAGE_SIZE < saves.length;103 return html`<section class="repo-section">104<details class="repo-section-toggle" open>105<summary class="section-head"><span class="section-label">timeline</span><span class="section-count">${count}</span></summary>106${timelineSvg(name, saves)}107</details>108</section>109${atlas}110<section class="repo-section" id="history">111<details class="repo-section-toggle" open>112<summary class="section-head"><span class="section-label">history</span><span class="section-count">${count}</span></summary>113${rows.map((save) => html`<a class="history-row" href="/${name}/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>`)}114${(pageNumber > 1 || older) && html`<p class="pager mono">${pageNumber > 1 && html`<a href="?page=${pageNumber - 1}#history">← newer</a>`}${older && html`<a href="?page=${pageNumber + 1}#history">older →</a>`}</p>`}115</details>116</section>`;117}118119function details(readme: Html): Html {120 return html`<section class="repo-details">121<details class="repo-details-toggle" open>122<summary class="repo-details-head">details</summary>123<div class="repo-details-body markdown">${readme}</div>124</details>125</section>`;126}