repo.ts

8.8 KB · raw

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 { visibleReleases } from "../releases/releases.ts";16import { setting } from "../server/db.ts";17import { atlasPanel, atlasPickerRows, atlasSection } from "./atlas.ts";18import { type Html, html } from "./html.ts";19import {20	cacheFor,21	notFound,22	openPublicRepo,23	type PageContext,24	type PublicRepo,25	page,26	plural,27	relativeTime,28} from "./layout.ts";29import { renderMarkdown, renderParagraph } from "./markdown.ts";30import { timelineSaves, timelineSvg } from "./timeline.ts";3132const PAGE_SIZE = 50;33// Each opens inline under the tabs (kepr.js), as Kepr 2's panels did; the page is the fallback.34const PANELS = [35	["issues", "issues"],36	["builds", "releases"],37	["archives", "archives"],38];39const HTTP_URL = /^https?:\/\//i;4041export function registerRepoPage(app: Hono, ctx: PageContext): void {42	app.get("/:repo", (c) => {43		const repo = openPublicRepo(ctx, c.req.param("repo"));44		if (!repo) return notFound(c, ctx);45		// `?at=<id>` shows the Atlas at that save (§21.5); the rest of the page stays at HEAD.46		const at = c.req.query("at");47		const atSave = at === undefined ? null : saveByPrefix(repo.mirror, at);48		if (at !== undefined && !atSave) return notFound(c, ctx);49		const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1);50		const name = repo.settings.name;51		const intro = repoIntro(repo);52		cacheFor(c, false);53		return c.html(54			page(ctx, {55				title: name,56				description: intro?.text ?? "",57				path: `/${name}`,58				body: html`${header(ctx, repo, intro?.html ?? null)}59${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)}60${details(repoDetails(repo))}`,61			}),62		);63	});64	// The timeline's saves, fetched by kepr.js once the page has drawn (§21.4).65	app.get("/:repo/timeline", (c) => {66		const repo = openPublicRepo(ctx, c.req.param("repo"));67		if (!repo) return notFound(c, ctx);68		cacheFor(c, false);69		return c.json(timelineSaves(timeline(repo.mirror)));70	});71	// The Atlas picker's rows, fetched by kepr.js when it first opens (§21.5).72	app.get("/:repo/atlas/saves", (c) => {73		const repo = openPublicRepo(ctx, c.req.param("repo"));74		const at = c.req.query("at");75		const save = repo && at !== undefined ? saveByPrefix(repo.mirror, at) : null;76		if (!repo || (at !== undefined && !save)) return notFound(c, ctx);77		cacheFor(c, false);78		const rows = atlasPickerRows(repo.settings.name, timeline(repo.mirror), save?.id ?? null);79		return c.html(rows.map(String).join(""));80	});81	// One Atlas row's detail panel, fetched by kepr.js on hover or select (§21.5).82	app.get("/:repo/atlas/panel", (c) => {83		const repo = openPublicRepo(ctx, c.req.param("repo"));84		const at = c.req.query("at");85		const path = c.req.query("path") ?? "";86		const save = repo && (at === undefined ? null : saveByPrefix(repo.mirror, at));87		const saveId = save?.id ?? (repo && headId(repo.mirror));88		const panel =89			repo &&90			saveId &&91			(at === undefined || save) &&92			isSafePath(path.replace(/\/$/, "")) &&93			atlasPanel(repo, saveId, save?.id ?? null, path);94		if (!panel) return notFound(c, ctx);95		cacheFor(c, save !== null && save !== undefined);96		return c.html(String(panel));97	});98}99100/**101 * The repo's description, as markup and as plain text: the operator's own, or else the README's102 * first paragraph (§21.4).103 */104export function repoIntro(repo: PublicRepo): { html: Html; text: string } | null {105	const { name, description } = repo.settings;106	if (description)107		return renderParagraph(description, { repo: name, dir: "", at: null, own: true });108	const readme = readmeAtHead(repo.mirror);109	const paragraph = readme === null ? null : describeFile("README.md", readme);110	return paragraph === null ? null : renderParagraph(paragraph, { repo: name, dir: "", at: null });111}112113/** The details section: the operator's own Markdown, or else the rendered README. */114export function repoDetails(repo: PublicRepo): Html | null {115	const { name, details } = repo.settings;116	if (details) return renderMarkdown(details, { repo: name, dir: "", at: null, own: true });117	const readme = readmeAtHead(repo.mirror);118	return readme === null ? null : renderMarkdown(readme, { repo: name, dir: "", at: null });119}120121export function readmeAtHead(mirror: PublicRepo["mirror"]): string | null {122	const head = headId(mirror);123	const path =124		head && filesAt(mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path;125	const file = head && path ? fileAt(mirror, head, path) : null;126	const bytes = file && objectBytes(mirror, file.hash);127	return bytes ? new TextDecoder().decode(bytes) : null;128}129130function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html {131	const { name, homepage, languages } = repo.settings;132	const steal = `koh steal ${ctx.domain}/${name}`;133	const stats = repoStats(repo.mirror);134	const handle = setting(ctx.kepr, "handle");135	// §21.4: the version is the newest release; with none, the header shows no version.136	const newest = visibleReleases(ctx.kepr, repo.mirror, name)[0];137	const meta = [138		newest && `v${newest.version}`,139		plural(stats.saves, "save"),140		stats.lines > 0 && plural(stats.lines, "line"),141		stats.updated !== null && `updated ${relativeTime(stats.updated)}`,142	].filter(Boolean);143	return html`<header class="repo-head">144<h1 class="repo-name">${name}</h1>145${handle && html`<p class="repo-contributor mono"><a href="/@${handle}">@${handle}</a></p>`}146${html`<div class="repo-desc"${!intro && " hidden"}><p>${intro}</p></div>`}147${148	HTTP_URL.test(homepage) &&149	html`<p class="repo-homepage"><a href="${homepage}">${homepage}</a></p>`150}151${152	(languages.length > 0 || stats.language) &&153	html`<div class="lang-badges">${(languages.length > 0 ? languages : [stats.language]).map(154		(language) => html`<span class="lang-badge">${language}</span>`,155	)}</div>`156}157<p class="repo-meta">${meta.join(" · ")}</p>158<div class="repo-tabbar">159<nav class="repo-tabs" aria-label="Repository panels">${PANELS.map(160		([label, path]) =>161			html`<a class="repo-tab" href="/${name}/${path}" data-panel="${path}">${label}</a>`,162	)}</nav>163<div class="install-lines">164<button type="button" class="repo-steal-btn" data-copy="${steal}">${steal}</button>165</div>166</div>167<div class="repo-panel" hidden></div>168</header>`;169}170171function sections(repo: PublicRepo, requestedPage: number, at: string | null): Html {172	const name = repo.settings.name;173	const saves = timeline(repo.mirror);174	// Past the last page, even at an unbindable number like Infinity, is page 1.175	const pageNumber = (requestedPage - 1) * PAGE_SIZE < saves.length ? requestedPage : 1;176	const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE);177	const pages: Pages = {178		at,179		newer: pageNumber > 1 ? pageNumber - 1 : null,180		older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null,181	};182	const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at);183	return html`<section class="repo-section">184<details class="repo-section-toggle" open>185<summary class="section-head"><span class="section-label">timeline</span></summary>186${timelineSvg(name, saves)}187</details>188</section>189${atlas}190<section class="repo-section" id="history">191<details class="repo-section-toggle" open>192<summary class="section-head"><span class="section-label">history</span></summary>193${saveList(name, rows, pages)}194</details>195</section>`;196}197198type Pages = { at: string | null; newer: number | null; older: number | null };199200/**201 * One page of saves in a box that scrolls four rows at a time, as Kepr 2's; kepr.js fetches the202 * older pages into it as it scrolls, and without JavaScript the pager links page the whole view.203 */204function saveList(repo: string, rows: Save[], pages: Pages): Html {205	const pageHref = (n: number) => {206		const query = new URLSearchParams(pages.at ? { at: pages.at } : {});207		query.set("page", String(n));208		return `?${query}#history`;209	};210	return html`<div class="save-list" data-list="history">211${rows.map(212	(save) =>213		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>`,214)}215${216	(pages.newer || pages.older) &&217	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>`218}219</div>`;220}221222// Empty and hidden when there is nothing to show, so a signed-in editor still has a place to write.223function details(body: Html | null): Html {224	return html`<section class="repo-details"${!body && " hidden"}>225<details class="repo-details-toggle" open>226<summary class="repo-details-head">details</summary>227<div class="repo-details-body markdown">${body}</div>228</details>229</section>`;230}