import { Marked } from 'marked'; import markedKatex from 'marked-katex-extension'; import { markedHighlight } from 'marked-highlight'; import { gfmHeadingId } from 'marked-gfm-heading-id'; import hljs from 'highlight.js/lib/core'; import bash from 'highlight.js/lib/languages/bash'; import css from 'highlight.js/lib/languages/css'; import diff from 'highlight.js/lib/languages/diff'; import go from 'highlight.js/lib/languages/go'; import javascript from 'highlight.js/lib/languages/javascript'; import json from 'highlight.js/lib/languages/json'; import markdown from 'highlight.js/lib/languages/markdown'; import python from 'highlight.js/lib/languages/python'; import rust from 'highlight.js/lib/languages/rust'; import shell from 'highlight.js/lib/languages/shell'; import sql from 'highlight.js/lib/languages/sql'; import typescript from 'highlight.js/lib/languages/typescript'; import xml from 'highlight.js/lib/languages/xml'; import yaml from 'highlight.js/lib/languages/yaml'; import DOMPurify from 'isomorphic-dompurify'; hljs.registerLanguage('bash', bash); hljs.registerLanguage('css', css); hljs.registerLanguage('diff', diff); hljs.registerLanguage('go', go); hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('json', json); hljs.registerLanguage('markdown', markdown); hljs.registerLanguage('python', python); hljs.registerLanguage('rust', rust); hljs.registerLanguage('shell', shell); hljs.registerLanguage('sql', sql); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('xml', xml); hljs.registerLanguage('yaml', yaml); function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } const renderer = new Marked() .setOptions({ gfm: true, breaks: false }) .use(gfmHeadingId()) .use( markedHighlight({ langPrefix: 'hljs language-', highlight(code, lang) { const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; }, }), ) .use(markedKatex({ throwOnError: false, nonStandard: true })) .use({ renderer: { image({ href, title, text }: { href: string; title: string | null; text: string }) { const safeHref = escapeHtml(href); const safeAlt = escapeHtml(text || ''); const safeTitle = title ? escapeHtml(title) : ''; const caption = title || text || ''; const figcaption = caption.trim() ? `
${escapeHtml(caption)}
` : ''; const titleAttr = safeTitle ? ` title="${safeTitle}"` : ''; return `
${safeAlt}${figcaption}
`; }, }, }); const KATEX_TAGS = [ 'math', 'annotation', 'semantics', 'mrow', 'mi', 'mo', 'mn', 'mtext', 'msup', 'msub', 'msubsup', 'mfrac', 'msqrt', 'mroot', 'mover', 'munder', 'munderover', 'mtable', 'mtr', 'mtd', 'mspace', 'mstyle', 'mphantom', 'mpadded', 'menclose', ]; export interface ImageDim { w: number; h: number } export type ImageDims = Record; function injectDimensions(html: string, dims?: ImageDims): string { if (!dims) return html; return html.replace(/]*?)\s*\/?>/g, (match, src, rest) => { const d = dims[src]; if (!d) return match; if (/\swidth\s*=/.test(rest) || /\sheight\s*=/.test(rest)) return match; return ``; }); } // Marked emits `

` because the image renderer returns // a block element from an inline slot. Strip the `

` wrapper when the // paragraph contains nothing but figures (and whitespace /
), so the // figure-grouping step below can see contiguous runs. function unwrapFiguresFromParagraphs(html: string): string { return html.replace(/

([\s\S]*?)<\/p>/g, (match, inner: string) => { const stripped = inner.replace(//g, '').trim(); if (!stripped) return match; if (/^(?:\s*

[\s\S]*?<\/figure>\s*)+$/.test(stripped)) { return stripped; } return match; }); } // Wrap runs of 2+ consecutive
elements in a `.figure-row` flex // container. Each figure gets `flex: ` so widths divide the // row proportionally and the final heights match. function groupFigures(html: string): string { return html.replace( /(?:
[\s\S]*?<\/figure>\s*){2,}/g, (run) => { const figures = run.match(/
[\s\S]*?<\/figure>/g) ?? []; const items = figures.map((fig) => { const m = fig.match(/]*\swidth="(\d+)"[^>]*\sheight="(\d+)"/); const ratio = m ? Number(m[1]) / Number(m[2]) : 1; const safe = Number.isFinite(ratio) && ratio > 0 ? ratio : 1; const r = safe.toFixed(3); // flex-basis tracks target row height (var --row-h) × aspect ratio, // so the browser fits as many figures per row as it can while keeping // each at roughly the target height; the rest wrap. max-width caps // any leftover row so a lone last image doesn't balloon. const style = [ `flex:${r} ${r} calc(${r} * var(--row-h, 16rem))`, `max-width:calc(${r} * var(--row-max, 30rem))`, ].join(';'); return fig.replace('
', `
`); }); return `
${items.join('')}
`; }, ); } export function renderMarkdown(src: string, dims?: ImageDims): string { let html = renderer.parse(src, { async: false }) as string; html = injectDimensions(html, dims); html = unwrapFiguresFromParagraphs(html); html = groupFigures(html); return DOMPurify.sanitize(html, { ADD_TAGS: [...KATEX_TAGS, 'figure', 'figcaption'], ADD_ATTR: ['aria-hidden', 'style', 'id', 'class', 'encoding', 'mathvariant', 'displaystyle', 'scriptlevel', 'loading'], }); }