export function esc(s: string): string { return s .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } /** cgit-style relative age with its color class, e.g. "3 days" / age-days. */ export function age(unixSecs: number): string { if (!unixSecs) return ""; const secs = Math.max(0, Math.floor(Date.now() / 1000) - unixSecs); const fmt = (n: number, unit: string, cls: string) => `${n} ${unit}${n === 1 ? "" : unit.endsWith(".") ? "" : "s"}`; if (secs < 3600) return fmt(Math.max(1, Math.floor(secs / 60)), "min.", "mins"); if (secs < 24 * 3600) return fmt(Math.floor(secs / 3600), "hour", "hours"); if (secs < 7 * 24 * 3600) return fmt(Math.floor(secs / (24 * 3600)), "day", "days"); if (secs < 31 * 24 * 3600) return fmt(Math.floor(secs / (7 * 24 * 3600)), "week", "weeks"); if (secs < 365 * 24 * 3600) return fmt(Math.floor(secs / (30 * 24 * 3600)), "month", "months"); return fmt(Math.floor(secs / (365 * 24 * 3600)), "year", "years"); } export function fmtDate(unixSecs: number, tz: string): string { const d = new Date(unixSecs * 1000); const pad = (n: number) => String(n).padStart(2, "0"); return ( `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} ${tz}` ); } const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; /** RFC 2822 date in the person's own timezone (for patch/atom output). */ export function fmtDate2822(unixSecs: number, tz: string): string { const offMin = (parseInt(tz.slice(0, 3), 10) || 0) * 60 + (parseInt(tz.slice(0, 1) + tz.slice(3), 10) || 0); const d = new Date((unixSecs + offMin * 60) * 1000); const pad = (n: number) => String(n).padStart(2, "0"); return ( `${DAYS[d.getUTCDay()]}, ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} ${tz}` ); } export interface LayoutOpts { site: string; siteDesc: string; title: string; /** repo name when rendering a repo page */ repo?: string; /** description line under the title */ sub: string; /** active tab id */ tab?: string; /** current ref for tab links */ ref?: string; /** breadcrumb path html (tree pages) */ pathBar?: string; /** show the about tab (repo has a README) */ hasAbout?: boolean; /** branch names for the switcher dropdown */ branches?: string[]; /** form action for the branch switcher (current page path) */ formAction?: string; body: string; } export function layout(o: LayoutOpts): string { const r = o.repo ? `/${encodeURIComponent(o.repo)}` : ""; const q = o.ref ? `?h=${encodeURIComponent(o.ref)}` : ""; const tabs: [string, string][] = o.repo ? [ ...(o.hasAbout ? [["about", `${r}/about/${q}`] as [string, string]] : []), ["summary", `${r}/${q}`], ["refs", `${r}/refs/${q}`], ["log", `${r}/log/${q}`], ["tree", `${r}/tree/${q}`], ["commit", `${r}/commit/${q}`], ["diff", `${r}/diff/${q}`], ["stats", `${r}/stats/${q}`], ] : [["index", "/"]]; const tabHtml = tabs .map(([id, href]) => `${id}`) .join(""); const crumb = o.repo ? ` : ${esc(o.repo)}` : ""; const switcher = o.repo && o.branches && o.branches.length ? `
` + `
` : ""; const atom = o.repo ? `` : ""; return ` ${esc(o.title)} ${atom}
${switcher}
${tabHtml}
${o.pathBar ? `
${o.pathBar}
` : ""}
${o.body}
`; } export function htmlResponse(html: string, status = 200): Response { return new Response(html, { status, headers: { "content-type": "text/html; charset=utf-8" }, }); } export function errorPage(o: Omit, msg: string, status = 404): Response { return htmlResponse(layout({ ...o, body: `
${esc(msg)}
` }), status); }