feat: mermaid diagram rendering with lazy loading and theme integration (#69)

* feat: mermaid diagram rendering with lazy loading and theme integration

Integrate mermaid.js 11.13.0 (self-hosted, MIT, ~2.9MB) for rendering
```mermaid code blocks as inline SVG diagrams. Covers flowcharts,
sequence, class, ER, state, gantt, pie, timeline, and mindmap.

- Lazy-loaded via dynamic script injection on first mermaid block
  detection (not eagerly loaded on every page view)
- 3-state loader (idle/loading/ready) with callback queue
- Serialized rendering to avoid mermaid internal state corruption
- Theme integration via getComputedStyle reading CSS design tokens;
  re-renders all diagrams on dark/light theme toggle
- Source preserved in data-mermaid-source for theme re-rendering
- Error handling with source code fallback display
- securityLevel: "strict" (DOMPurify) for SVG XSS prevention
- THIRD-PARTY-NOTICES updated with mermaid MIT license

* fix: mermaid render fixes from Copilot review

- Call result.bindFunctions(container) after SVG insertion for
  interactive diagram elements (click handlers, links, tooltips)
- Clear mermaid-error class on successful render (fixes stale error
  styling after theme toggle re-render)
- Clear mermaid-error in reRenderAllMermaid before re-render sequence
- Restructure postRenderMarkdown so mermaid rendering runs even when
  highlight.js is unavailable (hljs guard changed from early return
  to conditional block)
This commit is contained in:
Patrick Buckley
2026-03-15 02:09:10 -07:00
committed by GitHub
parent 4152ea2352
commit e2a199c9c3
7 changed files with 3282 additions and 25 deletions
+28
View File
@@ -67,3 +67,31 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
================================================================================
Mermaid 11.13.0
https://mermaid.js.org/
https://github.com/mermaid-js/mermaid
The MIT License (MIT)
Copyright (c) 2014-2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -78,6 +78,7 @@ include = [
"turnstone/shared_static/*.js",
"turnstone/shared_static/katex-0.16.38/**/*",
"turnstone/shared_static/hljs-11.11.1/**/*",
"turnstone/shared_static/mermaid-11.13.0/**/*",
"turnstone/sdk/py.typed",
]
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because one or more lines are too long
+1
View File
@@ -172,6 +172,7 @@ function updateThemeMenuItem() {
}
window.onThemeChange = function () {
updateThemeMenuItem();
reRenderAllMermaid();
};
updateThemeMenuItem();
+181 -25
View File
@@ -617,6 +617,7 @@ var _NO_HIGHLIGHT_LANGS = {
plaintext: true,
plain: true,
nohighlight: true,
mermaid: true,
};
var _TERMINAL_LANGS = {
bash: true,
@@ -629,33 +630,188 @@ var _TERMINAL_LANGS = {
var _hljsConfigured = false;
function postRenderMarkdown(containerEl) {
if (typeof hljs === "undefined") return;
if (!_hljsConfigured) {
hljs.configure({ ignoreUnescapedHTML: true });
_hljsConfigured = true;
}
var codeEls = containerEl.querySelectorAll("pre code[class*='language-']");
for (var i = 0; i < codeEls.length; i++) {
var el = codeEls[i];
if (el.classList.contains("hljs")) continue;
// Extract language name from class
var langClass = "";
for (var j = 0; j < el.classList.length; j++) {
if (el.classList[j].startsWith("language-")) {
langClass = el.classList[j].substring(9);
break;
// Syntax highlighting (skip if highlight.js unavailable)
if (typeof hljs !== "undefined") {
if (!_hljsConfigured) {
hljs.configure({ ignoreUnescapedHTML: true });
_hljsConfigured = true;
}
var codeEls = containerEl.querySelectorAll("pre code[class*='language-']");
for (var i = 0; i < codeEls.length; i++) {
var el = codeEls[i];
if (el.classList.contains("hljs")) continue;
// Extract language name from class
var langClass = "";
for (var j = 0; j < el.classList.length; j++) {
if (el.classList[j].startsWith("language-")) {
langClass = el.classList[j].substring(9);
break;
}
}
// Skip plaintext variants
if (_NO_HIGHLIGHT_LANGS[langClass]) {
el.classList.add("nohighlight");
continue;
}
// Apply highlighting
hljs.highlightElement(el);
// Add terminal styling class for shell languages
if (_TERMINAL_LANGS[langClass]) {
el.closest("pre").classList.add("code-terminal");
}
}
// Skip plaintext variants
if (_NO_HIGHLIGHT_LANGS[langClass]) {
el.classList.add("nohighlight");
continue;
}
// Apply highlighting
hljs.highlightElement(el);
// Add terminal styling class for shell languages
if (_TERMINAL_LANGS[langClass]) {
el.closest("pre").classList.add("code-terminal");
}
// Render mermaid diagrams (lazy-loads mermaid.js on first use)
postRenderMermaid(containerEl);
}
// ---------------------------------------------------------------------------
// Mermaid diagram rendering (lazy-loaded)
// ---------------------------------------------------------------------------
var _mermaidState = "idle"; // idle | loading | ready
var _mermaidQueue = []; // callbacks queued while loading
var _mermaidIdCounter = 0;
function _initMermaid() {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: _getMermaidTheme(),
});
}
function _loadMermaid(callback) {
if (_mermaidState === "ready") {
callback();
return;
}
_mermaidQueue.push(callback);
if (_mermaidState === "loading") return;
_mermaidState = "loading";
var script = document.createElement("script");
script.src = "/shared/mermaid-11.13.0/mermaid.min.js";
script.onload = function () {
_initMermaid();
_mermaidState = "ready";
var q = _mermaidQueue;
_mermaidQueue = [];
for (var i = 0; i < q.length; i++) q[i]();
};
script.onerror = function () {
_mermaidState = "idle";
_mermaidQueue = [];
var els = document.querySelectorAll(".mermaid-loading");
for (var i = 0; i < els.length; i++) {
els[i].classList.remove("mermaid-loading");
els[i].classList.add("mermaid-error");
els[i].textContent = "Failed to load diagram renderer";
}
};
document.head.appendChild(script);
}
function _getMermaidTheme() {
var s = getComputedStyle(document.documentElement);
return {
primaryColor: s.getPropertyValue("--bg-surface").trim(),
primaryTextColor: s.getPropertyValue("--fg").trim(),
primaryBorderColor:
s.getPropertyValue("--border-strong").trim() || "rgba(255,255,255,0.1)",
lineColor: s.getPropertyValue("--fg-dim").trim(),
secondaryColor: s.getPropertyValue("--bg-highlight").trim(),
tertiaryColor: s.getPropertyValue("--bg").trim(),
noteBkgColor: s.getPropertyValue("--bg-surface").trim(),
noteTextColor: s.getPropertyValue("--fg").trim(),
noteBorderColor: s.getPropertyValue("--accent").trim(),
actorTextColor: s.getPropertyValue("--fg-bright").trim(),
actorBkg: s.getPropertyValue("--bg-surface").trim(),
actorBorder: s.getPropertyValue("--accent").trim(),
signalColor: s.getPropertyValue("--fg").trim(),
signalTextColor: s.getPropertyValue("--fg").trim(),
};
}
// Render a single mermaid block, then call callback (serialized to avoid
// concurrent mermaid.render() calls which corrupt shared internal state)
function _renderMermaidBlock(container, callback) {
var source = container.getAttribute("data-mermaid-source");
if (!source) {
if (callback) callback();
return;
}
var id = "mermaid-" + ++_mermaidIdCounter;
function _onError(err) {
// Clean up orphaned temp SVG element mermaid may have left
var orphan = document.getElementById(id);
if (orphan) orphan.remove();
container.classList.remove("mermaid-loading");
container.classList.add("mermaid-error");
container.innerHTML =
'<div class="mermaid-error-msg">' +
escapeHtml(err.message || "Diagram error") +
"</div>" +
"<pre><code>" +
escapeHtml(source) +
"</code></pre>";
if (callback) callback();
}
try {
mermaid
.render(id, source)
.then(function (result) {
container.innerHTML = result.svg;
container.classList.remove("mermaid-loading", "mermaid-error");
container.classList.add("mermaid-rendered");
if (result.bindFunctions) result.bindFunctions(container);
if (callback) callback();
})
.catch(_onError);
} catch (err) {
_onError(err);
}
}
// Render mermaid blocks sequentially (mermaid uses shared state internally)
function _renderMermaidSequence(containers, idx) {
if (idx >= containers.length) return;
_renderMermaidBlock(containers[idx], function () {
_renderMermaidSequence(containers, idx + 1);
});
}
function postRenderMermaid(containerEl) {
var codeEls = containerEl.querySelectorAll("pre code.language-mermaid");
if (codeEls.length === 0) return;
var containers = [];
for (var i = 0; i < codeEls.length; i++) {
var pre = codeEls[i].closest("pre");
if (!pre) continue;
var source = codeEls[i].textContent;
var div = document.createElement("div");
div.className = "mermaid-container mermaid-loading";
div.setAttribute("data-mermaid-source", source);
div.textContent = "Loading diagram\u2026";
pre.replaceWith(div);
containers.push(div);
}
if (containers.length === 0) return;
_loadMermaid(function () {
_renderMermaidSequence(containers, 0);
});
}
function reRenderAllMermaid() {
if (_mermaidState !== "ready") return;
_initMermaid();
var els = document.querySelectorAll(
".mermaid-container[data-mermaid-source]",
);
var arr = [];
for (var i = 0; i < els.length; i++) {
els[i].classList.add("mermaid-loading");
els[i].classList.remove("mermaid-rendered", "mermaid-error");
arr.push(els[i]);
}
_renderMermaidSequence(arr, 0);
}
+28
View File
@@ -330,6 +330,34 @@ body { position: static; }
[data-theme="light"] .hljs-addition { background: rgba(4, 120, 87, 0.06); }
[data-theme="light"] .hljs-deletion { background: rgba(220, 38, 38, 0.06); }
/* Mermaid diagrams */
.msg-assistant .mermaid-container {
margin: 8px 0;
text-align: center;
border-radius: var(--radius);
overflow-x: auto;
min-height: 40px;
}
.msg-assistant .mermaid-container svg { max-width: 100%; height: auto; }
.msg-assistant .mermaid-loading {
padding: 16px;
color: var(--fg-dim);
font-size: 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
}
.msg-assistant .mermaid-error { text-align: left; }
.msg-assistant .mermaid-error-msg {
padding: 8px 12px;
color: var(--red);
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid var(--border);
}
@media (prefers-reduced-motion: reduce) {
.msg-assistant .mermaid-container svg * { animation: none !important; }
}
/* GFM Callouts / Alerts */
.msg-assistant .callout {
border-left: 3px solid var(--border-strong);