/** * 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 += ``; 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 += `${item.content}`; } while (stack.length > 0) { result += ``; stack.pop(); } return result; } static parseMarkdown(md) { let html = md; // STEP 1: Extract and protect code blocks FIRST (before any other processing) // This prevents LaTeX, emphasis, etc. from being parsed inside code const codeBlocks = []; const PLACEHOLDER = '\x00CODE_BLOCK_'; // Fenced code blocks (mermaid) - allow leading whitespace for indented blocks html = html.replace(/^[ \t]*```(?:mermaid|flow|flex)(?:\[([^\]]*)\])?\n([\s\S]*?)^[ \t]*```$/gm, (_, options, code) => { const centered = options && options.toLowerCase().includes('center'); const style = centered ? 'text-align:center;' : 'text-align:left;'; const rendered = `
${code.trim()}
`; codeBlocks.push(rendered); return `${PLACEHOLDER}${codeBlocks.length - 1}\x00`; }); // Fenced code blocks (regular) - allow leading whitespace for indented blocks html = html.replace(/^[ \t]*```(\w*)\n([\s\S]*?)^[ \t]*```$/gm, (_, lang, code) => { const rendered = `
${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(/^(.+)\n-+\s*$/gm, "

$1

"); const slugify = (text) => text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-'); html = html.replace(/^######\s*(.+)$/gm, (_, t) => `
${t}
`); html = html.replace(/^#####\s*(.+)$/gm, (_, t) => `
${t}
`); html = html.replace(/^####\s*(.+)$/gm, (_, t) => `

${t}

`); html = html.replace(/^###\s*(.+)$/gm, (_, t) => `

${t}

`); html = html.replace(/^##\s*(.+)$/gm, (_, t) => `

${t}

`); html = html.replace(/^#\s*(.+)$/gm, (_, t) => `

${t}

`); html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => { const target = href.startsWith('#') ? '' : ' target="_blank"'; return `${text}`; }); html = html.replace(/\*\*(.+?)\*\*/g, "$1"); html = html.replace(/__(.+?)__/g, "$1"); html = html.replace(/\*(.+?)\*/g, "$1"); html = html.replace(/(?$1"); html = html.replace(/~~(.+?)~~/g, "$1"); html = html.replace(/^>\s+(.*)$/gm, "
$1
"); html = html.replace(/^(\s*)- \[x\]\s+(.*)$/gim, (_, indent, text) => { const level = Math.floor(indent.length / 2); return ` ${text}`; }); html = html.replace(/^(\s*)- \[ \]\s+(.*)$/gm, (_, indent, text) => { const level = Math.floor(indent.length / 2); return ` ${text}`; }); html = html.replace(/^(\s*)\d+\.\s+(.*)$/gm, (_, indent, text) => { const level = Math.floor(indent.length / 2); return `${text}`; }); html = html.replace(/^(\s*)[-*+]\s+(.*)$/gm, (_, indent, text) => { const level = Math.floor(indent.length / 2); return `${text}`; }); html = html.replace(/(]*>.*?<\/oli>\s*)+/g, (match) => { return this.parseNestedList(match, 'ol', 'oli'); }); html = html.replace(/(]*>.*?<\/uli>\s*)+/g, (match) => { return this.parseNestedList(match, 'ul', 'uli'); }); html = html.replace(/^---$/gm, "
"); html = html.replace(/^\*\*\*$/gm, "
"); html = html.replace(/^(\|.+\|)\n(\|[-:\s|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, separatorRow, bodyRows) => { const parseRow = (row, cellTag) => { const cells = row.split("|").slice(1, -1); return `${cells.map(c => `<${cellTag}>${c.trim()}`).join("")}`; }; const alignments = separatorRow.split("|").slice(1, -1).map(cell => { const trimmed = cell.trim(); if (trimmed.startsWith(":") && trimmed.endsWith(":")) return "center"; if (trimmed.endsWith(":")) return "right"; return "left"; }); const headerCells = headerRow.split("|").slice(1, -1); const theadRow = `${headerCells.map((c, i) => `${c.trim()}`).join("")}`; const bodyRowsArray = bodyRows.trim().split("\n"); const tbodyRows = bodyRowsArray.map(row => { const cells = row.split("|").slice(1, -1); return `${cells.map((c, i) => `${c.trim()}`).join("")}`; }).join(""); return `${theadRow}${tbodyRows}
`; }); html = html.replace(/\n\n/g, "

"); html = "

" + html + "

"; // STEP 3: Restore protected code blocks BEFORE paragraph cleanup // (so
 tags can be properly unwrapped from 

tags) 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, ""); 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;