/** * Markdown View - Markdown renderer with Mermaid diagrams and KaTeX math support */ import { BaseView, escapeHtml, loadScriptText } from "./base_view.js"; import { triggerViewsRefresh } from "./view_loader.js"; const MERMAID_FILE = "mermaid.min.txt"; const KATEX_FILES = ["katex.min.txt", "katex-auto-render.min.txt"]; const KATEX_CSS_FILE = "katex-with-fonts.min.css.txt"; class MarkdownView extends BaseView { static id = "markdown"; static displayName = "Markdown"; static priority = 70; static scripts = { mermaid: null, katexCore: null, katexAutoRender: null, katexCss: null, }; static detect(content) { const trimmed = content.trim(); let score = 0; const mdPatterns = [ [/^#{1,6}\s+.+/m, 3], [/^\s*[-*+]\s+.+/m, 1], [/^\s*\d+\.\s+.+/m, 1], [/\[[^\]]+\]\([^)]+\)/, 2], [/^\s*```/m, 3], [/^\s*>/m, 1], [/\*\*[^*]+\*\*/, 1], [/~~[^~]+~~/, 1], [/^\s*\|.+\|.+\|/m, 2], [/!\[[^\]]*\]\([^)]+\)/, 2], ]; for (const [pattern, weight] of mdPatterns) { if (pattern.test(trimmed)) score += weight; } return score; } static async loadScripts(basePath) { const promises = []; if (!this.scripts.mermaid) { promises.push( loadScriptText(`${basePath}/views/markdown_scripts/${MERMAID_FILE}`) .then(script => { if (script && script.trim().startsWith('<')) { console.warn('[Markdown] Mermaid script appears to be HTML, not JS - likely 404'); this.scripts.mermaid = null; } else { this.scripts.mermaid = script; if (script) triggerViewsRefresh(); } }) ); } if (!this.scripts.katexCore || !this.scripts.katexAutoRender || !this.scripts.katexCss) { promises.push( Promise.all([ loadScriptText(`${basePath}/views/markdown_scripts/${KATEX_FILES[0]}`), loadScriptText(`${basePath}/views/markdown_scripts/${KATEX_FILES[1]}`), loadScriptText(`${basePath}/views/markdown_scripts/${KATEX_CSS_FILE}`), ]).then(([katexCore, autoRender, katexCss]) => { // Validate scripts aren't HTML error pages this.scripts.katexCore = (katexCore && !katexCore.trim().startsWith('<')) ? katexCore : null; this.scripts.katexAutoRender = (autoRender && !autoRender.trim().startsWith('<')) ? autoRender : null; this.scripts.katexCss = katexCss; // CSS can start with < in comments if (this.scripts.katexCore && this.scripts.katexCss) triggerViewsRefresh(); }) ); } await Promise.all(promises); return true; } static isReady() { return true; } static parseNestedList(match, listTag, itemTag) { const regex = new RegExp(`<${itemTag}[^>]*data-level="(\\d+)"[^>]*(?:class="([^"]*)")?[^>]*>(.*?)<\\/${itemTag}>`, 'g'); const items = []; let m; while ((m = regex.exec(match)) !== null) { items.push({ level: parseInt(m[1]), className: m[2] || '', content: m[3] }); } if (items.length === 0) return match; let result = ''; let stack = []; for (const item of items) { while (stack.length > 0 && stack[stack.length - 1] > item.level) { result += `${listTag}>`; stack.pop(); } if (stack.length === 0 || stack[stack.length - 1] < item.level) { result += `<${listTag}>`; stack.push(item.level); } else if (stack[stack.length - 1] === item.level && result.length > 0) { result += ''; } const classAttr = item.className ? ` class="${item.className}"` : ''; result += `
${escapeHtml(code.trim())}`;
codeBlocks.push(rendered);
return `${PLACEHOLDER}${codeBlocks.length - 1}\x00`;
});
// Inline code
html = html.replace(/`([^`]+)`/g, (_, code) => {
const rendered = `${escapeHtml(code)}`;
codeBlocks.push(rendered);
return `${PLACEHOLDER}${codeBlocks.length - 1}\x00`;
});
// STEP 2: Now process LaTeX (code is already protected)
html = html.replace(/\$\$\s*\\\(([^]*?)\\\)\s*\$\$/g, '$$$$$1$$$$');
html = html.replace(/\$\$\s*\\\[([^]*?)\\\]\s*\$\$/g, '$$$$$1$$$$');
html = html.replace(/([^\n$\x00])\$\$([^$\n]+)\$\$([^\n$])/g, '$1$$$2$$$3');
html = html.replace(/^(.+)\n=+\s*$/gm, "$1"); html = html.replace(/^(\s*)- \[x\]\s+(.*)$/gim, (_, indent, text) => { const level = Math.floor(indent.length / 2); return `
"); html = "
" + html + "
"; // STEP 3: Restore protected code blocks BEFORE paragraph cleanup // (sotags can be properly unwrapped fromtags) html = html.replace(/\x00CODE_BLOCK_(\d+)\x00/g, (_, idx) => codeBlocks[parseInt(idx)] || ''); // STEP 4: Clean up paragraph tags around block elements html = html.replace(/
\s*<(h[1-6]|ul|ol|pre|blockquote|hr|table|div)/g, "<$1"); html = html.replace(/<\/(h[1-6]|ul|ol|pre|blockquote|table|div)>\s*<\/p>/g, "$1>"); html = html.replace(/
\s*<\/p>/g, ""); return html; } static render(content, theme) { return this.parseMarkdown(content); } static getStyles(theme) { return ` .mermaid { background: transparent; text-align: center; padding: 16px; } .math-block { text-align: center; margin: 16px 0; overflow-x: auto; } .task-item { list-style: none; margin-left: -20px; } .task-item input[type="checkbox"] { margin-right: 8px; accent-color: ${theme.accent}; } .task-item.checked { color: ${theme.fg}; } `; } static hasMermaid(content) { return /```(?:mermaid|flow|flex)/.test(content) || content.includes("
"); } static hasLatex(content) { return content.includes("$$") || content.includes("\\(") || content.includes("\\[") || /\$[^$\n]+\$/.test(content); } static getScripts(content) { // Legacy method - returns empty, use getScriptData instead for postMessage injection return ""; } static getScriptData(content) { const scripts = []; const hasMermaid = this.hasMermaid(content); // Only inject mermaid if it's valid JS (not an HTML error page) if (hasMermaid && this.scripts.mermaid && !this.scripts.mermaid.trim().startsWith('<')) { scripts.push({ code: this.scripts.mermaid, init: `mermaid.initialize({ startOnLoad: true, theme: 'dark' });` }); } const hasLatex = this.hasLatex(content); // Only inject KaTeX if it's valid JS (not an HTML error page) const katexCoreValid = this.scripts.katexCore && !this.scripts.katexCore.trim().startsWith('<'); const katexAutoRenderValid = this.scripts.katexAutoRender && !this.scripts.katexAutoRender.trim().startsWith('<'); if (hasLatex && katexCoreValid && katexAutoRenderValid) { scripts.push({ code: this.scripts.katexCore }); scripts.push({ code: this.scripts.katexAutoRender, init: `if (typeof renderMathInElement === 'function') { renderMathInElement(document.body, { delimiters: [ {left: '$$', right: '$$', display: true}, {left: '$', right: '$', display: false}, {left: '\\\\(', right: '\\\\)', display: false}, {left: '\\\\[', right: '\\\\]', display: true} ], ignoredTags: ['script', 'noscript', 'style', 'textarea', 'option', 'code', 'pre'], throwOnError: false }); }` }); } return scripts; } static getKatexCss() { return this.scripts.katexCss || ""; } static needsPrism() { return true; } } export default MarkdownView;