Files
WASasquatch-ComfyUI_Viewer/web/views/markdown.js
T

347 lines
13 KiB
JavaScript

/**
* 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 += `</li></${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 += '</li>';
}
const classAttr = item.className ? ` class="${item.className}"` : '';
result += `<li${classAttr}>${item.content}`;
}
while (stack.length > 0) {
result += `</li></${listTag}>`;
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 = `<div class="mermaid" style="${style}">${code.trim()}</div>`;
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 = `<pre><code class="language-${lang || "text"}">${escapeHtml(code.trim())}</code></pre>`;
codeBlocks.push(rendered);
return `${PLACEHOLDER}${codeBlocks.length - 1}\x00`;
});
// Inline code
html = html.replace(/`([^`]+)`/g, (_, code) => {
const rendered = `<code>${escapeHtml(code)}</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, "<h1>$1</h1>");
html = html.replace(/^(.+)\n-+\s*$/gm, "<h2>$1</h2>");
const slugify = (text) => text.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-');
html = html.replace(/^######\s*(.+)$/gm, (_, t) => `<h6 id="${slugify(t)}">${t}</h6>`);
html = html.replace(/^#####\s*(.+)$/gm, (_, t) => `<h5 id="${slugify(t)}">${t}</h5>`);
html = html.replace(/^####\s*(.+)$/gm, (_, t) => `<h4 id="${slugify(t)}">${t}</h4>`);
html = html.replace(/^###\s*(.+)$/gm, (_, t) => `<h3 id="${slugify(t)}">${t}</h3>`);
html = html.replace(/^##\s*(.+)$/gm, (_, t) => `<h2 id="${slugify(t)}">${t}</h2>`);
html = html.replace(/^#\s*(.+)$/gm, (_, t) => `<h1 id="${slugify(t)}">${t}</h1>`);
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width:100%;">');
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => {
const target = href.startsWith('#') ? '' : ' target="_blank"';
return `<a href="${href}"${target}>${text}</a>`;
});
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/__(.+?)__/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/(?<![a-zA-Z0-9\/_])_([^_\s][^_]*)_(?![a-zA-Z0-9\/_])/g, "<em>$1</em>");
html = html.replace(/~~(.+?)~~/g, "<del>$1</del>");
html = html.replace(/^>\s+(.*)$/gm, "<blockquote>$1</blockquote>");
html = html.replace(/^(\s*)- \[x\]\s+(.*)$/gim, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<uli data-level="${level}" class="task-item checked"><input type="checkbox" checked disabled> ${text}</uli>`;
});
html = html.replace(/^(\s*)- \[ \]\s+(.*)$/gm, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<uli data-level="${level}" class="task-item"><input type="checkbox" disabled> ${text}</uli>`;
});
html = html.replace(/^(\s*)\d+\.\s+(.*)$/gm, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<oli data-level="${level}">${text}</oli>`;
});
html = html.replace(/^(\s*)[-*+]\s+(.*)$/gm, (_, indent, text) => {
const level = Math.floor(indent.length / 2);
return `<uli data-level="${level}">${text}</uli>`;
});
html = html.replace(/(<oli[^>]*>.*?<\/oli>\s*)+/g, (match) => {
return this.parseNestedList(match, 'ol', 'oli');
});
html = html.replace(/(<uli[^>]*>.*?<\/uli>\s*)+/g, (match) => {
return this.parseNestedList(match, 'ul', 'uli');
});
html = html.replace(/^---$/gm, "<hr>");
html = html.replace(/^\*\*\*$/gm, "<hr>");
html = html.replace(/^(\|.+\|)\n(\|[-:\s|]+\|)\n((?:\|.+\|\n?)+)/gm, (match, headerRow, separatorRow, bodyRows) => {
const parseRow = (row, cellTag) => {
const cells = row.split("|").slice(1, -1);
return `<tr>${cells.map(c => `<${cellTag}>${c.trim()}</${cellTag}>`).join("")}</tr>`;
};
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 = `<tr>${headerCells.map((c, i) => `<th style="text-align:${alignments[i] || "left"}">${c.trim()}</th>`).join("")}</tr>`;
const bodyRowsArray = bodyRows.trim().split("\n");
const tbodyRows = bodyRowsArray.map(row => {
const cells = row.split("|").slice(1, -1);
return `<tr>${cells.map((c, i) => `<td style="text-align:${alignments[i] || "left"}">${c.trim()}</td>`).join("")}</tr>`;
}).join("");
return `<table><thead>${theadRow}</thead><tbody>${tbodyRows}</tbody></table>`;
});
html = html.replace(/\n\n/g, "</p><p>");
html = "<p>" + html + "</p>";
// STEP 3: Restore protected code blocks BEFORE paragraph cleanup
// (so <pre> tags can be properly unwrapped from <p> 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(/<p>\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(/<p>\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("<div class=\"mermaid\">");
}
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;