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

b51_render_layout.ts 126 lines · 6127 bytes raw
// c51 (layout) — UI: page chrome + small response/format helpers shared
// across every domain. Bigger per-domain body builders live next to this
// file as `c51_render_<domain>.ts` (projects, reports). Layout exports
// `escape`, `renderPage`, `renderNotFound`, `htmlResponse`, `errorPage`,
// `phaseSpan`, `relativeTime`, plus the `Section` + `PageOptions` types.
// Per the SAMA convention, lower layers don't import from this one.

import { marked } from "marked";
import type { Phase } from "./a31_commits.ts";

const STYLE_CSS = "./public/style.css";
const css = await Bun.file(STYLE_CSS).text();

export type Section = "home" | "games" | "guides" | "blog" | "agents" | "leaderboard" | "sama" | "goals" | "contributing";

export interface PageOptions {
  title: string;
  // Provide either bodyMarkdown (parsed by marked) or bodyHtml
  // (passed through as-is). bodyHtml is what the docs layout uses
  // when it has already done its own marked.parse and wrapped the
  // result in sidebar/content/anchor-rail chrome.
  bodyMarkdown?: string;
  bodyHtml?: string;
  description?: string;
  ogPath?: string;
  active?: Section;
  noindex?: boolean;
  jsonLd?: Record<string, unknown>;
  bodyClass?: string;
  // Skip the top nav bar (tdd.md · games · guides · sama · blog · agents
  // · leaderboard). Used by the /GIT views which have their own
  // breadcrumb chrome and don't need the site-wide nav competing for
  // space at the top of the page.
  hideNav?: boolean;
}

const SITE_DESCRIPTION = "SAMA — the architectural standard for AI-agent codebases. Sorted, Architecture, Modeled, Atomic. Four pillars, one CI verifier.";

export const escape = (s: string): string =>
  s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");

const navLink = (href: string, label: string, active: boolean): string => {
  const cls = active ? ' class="nav-active"' : "";
  return `<a href="${href}"${cls}>${label}</a>`;
};

const nav = (active?: Section): string => `<nav class="md-nav">${navLink("/", "tdd.md", active === "home")} <span class="md-nav-sep">·</span> ${navLink("/games", "games", active === "games")} <span class="md-nav-sep">·</span> ${navLink("/guides", "guides", active === "guides")} <span class="md-nav-sep">·</span> ${navLink("/sama", "sama", active === "sama")} <span class="md-nav-sep">·</span> ${navLink("/goals", "goals", active === "goals")} <span class="md-nav-sep">·</span> ${navLink("/contributing", "contributing", active === "contributing")} <span class="md-nav-sep">·</span> ${navLink("/blog", "blog", active === "blog")} <span class="md-nav-sep">·</span> ${navLink("/agents", "agents", active === "agents")} <span class="md-nav-sep">·</span> ${navLink("/leaderboard", "leaderboard", active === "leaderboard")}</nav>`;

export const renderPage = async (opts: PageOptions): Promise<string> => {
  const body = opts.bodyHtml ?? await marked.parse(opts.bodyMarkdown ?? "", { gfm: true, breaks: false });
  const description = opts.description ?? SITE_DESCRIPTION;
  const bodyClassAttr = opts.bodyClass ? ` class="${escape(opts.bodyClass)}"` : "";
  const ogPath = opts.ogPath ?? "https://tdd.md";
  const robots = opts.noindex ? `<meta name="robots" content="noindex,nofollow">\n` : "";
  const jsonLd = opts.jsonLd
    ? `<script type="application/ld+json">${JSON.stringify(opts.jsonLd)}</script>\n`
    : "";
  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark light">
<meta name="description" content="${escape(description)}">
${robots}<link rel="canonical" href="${escape(ogPath)}">
<meta property="og:title" content="${escape(opts.title)}">
<meta property="og:description" content="${escape(description)}">
<meta property="og:type" content="website">
<meta property="og:url" content="${escape(ogPath)}">
<meta property="og:image" content="https://tdd.md/og.png?v=3">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:site_name" content="tdd.md">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${escape(opts.title)}">
<meta name="twitter:description" content="${escape(description)}">
<meta name="twitter:image" content="https://tdd.md/og.png?v=3">
<title>${escape(opts.title)}</title>
${jsonLd}<style>${css}</style>
</head>
<body${bodyClassAttr}>
${opts.hideNav ? "" : nav(opts.active)}
<main class="md">
${body}
</main>
</body>
</html>`;
};

export const renderNotFound = async (path: string): Promise<string> =>
  renderPage({
    title: "404 — tdd.md",
    bodyMarkdown: `# 404\n\n> No such path: \`${path}\`\n\nTry [home](/), [games](/games), [agents](/agents), or [leaderboard](/leaderboard).`,
    noindex: true,
  });

// ---------------------------------------------------------------------
// Small response/formatting helpers used by c21 handlers + domain renders.
// ---------------------------------------------------------------------

export const htmlResponse = (html: string, status = 200): Response =>
  new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });

export const errorPage = async (message: string, status = 400): Promise<Response> => {
  const html = await renderPage({
    title: "error — tdd.md",
    bodyMarkdown: `# error\n\n> ${message}\n\n[← back](/agents/register)`,
    active: "agents",
  });
  return htmlResponse(html, status);
};

export const phaseSpan = (p: Phase): string => {
  const cls = p === "red" ? "red" : p === "green" ? "green" : p === "refactor" ? "blue" : "muted";
  return `<span class="${cls}">${p}</span>`;
};

export const relativeTime = (iso: string): string => {
  const ms = Date.now() - new Date(iso).getTime();
  if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s ago`;
  if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m ago`;
  if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)}h ago`;
  return `${Math.floor(ms / 86_400_000)}d ago`;
};