Skip to content

Instantly share code, notes, and snippets.

@Taik
Last active August 17, 2025 20:47
Show Gist options
  • Select an option

  • Save Taik/005c8132df7f46c83395b50c9f5e77fc to your computer and use it in GitHub Desktop.

Select an option

Save Taik/005c8132df7f46c83395b50c9f5e77fc to your computer and use it in GitHub Desktop.
A Tampermonkey script which auto-embed mermaid diagrams in ChatGPT using mermaid.ink.
// ==UserScript==
// @name ChatGPT Mermaid Render
// @namespace http://tampermonkey.net/
// @version 2025-06-24
// @description Auto-embed mermaid diagrams in ChatGPT using mermaid.ink
// @author Thinh Nguyen
// @match https://chat.openai.com/*
// @match https://chatgpt.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=openai.com
// @grant GM_addStyle
// @run-at document-end
// ==/UserScript==
(() => {
// No custom styles needed - using existing ChatGPT button classes
// Sanitize Mermaid source to improve parser robustness across renderers
function sanitizeMermaid(code) {
if (typeof code !== "string") return code;
// 1) Strip code fences if present
code = code.replace(/^\s*```mermaid\s*/i, "").replace(/\s*```\s*$/, "");
// Only sanitize specific diagram types to avoid disturbing others
const firstNonEmpty = (code.match(/^(\s*\S.*)$/m) || ["", ""])[1] || "";
// Sequence diagrams: escape semicolons in message text; safely quote participant labels
if (/^\s*sequenceDiagram\b/i.test(firstNonEmpty)) {
// Quote participant labels after `as` when they contain spaces or punctuation
code = code.replace(
/^(\s*participant\s+[A-Za-z][\w-]*\s+as\s+)(.+?)\s*$/gmi,
(m, head, label) => {
// If already quoted, keep as-is
if (/^".*"$/.test(label.trim())) return m;
const needsQuoting = /[^A-Za-z0-9_-]/.test(label);
return needsQuoting ? `${head}"${label.trim().replace(/"/g, '\\"')}"` : m;
},
);
// For message lines, escape semicolons in the RHS (message text) to avoid multi-statement parsing
code = code.replace(
/^(\s*[^:\n]*?(?:<->|<-->|<--|-->|-{1,2}>>|->{1,2}|--x|x-|--o|o-|\.->|<-\.)[^:\n]*?:\s*)(.*)$/gmi,
(m, head, msg) => {
void m;
// Replace unescaped semicolons with fullwidth semicolon to avoid statement splitting
const sanitizedMsg = msg.replace(/(?<!\\);/g, ";");
return head + sanitizedMsg;
},
);
return code;
}
const startsFlow = /^\s*(flowchart|graph)\b/i.test(firstNonEmpty);
if (!startsFlow) return code;
const sanitizeLabel = (text) =>
String(text)
.replace(/\\n/g, "<br/>") // explicit \n in labels -> HTML line break
.replace(/\r?\n/g, " ") // hard line breaks become spaces inside labels
.replace(/"/g, '\\"')
.trim();
const unwrapOuterQuotes = (text) => {
if (typeof text !== "string") return text;
const trimmed = text.trim();
const hasDouble = trimmed.startsWith('"') && trimmed.endsWith('"');
const hasSingle = trimmed.startsWith("'") && trimmed.endsWith("'");
if (hasDouble || hasSingle) return trimmed.slice(1, -1);
return text;
};
// 2) Quote subgraph titles: subgraph ID[Title] -> subgraph ID["Title"]
code = code.replace(
/(\n|^)\s*(subgraph\s+)([A-Za-z][\w-]*)(\s*\[)([^\]\n]+)(\])\s*($|\n)/g,
(m, prefix, kw, id, _lb, title, _rb, suffix) => {
void m; void _lb; void _rb; // mark unused for linter
const unwrapped = unwrapOuterQuotes(title);
return `${prefix}${kw}${id}["${sanitizeLabel(unwrapped)}"]${suffix}`;
},
);
// 2b) Normalize subgraph lines with punctuation-only titles (no explicit ID):
// subgraph Services (HTTP/2) -> subgraph Services["Services (HTTP/2)"]
code = code.replace(
/(\n|^)\s*(subgraph)\s+([^\[\n]+?)\s*($|\n)/g,
(m, prefix, kw, title, suffix) => {
// If already safe (no punctuation), keep as-is
if (!/[()\/:+#]/.test(title)) return m;
const firstToken = title.trim().split(/\s+/)[0] || "sg";
let id = firstToken.replace(/[^A-Za-z0-9_-]/g, "_");
if (!/^[A-Za-z]/.test(id)) id = `sg_${id}`;
return `${prefix}${kw} ${id}["${sanitizeLabel(title)}"]${suffix}`;
},
);
// 3) Quote node labels in rectangle syntax: ID[Label] -> ID["Label"]
// Avoid touching other bracket uses by requiring an identifier before [
code = code.replace(/\b([A-Za-z][\w-]*)\[(.*?)\]/g, (m, id, label) => {
const trimmed = String(label).trim();
const isAlreadyQuoted =
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"));
// Preserve special shapes like [(text)], [[text]], {text}, <text>
const hasShapeDelimiters = /^[\(\[\{<]/.test(trimmed) && /[\)\]\}>]$/.test(trimmed);
if (hasShapeDelimiters) return m;
if (isAlreadyQuoted) return `${id}[${trimmed}]`;
const unwrapped = unwrapOuterQuotes(trimmed);
return `${id}["${sanitizeLabel(unwrapped)}"]`;
});
// 4) Quote edge labels when unquoted
const quote = (t) => `"${sanitizeLabel(t)}"`;
// -- label -->
code = code.replace(/--\s*(?!["'])([^->\n]+?)\s*-->/g, (m, t) => { void m; return `-- ${quote(t)} -->`; });
// <-- label --
code = code.replace(/<--\s*(?!["'])([^-\n]+?)\s*--/g, (m, t) => { void m; return `<-- ${quote(t)} --`; });
// -. label .->
code = code.replace(/-\.\s*(?!["'])([^.\n]+?)\s*\.->/g, (m, t) => { void m; return `-. ${quote(t)} .->`; });
// <-. label .->
code = code.replace(/<-\.\s*(?!["'])([^.\n]+?)\s*\.->/g, (m, t) => { void m; return `<-. ${quote(t)} .->`; });
// 5) Ensure HTML labels are enabled for <br/>
if (!/%%\{init:/.test(code)) {
code = `%%{init: {"flowchart": {"htmlLabels": true}}}%%\n${code}`;
}
return code;
}
/**
* Encode a Mermaid diagram for mermaid.ink
* Since we can't exactly replicate pako's deflateRaw without the library,
* we'll use the base64 format which mermaid.ink also supports
*/
function encodeMermaidForInk(code) {
// mermaid.ink supports base64 encoded diagrams
// Format: https://mermaid.ink/img/{base64}
try {
// Convert string to base64 using modern approach
const encoder = new TextEncoder();
const data = encoder.encode(code);
const base64 = btoa(String.fromCharCode(...data));
return { type: "base64", encoded: base64 };
} catch (e) {
console.error("Failed to encode diagram:", e);
// Fallback to a simple encoding
const simpleBase64 = btoa(code);
return { type: "base64", encoded: simpleBase64 };
}
}
function processMermaidBlock(codeElement) {
const parentPre = codeElement.closest("pre");
if (!parentPre) return;
// Skip if already processed
if (parentPre.dataset.mermaidProcessed === "true") return;
const codeContainer = parentPre.querySelector(".overflow-y-auto.p-4");
if (!codeContainer) return;
// Mark as processed immediately
parentPre.dataset.mermaidProcessed = "true";
const mermaidCode = sanitizeMermaid(codeElement.textContent);
try {
// Encode the mermaid code
const { encoded } = encodeMermaidForInk(mermaidCode);
// Create the image URL using SVG format for better quality
const imgUrl = `https://mermaid.ink/svg/${encoded}`;
// Create image element
const img = document.createElement("img");
img.src = imgUrl;
img.style.maxWidth = "100%";
img.style.marginTop = "1rem";
img.alt = "Mermaid Diagram";
// Insert image after the code block within the container
codeContainer.appendChild(img);
} catch (error) {
console.error("Mermaid embed error:", error);
}
}
function scanForMermaidBlocks(container = document) {
// Look for unprocessed mermaid code blocks
const mermaidBlocks = container.querySelectorAll(
'pre:not([data-mermaid-processed="true"]) code.language-mermaid',
);
mermaidBlocks.forEach(processMermaidBlock);
}
// Function to initialize observer on conversation container
function initializeObserver() {
// Initial scan for any existing mermaid blocks
scanForMermaidBlocks();
// Set up observer for new messages only
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Process only added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
// Element node
// Check this node and its children for mermaid blocks
scanForMermaidBlocks(node);
}
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
// Initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeObserver);
} else {
initializeObserver();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment