syntaxai/tdd.md · main · src / b51_render_docs_layout.ts
// 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",
});
};