Files

250 lines
10 KiB
JavaScript

/**
* Doctor Rendering Utilities
* ==========================
* Shared rendering pipeline for sanitization, markdown, and code highlighting.
* Used by Preact islands and vanilla fallback components.
*
* @module doctor_rendering
*/
// ═══════════════════════════════════════════════════════════════════════════
// ASSET PATHS
// ═══════════════════════════════════════════════════════════════════════════
const LOCAL_MARKED = "/extensions/ComfyUI-Doctor/lib/marked.min.js";
const LOCAL_HIGHLIGHT = "/extensions/ComfyUI-Doctor/lib/highlight.min.js";
const LOCAL_HIGHLIGHT_CSS = "/extensions/ComfyUI-Doctor/lib/github-dark.min.css";
const CDN_MARKED = "https://cdn.jsdelivr.net/npm/marked@15.0.4/marked.min.js";
const CDN_HIGHLIGHT = "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js";
const CDN_HIGHLIGHT_CSS = "https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/styles/github-dark.min.css";
// ═══════════════════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════════════════
let assetsLoaded = false;
let loadPromise = null;
// ═══════════════════════════════════════════════════════════════════════════
// ASSET LOADING
// ═══════════════════════════════════════════════════════════════════════════
/**
* Load a script dynamically.
* @param {string} src - Script URL
* @returns {Promise<void>}
*/
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* Load all rendering assets (marked, highlight.js, CSS).
* Uses local vendor files with CDN fallback.
* @returns {Promise<void>}
*/
export async function loadRenderingAssets() {
if (assetsLoaded) return;
if (loadPromise) return loadPromise;
loadPromise = (async () => {
// CSS
if (!document.getElementById('hljs-style')) {
const link = document.createElement('link');
link.id = 'hljs-style';
link.rel = 'stylesheet';
link.href = LOCAL_HIGHLIGHT_CSS;
link.onerror = () => { link.href = CDN_HIGHLIGHT_CSS; };
document.head.appendChild(link);
}
// Marked
if (!window.marked) {
try {
await loadScript(LOCAL_MARKED);
console.log('[DoctorRendering] ✅ marked loaded from local');
} catch {
await loadScript(CDN_MARKED);
console.log('[DoctorRendering] ✅ marked loaded from CDN');
}
}
// Highlight.js
if (!window.hljs) {
try {
await loadScript(LOCAL_HIGHLIGHT);
console.log('[DoctorRendering] ✅ hljs loaded from local');
} catch {
await loadScript(CDN_HIGHLIGHT);
console.log('[DoctorRendering] ✅ hljs loaded from CDN');
}
}
assetsLoaded = true;
})();
return loadPromise;
}
// ═══════════════════════════════════════════════════════════════════════════
// SANITIZATION
// ═══════════════════════════════════════════════════════════════════════════
/**
* Sanitize HTML to prevent XSS attacks.
* Removes dangerous tags and event handlers.
*
* @param {string} unsafeHtml - HTML content to sanitize
* @returns {string} Sanitized HTML
*/
export function sanitizeHtml(unsafeHtml) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(unsafeHtml, 'text/html');
// Remove dangerous tags
const blockedTags = ['script', 'style', 'iframe', 'object', 'embed', 'link', 'meta'];
blockedTags.forEach(tag => {
doc.querySelectorAll(tag).forEach(node => node.remove());
});
// Remove event handlers and javascript: links
doc.querySelectorAll('*').forEach(node => {
[...node.attributes].forEach(attr => {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (name.startsWith('on')) {
node.removeAttribute(attr.name);
}
if ((name === 'src' || name === 'href') && /^javascript:/i.test(value.trim())) {
node.removeAttribute(attr.name);
}
});
});
return doc.body.innerHTML;
} catch (e) {
// Fallback: escape all HTML
const div = document.createElement('div');
div.textContent = unsafeHtml;
return div.innerHTML;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// MARKDOWN RENDERING
// ═══════════════════════════════════════════════════════════════════════════
/**
* Render markdown content to sanitized HTML.
* Requires assets to be loaded first.
*
* @param {string} content - Markdown content
* @returns {string} Sanitized HTML
*/
export function renderMarkdown(content) {
if (!window.marked) {
console.warn('[DoctorRendering] marked not loaded, returning raw content');
return sanitizeHtml(content);
}
const rawHtml = window.marked.parse(content || '');
return sanitizeHtml(rawHtml);
}
// ═══════════════════════════════════════════════════════════════════════════
// CODE HIGHLIGHTING
// ═══════════════════════════════════════════════════════════════════════════
/**
* Apply syntax highlighting to code blocks in a container.
*
* @param {HTMLElement} container - DOM container with code blocks
*/
export function highlightCodeBlocks(container) {
if (!window.hljs || !container) return;
container.querySelectorAll('pre code').forEach(block => {
window.hljs.highlightElement(block);
});
}
/**
* Add copy buttons to pre blocks in a container.
*
* @param {HTMLElement} container - DOM container with pre blocks
*/
export function addCopyButtons(container) {
if (!container) return;
container.querySelectorAll('pre').forEach(pre => {
if (pre.querySelector('.copy-btn')) return;
if (!pre.querySelector('code')) return;
const btn = document.createElement('button');
btn.className = 'copy-btn';
btn.textContent = '📋';
btn.title = 'Copy code';
btn.onclick = () => {
const code = pre.querySelector('code').innerText;
navigator.clipboard.writeText(code)
.then(() => { btn.textContent = '✅'; })
.catch(() => { btn.textContent = '❌'; });
setTimeout(() => { btn.textContent = '📋'; }, 2000);
};
pre.style.position = 'relative';
pre.appendChild(btn);
});
}
// ═══════════════════════════════════════════════════════════════════════════
// FULL PIPELINE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Full rendering pipeline: markdown → sanitize → highlight → copy buttons.
*
* @param {string} content - Markdown content to render
* @param {HTMLElement} container - Target container element
* @returns {Promise<void>}
*/
export async function renderContentPipeline(content, container) {
await loadRenderingAssets();
const html = renderMarkdown(content);
container.innerHTML = html;
highlightCodeBlocks(container);
addCopyButtons(container);
}
/**
* Escape HTML to prevent XSS (for text-only content).
*
* @param {string} text - Plain text
* @returns {string} Escaped HTML
*/
export function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ═══════════════════════════════════════════════════════════════════════════
// EXPORTS FOR TESTING
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reset the assets loaded state (for testing only).
*/
export function _resetAssets() {
assetsLoaded = false;
loadPromise = null;
}