// Centralized Markdown rendering utility // Safe defaults per requirements: no inline HTML, no images, no code blocks, allow tables, links open in new tab with rel attributes export function renderMarkdown(text, options = {}) { const opts = { allowInlineHTML: false, enableTables: true, enableBreaks: true, enableImages: false, enableCodeBlocks: false, allowInlineCode: false, // inline code currently not allowed linkTargetBlank: true, sidebarAccent: false, consoleWarnOnFallback: true, ...options }; const input = typeof text === 'string' ? text : String(text ?? ''); if (!input) return ''; // Optional sidebar accent [[...]] -> ... BEFORE parsing const preprocessed = opts.sidebarAccent ? input.replace(/\[\[(.*?)\]\]/g, '$1') : input; try { const hasMarked = typeof window !== 'undefined' && window.marked; if (!hasMarked) { if (opts.consoleWarnOnFallback) { console.warn('renderMarkdown: Marked not available; falling back to plain text'); } return escapeToPlainHtml(preprocessed); } // Configure marked options defensively if (typeof window.marked?.use === 'function') { // Create a lightweight renderer with restricted features const renderer = new window.marked.Renderer(); // Links: target _blank + rel attributes; block unsafe protocols renderer.link = (href, title, text) => { const safeHref = sanitizeUrl(href); if (!safeHref) { return text; // drop link, keep text } const t = opts.linkTargetBlank ? ' target="_blank"' : ''; const rel = ' rel="noopener noreferrer nofollow"'; const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''; return `${text}`; }; // Images disabled: either drop or render alt text renderer.image = (href, title, text) => { if (!opts.enableImages) { return text ? escapeToPlainHtml(text) : ''; } // If enabled in future, still sanitize const safeHref = sanitizeUrl(href); if (!safeHref) return text ? escapeToPlainHtml(text) : ''; const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''; return `${escapeHtmlAttr(text || '')}`; }; // Code blocks disabled renderer.code = (code, infostring) => { if (!opts.enableCodeBlocks) { return `
${escapeToPlainHtml(code)}
`; // still show as plain } return `
${escapeToPlainHtml(code)}
`; }; // Inline code disabled renderer.codespan = (code) => { if (!opts.allowInlineCode) { return escapeToPlainHtml(code); } return `${escapeToPlainHtml(code)}`; }; // Disallow raw HTML if not allowed const mOptions = { gfm: true, breaks: !!opts.enableBreaks, headerIds: false, mangle: false, renderer }; // Table support via GFM is on by default; if disabled, override table renderers to simple paragraphs if (!opts.enableTables) { renderer.table = (header, body) => `${header}${body}`; } const html = window.marked.parse(preprocessed, mOptions); // Sanitize output const sanitized = sanitizeHtml(html); return sanitized; } // Fallback older API: window.marked is a function const html = typeof window.marked === 'function' ? window.marked(preprocessed) : String(preprocessed); const sanitized = sanitizeHtml(html); return sanitized; } catch (err) { console.warn('renderMarkdown: error while rendering; falling back to plain text', err); return escapeToPlainHtml(preprocessed); } } // Very small sanitizer if DOMPurify is not provided globally. Prefer window.DOMPurify if available. function sanitizeHtml(html) { try { if (typeof window !== 'undefined' && window.DOMPurify && typeof window.DOMPurify.sanitize === 'function') { return window.DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, ADD_ATTR: ['target', 'rel', 'title'], ALLOWED_TAGS: undefined // use default safe set }); } } catch (e) { console.warn('sanitizeHtml: DOMPurify error, using basic sanitizer', e); } // Basic sanitizer: strip script tags and on* attributes return String(html) .replace(/]*>[\s\S]*?<\/script>/gi, '') .replace(/ on[a-z]+="[^"]*"/gi, '') .replace(/ on[a-z]+='[^']*'/gi, ''); } // Escape plain text to safe HTML function escapeToPlainHtml(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') .replace(/\n/g, '
'); } function sanitizeUrl(href) { if (!href) return ''; try { const str = String(href).trim(); const lower = str.toLowerCase(); // Block javascript: and data: except maybe safe data images (we disable images anyway) if (lower.startsWith('javascript:')) return ''; if (lower.startsWith('data:')) return ''; if (lower.startsWith('vbscript:')) return ''; return str; } catch (e) { return ''; } } function escapeHtmlAttr(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }