repo.ts

6.8 KB · raw

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 { type Release, 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;32// Each opens inline under the tabs (kepr.js), as Kepr 2's panels did; the page is the fallback.33const 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		// `?at=<id>` shows the Atlas at that save (§21.5); the rest of the page stays at HEAD.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 readme = readmeAtHead(repo);50		const name = repo.settings.name;51		const releases = visibleReleases(ctx.kepr, repo.mirror, name);52		const intro = readmeIntro(name, readme);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, releases, intro?.html ?? null)}60${headId(repo.mirror) && sections(repo, releases, pageNumber, atSave?.id ?? null)}61${readme && details(renderMarkdown(readme, { repo: name, dir: "", at: null }))}`,62			}),63		);64	});65}6667/** The README's first paragraph (§21.4's description), as markup and as plain text. */68export function readmeIntro(69	repo: string,70	readme: string | null,71): { html: Html; text: string } | null {72	const paragraph = readme === null ? null : describeFile("README.md", readme);73	return paragraph === null ? null : renderParagraph(paragraph, { repo, dir: "", at: null });74}7576export function readmeAtHead(repo: PublicRepo): string | null {77	const head = headId(repo.mirror);78	const path =79		head && filesAt(repo.mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;80	const file = head && path ? fileAt(repo.mirror, head, path) : null;81	const bytes = file && objectBytes(repo.mirror, file.hash);82	return bytes ? new TextDecoder().decode(bytes) : null;83}8485function header(ctx: PageContext, repo: PublicRepo, releases: Release[], intro: Html | null): Html {86	const { name, homepage, installLines } = repo.settings;87	const steal = `koh steal ${ctx.domain}/${name}`;88	const stats = repoStats(repo.mirror);89	const handle = setting(ctx.kepr, "handle");90	// §21.4: the version is the newest release; with none, the header shows no version.91	const newest = releases[0];92	const meta = [93		newest && `v${newest.version}`,94		plural(stats.saves, "save"),95		stats.lines > 0 && plural(stats.lines, "line"),96		stats.updated !== null && `updated ${relativeTime(stats.updated)}`,97	].filter(Boolean);98	return html`<header class="repo-head">99<h1 class="repo-name">${name}</h1>100${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}101${intro && html`<div class="repo-desc"><p>${intro}</p></div>`}102${103	HTTP_URL.test(homepage) &&104	html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`105}106${107	stats.language &&108	html`<div class="lang-badges"><span class="lang-badge">${stats.language}</span></div>`109}110<p class="repo-meta">${meta.join(" · ")}</p>111<div class="repo-tabbar">112<nav class="repo-tabs" aria-label="Repository panels">${PANELS.map(113		([label, path]) =>114			html`<a class="repo-tab" href="/${name}/${path}" data-panel="${path}">${label}</a>`,115	)}</nav>116<div class="install-lines">117<button type="button" class="repo-steal-btn" data-copy="${steal}">${steal}</button>118${installLines.map(119	(line) =>120		html`<div class="install-line"><code>${line}</code>${html`<button type="button" class="copy-btn" data-copy="${line}" hidden>copy</button>`}</div>`,121)}122</div>123</div>124<div class="repo-panel" hidden></div>125</header>`;126}127128function sections(129	repo: PublicRepo,130	releases: Release[],131	requestedPage: number,132	at: string | null,133): Html {134	const name = repo.settings.name;135	const saves = timeline(repo.mirror);136	// Past the last page, even at an unbindable number like Infinity, is page 1.137	const pageNumber = (requestedPage - 1) * PAGE_SIZE < saves.length ? requestedPage : 1;138	const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);139	const pages: Pages = {140		at,141		newer: pageNumber > 1 ? pageNumber - 1 : null,142		older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null,143	};144	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at, saves, releases);145	return html`<section class="repo-section">146<details class="repo-section-toggle" open>147<summary class="section-head"><span class="section-label">timeline</span></summary>148${timelineSvg(name, saves)}149</details>150</section>151${atlas}152<section class="repo-section" id="history">153<details class="repo-section-toggle" open>154<summary class="section-head"><span class="section-label">history</span></summary>155${saveList(name, rows, pages)}156</details>157</section>`;158}159160type Pages = { at: string | null; newer: number | null; older: number | null };161162/**163 * One page of saves in a box that scrolls four rows at a time, as Kepr 2's; kepr.js fetches the164 * older pages into it as it scrolls, and without JavaScript the pager links page the whole view.165 */166function saveList(repo: string, rows: Save[], pages: Pages): Html {167	const pageHref = (n: number) => {168		const query = new URLSearchParams(pages.at ? { at: pages.at } : {});169		query.set("page", String(n));170		return `?${query}#history`;171	};172	return html`<div class="save-list" data-list="history">173${rows.map(174	(save) =>175		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>`,176)}177${178	(pages.newer || pages.older) &&179	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>`180}181</div>`;182}183184function details(readme: Html): Html {185	return html`<section class="repo-details">186<details class="repo-details-toggle" open>187<summary class="repo-details-head">details</summary>188<div class="repo-details-body markdown">${readme}</div>189</details>190</section>`;191}