import type { Hono } from "hono";
import { isSafePath } from "../bucket/repo.ts";
import { publicIssueCount } from "../issues/store.ts";
import { languageLines } from "../mirror/queries.ts";
import { publicDownloadCount } from "../releases/releases.ts";
import { setting } from "../server/db.ts";
import { type Html, html } from "./html.ts";
import { cacheFor, notFound, type PageContext, page, relativeTime } from "./layout.ts";
import {
type LibraryRepo,
publicLibrary,
type RecentSave,
recentSaves,
repoCards,
} from "./library.ts";
const HEX_COLUMNS = 48;
const RECENT_ACTIVITY = 20;
const BAR_LANGUAGES = 4;
const HTTP_URL = /^https?:\/\//i;
/** §21.2's profile of the one operator; any other handle is a 404. */
export function registerProfilePage(app: Hono, ctx: PageContext): void {
app.get("/:handle{@[^/]+}", (c) => {
const handle = setting(ctx.kepr, "handle") ?? "";
if (handle === "" || c.req.param("handle") !== `@${handle}`) return notFound(c, ctx);
const library = publicLibrary(ctx);
const bio = setting(ctx.kepr, "bio") ?? "";
cacheFor(c, false);
return c.html(
page(ctx, {
title: `@${handle}`,
description: bio || `@${handle}'s repositories.`,
path: `/@${handle}`,
body: html`
${cover(ctx, handle, recentSaves(library, HEX_COLUMNS))}
${identityCard(ctx, bio)}
${stats(ctx, library)}
${languageBar(library)}
recent activity
${activity(recentSaves(library, RECENT_ACTIVITY))}
repos · ${library.length}
${repoCards(library)}
`,
}),
);
});
}
function cover(ctx: PageContext, handle: string, recent: RecentSave[]): Html {
const since = setting(ctx.kepr, "building_since");
const signature = setting(ctx.kepr, "signature");
// Duplicate each group so the loop joins without replacing or inventing save IDs.
const columns =
recent.length === 0
? []
: Array.from({ length: HEX_COLUMNS }, (_, i) =>
Array.from({ length: 4 }, (_, j) => recent[(i + j) % recent.length]?.save.id),
);
// Without an uploaded signature, the handle in Sacramento stands in for it, as on Kepr 2.
const name =
signature && isSafePath(signature) && !signature.includes("/")
? html` `
: handle;
return html`
${columns.map((ids) => {
const group = html`${ids.map(
(id) => html`${id} `,
)} `;
return html`${group}${group} `;
})}
${name}
${since && html`
building since ${since}
`}
◈ operator
`;
}
function identityCard(ctx: PageContext, bio: string): Html | null {
const location = setting(ctx.kepr, "location");
const link = setting(ctx.kepr, "link") ?? "";
const hasLink = HTTP_URL.test(link);
if (!bio && !location && !hasLink) return null;
return html`
${bio && html`
${bio}
`}
${location && html`
${location}
`}
${
hasLink &&
html`
${link} `
}
`;
}
function stats(ctx: PageContext, library: LibraryRepo[]): Html {
const tiles: [string, number][] = [
["repos", library.length],
["saves", library.reduce((sum, repo) => sum + repo.stats.saves, 0)],
["issues", publicIssueCount(ctx.kepr)],
["downloads", publicDownloadCount(ctx.kepr)],
];
return html`${tiles.map(
([label, n]) =>
html`
${n.toLocaleString("en-US")}
${label}
`,
)}
`;
}
// Lines per language summed across public repos, as SVG so the widths need no inline style (§25).
function languageBar(library: LibraryRepo[]): Html | null {
const lines = new Map();
for (const repo of library) {
for (const [language, n] of languageLines(repo.mirror)) {
lines.set(language, (lines.get(language) ?? 0) + n);
}
}
const top = [...lines].sort((a, b) => b[1] - a[1]).slice(0, BAR_LANGUAGES);
const total = top.reduce((sum, [, n]) => sum + n, 0);
if (total === 0) return null;
let x = 0;
const segments = top.map(([language, n]) => {
const width = (100 * n) / total;
const segment = html`${language} ${Math.round(width)}% `;
x += width;
return segment;
});
return html`
${
segments
}
${top
.slice(0, 3)
.map(([language]) => html`${language} `)}
`;
}
function activity(recent: RecentSave[]): Html {
if (recent.length === 0) return html`no activity yet
`;
return html`${recent.map(
({ repo, save }) =>
html`
`,
)}
`;
}