/** HTML shell + markdown→HTML rendering with server-side diff/code highlighting and client-side mermaid. */ import { Marked } from "marked"; import { parse as diffParse, html as diffHtml } from "diff2html"; import hljs from "highlight.js"; import { CONFIG, MERMAID_CDN } from "./config.js"; import { BASE_CSS, D2H_CSS, HLJS_CSS } from "./styles.js"; export interface RenderFlags { hasMermaid: boolean; hasDiff: boolean; hasCode: boolean; } /** Escape HTML special characters. */ function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } /** Escape for attribute context (quotes too). */ function escapeAttr(s: string): string { return escapeHtml(s).replace(/"/g, """); } /** Render markdown to an HTML body fragment, handling diff/code/mermaid fences. */ export function renderMarkdown(content: string, flags: RenderFlags): string { // Fresh instance per render: the code renderer closes over `flags`, and mutating // the global marked singleton would stack an override per call. const marked = new Marked({ gfm: true, breaks: false, renderer: { code({ text, lang }: { text: string; lang?: string }): string { const language = (lang ?? "").trim().toLowerCase(); if (language === "mermaid") { flags.hasMermaid = true; return `
${escapeHtml(text)}
`; } if (language === "diff") { const rendered = renderDiff(text); if (rendered) { flags.hasDiff = true; return rendered; } // Malformed diff — fall through to plain code block } const known = language && hljs.getLanguage(language); if (known) { flags.hasCode = true; try { const out = hljs.highlight(text, { language }).value; return `
${out}
`; } catch { // fall through } } // Plain escaped code block (no unreliable auto-detect on short snippets) if (language) flags.hasCode = true; return `
${escapeHtml(text)}
`; }, }, }); return marked.parse(content) as string; } /** Render a unified-diff string to diff2html HTML (server-side). Returns null if parse yields nothing. */ function renderDiff(diffText: string): string | null { try { const json = diffParse(diffText, {}); if (!json || json.length === 0) return null; return diffHtml(json, { outputFormat: "line-by-line", drawFileList: false, }); } catch { return null; } } // ─── Shell ──────────────────────────────────────────────────────────────────── /** SSE snippet: subscribe to /events, reload only on events for this slug. */ function sseSnippet(slug: string): string { return ` `; } /** Mermaid init: render after the library loads. Uses the "base" theme fed with * the document's own tokens (read from CSS variables at runtime), so diagrams * follow the scheme and the configured accent instead of mermaid's built-ins — * "neutral" in particular draws grayscale text that's unreadably pale. */ function mermaidSnippet(): string { return ` `; } /** Build the full HTML document shell around a body fragment. */ export function buildShell(opts: { title: string; slug: string; kind: "markdown" | "html"; bodyHtml: string; flags: RenderFlags; }): string { const { title, slug, kind, bodyHtml, flags } = opts; const projectPath = process.cwd(); const generated = Date.now(); const styles: string[] = [``]; if (flags.hasDiff) styles.push(``); if (flags.hasCode) styles.push(``); const scripts: string[] = []; if (flags.hasMermaid) scripts.push(mermaidSnippet()); scripts.push(sseSnippet(slug)); const generatedIso = new Date(generated).toISOString(); return ` ${escapeHtml(title)} ${styles.join("\n")}

${escapeHtml(title)}

${kind} ${escapeHtml(generatedIso.replace("T", " ").slice(0, 19))}
${bodyHtml}
${scripts.join("\n")} `; } /** Render a markdown artifact to a full HTML document. */ export function renderMarkdownDocument(title: string, slug: string, content: string): string { const flags: RenderFlags = { hasMermaid: false, hasDiff: false, hasCode: false }; const bodyHtml = renderMarkdown(content, flags); return buildShell({ title, slug, kind: "markdown", bodyHtml, flags }); } /** Render an html artifact: full documents get the shell's metadata metas + SSE reload snippet spliced in; fragments get the full shell. */ export function renderHtmlDocument(title: string, slug: string, content: string): string { const trimmed = content.trimStart(); const isFullDoc = /^]/i.test(trimmed); if (isFullDoc) { // Full documents bypass the shell, so inject the same metadata metas the shell // writes (artifact-kind/generated/project) plus the SSE reload listener. Both // are passive — they change no rendering — but they keep full-doc html consistent // on the index page (date + kind badge) and let `update` live-reload like any // other artifact. Skip any meta the document already declares to avoid dupes. const generated = Date.now(); const projectPath = process.cwd(); const metas: string[] = []; if (!content.includes('name="artifact-kind"')) metas.push(``); if (!content.includes('name="artifact-generated"')) metas.push(``); if (!content.includes('name="artifact-project"')) metas.push(``); const metaBlock = metas.length ? metas.join("\n") + "\n" : ""; const snippet = sseSnippet(slug); let out = content; // Metas belong in ; splice there when present. const headClose = out.search(/<\/head>/i); if (headClose !== -1 && metaBlock) { out = out.slice(0, headClose) + metaBlock + out.slice(headClose); } // SSE listener before . If there was no , also drop the metas here — // the index parser is regex-based, so location is irrelevant to function. const tail = (headClose === -1 ? metaBlock : "") + snippet; const bodyClose = out.search(/<\/body>/i); out = bodyClose !== -1 ? out.slice(0, bodyClose) + tail + out.slice(bodyClose) : out + tail; return out; } const flags: RenderFlags = { hasMermaid: false, hasDiff: false, hasCode: false }; return buildShell({ title, slug, kind: "html", bodyHtml: content, flags }); } /** Build the index page listing all artifacts, newest first. */ export function renderIndexPage(entries: { slug: string; title: string; kind: string; mtime: number }[]): string { const projectPath = process.cwd(); const rows = entries.map((e) => { const when = e.mtime ? new Date(e.mtime).toISOString().replace("T", " ").slice(0, 19) : ""; const href = `/${e.slug}.html`; return `${escapeHtml(e.title)}` + `${escapeHtml(e.kind)}` + `${escapeHtml(when)}`; }); const empty = entries.length === 0 ? `

No artifacts yet. Use the artifact tool to create one.

` : `${rows.join("\n")}
TitleKindGenerated
`; const styles = [``]; return ` Artifacts — ${escapeHtml(projectPath)} ${styles.join("\n")}

Artifacts

index
${empty}
`; }