(function(){ function escapeHtml(value){ return String(value || '').replace(/[&<>"']/g, function(ch){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]; }); } function safeUrl(url){ url = String(url || '').trim(); if (!url) return '#'; try { const parsed = new URL(url, window.location.origin); if (!['http:', 'https:', 'mailto:'].includes(parsed.protocol)) return '#'; return parsed.href; } catch (_) { return '#'; } } function inlineMarkdown(text){ let out = escapeHtml(text); out = out.replace(/`([^`]+)`/g, '$1'); out = out.replace(/\*\*([^*]+)\*\*/g, '$1'); out = out.replace(/\*([^*]+)\*/g, '$1'); out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function(_, label, url){ return ''+label+''; }); return out; } function renderMarkdown(markdown){ const lines = String(markdown || '').replace(/\r\n?/g, '\n').trim().split('\n'); if (!String(markdown || '').trim()) return '

Nothing to preview yet.

'; let html = '', para = [], list = null, quote = [], code = false, codeLines = []; function softBreaks(value){ return inlineMarkdown(value).replace(/\n/g, '
'); } function flushPara(){ if (para.length) { html += '

'+softBreaks(para.join('\n'))+'

'; para = []; } } function flushList(){ if (!list) return; html += '<'+list.type+'>'; list.items.forEach(item => html += '
  • '+inlineMarkdown(item)+'
  • '); html += ''; list = null; } function flushQuote(){ if (quote.length) { html += '
    '+softBreaks(quote.join('\n'))+'
    '; quote = []; } } lines.forEach(function(line){ if (/^```/.test(line)) { if (code) { html += '
    '+escapeHtml(codeLines.join('\n'))+'
    '; code = false; codeLines = []; } else { flushPara(); flushList(); flushQuote(); code = true; } return; } if (code) { codeLines.push(line); return; } if (!line.trim()) { flushPara(); flushList(); flushQuote(); return; } let m; if ((m = line.match(/^(#{1,3})\s+(.+)$/))) { flushPara(); flushList(); flushQuote(); const level = m[1].length + 2; html += ''+inlineMarkdown(m[2])+''; return; } if ((m = line.match(/^>\s?(.*)$/))) { flushPara(); flushList(); quote.push(m[1]); return; } if ((m = line.match(/^[-*]\s+(.+)$/))) { flushPara(); flushQuote(); if (!list || list.type !== 'ul') list = {type:'ul',items:[]}; list.items.push(m[1]); return; } if ((m = line.match(/^\d+\.\s+(.+)$/))) { flushPara(); flushQuote(); if (!list || list.type !== 'ol') list = {type:'ol',items:[]}; list.items.push(m[1]); return; } flushList(); flushQuote(); para.push(line); }); if (code) html += '
    '+escapeHtml(codeLines.join('\n'))+'
    '; flushPara(); flushList(); flushQuote(); return html; } function applyWrap(textarea, before, after, placeholder){ const start = textarea.selectionStart || 0; const end = textarea.selectionEnd || 0; const value = textarea.value; const selected = value.slice(start, end) || placeholder || ''; textarea.value = value.slice(0, start) + before + selected + after + value.slice(end); textarea.focus(); textarea.selectionStart = start + before.length; textarea.selectionEnd = start + before.length + selected.length; textarea.dispatchEvent(new Event('input', {bubbles:true})); } function applyLine(textarea, prefix){ const start = textarea.selectionStart || 0; const value = textarea.value; const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1; textarea.value = value.slice(0, lineStart) + prefix + value.slice(lineStart); textarea.focus(); textarea.selectionStart = textarea.selectionEnd = start + prefix.length; textarea.dispatchEvent(new Event('input', {bubbles:true})); } function enhance(textarea){ if (textarea.dataset.markdownEnhanced === '1') return; textarea.dataset.markdownEnhanced = '1'; const shell = document.createElement('div'); shell.className = 'md-editor'; const toolbar = document.createElement('div'); toolbar.className = 'md-toolbar'; const tools = [ ['B', 'Bold', () => applyWrap(textarea, '**', '**', 'bold text')], ['I', 'Italic', () => applyWrap(textarea, '*', '*', 'italic text')], ['#', 'Heading', () => applyLine(textarea, '### ')], ['•', 'Bullet list', () => applyLine(textarea, '- ')], ['1.', 'Numbered list', () => applyLine(textarea, '1. ')], ['“”', 'Quote', () => applyLine(textarea, '> ')], ['{}', 'Code', () => applyWrap(textarea, '`', '`', 'code')], ['↗', 'Link', () => { const url = window.prompt('Link URL'); if (url) applyWrap(textarea, '[', ']('+url+')', 'link text'); }] ]; tools.forEach(function(tool){ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'md-tool'; btn.textContent = tool[0]; btn.title = tool[1]; btn.setAttribute('aria-label', tool[1]); btn.addEventListener('click', tool[2]); toolbar.appendChild(btn); }); const panes = document.createElement('div'); panes.className = 'md-panes'; const write = document.createElement('div'); write.className = 'md-write'; const preview = document.createElement('div'); preview.className = 'md-preview markdown-body'; preview.setAttribute('aria-label', 'Markdown preview'); textarea.parentNode.insertBefore(shell, textarea); shell.appendChild(toolbar); shell.appendChild(panes); panes.appendChild(write); panes.appendChild(preview); write.appendChild(textarea); textarea.classList.add('md-source'); function update(){ preview.innerHTML = renderMarkdown(textarea.value); } textarea.addEventListener('input', update); update(); } document.addEventListener('DOMContentLoaded', function(){ document.querySelectorAll('textarea.markdown-editor').forEach(enhance); }); })();