<script lang="ts">
import { onMount } from "svelte";
import { Marked } from "marked";
import DOMPurify from "dompurify";
interface Props {
content: string;
}
let { content }: Props = $props();
let html = $state("");
let mermaidLoaded = $state(false);
let katexLoaded = $state(false);
let renderContainer: HTMLDivElement | undefined = $state();
const MERMAID_PLACEHOLDER = "data-mermaid-src";
const parser = new Marked({
gfm: true,
breaks: false,
renderer: {
code({ text, lang }) {
if (lang === "mermaid") {
const escaped = text
.replace(/&/g, "&")
.replace(/"/g, """);
return `<div class="mermaid-block" ${MERMAID_PLACEHOLDER}="${escaped}"></div>`;
}
return false;
},
},
});
function preprocessMath(src: string): string {
src = src.replace(/\$\$([\s\S]+?)\$\$/g, (_, math) => {
const encoded = encodeURIComponent(math);
return `<div class="math-display" data-math="${encoded}"></div>`;
});
src = src.replace(/(?<!\$)\$([^$\n]+?)\$(?!\$)/g, (_, math) => {
const encoded = encodeURIComponent(math);
return `<span class="math-inline" data-math="${encoded}"></span>`;
});
return src;
}
async function renderMath(container: HTMLDivElement) {
if (!katexLoaded) return;
const katex = (await import("katex")).default;
container.querySelectorAll<HTMLElement>(".math-display, .math-inline").forEach((el) => {
const math = decodeURIComponent(el.dataset.math ?? "");
const displayMode = el.classList.contains("math-display");
try {
katex.render(math, el, { displayMode, throwOnError: false });
} catch {
el.textContent = math;
}
});
}
async function renderMermaid(container: HTMLDivElement) {
if (!mermaidLoaded) return;
const mermaid = (await import("mermaid")).default;
const blocks = container.querySelectorAll<HTMLElement>(`[${MERMAID_PLACEHOLDER}]`);
let idx = 0;
for (const block of blocks) {
const src = block.getAttribute(MERMAID_PLACEHOLDER) ?? "";
const decoded = src
.replace(/&/g, "&")
.replace(/"/g, '"');
const id = `mermaid-${Date.now()}-${idx++}`;
try {
const { svg } = await mermaid.render(id, decoded);
block.innerHTML = svg;
} catch (error) {
block.innerHTML = `<pre class="mermaid-error">${String(error)}</pre>`;
}
}
}
async function buildHtml(src: string): Promise<string> {
const preprocessed = preprocessMath(src);
const raw = parser.parse(preprocessed) as string;
return DOMPurify.sanitize(raw, {
ADD_ATTR: ["data-math", MERMAID_PLACEHOLDER],
FORCE_BODY: false,
});
}
async function rerender() {
html = await buildHtml(content);
await Promise.resolve();
if (renderContainer) {
await renderMermaid(renderContainer);
await renderMath(renderContainer);
}
}
onMount(async () => {
const [mermaidMod] = await Promise.all([
import("mermaid"),
import("katex"),
]);
mermaidMod.default.initialize({ startOnLoad: false, theme: "neutral" });
mermaidLoaded = true;
katexLoaded = true;
await rerender();
});
$effect(() => {
void rerender();
content;
});
</script>
<svelte:head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.44/dist/katex.min.css"
crossorigin="anonymous"
/>
</svelte:head>
<div class="md-scroll">
<div class="md-body" bind:this={renderContainer}>
{@html html}
</div>
</div>
<style>
.md-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.md-body {
max-width: 800px;
margin: 0 auto;
padding: var(--wb-spacing-32) var(--wb-spacing-40) var(--wb-spacing-64);
font-size: var(--wb-font-size-14);
line-height: 1.7;
color: var(--vscode-editor-foreground);
font-family: var(--wsb-font-sans);
}
.md-body :global(h1),
.md-body :global(h2),
.md-body :global(h3),
.md-body :global(h4),
.md-body :global(h5),
.md-body :global(h6) {
font-weight: 650;
line-height: 1.3;
margin: 1.5em 0 0.5em;
color: var(--vscode-editor-foreground);
}
.md-body :global(h1) { font-size: 1.8em; border-bottom: 1px solid var(--vscode-panel-border); padding-bottom: 0.3em; }
.md-body :global(h2) { font-size: 1.4em; border-bottom: 1px solid var(--vscode-widget-border); padding-bottom: 0.2em; }
.md-body :global(h3) { font-size: 1.15em; }
.md-body :global(p) { margin: 0.8em 0; }
.md-body :global(a) {
color: var(--vscode-focusBorder);
text-decoration: underline;
text-underline-offset: 2px;
}
.md-body :global(code) {
font-family: var(--wsb-font-mono, monospace);
font-size: 0.875em;
background: var(--vscode-sideBar-background);
border: 1px solid var(--vscode-widget-border);
border-radius: var(--wb-corner-radius-3);
padding: 0.15em 0.35em;
}
.md-body :global(pre) {
background: var(--vscode-sideBar-background);
border: 1px solid var(--vscode-widget-border);
border-radius: var(--wb-corner-radius-6);
padding: var(--wb-spacing-14) var(--wb-spacing-16);
overflow-x: auto;
font-size: var(--wb-font-size-13);
line-height: 1.5;
}
.md-body :global(pre code) {
background: none;
border: none;
padding: 0;
font-size: inherit;
}
.md-body :global(blockquote) {
margin: 0.8em 0;
padding: 0.4em 0 0.4em 1em;
border-left: 3px solid var(--vscode-focusBorder);
color: var(--vscode-foreground);
}
.md-body :global(ul),
.md-body :global(ol) {
padding-left: 1.5em;
margin: 0.5em 0;
}
.md-body :global(li) { margin: 0.25em 0; }
.md-body :global(table) {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
font-size: var(--wb-font-size-13);
}
.md-body :global(th),
.md-body :global(td) {
border: 1px solid var(--vscode-panel-border);
padding: var(--wb-spacing-6) var(--wb-spacing-12);
text-align: left;
}
.md-body :global(th) {
background: var(--vscode-sideBar-background);
font-weight: 600;
}
.md-body :global(tr:nth-child(even) td) {
background: color-mix(in srgb, var(--vscode-sideBar-background) 40%, transparent);
}
.md-body :global(hr) {
border: none;
border-top: 1px solid var(--vscode-panel-border);
margin: 1.5em 0;
}
.md-body :global(img) {
max-width: 100%;
border-radius: var(--wb-corner-radius-4);
}
.md-body :global(.mermaid-block) {
margin: 1em 0;
text-align: center;
overflow-x: auto;
}
.md-body :global(.mermaid-block svg) {
max-width: 100%;
height: auto;
}
.md-body :global(.mermaid-error) {
color: var(--vscode-errorForeground);
font-size: var(--wb-font-size-12);
padding: var(--wb-spacing-8);
background: color-mix(in srgb, var(--vscode-errorForeground) 10%, transparent);
border-radius: var(--wb-corner-radius-4);
border: 1px solid color-mix(in srgb, var(--vscode-errorForeground) 30%, transparent);
}
.md-body :global(.math-display) {
display: block;
text-align: center;
margin: 1em 0;
overflow-x: auto;
}
.md-body :global(.math-inline) {
display: inline;
}
</style>