// ── Markdown rendering ── import { esc } from './util.js'; import { state } from './state.js'; marked.use({ breaks: true, gfm: true, renderer: { link({ href, text }) { const isExternal = /^https?:\/\//.test(href); if (isExternal) return `${text}`; return `${text}`; } }}); const purifyConfig = { ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'], ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel','align'], ALLOW_DATA_ATTR: true, }; function normalizeListBlock(block) { const lines = String(block || '').replace(/\r\n?/g, '\n').split('\n'); while (lines.length && !lines[0].trim()) lines.shift(); while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); if (!lines.length) return ''; const minIndent = lines .filter(l => l.trim()) .map(l => (l.match(/^[ \t]*/) || [''])[0].replace(/\t/g, ' ').length) .reduce((min, n) => Math.min(min, n), Infinity); return lines .map(l => { const expanded = l.replace(/\t/g, ' '); if (!expanded.trim()) return ''; return expanded.slice(Math.min(minIndent, expanded.length)); }) .join('\n'); } export function renderMd(src) { let processed = String(src || '').replace(/\r\n?/g, '\n'); // Convert Unicode box-drawing tables to HTML processed = processed.replace(/([ \t]*[┌╔][\s\S]*?[┘╝][ \t]*)/g, (block) => { const lines = block.split('\n').filter(l => l.trim()); const rows = []; let currentRow = null; for (const line of lines) { const trimmed = line.trim(); // Skip border lines (┌─┬─┐, ├─┼─┤, └─┴─┘, ╔═╦═╗, etc.) if (/^[┌┐└┘├┤┬┴┼─│╔╗╚╝╠╣╦╩╬═║\s]+$/.test(trimmed)) { if (currentRow) { rows.push(currentRow); currentRow = null; } continue; } // Data row: │ cell │ cell │ or ║ cell ║ if (/^[│║]/.test(trimmed)) { const cells = trimmed.split(/[│║]/).slice(1, -1).map(c => c.trim()); if (currentRow && currentRow.length === cells.length) { // Continuation line — append to previous cells for (let i = 0; i < cells.length; i++) { if (cells[i]) currentRow[i] = ((currentRow[i] || '') + ' ' + cells[i]).trim(); } } else { if (currentRow) rows.push(currentRow); currentRow = cells; } } } if (currentRow) rows.push(currentRow); if (rows.length < 2) return block; const header = rows[0]; const body = rows.slice(1); return '\n' + header.map(c => ``).join('') + '' + body.map(r => '' + r.map(c => ``).join('') + '').join('') + '
${esc(c)}
${esc(c)}
\n'; }); processed = processed // Gallery fenced block: ```gallery .replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => { const imgs = []; const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g; let m; while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] }); if (!imgs.length) return ''; return ''; }) // Custom fenced list blocks: ```tree / ```bullets with or without closing ``` .replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => { const normalized = normalizeListBlock(content); return `\n\n${normalized}\n`; }) // Help parser: ensure a list can start after text without requiring manual blank line .replace(/([^\n])\n(?=(?: {0,3}[-*+]\s| {0,3}\d+\.\s))/g, '$1\n\n') // Preserve extra blank lines as
spacers (3+ newlines = 1+ extra blank lines) // Insert
BETWEEN paragraphs but not before block-level elements .replace(/\n{3,}/g, m => { const extra = m.length - 2; return '\n\n' + '
\n\n'.repeat(extra > 3 ? 3 : extra); }) // Ensure hr rules have clean blank lines .replace(/
\n\n(---+|___+|\*\*\*+)/gm, '\n\n$1') .replace(/(---+|___+|\*\*\*+)\n\n
/gm, '$1\n\n'); const linked = processed.replace(/\[\[([^\]]+)\]\]/g, (_, title) => { const card = state.cards.find(c => c.title.toLowerCase() === title.toLowerCase()); if (card) return `${esc(title)}`; return `${esc(title)}`; }); let html = marked.parse(linked); // Convert list hints before sanitize (DOMPurify strips comments) html = html.replace(/\s*