import type { Hono } from "hono"; import { isSafePath } from "../bucket/repo.ts"; import { describeFile } from "../describe/describe.ts"; import { fileAt, filesAt, headId, history, objectBytes, repoStats, type Save, saveByPrefix, timeline, } from "../mirror/queries.ts"; import { mirrorState } from "../mirror/store.ts"; import { visibleReleases } from "../releases/releases.ts"; import { setting } from "../server/db.ts"; import { atlasPanel, atlasPickerRows, atlasSection } from "./atlas.ts"; import { type Html, html } from "./html.ts"; import { cacheFor, notFound, openPublicRepo, type PageContext, type PublicRepo, page, plural, relativeTime, } from "./layout.ts"; import { renderMarkdown, renderParagraph } from "./markdown.ts"; import { timelineSaves, timelineSvg } from "./timeline.ts"; const PAGE_SIZE = 50; // Each opens inline under the tabs (kepr.js), as Kepr 2's panels did; the page is the fallback. const PANELS = [ ["issues", "issues"], ["builds", "releases"], ["archives", "archives"], ]; const HTTP_URL = /^https?:\/\//i; export function registerRepoPage(app: Hono, ctx: PageContext): void { app.get("/:repo", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); if (!repo) return notFound(c, ctx); // `?at=` shows the Atlas at that save (§21.5); the rest of the page stays at HEAD. const at = c.req.query("at"); const atSave = at === undefined ? null : saveByPrefix(repo.mirror, at); if (at !== undefined && !atSave) return notFound(c, ctx); const pageNumber = Math.max(1, Number.parseInt(c.req.query("page") ?? "1", 10) || 1); const name = repo.settings.name; const intro = repoIntro(repo); cacheFor(c, false); return c.html( page(ctx, { title: name, description: intro?.text ?? "", path: `/${name}`, body: html`${header(ctx, repo, intro?.html ?? null)} ${headId(repo.mirror) && sections(repo, pageNumber, atSave?.id ?? null)} ${details(repoDetails(repo))}`, }), ); }); // The timeline's saves, fetched by kepr.js once the page has drawn (§21.4). app.get("/:repo/timeline", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); if (!repo) return notFound(c, ctx); cacheFor(c, false); return c.json(timelineSaves(timeline(repo.mirror))); }); // The Atlas picker's rows, fetched by kepr.js when it first opens (§21.5). app.get("/:repo/atlas/saves", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); const at = c.req.query("at"); const save = repo && at !== undefined ? saveByPrefix(repo.mirror, at) : null; if (!repo || (at !== undefined && !save)) return notFound(c, ctx); cacheFor(c, false); const rows = atlasPickerRows(repo.settings.name, timeline(repo.mirror), save?.id ?? null); return c.html(rows.map(String).join("")); }); // One Atlas row's detail panel, fetched by kepr.js on hover or select (§21.5). app.get("/:repo/atlas/panel", (c) => { const repo = openPublicRepo(ctx, c.req.param("repo")); const at = c.req.query("at"); const path = c.req.query("path") ?? ""; const save = repo && (at === undefined ? null : saveByPrefix(repo.mirror, at)); const saveId = save?.id ?? (repo && headId(repo.mirror)); const panel = repo && saveId && (at === undefined || save) && isSafePath(path.replace(/\/$/, "")) && atlasPanel(repo, saveId, save?.id ?? null, path); if (!panel) return notFound(c, ctx); cacheFor(c, save !== null && save !== undefined); return c.html(String(panel)); }); } /** * The repo's description, as markup and as plain text: the operator's own, or else the README's * first paragraph (§21.4). */ export function repoIntro(repo: PublicRepo): { html: Html; text: string } | null { const { name, description } = repo.settings; if (description) return renderParagraph(description, { repo: name, dir: "", at: null, own: true }); const readme = readmeAtHead(repo.mirror); const paragraph = readme === null ? null : describeFile("README.md", readme); return paragraph === null ? null : renderParagraph(paragraph, { repo: name, dir: "", at: null }); } /** The details section: the operator's own Markdown, or else the rendered README. */ export function repoDetails(repo: PublicRepo): Html | null { const { name, details } = repo.settings; if (details) return renderMarkdown(details, { repo: name, dir: "", at: null, own: true }); const readme = readmeAtHead(repo.mirror); return readme === null ? null : renderMarkdown(readme, { repo: name, dir: "", at: null }); } export function readmeAtHead(mirror: PublicRepo["mirror"]): string | null { const head = headId(mirror); const path = head && filesAt(mirror, head).find((f) => f.path.toLowerCase() === "readme.md")?.path; const file = head && path ? fileAt(mirror, head, path) : null; const bytes = file && objectBytes(mirror, file.hash); return bytes ? new TextDecoder().decode(bytes) : null; } function header(ctx: PageContext, repo: PublicRepo, intro: Html | null): Html { const { name, homepage, languages } = repo.settings; const steal = `koh steal ${ctx.domain}/${name}`; const stats = repoStats(repo.mirror); const handle = setting(ctx.kepr, "handle"); // §21.4: the version is the newest release; with none, the version the project declares, by // Koh's rule, at its newest save. const version = visibleReleases(ctx.kepr, repo.mirror, name)[0]?.version || mirrorState(repo.mirror, "declared_version"); const meta = [ version && `v${version}`, plural(stats.saves, "save"), stats.lines > 0 && plural(stats.lines, "line"), stats.updated !== null && `updated ${relativeTime(stats.updated)}`, ].filter(Boolean); return html`

${name}

${handle && html`

@${handle}

`} ${html`

${intro}

`} ${ HTTP_URL.test(homepage) && html`

${homepage}

` } ${ (languages.length > 0 || stats.language) && html`
${(languages.length > 0 ? languages : [stats.language]).map( (language) => html`${language}`, )}
` }

${meta.join(" · ")}

`; } function sections(repo: PublicRepo, requestedPage: number, at: string | null): Html { const name = repo.settings.name; const saves = timeline(repo.mirror); // Past the last page, even at an unbindable number like Infinity, is page 1. const pageNumber = (requestedPage - 1) * PAGE_SIZE < saves.length ? requestedPage : 1; const rows = history(repo.mirror, PAGE_SIZE, (pageNumber - 1) * PAGE_SIZE); const pages: Pages = { at, newer: pageNumber > 1 ? pageNumber - 1 : null, older: pageNumber * PAGE_SIZE < saves.length ? pageNumber + 1 : null, }; const atlas = atlasSection(repo, at ?? (headId(repo.mirror) as string), at); return html`
${timelineSvg(name, saves)}
${atlas}
${saveList(name, rows, pages)}
`; } type Pages = { at: string | null; newer: number | null; older: number | null }; /** * One page of saves in a box that scrolls four rows at a time, as Kepr 2's; kepr.js fetches the * older pages into it as it scrolls, and without JavaScript the pager links page the whole view. */ function saveList(repo: string, rows: Save[], pages: Pages): Html { const pageHref = (n: number) => { const query = new URLSearchParams(pages.at ? { at: pages.at } : {}); query.set("page", String(n)); return `?${query}#history`; }; return html`
${rows.map( (save) => html`${save.id}${save.message.split("\n")[0]}${relativeTime(save.created_at)}`, )} ${ (pages.newer || pages.older) && html`

${pages.newer && html`← newer`}${pages.older && html`older →`}

` }
`; } // Empty and hidden when there is nothing to show, so a signed-in editor still has a place to write. function details(body: Html | null): Html { return html`
details
${body}
`; }