This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/packages/mermaid-renderer/js/script.js
Jeffrey Smith 2adaabe5fa
All checks were successful
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 6s
CI/CD / test-go-pg (pull_request) Successful in 2m43s
CI/CD / test-sqlite (pull_request) Successful in 2m51s
CI/CD / build-and-deploy (pull_request) Successful in 1m13s
v0.6.5: Renderer pipeline, docs rewrite, architecture diagrams
Lift block rendering to kernel SDK primitives (sw.renderers + sw.markdown)
so all surfaces share one markdown pipeline. Rewrite docs for external
audience — remove all fork history references. Add Mermaid architecture
diagrams, CONTRIBUTING guide, and extension tutorial.

- sw.renderers SDK module: kernel-level renderer registry
- sw.markdown SDK module: unified marked v16 + DOMPurify pipeline
- Browser extension script loader for renderer injection
- Notes + Docs surfaces migrated to sw.markdown.renderSync()
- 4 renderer extensions rewritten to IIFE + sw.renderers.register()
- 6 Mermaid diagrams in ARCHITECTURE.md
- CONTRIBUTING.md + TUTORIAL-FIRST-EXTENSION.md
- DESIGN-WORKFLOWS.md replaces fork-era design doc
- Surface alias routes removed from main.go
- ICD/SDK runners migrated to /admin/packages/ endpoints
- 13 new renderer tests
- Docs, CHANGELOG, ROADMAP cleaned of fork references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:26:44 +00:00

641 lines
26 KiB
JavaScript

// ==========================================
// Mermaid Diagram Renderer — Browser Extension
// ==========================================
// Renders ```mermaid code blocks as interactive SVG diagrams.
// Features: viewBox-based zoom/pan, fullscreen expand,
// SVG/PNG export, source copy.
// Loads mermaid.js dynamically on first use.
//
// Registers with sw.renderers via the sw:ready event.
// ==========================================
(function () {
'use strict';
let _mermaidReady = false;
let _mermaidLoading = null;
function _isDark() {
return document.documentElement.dataset.theme === 'dark' ||
document.documentElement.classList.contains('dark');
}
function _escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ── ViewBox Zoom/Pan State ──────────────
function _getState(id) {
const vp = document.querySelector(`[data-viewport="${id}"]`);
if (!vp) return null;
if (!vp._mmdState) {
vp._mmdState = {
natX: 0, natY: 0, natW: 0, natH: 0,
vbX: 0, vbY: 0, vbW: 0, vbH: 0,
dragging: false, startX: 0, startY: 0,
startVbX: 0, startVbY: 0,
ready: false,
};
}
return vp._mmdState;
}
function _initState(id) {
const state = _getState(id);
if (!state) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const vb = svg.getAttribute('viewBox');
if (!vb) {
const bbox = svg.getBBox();
state.natX = bbox.x; state.natY = bbox.y;
state.natW = bbox.width; state.natH = bbox.height;
} else {
const parts = vb.trim().split(/[\s,]+/).map(Number);
state.natX = parts[0] || 0; state.natY = parts[1] || 0;
state.natW = parts[2] || 0; state.natH = parts[3] || 0;
}
state.vbX = state.natX; state.vbY = state.natY;
state.vbW = state.natW; state.vbH = state.natH;
state.ready = true;
_applyViewBox(id);
}
function _applyViewBox(id) {
const state = _getState(id);
if (!state || !state.ready) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
svg.setAttribute('viewBox', `${state.vbX} ${state.vbY} ${state.vbW} ${state.vbH}`);
const zoomPct = Math.round((state.natW / state.vbW) * 100);
const label = document.querySelector(`[data-zoom-label="${id}"]`);
if (label) label.textContent = zoomPct + '%';
}
function _zoom(id, factor) {
const state = _getState(id);
if (!state || !state.ready) return;
const scale = 1 / (1 + factor);
const newW = state.vbW * scale;
const newH = state.vbH * scale;
if (newW < state.natW / 20 || newW > state.natW * 2) return;
const cx = state.vbX + state.vbW / 2;
const cy = state.vbY + state.vbH / 2;
state.vbW = newW; state.vbH = newH;
state.vbX = cx - newW / 2; state.vbY = cy - newH / 2;
_applyViewBox(id);
}
function _zoomAt(id, factor, clientX, clientY) {
const state = _getState(id);
if (!state || !state.ready) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0 || svgRect.height === 0) return;
const fx = (clientX - svgRect.left) / svgRect.width;
const fy = (clientY - svgRect.top) / svgRect.height;
const pointX = state.vbX + fx * state.vbW;
const pointY = state.vbY + fy * state.vbH;
const scale = 1 / (1 + factor);
const newW = state.vbW * scale;
const newH = state.vbH * scale;
if (newW < state.natW / 20 || newW > state.natW * 2) return;
state.vbW = newW; state.vbH = newH;
state.vbX = pointX - fx * newW; state.vbY = pointY - fy * newH;
_applyViewBox(id);
}
function _zoomReset(id) {
const state = _getState(id);
if (!state || !state.ready) return;
state.vbX = state.natX; state.vbY = state.natY;
state.vbW = state.natW; state.vbH = state.natH;
_applyViewBox(id);
}
function _zoomFit(id) {
const state = _getState(id);
if (!state || !state.ready) return;
const vp = document.querySelector(`[data-viewport="${id}"]`);
if (!vp) return;
const vpRect = vp.getBoundingClientRect();
if (vpRect.width === 0 || vpRect.height === 0) return;
const vpAspect = vpRect.width / vpRect.height;
const natAspect = state.natW / state.natH;
if (vpAspect > natAspect) {
const newW = state.natH * vpAspect;
state.vbX = state.natX - (newW - state.natW) / 2;
state.vbY = state.natY;
state.vbW = newW; state.vbH = state.natH;
} else {
const newH = state.natW / vpAspect;
state.vbX = state.natX;
state.vbY = state.natY - (newH - state.natH) / 2;
state.vbW = state.natW; state.vbH = newH;
}
_applyViewBox(id);
}
// ── Fullscreen ──────────────────────────
function _toggleFullscreen(id) {
const block = document.querySelector(`[data-mermaid-id="${id}"]`);
if (!block) return;
const isFS = block.classList.toggle('mermaid-fullscreen');
if (isFS) {
document.body.style.overflow = 'hidden';
const closeBtn = document.createElement('button');
closeBtn.className = 'mmd-fullscreen-close';
closeBtn.innerHTML = '\u2715';
closeBtn.title = 'Close fullscreen';
closeBtn.addEventListener('click', () => _toggleFullscreen(id));
block.appendChild(closeBtn);
block._mmdEscHandler = (e) => { if (e.key === 'Escape') _toggleFullscreen(id); };
document.addEventListener('keydown', block._mmdEscHandler);
requestAnimationFrame(() => _zoomFit(id));
} else {
document.body.style.overflow = '';
const closeBtn = block.querySelector('.mmd-fullscreen-close');
if (closeBtn) closeBtn.remove();
if (block._mmdEscHandler) {
document.removeEventListener('keydown', block._mmdEscHandler);
delete block._mmdEscHandler;
}
}
}
// ── Toolbar Wiring ──────────────────────
function _wireToolbar(container) {
if (container._mmdWired) return;
container._mmdWired = true;
container.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
const id = btn.dataset.target;
if (!id) return;
switch (action) {
case 'zoom-in': _zoom(id, 0.25); break;
case 'zoom-out': _zoom(id, -0.2); break;
case 'zoom-reset': _zoomReset(id); break;
case 'zoom-fit': _zoomFit(id); break;
case 'expand': _toggleFullscreen(id); break;
case 'export-svg': _exportSVG(id); break;
case 'export-png': _exportPNG(id); break;
case 'copy-src': _copySource(id); break;
}
});
// Wire each viewport for mouse/touch interaction
container.querySelectorAll('.mermaid-viewport').forEach(vp => {
if (vp._mmdWired) return;
vp._mmdWired = true;
const id = vp.dataset.viewport;
vp.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
_zoomAt(id, delta, e.clientX, e.clientY);
}, { passive: false });
vp.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
const state = _getState(id);
if (!state || !state.ready) return;
state.dragging = true;
state.startX = e.clientX; state.startY = e.clientY;
state.startVbX = state.vbX; state.startVbY = state.vbY;
vp.classList.add('mmd-grabbing');
e.preventDefault();
});
vp.addEventListener('mousemove', (e) => {
const state = _getState(id);
if (!state?.dragging) return;
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0) return;
const pxToVb = state.vbW / svgRect.width;
state.vbX = state.startVbX - (e.clientX - state.startX) * pxToVb;
state.vbY = state.startVbY - (e.clientY - state.startY) * pxToVb;
_applyViewBox(id);
});
const endDrag = () => {
const state = _getState(id);
if (!state) return;
state.dragging = false;
vp.classList.remove('mmd-grabbing');
};
vp.addEventListener('mouseup', endDrag);
vp.addEventListener('mouseleave', endDrag);
let lastTouchDist = 0;
vp.addEventListener('touchstart', (e) => {
const state = _getState(id);
if (!state || !state.ready) return;
if (e.touches.length === 1) {
state.dragging = true;
state.startX = e.touches[0].clientX;
state.startY = e.touches[0].clientY;
state.startVbX = state.vbX;
state.startVbY = state.vbY;
} else if (e.touches.length === 2) {
lastTouchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
}
}, { passive: true });
vp.addEventListener('touchmove', (e) => {
const state = _getState(id);
if (!state || !state.ready) return;
if (e.touches.length === 1 && state.dragging) {
const svg = document.querySelector(`[data-diagram="${id}"] svg`);
if (!svg) return;
const svgRect = svg.getBoundingClientRect();
if (svgRect.width === 0) return;
const pxToVb = state.vbW / svgRect.width;
state.vbX = state.startVbX - (e.touches[0].clientX - state.startX) * pxToVb;
state.vbY = state.startVbY - (e.touches[0].clientY - state.startY) * pxToVb;
_applyViewBox(id);
e.preventDefault();
} else if (e.touches.length === 2) {
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
const center = {
x: (e.touches[0].clientX + e.touches[1].clientX) / 2,
y: (e.touches[0].clientY + e.touches[1].clientY) / 2,
};
if (lastTouchDist > 0) {
const factor = (dist - lastTouchDist) / lastTouchDist;
_zoomAt(id, factor, center.x, center.y);
}
lastTouchDist = dist;
e.preventDefault();
}
}, { passive: false });
vp.addEventListener('touchend', () => {
const state = _getState(id);
if (state) state.dragging = false;
lastTouchDist = 0;
}, { passive: true });
});
}
// ── Export ───────────────────────────────
function _exportSVG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = _getState(id);
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (state?.ready) {
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
}
const blob = new Blob([clone.outerHTML], { type: 'image/svg+xml' });
_download(blob, `diagram-${id}.svg`);
}
function _exportPNG(id) {
const diagram = document.querySelector(`[data-diagram="${id}"]`);
const svg = diagram?.querySelector('svg');
if (!svg) return;
const state = _getState(id);
const clone = svg.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
if (state?.ready) {
clone.setAttribute('viewBox', `${state.natX} ${state.natY} ${state.natW} ${state.natH}`);
clone.setAttribute('width', state.natW);
clone.setAttribute('height', state.natH);
}
const svgData = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth * scale;
canvas.height = img.naturalHeight * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob((blob) => {
if (blob) _download(blob, `diagram-${id}.png`);
}, 'image/png');
};
img.onerror = () => {
URL.revokeObjectURL(url);
console.error('[Mermaid] PNG export failed');
};
img.src = url;
}
function _download(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}
function _copySource(id) {
const code = document.querySelector(`[data-source="${id}"]`);
if (!code) return;
navigator.clipboard.writeText(code.textContent).then(() => {
if (window.sw?.toast) sw.toast('Source copied', 'success');
});
}
// ── Diagram Rendering ───────────────────
async function _renderDiagram(el) {
const code = decodeURIComponent(el.dataset.mermaidSrc);
const id = el.dataset.diagram;
try {
await _loadMermaid();
const svgId = 'mmd-svg-' + Math.random().toString(36).slice(2, 9);
const { svg } = await mermaid.render(svgId, code);
el.innerHTML = svg;
el.dataset.rendered = 'true';
const svgEl = el.querySelector('svg');
if (svgEl) {
svgEl.removeAttribute('height');
svgEl.setAttribute('width', '100%');
svgEl.style.maxWidth = 'none';
svgEl.setAttribute('preserveAspectRatio', 'xMidYMid meet');
}
_initState(id);
} catch (e) {
el.innerHTML = `
<div class="mermaid-error">
<strong>Diagram error:</strong> ${_escapeHtml(e.message || String(e))}
</div>
`;
el.dataset.rendered = 'error';
}
}
// ── Mermaid Library Loading ──────────────
function _loadMermaid() {
if (_mermaidReady) return Promise.resolve();
if (_mermaidLoading) return _mermaidLoading;
_mermaidLoading = new Promise((resolve, reject) => {
if (typeof mermaid !== 'undefined') {
_initMermaid();
_mermaidReady = true;
resolve();
return;
}
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 = () => {
_initMermaid();
_mermaidReady = true;
resolve();
};
script.onerror = () => {
console.warn('[Mermaid] Local vendor not found, trying CDN');
const cdn = document.createElement('script');
cdn.src = cdnSrc;
cdn.onload = () => {
_initMermaid();
_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 _mermaidLoading;
}
function _initMermaid() {
if (typeof mermaid === 'undefined') return;
mermaid.initialize({
startOnLoad: false,
theme: _isDark() ? 'dark' : 'default',
securityLevel: 'strict',
fontFamily: 'inherit',
logLevel: 'error',
});
}
// ── Styles ──────────────────────────────
function _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-block.mermaid-fullscreen {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
z-index: 10000; border-radius: 0; margin: 0;
display: flex; flex-direction: column;
background: var(--bg-1, var(--bg-2, #1a1a2e));
}
.mermaid-block.mermaid-fullscreen .mermaid-viewport {
background: var(--bg-2, #1a1a2e); flex: 1; max-height: none;
}
.mermaid-block.mermaid-fullscreen .mermaid-toolbar {
border-bottom: 1px solid var(--border); padding: 6px 14px;
}
.mmd-fullscreen-close {
position: absolute; top: 12px; right: 12px; z-index: 10001;
width: 40px; height: 40px; border-radius: 50%;
background: var(--bg-raised, rgba(0,0,0,0.6));
border: 1px solid var(--border, rgba(255,255,255,0.2));
color: var(--text, #fff); font-size: 20px; line-height: 1;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s, transform 0.15s;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.mmd-fullscreen-close:hover {
background: var(--bg-hover, rgba(255,255,255,0.15)); transform: scale(1.1);
}
.mermaid-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 4px 10px; border-bottom: 1px solid var(--border);
font-size: 12px; color: var(--text-3); flex-wrap: wrap;
}
.mermaid-title { font-weight: 600; color: var(--text-2); margin-right: auto; }
.mermaid-zoom-label {
font-family: var(--mono); font-size: 11px; min-width: 36px;
text-align: center; color: var(--text-3);
}
.mermaid-toolbar-btns { display: flex; gap: 2px; align-items: center; }
.mmd-btn {
background: none; border: 1px solid var(--border); border-radius: 4px;
padding: 1px 7px; font-size: 11px; cursor: pointer;
color: var(--text-3); font-family: var(--mono); line-height: 1.6;
}
.mmd-btn:hover { color: var(--text-1); border-color: var(--text-3); background: var(--bg-3); }
.mmd-sep { width: 1px; height: 16px; background: var(--border); margin: 0 4px; }
.mermaid-viewport {
overflow: hidden; position: relative;
min-height: 80px; max-height: 600px;
cursor: grab; user-select: none;
}
.mermaid-viewport.mmd-grabbing { cursor: grabbing; }
.mermaid-diagram { width: 100%; height: 100%; min-height: 60px; }
.mermaid-diagram svg { display: block; width: 100%; height: 100%; }
.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; display: flex; align-items: center; gap: 8px;
}
.mermaid-source summary:hover { color: var(--text-2); }
.mermaid-source summary span { flex: 1; }
.mmd-copy-src { font-size: 11px; }
.mermaid-source pre {
margin: 0; border-radius: 0; border: none;
max-height: 200px; overflow: auto;
}
.mermaid-block.mermaid-fullscreen .mermaid-source { display: none; }
@media (max-width: 768px) {
.mermaid-toolbar { padding: 6px 10px; gap: 4px; }
.mmd-btn { padding: 4px 10px; font-size: 12px; }
.mmd-sep { margin: 0 2px; }
.mmd-fullscreen-close { width: 48px; height: 48px; font-size: 24px; top: 16px; right: 16px; }
}`;
document.head.appendChild(style);
}
// ── Registration ────────────────────────
function register() {
if (!window.sw?.renderers) return;
_injectStyles();
// Block renderer: match ```mermaid
sw.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 = `
<div class="mermaid-block" data-mermaid-id="${id}">
<div class="mermaid-toolbar">
<span class="mermaid-title">\u{1f4ca} Diagram</span>
<span class="mermaid-zoom-label" data-zoom-label="${id}">100%</span>
<div class="mermaid-toolbar-btns">
<button class="mmd-btn" data-action="zoom-in" data-target="${id}" title="Zoom in">+</button>
<button class="mmd-btn" data-action="zoom-out" data-target="${id}" title="Zoom out">\u2212</button>
<button class="mmd-btn" data-action="zoom-fit" data-target="${id}" title="Fit to view">\u22a1</button>
<button class="mmd-btn" data-action="zoom-reset" data-target="${id}" title="Reset zoom">1:1</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="expand" data-target="${id}" title="Fullscreen">\u26f6</button>
<span class="mmd-sep"></span>
<button class="mmd-btn" data-action="export-svg" data-target="${id}" title="Download SVG">SVG</button>
<button class="mmd-btn" data-action="export-png" data-target="${id}" title="Download PNG">PNG</button>
</div>
</div>
<div class="mermaid-viewport" data-viewport="${id}">
<div class="mermaid-diagram" data-mermaid-src="${encodeURIComponent(code.trim())}" data-diagram="${id}">
<div class="mermaid-loading">
<span class="mermaid-spinner"></span> Rendering diagram\u2026
</div>
</div>
</div>
<details class="mermaid-source">
<summary>
<span>\u{1f4cb} View source</span>
<button class="mmd-btn mmd-copy-src" data-action="copy-src" data-target="${id}" title="Copy source" onclick="event.stopPropagation()">Copy</button>
</summary>
<pre><code class="language-mermaid" data-source="${id}">${_escapeHtml(code.trim())}</code></pre>
</details>
</div>
`;
}
});
// Post renderer: render diagrams + wire interactivity
sw.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 => {
if (el.dataset.rendered) return;
el.dataset.rendered = 'pending';
_renderDiagram(el);
});
_wireToolbar(container);
}
});
_loadMermaid();
}
// Boot: register immediately if SDK ready, otherwise listen
if (window.sw?._sdk) {
register();
} else {
document.addEventListener('sw:ready', register, { once: true });
}
})();