layout.ts

5.2 KB · raw

1import type { Database } from "bun:sqlite";2import type { Context } from "hono";3import { openMirror } from "../mirror/store.ts";4import { publicRepo, type RepoSettings, setting } from "../server/db.ts";5import { type Html, html } from "./html.ts";67export type PageContext = { domain: string; kepr: Database; mirrorDir: string };89export type PublicRepo = { settings: RepoSettings; mirror: Database };1011// Static files are cached for an hour; the release in their URLs makes a new one load at once.12const { version } = (await Bun.file(`${import.meta.dir}/../../package.json`).json()) as {13	version: string;14};1516const HEAD_CACHE = "public, max-age=60";17// Content at a save never changes, but strip can remove it, so it is long-lived rather than18// immutable, and `private`, so no shared cache keeps it after the repo turns private (§20).19const SAVE_CACHE = "private, max-age=31536000";2021/**22 * The gate of §20: a private repo and an unknown one both come back null, and so both 404 alike.23 */24export function openPublicRepo(25	ctx: Pick<PageContext, "kepr" | "mirrorDir">,26	name: string,27): PublicRepo | null {28	const settings = publicRepo(ctx.kepr, name);29	return settings ? { settings, mirror: openMirror(ctx.mirrorDir, name) } : null;30}3132export function cacheFor(c: Context, atSave: boolean): void {33	c.header("Cache-Control", atSave ? SAVE_CACHE : HEAD_CACHE);34}3536export function notFound(c: Context, ctx: PageContext): Response {37	c.header("Cache-Control", "no-store");38	const body = html`<h1 class="file-page-title">Not found</h1>39		<p class="muted">Nothing lives at this address.</p>`;40	return c.html(page(ctx, { title: "Not found", description: "", path: c.req.path, body }), 404);41}4243type PageOptions = { title: string; description: string; path: string; body: Html };4445export function page(ctx: PageContext, options: PageOptions): string {46	const site = setting(ctx.kepr, "site_name") ?? "kepr";47	const branding = setting(ctx.kepr, "branding");48	// Visitors are counted, never the operator's console or sign-in.49	const tracked = !/^\/(admin|sign-in|connect)(\/|$)/.test(options.path);50	const umamiSrc = tracked && setting(ctx.kepr, "umami_src");51	const umamiId = setting(ctx.kepr, "umami_id");52	// The front page is the site itself, so its title is the bare site name.53	const title = options.title ? `${options.title} — ${site}` : site;54	const url = `https://${ctx.domain}${options.path}`;55	return html`<!doctype html>56<html lang="en">57<head>58<meta charset="utf-8">59<meta name="viewport" content="width=device-width, initial-scale=1">60<title>${title}</title>61<meta name="description" content="${options.description}">62<meta property="og:site_name" content="${site}">63<meta property="og:type" content="website">64<meta property="og:url" content="${url}">65<meta property="og:title" content="${title}">66<meta property="og:description" content="${options.description}">67<meta name="twitter:card" content="summary">68<meta name="twitter:title" content="${title}">69<meta name="twitter:description" content="${options.description}">70<meta name="theme-color" content="#090706">71<link rel="canonical" href="${url}">72<link rel="icon" href="/favicon.svg" type="image/svg+xml">73<link rel="stylesheet" href="/static/css/kepr.css?v=${version}">74<script src="/static/js/kepr.js?v=${version}" defer></script>75${umamiSrc && umamiId && html`<script defer src="${umamiSrc}" data-website-id="${umamiId}"></script>`}76</head>77<body>78<nav>79<a class="wordmark" href="/" aria-label="${80		site81	}"><svg viewBox="0 0 32 32" class="wordmark-icon" aria-hidden="true"><g class="tri-outer"><polygon points="16,3 27,23 5,23"/></g><g class="tri-inner"><polygon points="16,10 22,21 10,21"/></g></svg></a>82${branding && html`<span class="wordmark-by">${branding}</span>`}83<span class="nav-links"><a href="/browse">browse</a><a href="/docs">docs</a>${!setting(ctx.kepr, "support_off") && html`<a href="/support">support</a>`}<a href="/sign-in?next=${encodeURIComponent(options.path).replaceAll("%2F", "/")}">log in</a></span>84</nav>85<script src="/static/js/nav.js?v=${version}"></script>86<main>87${options.body}88</main>89<footer><div class="credit">${site}</div></footer>90</body>91</html>`.value;92}9394const UNITS: [number, string][] = [95	[365 * 86400, "year"],96	[30 * 86400, "month"],97	[7 * 86400, "week"],98	[86400, "day"],99	[3600, "hour"],100	[60, "minute"],101];102103export function relativeTime(unixSeconds: number, now = Date.now() / 1000): string {104	const elapsed = now - unixSeconds;105	for (const [seconds, unit] of UNITS) {106		const n = Math.floor(elapsed / seconds);107		if (n >= 1) return `${n} ${unit}${n === 1 ? "" : "s"} ago`;108	}109	return "just now";110}111112export function formatDate(unixSeconds: number): string {113	return new Date(unixSeconds * 1000).toISOString().slice(0, 16).replace("T", " ");114}115116export function plural(n: number, noun: string): string {117	return `${n.toLocaleString("en-US")} ${noun}${n === 1 ? "" : "s"}`;118}119120export function formatSize(bytes: number): string {121	if (bytes < 1024) return `${bytes} B`;122	if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;123	return `${(bytes / 1024 / 1024).toFixed(1)} MB`;124}125126/** Percent-encodes each segment of a repo path for use in a URL. */127export function pathUrl(path: string): string {128	return path.split("/").map(encodeURIComponent).join("/");129}