// 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 [[...]] -> keep content but mark with markdown strong (no raw HTML) 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'); console.log('renderMarkdown: input:', input); } return escapeToPlainHtml(preprocessed); } // Configure marked options defensively if (typeof window.marked?.use === 'function') { // Use the global singleton API exposed by Marked in browser builds const md = window.marked; // One-time registration of our token-based renderer; renderer reads dynamic opts from md.__evieOptions if (!md.__evieRendererApplied) { md.use({ renderer: { link(token) { const { href, title, text } = token; const cur = md.__evieOptions || {}; const safeHref = sanitizeUrl(href); if (!safeHref) return text || ''; const t = cur.linkTargetBlank ? ' target="_blank"' : ''; const rel = ' rel="noopener noreferrer nofollow"'; const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''; return `${text}`; }, image(token) { const { href, title, text } = token; const cur = md.__evieOptions || {}; if (!cur.enableImages) return text ? escapeToPlainHtml(text) : ''; const safeHref = sanitizeUrl(href); if (!safeHref) return text ? escapeToPlainHtml(text) : ''; const titleAttr = title ? ` title="${escapeHtmlAttr(title)}"` : ''; return `${escapeHtmlAttr(text || '')}`; }, code(token) { const { text } = token; // Render as escaped pre/code regardless of highlighting return `
${escapeToPlainHtml(text)}
`; }, codespan(token) { const { text } = token; const cur = md.__evieOptions || {}; if (!cur.allowInlineCode) return escapeToPlainHtml(text); return `${escapeToPlainHtml(text)}`; }, table(token) { const cur = md.__evieOptions || {}; if (cur.enableTables) return undefined; // default rendering const headerCells = token.header?.map(h => h.text).join(' | ') || ''; const rowTexts = (token.rows || []).map(row => row.map(c => c.text).join(' | ')); const all = [headerCells, ...rowTexts].filter(Boolean).join('\n'); return `

${escapeToPlainHtml(all)}

`; } } }); md.__evieRendererApplied = true; } // Set dynamic options per render md.__evieOptions = { allowInlineHTML: !!opts.allowInlineHTML, enableTables: !!opts.enableTables, enableBreaks: !!opts.enableBreaks, enableImages: !!opts.enableImages, enableCodeBlocks: !!opts.enableCodeBlocks, allowInlineCode: !!opts.allowInlineCode, linkTargetBlank: !!opts.linkTargetBlank }; md.setOptions({ gfm: true, breaks: !!opts.enableBreaks, headerIds: false, mangle: false }); const html = md.parse(preprocessed); 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, '''); }