/** 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'; import { readerBody, READER_ATTRIBUTES, READER_SCRIPT, READER_STYLES } from './reader.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)}${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 an annotation comment's markdown for display (popover preview, panel
* items). Plain GFM with soft breaks — no diff/code/mermaid renderers, since the
* page has no d2h/hljs stylesheets for this content. Strips script/iframe and
* inline event handlers: the author is the local user, but a pasted snippet
* should not execute in the artifact page.
*/
const commentMarked = new Marked({
gfm: true,
breaks: true,
renderer: {
html({ text }) {
return escapeHtml(text);
},
link({ href, tokens }) {
const label = this.parser.parseInline(tokens);
try {
const url = new URL(href, 'http://artifact.invalid');
if (!['http:', 'https:', 'mailto:'].includes(url.protocol)) return label;
} catch {
return label;
}
return `${label}`;
},
image({ text }) {
return escapeHtml(text);
},
},
});
export function renderCommentMarkdown(md: string): string {
const html = commentMarked.parse(md) as string;
return html;
}
/** 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 (kind === 'markdown') styles.push(READER_STYLES);
if (flags.hasDiff) styles.push(``);
if (flags.hasCode) styles.push(``);
const scripts: string[] = kind === 'markdown' ? [READER_SCRIPT] : [];
if (flags.hasMermaid) scripts.push(mermaidSnippet());
scripts.push(sseSnippet(slug));
const generatedIso = new Date(generated).toISOString();
const body =
kind === 'markdown'
? readerBody(title, bodyHtml, generatedIso, projectPath)
: `${escapeHtml(when)}No artifacts yet. Use the artifact tool to create one.
| Title | Kind | Generated |
|---|