mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
+3022
File diff suppressed because one or more lines are too long
@@ -172,6 +172,7 @@ function updateThemeMenuItem() {
|
||||
}
|
||||
window.onThemeChange = function () {
|
||||
updateThemeMenuItem();
|
||||
reRenderAllMermaid();
|
||||
};
|
||||
updateThemeMenuItem();
|
||||
|
||||
|
||||
+181
-25
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user