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

b51_render_repo.ts 155 lines · 6189 bytes raw
// c51 — UI: tree listing + blob viewer for the local bare repo.
// Visited at /GIT/:repo/tree/:ref/<path> and /blob/:ref/<path>.
// Renders through tdd.md's chrome (renderPage with bodyHtml). Markdown
// blobs get parsed via marked; everything else is rendered as
// preformatted source.

import { marked } from "marked";
import { renderPage, escape } from "./b51_render_layout.ts";
import type { TreeEntry } from "./a31_git_parse.ts";

const shortSha = (sha: string): string => sha.slice(0, 7);

// Build a breadcrumb: "owner/repo · main · content/blog" with each
// segment a clickable link to /GIT/.../tree/<ref>/<segments-so-far>.
const renderBreadcrumb = (params: {
  owner: string;
  repo: string;
  ref: string;
  path: string;
  asBlob?: boolean;
}): string => {
  const { owner, repo, ref, path, asBlob } = params;
  const repoLink = `<a href="/GIT/${escape(repo)}/tree/${escape(ref)}"><strong>${escape(owner)}/${escape(repo)}</strong></a>`;
  const refLink = `<a class="commit-meta-pill" href="/GIT/${escape(repo)}/tree/${escape(ref)}"><code>${escape(ref)}</code></a>`;
  if (path === "") return `<p class="commit-breadcrumb">${repoLink} · ${refLink}</p>`;

  const segments = path.split("/");
  const lastIdx = segments.length - 1;
  const links = segments
    .map((seg, i) => {
      const so_far = segments.slice(0, i + 1).join("/");
      // For blob view, the last segment is the file itself — no link.
      // For tree view, every segment links to the tree at that depth.
      const isLastFile = asBlob && i === lastIdx;
      if (isLastFile) return `<code>${escape(seg)}</code>`;
      return `<a href="/GIT/${escape(repo)}/tree/${escape(ref)}/${escape(so_far)}"><code>${escape(seg)}</code></a>`;
    })
    .join(" / ");
  return `<p class="commit-breadcrumb">${repoLink} · ${refLink} · ${links}</p>`;
};

// Sort: trees first, then blobs, alphabetically within each group.
// Mirrors what GitHub / Forgejo's tree views do.
const sortEntries = (entries: TreeEntry[]): TreeEntry[] => {
  return [...entries].sort((a, b) => {
    if (a.type !== b.type) return a.type === "tree" ? -1 : 1;
    return a.name.localeCompare(b.name);
  });
};

const renderTreeRow = (params: {
  entry: TreeEntry;
  owner: string;
  repo: string;
  ref: string;
  parentPath: string;
}): string => {
  const { entry, owner, repo, ref, parentPath } = params;
  const childPath = parentPath === "" ? entry.name : `${parentPath}/${entry.name}`;
  const icon =
    entry.type === "tree" ? "📁" :
    entry.type === "commit" ? "🔗" :  // submodule
    "📄";
  const kind = entry.type === "tree" ? "tree" : "blob";
  const href = `/GIT/${escape(repo)}/${kind}/${escape(ref)}/${escape(childPath)}`;
  return `<tr class="repo-tree-row repo-tree-row-${entry.type}">
  <td class="repo-tree-icon">${icon}</td>
  <td class="repo-tree-name"><a href="${href}">${escape(entry.name)}</a></td>
  <td class="repo-tree-sha"><code>${escape(shortSha(entry.sha))}</code></td>
</tr>`;
};

export const renderRepoTree = async (params: {
  owner: string;
  repo: string;
  ref: string;
  path: string;
  entries: TreeEntry[];
}): Promise<string> => {
  const { owner, repo, ref, path, entries } = params;
  const sorted = sortEntries(entries);
  const upRow = path === ""
    ? ""
    : (() => {
        const parentPath = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
        const upHref = parentPath === ""
          ? `/GIT/${escape(repo)}/tree/${escape(ref)}`
          : `/GIT/${escape(repo)}/tree/${escape(ref)}/${escape(parentPath)}`;
        return `<tr class="repo-tree-row repo-tree-row-up"><td class="repo-tree-icon">⬆</td><td class="repo-tree-name"><a href="${upHref}">..</a></td><td></td></tr>`;
      })();
  const rows = entries.length === 0
    ? `<tr><td colspan="3" class="commit-empty">empty tree</td></tr>`
    : upRow + sorted.map((entry) => renderTreeRow({ entry, owner, repo, ref, parentPath: path })).join("");

  const titlePath = path === "" ? "" : ` · ${path}`;
  const inner = `<main class="md commit-view">
  ${renderBreadcrumb({ owner, repo, ref, path })}
  <h1 class="commit-subject">${escape(path === "" ? `${owner}/${repo}` : path)}</h1>
  <p class="commit-files-summary">${entries.length} entr${entries.length === 1 ? "y" : "ies"} at <code>${escape(ref)}</code></p>
  <table class="repo-tree-table"><tbody>${rows}</tbody></table>
</main>`;

  return renderPage({
    title: `${owner}/${repo}${titlePath} — tdd.md`,
    bodyHtml: inner,
    description: `Repository tree at ${ref}${path ? "/" + path : ""} on tdd.md.`,
    noindex: true,
    bodyClass: "commit-body-page",
    hideNav: true,
  });
};

const isMarkdown = (path: string): boolean => path.endsWith(".md");

export const renderRepoBlob = async (params: {
  owner: string;
  repo: string;
  ref: string;
  path: string;
  content: string;
}): Promise<string> => {
  const { owner, repo, ref, path, content } = params;
  const filename = path.split("/").pop() ?? path;

  // Markdown gets rendered through marked; code files get a <pre><code>
  // block; everything else also <pre> (we don't try to syntax-highlight,
  // just render readable monospace).
  const bodyHtml = isMarkdown(path)
    ? `<div class="repo-blob-rendered md">${await marked.parse(content, { gfm: true, breaks: false })}</div>`
    : `<pre class="repo-blob-source"><code>${escape(content)}</code></pre>`;

  const inner = `<main class="md commit-view">
  ${renderBreadcrumb({ owner, repo, ref, path, asBlob: true })}
  <header class="repo-blob-header">
    <code class="repo-blob-path">${escape(filename)}</code>
    <span class="repo-blob-meta">${content.split("\n").length} lines · ${content.length} bytes</span>
    <span class="repo-blob-actions">
      <a href="/GIT/${escape(repo)}/raw/${escape(ref)}/${escape(path)}">raw</a>
      ${isMarkdown(path) ? `· <a href="/GIT/${escape(repo)}/blob/${escape(ref)}/${escape(path)}?source=1">source</a>` : ""}
    </span>
  </header>
  ${bodyHtml}
</main>`;

  return renderPage({
    title: `${path} · ${owner}/${repo} — tdd.md`,
    bodyHtml: inner,
    description: `${path} at ${ref} on tdd.md.`,
    noindex: true,
    bodyClass: "commit-body-page",
    hideNav: true,
  });
};