// c51 — UI: SAMA-native commit detail page. Replaces what visitors // would see at git.tdd.md///commit/ with the same // information rendered through tdd.md's chrome. Consumes the parsed // diff (c31_diff_parse) and commit metadata (any source — c14_git or // c14_forgejo can both produce it). import { renderPage, escape } from "./b51_render_layout.ts"; import type { DiffFile, DiffHunk, ParsedDiff } from "./a31_diff_parse.ts"; // Source-agnostic commit shape this renderer consumes. Both c14_git's // GitCommit and c14_forgejo's ForgejoCommitDetail fit this surface. export interface CommitForView { sha: string; parents: string[]; authorName: string; authorEmail: string; authorDate: string; committerName: string; committerEmail: string; committerDate: string; message: string; } const shortSha = (sha: string): string => sha.slice(0, 7); // "2026-05-10 12:31:07 +01:00" — ISO-ish, easy to scan. const ts = (iso: string): string => { // Trust Forgejo's ISO format; only chop the timezone/seconds for compactness. return iso.replace("T", " ").replace(/\+\d{2}:\d{2}$/, (m) => " " + m); }; // First line of the commit message is the subject; rest is body. const splitMessage = (msg: string): { subject: string; body: string } => { const newline = msg.indexOf("\n"); if (newline === -1) return { subject: msg, body: "" }; return { subject: msg.slice(0, newline), body: msg.slice(newline + 1).trim(), }; }; const statusBadge = (status: DiffFile["status"]): string => { const label = status === "added" ? "added" : status === "removed" ? "removed" : status === "renamed" ? "renamed" : "modified"; return `${label}`; }; const renderHunk = (hunk: DiffHunk): string => { const headingHtml = hunk.heading ? `${escape(hunk.heading)}` : ""; const headerRow = `@@ -${hunk.oldStart},${hunk.oldLength} +${hunk.newStart},${hunk.newLength} @@ ${headingHtml}`; const lineRows = hunk.lines.map((line) => { const marker = line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "; const oldNum = line.oldNum === null ? "" : String(line.oldNum); const newNum = line.newNum === null ? "" : String(line.newNum); return `${oldNum}${newNum}${escape(marker + line.text)}`; }).join(""); return headerRow + lineRows; }; const renderFile = (file: DiffFile): string => { const renamed = file.status === "renamed" && file.oldPath !== file.path ? `${escape(file.oldPath)}` : ""; return `
${statusBadge(file.status)} ${renamed}${escape(file.path)} +${file.added} −${file.removed}
${file.hunks.map(renderHunk).join("")}
`; }; export const renderCommitView = async (params: { owner: string; repo: string; detail: CommitForView; diff: ParsedDiff; }): Promise => { const { owner, repo, detail, diff } = params; const { subject, body } = splitMessage(detail.message); const parentLinks = detail.parents.length === 0 ? `no parent (root commit)` : detail.parents.map((p) => `${escape(shortSha(p))}`, ).join(" · "); const totalAdded = diff.files.reduce((s, f) => s + f.added, 0); const totalRemoved = diff.files.reduce((s, f) => s + f.removed, 0); const filesSummary = diff.files.length === 0 ? `

No file changes (empty / merge commit).

` : `

${diff.files.length} file${diff.files.length === 1 ? "" : "s"} changed · +${totalAdded} −${totalRemoved}

`; const inner = `

${escape(owner)}/${escape(repo)} · commit ${escape(shortSha(detail.sha))}

${escape(subject)}

${body ? `
${escape(body)}
` : ""}
author
${escape(detail.authorName)} <${escape(detail.authorEmail)}>
date
${escape(ts(detail.authorDate))}
parent
${parentLinks}
commit
${escape(detail.sha)}
${filesSummary} ${diff.files.map(renderFile).join("")}
`; return renderPage({ title: `${shortSha(detail.sha)} · ${subject} — tdd.md`, bodyHtml: inner, description: `Commit ${shortSha(detail.sha)} on ${owner}/${repo}: ${subject}`, noindex: true, bodyClass: "commit-body-page", hideNav: true, }); };