syntaxai/tdd.md · main · src / b51_render_commit.ts
// c51 — UI: SAMA-native commit detail page. Replaces what visitors
// would see at git.tdd.md/<owner>/<repo>/commit/<sha> 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 `<span class="commit-file-status commit-file-status-${status}">${label}</span>`;
};
const renderHunk = (hunk: DiffHunk): string => {
const headingHtml = hunk.heading
? `<span class="commit-hunk-heading">${escape(hunk.heading)}</span>`
: "";
const headerRow = `<tr class="commit-hunk-header"><td colspan="3">@@ -${hunk.oldStart},${hunk.oldLength} +${hunk.newStart},${hunk.newLength} @@ ${headingHtml}</td></tr>`;
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 `<tr class="commit-line commit-line-${line.kind}"><td class="commit-line-old">${oldNum}</td><td class="commit-line-new">${newNum}</td><td class="commit-line-text">${escape(marker + line.text)}</td></tr>`;
}).join("");
return headerRow + lineRows;
};
const renderFile = (file: DiffFile): string => {
const renamed = file.status === "renamed" && file.oldPath !== file.path
? `<span class="commit-file-rename"><code>${escape(file.oldPath)}</code> → </span>`
: "";
return `<section class="commit-file">
<header class="commit-file-header">
${statusBadge(file.status)}
${renamed}<code class="commit-file-path">${escape(file.path)}</code>
<span class="commit-file-stats">
<span class="commit-file-add">+${file.added}</span>
<span class="commit-file-rem">−${file.removed}</span>
</span>
</header>
<table class="commit-diff-table"><tbody>${file.hunks.map(renderHunk).join("")}</tbody></table>
</section>`;
};
export const renderCommitView = async (params: {
owner: string;
repo: string;
detail: CommitForView;
diff: ParsedDiff;
}): Promise<string> => {
const { owner, repo, detail, diff } = params;
const { subject, body } = splitMessage(detail.message);
const parentLinks = detail.parents.length === 0
? `<span class="commit-meta-empty">no parent (root commit)</span>`
: detail.parents.map((p) =>
`<a class="commit-parent" href="/GIT/${escape(repo)}/commit/${escape(p)}"><code>${escape(shortSha(p))}</code></a>`,
).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
? `<p class="commit-empty">No file changes (empty / merge commit).</p>`
: `<p class="commit-files-summary">${diff.files.length} file${diff.files.length === 1 ? "" : "s"} changed · <span class="commit-file-add">+${totalAdded}</span> <span class="commit-file-rem">−${totalRemoved}</span></p>`;
const inner = `<main class="md commit-view">
<header class="commit-header">
<p class="commit-breadcrumb"><a href="/${escape(owner)}/${escape(repo)}">${escape(owner)}/${escape(repo)}</a> · commit <code>${escape(shortSha(detail.sha))}</code></p>
<h1 class="commit-subject">${escape(subject)}</h1>
${body ? `<pre class="commit-body">${escape(body)}</pre>` : ""}
<dl class="commit-meta">
<dt>author</dt><dd><strong>${escape(detail.authorName)}</strong> <span class="commit-meta-email"><${escape(detail.authorEmail)}></span></dd>
<dt>date</dt><dd>${escape(ts(detail.authorDate))}</dd>
<dt>parent</dt><dd>${parentLinks}</dd>
<dt>commit</dt><dd><code>${escape(detail.sha)}</code></dd>
</dl>
</header>
${filesSummary}
${diff.files.map(renderFile).join("")}
<p class="commit-footer">
<a href="/GIT/${escape(repo)}/commit/${escape(detail.sha)}.diff">raw .diff</a>
</p>
</main>`;
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,
});
};