Last active
August 17, 2025 20:47
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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