clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / render.ts
render.ts 14.4K
1// HTML for the heavy parts (diffs, blobs, range-diffs). Built in code rather2// than templates because a large diff is tens of thousands of lines.3//4// Code views are a column of rows, each a line-number gutter and the code.5// The gutter is its own box, so tab stops count from the code, copying skips6// the numbers, and it stays put when a long line scrolls sideways. All rows7// are as wide as the widest, so diff highlights run the full line. The8// gutter's width comes from the file's largest line number (--w, in digits),9// so its sticky columns line up and never overlap.10import type { CiStatus, DiffFile, Hunk } from './types.ts';11import { highlight, highlightLines, language, splitLines } from './highlight.ts';1213const ENTITIES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };14export const esc = (s: unknown): string => String(s ?? '').replace(/[&<>"']/g, (c) => ENTITIES[c]);1516// A timestamp as <time>, absolute UTC in its datetime and title. Static17// pages go stale, so site/assets/ui.ts says it relative to now ("5m"), in18// the reader's zone with their full local time on hover. A time in its own19// column is compact; one in a sentence (`sentence`, marked data-ago) reads20// as one ("5m ago"). The page itself has the day, as short as what ui.ts21// puts there ("Sep 24", "2025-03-01" from an earlier year; in a sentence22// "Sep 24, 2025"), so a column doesn't change width when the script runs.23// FORGE_NOW pins the year, as it does the clock for the demo and tests.24const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];25export function time(t: Date, sentence = false): string {26 if (isNaN(t.getTime())) return '';27 const iso = t.toISOString().replace(/\.\d+Z$/, 'Z');28 const year = new Date(Number(process.env.FORGE_NOW) * 1000 || Date.now()).getUTCFullYear();29 const day = `${MONTHS[t.getUTCMonth()]} ${t.getUTCDate()}`;30 const text = t.getUTCFullYear() === year ? day : sentence ? `${day}, ${t.getUTCFullYear()}` : iso.slice(0, 10);31 return `<time datetime="${iso}" title="${iso.slice(0, 16).replace('T', ' ')} UTC"${sentence ? ' data-ago' : ''}>${text}</time>`;32}3334// Unix seconds (review logs) or an ISO date: in a column, and in a sentence.35const at = (ts: number | string | null | undefined): Date | null => (ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null);36export const date = (ts: number | string | null | undefined): string => {37 const t = at(ts);38 return t ? time(t) : '';39};40export const ago = (ts: number | string | null | undefined): string => {41 const t = at(ts);42 return t ? time(t, true) : '';43};4445// A page renders at most this many diff lines; the rest of the files are listed.46const MAX_PAGE_LINES = 6000;4748const statusWord: Record<string, string> = { A: 'added', D: 'deleted', M: 'modified', R: 'renamed', C: 'copied' };4950// Paths break only after a slash, so a narrow column keeps file names whole.51const pathHtml = (p: string | null): string => esc(p).replaceAll('/', '/<wbr>');52const fileTitle = (f: DiffFile): string => (f.status === 'R' ? `${pathHtml(f.oldPath)} &rarr; ${pathHtml(f.newPath)}` : pathHtml(f.path));5354// A hunk's rows as HTML. The old and new sides are highlighted as continuous55// text, so strings and comments spanning lines mostly come out right, then56// mapped back to rows: removed lines from the old side, the rest from the new.57function hunkCode(h: Hunk, lang: string | null): string[] {58 const side = (t: string): string => h.lines.filter((l) => l.t === ' ' || l.t === t).map((l) => l.text).join('\n');59 const old = h.lines.some((l) => l.t === '-') ? highlightLines(side('-'), lang) : [];60 const neu = old && highlightLines(side('+'), lang);61 if (!old || !neu) return h.lines.map((l) => esc(l.text));62 let o = 0;63 let n = 0;64 return h.lines.map((l) => {65 if (l.t === '\\') return esc(l.text);66 if (l.t === '-') return old[o++];67 if (l.t === ' ') o++;68 return neu[n++];69 });70}7172// The diffstat: a panel whose head strip sums it up, with `tools` (the73// page's controls, like wrap lines) at its end.74export function diffstat(files: DiffFile[], prefix = '', tools = ''): string {75 if (!files.length) return '';76 const max = Math.max(...files.map((f) => f.add + f.del), 1);77 const width = 30;78 const scale = max > width ? width / max : 1;79 const rows = files.map((f, i) => {80 const total = f.add + f.del;81 const a = f.add ? Math.max(1, Math.round(f.add * scale)) : 0;82 const d = f.del ? Math.max(1, Math.round(f.del * scale)) : 0;83 return `<tr><td class="st ${f.status}" title="${statusWord[f.status]}">${f.status}</td><td class="main"><a href="#${prefix}f${i}">${fileTitle(f)}</a></td>` +84 `<td class="num">${f.binary ? 'bin' : total}</td><td class="graph"><span class="i">${'+'.repeat(a)}</span><span class="d">${'-'.repeat(d)}</span></td></tr>`;85 });86 const add = files.reduce((s, f) => s + f.add, 0);87 const del = files.reduce((s, f) => s + f.del, 0);88 return `<section class="panel"><header class="panel-head"><span class="grow">${files.length} file${files.length === 1 ? '' : 's'} changed, <span class="i">+${add}</span> <span class="d">-${del}</span></span>` +89 (tools ? `<span class="end">${tools}</span>` : '') + `</header><table class="diffstat list">${rows.join('')}</table></section>`;90}9192// `annotate(path, newLine)` may return HTML to show under that line (review comments).93type Annotate = ((path: string, newLine: number) => string | null) | null;9495// `mechanical(file)` says Jev judged the change mechanical (a rename,96// lockfile, generated file...): it is labelled, and diffs.ts folds it.97// With `paths`, each file that still exists carries its path98// (data-path), for line comments from the page (discuss.ts).99export function diff(files: DiffFile[], { prefix = '', blobHref, annotate, mechanical, paths = false }: { prefix?: string; blobHref?: ((path: string) => string | null) | null; annotate?: Annotate; mechanical?: ((f: DiffFile) => boolean) | null; paths?: boolean } = {}): string {100 let budget = MAX_PAGE_LINES;101 return files.map((f, i) => {102 const id = `${prefix}f${i}`;103 const title = fileTitle(f);104 const fileHref = blobHref && f.status !== 'D' ? blobHref(f.path) : null;105 const mech = !!mechanical?.(f);106 const head = `<header class="panel-head fh"><span class="st ${f.status}" title="${statusWord[f.status]}">${f.status}</span> <a href="#${id}" class="fname">${title}</a>` +107 ` <span class="counts"><span class="i">+${f.add}</span> <span class="d">-${f.del}</span></span>` +108 (mech ? ' <span class="jev-mech" title="Likely mechanical (Jev): a rename, reformatting, generated or vendored code, a lockfile, a version bump or a search-and-replace">mechanical</span>' : '') +109 (fileHref ? ` <a class="view" href="${fileHref}">view file</a>` : '') + '</header>';110 const at = paths && f.status !== 'D' ? ` data-path="${esc(f.path)}"` : '';111 const wrap = (body: string): string => `<section class="panel file jump" id="${id}"${at}${mech ? ' data-mechanical' : ''}>${head}${body}</section>`;112 if (f.omitted) return wrap('<p class="note">Diff not shown: this commit changes too many lines.</p>');113 if (f.binary) return wrap('<p class="note">Binary file.</p>');114 if (!f.hunks.length) return wrap(`<p class="note">${f.mode ? 'File mode ' + esc(f.mode) : 'No content changes.'}</p>`);115 const lines = f.hunks.reduce((n, h) => n + h.lines.length, 0);116 if (budget <= 0) return wrap(`<p class="note">Not shown: this page already shows ${MAX_PAGE_LINES} lines of diff.</p>`);117 budget -= lines;118 const first = f.hunks[0].lines[0];119 const lang = language(f.path, first && (first.o === 1 || first.n === 1) ? first.text : '');120 // Line numbers only grow, so the last hunk holds the widest.121 const last = f.hunks.at(-1)!.lines.reduce((m, l) => Math.max(m, l.o ?? 0, l.n ?? 0), 0);122 let out = `<div class="code diff" style="--w:${String(last).length}"><div class="rows">`;123 f.hunks.forEach((h, hi) => {124 out += `<div class="hunk"><span>${esc(h.header)}</span></div>`;125 const code = hunkCode(h, lang);126 h.lines.forEach((l, li) => {127 const lid = `${id}-${hi}-${li}`;128 const cls = l.t === '+' ? 'add' : l.t === '-' ? 'del' : l.t === '\\' ? 'meta' : '';129 out += `<a href="#${lid}" id="${lid}" class="l ${cls}"><span class="ln">${l.o ?? ''}</span><span class="ln">${l.n ?? ''}</span><span class="mk">${l.t === '\\' ? '' : l.t}</span><span class="lc">${code[li]}</span></a>`;130 if (annotate && l.n != null) out += annotate(f.path, l.n) ?? '';131 });132 });133 if (f.truncated) out += '<div class="hunk">diff truncated</div>';134 return wrap(out + '</div></div>');135 }).join('\n');136}137138// A file's lines; `path` picks the highlighting language, and `hl` does the139// highlighting (forge.ts passes one that caches by blob id).140export function blob(text: string, path = '', hl: (code: string, lang: string | null) => string | null = highlight): string {141 const lines = text.split('\n');142 if (lines.at(-1) === '') lines.pop();143 const h = hl(lines.join('\n'), language(path, lines[0]));144 const code = h == null ? lines.map(esc) : splitLines(h);145 return `<div class="code blob" style="--w:${String(lines.length).length}"><div class="rows">` + code.map((l, i) => {146 const n = i + 1;147 return `<a href="#l${n}" class="l" id="l${n}"><span class="ln">${n}</span><span class="lc">${l}</span></a>`;148 }).join('') + '</div></div>';149}150151const PAIR: Record<string, [string, string]> = { '=': ['same', 'unchanged'], '!': ['changed', 'rewritten'], '>': ['add', 'added'], '<': ['del', 'dropped'] };152153// The pairs in `git range-diff` output: the mark (= ! > <), the two short154// shas ("-------" for a missing side), the subject and the pair's diff lines.155export function rangePairs(text: string): { mark: string; a: string; b: string; subject: string; body: string[] }[] {156 const head = /^\s*(?:\d+|-):\s+([0-9a-f]+|-+) ([=!<>]) \s*(?:\d+|-):\s+([0-9a-f]+|-+) (.*)$/;157 const pairs: ReturnType<typeof rangePairs> = [];158 for (const line of text.split('\n')) {159 const m = head.exec(line);160 if (m) pairs.push({ mark: m[2], a: m[1], b: m[3], subject: m[4], body: [] });161 else if (pairs.length && line.trim()) pairs[pairs.length - 1].body.push(line);162 }163 return pairs;164}165166// `git range-diff` output, one block per pair of commits. Rewritten pairs show167// the diff between the two versions of the commit's patch: the row colour is168// whether a patch line was added or removed, the coloured mark inside is what169// that patch line itself does. Runs of unchanged pairs fold away. `change(a, b)`170// labels a rewritten pair with what Jev says the rewrite changed.171export function rangeDiff(text: string, { commitHref, change }: { commitHref?: (short: string) => string | null; change?: ((a: string, b: string) => { kind: string; text: string } | null) | null } = {}): string {172 const pairs = rangePairs(text);173 if (!pairs.length) return '<p class="empty">No commits to compare.</p>';174 const sha = (s: string): string => {175 const h = commitHref?.(s);176 return h ? `<a class="sha" href="${h}">${esc(s)}</a>` : `<span class="sha">${esc(s)}</span>`;177 };178 const label = (p: (typeof pairs)[number]): string => {179 const [cls, word] = PAIR[p.mark];180 const shas = p.mark === '>' ? sha(p.b) : p.mark === '<' ? sha(p.a) : `${sha(p.a)} &rarr; ${sha(p.b)}`;181 const c = p.mark === '!' ? change?.(p.a, p.b) : null;182 return `<span class="pk ${cls}">${word}</span> ${shas} <span class="subj">${esc(p.subject)}</span>` +183 (c ? ` <span class="jev-change ${esc(c.kind)}" title="What changed between the two versions, according to Jev">${esc(c.text)}</span>` : '');184 };185 const row = (line: string): string => {186 const body = line.startsWith(' ') ? line.slice(4) : line;187 if (body.startsWith('@@')) return `<div class="hunk">${esc(body)}</div>`;188 const outer = body[0] === '+' || body[0] === '-' ? body[0] : ' ';189 const rest = body.slice(1);190 const cls = outer === '+' ? 'add' : outer === '-' ? 'del' : /^## .* ##$/.test(rest) ? 'sect' : rest.startsWith('@@') ? 'meta' : '';191 const inner = rest[0] === '+' ? 'i' : rest[0] === '-' ? 'd' : '';192 const lc = inner ? `<span class="${inner}">${esc(rest[0])}</span>${esc(rest.slice(1))}` : esc(rest);193 return `<div class="l ${cls}"><span class="mk">${outer.trim()}</span><span class="lc">${lc}</span></div>`;194 };195196 const n = (mark: string): number => pairs.filter((p) => p.mark === mark).length;197 const sum = Object.keys(PAIR).filter((k) => n(k)).map((k) => `${n(k)} ${PAIR[k][1]}`);198 // Pairs without a diff are rows, in a panel until the next rewritten199 // pair, which is a panel of its own like a diff's file.200 let out = `<p class="desc">${pairs.length} commit${pairs.length === 1 ? '' : 's'}: ${sum.join(', ')}.</p>`;201 let rows = '';202 let same: string[] = [];203 const flush = (): void => {204 if (same.length) rows += `<details class="pairs"><summary>${same.length} unchanged commit${same.length === 1 ? '' : 's'}</summary>${same.join('')}</details>`;205 same = [];206 };207 const panel = (): void => {208 flush();209 if (rows) out += `<div class="panel pairlist">${rows}</div>`;210 rows = '';211 };212 pairs.forEach((p, i) => {213 if (p.mark === '=') {214 same.push(`<div class="pairrow">${label(p)}</div>`);215 return;216 }217 flush();218 if (p.mark !== '!' || !p.body.length) rows += `<div class="pairrow">${label(p)}</div>`;219 else {220 panel();221 out += `<section class="panel file pair jump" id="p${i + 1}"><header class="panel-head fh">${label(p)}</header><div class="code diff range"><div class="rows">${p.body.map(row).join('')}</div></div></section>`;222 }223 });224 panel();225 return out;226}227228const MARK = { pass: '✓', fail: '✗', unverified: '?' };229const WORD = { pass: 'passed', fail: 'failed', unverified: 'unverified signature' };230231// CI as a mark (✓ ✗ ?) with details on hover, or a sentence in the long form.232export function ci(status: CiStatus | null | undefined, { long = false }: { long?: boolean } = {}): string {233 if (!status) return long ? '<span class="desc">no CI result</span>' : '';234 const a = status.latest;235 const who = a.verification.signer ?? 'an unknown key';236 const word = WORD[status.state];237 if (long) return `<span class="ci ${status.state}">${MARK[status.state]} ${esc(word)}</span> <span class="desc">in ${esc(a.duration ?? '?')}, signed by ${esc(who)}</span>`;238 const detail = `${word} in ${a.duration ?? '?'}, signed by ${who}${a.via ? ', same tree as ' + a.via.slice(0, 7) : ''}`;239 return `<span class="ci ${status.state}" title="${esc(detail)}">${MARK[status.state]}</span>`;240}