syntaxai/tdd.md · main · src / b51_render_commit.ts

b51_render_commit.ts 129 lines · 5586 bytes raw
// 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">&lt;${escape(detail.authorEmail)}&gt;</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,
  });
};