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

b51_render_docs_layout.ts 124 lines · 5052 bytes raw
// c51 (docs-layout) — UI: GitBook-style chrome around the existing
// renderPage. Wraps content with a right "on this page" anchor rail
// (h2/h3 from the rendered body), an edit-on-GitHub link at the top
// of content, and a prev/next navigator at the bottom. Per SAMA:
// imports c31 (data), c32 (logic), and c51_render_layout (chrome).
// No I/O of its own.

import { marked } from "marked";
import {
  resolveDocsLocation,
  type ResolvedDocsLocation,
} from "./a31_docs_nav.ts";
import { extractAnchors, type Anchor } from "./b32_anchor_extract.ts";
import {
  renderPage,
  escape,
  type PageOptions,
} from "./b51_render_layout.ts";

export interface DocsPageOptions extends Omit<PageOptions, "bodyHtml"> {
  // The route path the user is on, e.g. "/sama/discipline/sorted". Used to
  // compute prev/next.
  pathForDocs: string;
  // Optional override of which file the "edit on GitHub" link
  // targets, when the body isn't a content/<section>/<slug>.md.
  // Defaults to the editPath from the resolved nav location.
  editPathOverride?: string | null;
}

const renderAnchorRail = (anchors: Anchor[]): string => {
  if (anchors.length === 0) return "";
  const items = anchors
    .map((a) => {
      const cls = a.level === 3 ? "docs-rail-link docs-rail-link-h3" : "docs-rail-link";
      return `<li><a class="${cls}" href="#${escape(a.id)}">${escape(a.text)}</a></li>`;
    })
    .join("");
  return `<aside class="docs-rail" aria-label="on this page">
  <p class="docs-rail-title">on this page</p>
  <ul class="docs-rail-list">${items}</ul>
</aside>`;
};

// Derive (section, slug) from a content/<section>/<slug>.md editPath.
// Returns null when the path doesn't follow the convention (in which
// case there's no editor route to link to).
const sectionSlugFromEditPath = (editPath: string): { section: string; slug: string } | null => {
  const m = /^content\/([a-z]+)\/([a-z0-9][a-z0-9-]*)\.md$/.exec(editPath);
  return m ? { section: m[1]!, slug: m[2]! } : null;
};

const renderEditLink = (editPath: string | null): string => {
  if (!editPath) return "";
  // Source view is served from tdd.md itself (c21_handlers_source);
  // we no longer depend on the git.tdd.md (Forgejo) subdomain for
  // the docs site's "view source" link.
  const ss = sectionSlugFromEditPath(editPath);
  const sourceHref = ss ? `/content/${ss.section}/${ss.slug}.md` : `/${editPath}`;
  const editHref = ss ? `/edit/${ss.section}/${ss.slug}` : null;
  const editAnchor = editHref
    ? `<a href="${escape(editHref)}">propose an edit →</a> · `
    : "";
  return `<p class="docs-edit">${editAnchor}<a href="${escape(sourceHref)}">view source →</a></p>`;
};

const renderPrevNext = (loc: ResolvedDocsLocation | null): string => {
  if (!loc) return "";
  const prev = loc.prev
    ? `<a class="docs-pn-prev" href="${loc.prev.href}"><span class="docs-pn-arrow">←</span><span class="docs-pn-label">${escape(loc.prev.label)}</span></a>`
    : `<span class="docs-pn-spacer"></span>`;
  const next = loc.next
    ? `<a class="docs-pn-next" href="${loc.next.href}"><span class="docs-pn-label">${escape(loc.next.label)}</span><span class="docs-pn-arrow">→</span></a>`
    : `<span class="docs-pn-spacer"></span>`;
  return `<nav class="docs-prev-next" aria-label="previous and next page">${prev}${next}</nav>`;
};

// Wrap a heading element with an anchor link for hover-click access.
// Also injects an `id` if marked didn't (rare with our config but
// possible). Operates on the rendered HTML before composing the page.
const enrichHeadings = (html: string): string =>
  html.replace(
    /<h([23])(\s+[^>]*)?>([\s\S]*?)<\/h\1>/g,
    (_full, level, attrs, inner) => {
      const idMatch = /\bid="([^"]+)"/.exec(attrs ?? "");
      const id = idMatch?.[1] ?? inner.replace(/<[^>]*>/g, "").toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-");
      const finalAttrs = idMatch ? attrs : `${attrs ?? ""} id="${id}"`;
      return `<h${level}${finalAttrs}><a class="docs-h-anchor" href="#${id}" aria-label="link to this section">#</a>${inner}</h${level}>`;
    },
  );

export const renderDocsPage = async (opts: DocsPageOptions): Promise<string> => {
  const rawHtml = opts.bodyMarkdown
    ? await marked.parse(opts.bodyMarkdown, { gfm: true, breaks: false })
    : "";
  const enriched = enrichHeadings(rawHtml);
  const anchors = extractAnchors(enriched);
  const loc = resolveDocsLocation(opts.pathForDocs);
  const editPath = opts.editPathOverride !== undefined ? opts.editPathOverride : loc?.current.editPath ?? null;

  const rail = renderAnchorRail(anchors);
  const editLink = renderEditLink(editPath);
  const prevNext = renderPrevNext(loc);

  const composed = `<div class="docs-layout">
<article class="docs-content">
${editLink}
${enriched}
${prevNext}
</article>
${rail}
</div>`;

  return renderPage({
    title: opts.title,
    bodyHtml: composed,
    description: opts.description,
    ogPath: opts.ogPath,
    active: opts.active,
    noindex: opts.noindex,
    jsonLd: opts.jsonLd,
    bodyClass: "docs-body",
  });
};