// ========================================== // Mermaid Diagram Renderer — Browser Extension // ========================================== // Renders ```mermaid code blocks as SVG diagrams. // Loads mermaid.js dynamically on first use. // // Install: Admin → Extensions → paste manifest + this script. // ========================================== Extensions.register({ id: 'mermaid-renderer', _mermaidReady: false, _mermaidLoading: null, _renderQueue: [], async init(ctx) { const self = this; // ── Inject styles ── this._injectStyles(); // ── Block renderer: match ```mermaid, output placeholder ── ctx.renderers.register('mermaid', { type: 'block', pattern: 'mermaid', priority: 10, render(lang, code, container) { const id = 'mmd-' + Math.random().toString(36).slice(2, 9); container.innerHTML = `
Rendering diagram…
📊 View source
${self._escapeHtml(code.trim())}
`; } }); // ── Post renderer: find placeholders, render with mermaid.js ── ctx.renderers.register('mermaid-post', { type: 'post', priority: 10, render(container) { const diagrams = container.querySelectorAll('.mermaid-diagram[data-mermaid-src]'); if (diagrams.length === 0) return; diagrams.forEach(el => { // Skip already-rendered diagrams if (el.dataset.rendered) return; el.dataset.rendered = 'pending'; self._renderDiagram(el); }); } }); // Pre-load mermaid library this._loadMermaid(); }, async _renderDiagram(el) { const code = decodeURIComponent(el.dataset.mermaidSrc); try { await this._loadMermaid(); const id = 'mmd-svg-' + Math.random().toString(36).slice(2, 9); const { svg } = await mermaid.render(id, code); el.innerHTML = svg; el.dataset.rendered = 'true'; // Styling handled by .mermaid-diagram svg in CSS const svgEl = el.querySelector('svg'); if (svgEl) { svgEl.removeAttribute('height'); } } catch (e) { el.innerHTML = `
Diagram error: ${this._escapeHtml(e.message || String(e))}
`; el.dataset.rendered = 'error'; } }, _loadMermaid() { if (this._mermaidReady) return Promise.resolve(); if (this._mermaidLoading) return this._mermaidLoading; this._mermaidLoading = new Promise((resolve, reject) => { // Check if already loaded (e.g. by another script) if (typeof mermaid !== 'undefined') { this._initMermaid(); this._mermaidReady = true; resolve(); return; } // Try local vendor first, fall back to CDN const base = (window.__BASE__ || ''); const localSrc = `${base}/vendor/mermaid/mermaid.min.js`; const cdnSrc = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js'; const script = document.createElement('script'); script.src = localSrc; script.onload = () => { this._initMermaid(); this._mermaidReady = true; resolve(); }; script.onerror = () => { // Local failed — try CDN console.warn('[Mermaid] Local vendor not found, trying CDN'); const cdn = document.createElement('script'); cdn.src = cdnSrc; cdn.onload = () => { this._initMermaid(); this._mermaidReady = true; resolve(); }; cdn.onerror = () => { console.error('[Mermaid] Failed to load from both local and CDN'); reject(new Error('Failed to load mermaid.js')); }; document.head.appendChild(cdn); }; document.head.appendChild(script); }); return this._mermaidLoading; }, _initMermaid() { if (typeof mermaid === 'undefined') return; // Detect dark mode const isDark = document.body.classList.contains('dark-theme') || window.matchMedia('(prefers-color-scheme: dark)').matches; mermaid.initialize({ startOnLoad: false, theme: isDark ? 'dark' : 'default', securityLevel: 'strict', fontFamily: 'inherit', logLevel: 'error', }); }, _injectStyles() { if (document.getElementById('ext-style-mermaid-renderer')) return; const style = document.createElement('style'); style.id = 'ext-style-mermaid-renderer'; style.textContent = ` .mermaid-block { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; margin: 12px 0; } .mermaid-diagram { padding: 16px; text-align: center; min-height: 60px; max-height: 600px; overflow: auto; } .mermaid-diagram svg { max-width: 100%; max-height: 560px; height: auto; width: auto; } .mermaid-loading { color: var(--text-3); font-size: 13px; padding: 20px; display: flex; align-items: center; justify-content: center; gap: 8px; } .mermaid-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: mmd-spin 0.8s linear infinite; } @keyframes mmd-spin { to { transform: rotate(360deg); } } .mermaid-error { color: var(--error, #e74c3c); background: var(--bg-3, rgba(231,76,60,0.1)); padding: 12px 16px; border-radius: 4px; font-size: 13px; font-family: var(--mono); white-space: pre-wrap; } .mermaid-source { border-top: 1px solid var(--border); font-size: 12px; } .mermaid-source summary { padding: 6px 12px; cursor: pointer; color: var(--text-3); user-select: none; } .mermaid-source summary:hover { color: var(--text-2); } .mermaid-source pre { margin: 0; border-radius: 0; border: none; max-height: 200px; overflow: auto; }`; document.head.appendChild(style); }, _escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; }, destroy() { document.getElementById('ext-style-mermaid-renderer')?.remove(); } });