KaTeX-Style Auto-Renderer for Chemical Structures — auto-render-mol.js
The Problem
If you write scientific or chemistry-related content in Markdown, you've probably wished for something like this:
- KaTeX lets you write
$E = mc^2$and it magically becomes a beautiful equation. - But if you write
CCCC(a SMILES string for butane), it just stays as plain text.
There was no equivalent "auto-render" experience for chemical structures — until now.
auto-render-mol.js is a lightweight, zero-dependency JavaScript library that scans your HTML for SMILES/Molfile code blocks and inline codes, then replaces them with publication-quality 2D structure diagrams using RDKit.js (WebAssembly).
| KaTeX | auto-render-mol |
|---|---|
renderMathInElement(document.body) |
renderMolInElement(document.body) |
Scans for $...$ delimiters |
Scans for code.language-mol and mol: prefix |
Replaces text nodes with <span class="katex"> |
Replaces code blocks with <div class="mol-container"> |
| Synchronous (pure JS) | Asynchronous (WASM loading) |
One function call. That's it.
Quick Start
<!-- 1. Load RDKit WASM -->
<script src="https://unpkg.com/@rdkit/rdkit/dist/RDKit_minimal.js"></script>
<!-- 2. Load auto-render-mol, render on load -->
<script src="auto-render-mol.js"
onload="renderMolInElement(document.body)"></script>
Markdown Syntax
Block-Level Molecules (Fenced Code Blocks)
Use the standard Markdown fenced code block with mol as the language identifier. Any Markdown parser (marked.js, markdown-it, Markdig, etc.) will convert this to <code class="language-mol">, which auto-render-mol picks up automatically.
Default size (300×200):
```mol
CC(=O)Oc1ccccc1C(=O)O
```
Custom size — append {width, height} inside the block:
```mol
CC(=O)Oc1ccccc1C(=O)O
{500, 400}
```
Width only (height falls back to default):
```mol
c1ccccc1
{600}
```
Molfile
```mol
NoName
-INDIGO-09162620492D
40 43 0 0 0 0 0 0 0 0999 V2000
5.0068 -3.6090 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
5.5068 -4.4751 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
5.0068 -5.3411 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0
5.5068 -6.2071 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
5.0068 -7.0731 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
4.0068 -7.0731 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
3.5068 -6.2071 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
2.5068 -6.2071 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
2.0068 -7.0731 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1.0068 -7.0731 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
0.0068 -7.0731 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0
2.5068 -7.9392 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
3.5068 -7.9392 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0
6.5068 -6.2071 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
7.0068 -7.0731 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
6.5068 -7.9392 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0
8.0068 -7.0731 0.0000 N 0 0 0 0 0 0 0 0 0 0 0 0
8.5068 -7.9392 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
9.5068 -7.9392 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
10.0068 -8.8052 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.0068 -8.8052 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
11.5068 -7.9392 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
12.5068 -7.9392 0.0000 S 0 0 0 0 0 0 0 0 0 0 0 0
12.5068 -6.9392 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
12.5068 -8.9392 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0
13.5068 -7.9392 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0
11.0068 -7.0731 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
10.0068 -7.0731 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
7.0068 -5.3411 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
8.0068 -5.3411 0.0000 O 0 0 0 0 0 0 0 0 0 0 0 0
6.5068 -4.4751 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
7.0068 -3.6090 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
8.0068 -3.6090 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
8.5068 -2.7430 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
8.0068 -1.8770 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
7.0068 -1.8770 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
6.5068 -1.0110 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
5.5068 -1.0110 0.0000 F 0 0 0 0 0 0 0 0 0 0 0 0
7.0068 -0.1449 0.0000 F 0 0 0 0 0 0 0 0 0 0 0 0
6.5068 -2.7430 0.0000 C 0 0 0 0 0 0 0 0 0 0 0 0
1 2 1 0 0 0 0
2 3 4 0 0 0 0
3 4 4 0 0 0 0
4 5 1 0 0 0 0
5 6 1 0 0 0 0
6 7 4 0 0 0 0
7 8 4 0 0 0 0
8 9 4 0 0 0 0
9 10 1 0 0 0 0
10 11 3 0 0 0 0
9 12 4 0 0 0 0
12 13 4 0 0 0 0
13 6 4 0 0 0 0
4 14 4 0 0 0 0
14 15 1 0 0 0 0
15 16 2 0 0 0 0
15 17 1 0 0 0 0
17 18 1 0 0 0 0
18 19 1 0 0 0 0
19 20 4 0 0 0 0
20 21 4 0 0 0 0
21 22 4 0 0 0 0
22 23 1 0 0 0 0
23 24 1 0 0 0 0
23 25 2 0 0 0 0
23 26 2 0 0 0 0
22 27 4 0 0 0 0
27 28 4 0 0 0 0
28 19 4 0 0 0 0
14 29 4 0 0 0 0
29 30 2 0 0 0 0
29 31 4 0 0 0 0
31 2 4 0 0 0 0
31 32 1 0 0 0 0
32 33 4 0 0 0 0
33 34 4 0 0 0 0
34 35 4 0 0 0 0
35 36 4 0 0 0 0
36 37 1 0 0 0 0
37 38 1 0 0 0 0
37 39 1 0 0 0 0
36 40 4 0 0 0 0
40 32 4 0 0 0 0
M STY 1 1 DAT
M SLB 1 1 1
M SAL 1 1 3
M SDT 1 MRV_IMPLICIT_H
M SDD 1 0.0000 0.0000 DA ALL 1 1
M SED 1 IMPL_H1
M END
{300, 200}
```
The size annotation can appear before or after the SMILES string — the parser handles both.
Inline Molecules
For inline molecules within a paragraph, use a backtick code span with the mol: prefix:
Ethanol `mol:CCO` is the most common alcohol.
Benzene `mol:c1ccccc1` is the simplest aromatic hydrocarbon.
Inline with custom size:
Aspirin `mol:CC(=O)Oc1ccccc1C(=O)O {80, 60}` is an analgesic.
Syntax Summary
| Context | Markdown Syntax | Default Size |
|---|---|---|
| Block (large diagram) | mol\nSMILES\n |
300 × 200 |
| Block with size | mol\nSMILES\n{W, H}\n |
W × H |
| Inline (small diagram) | mol:SMILES |
80 × 60 |
| Inline with size | mol:SMILES {W, H} |
W × H |
How It Works
The rendering pipeline follows 5 steps:
Markdown source
│ (marked.js / markdown-it / Markdig / any parser)
▼
HTML with <pre><code class="language-mol">SMILES</code></pre>
│ and <code>mol:SMILES</code>
│
▼ renderMolInElement(rootElement)
│
├─ 1. Wait for RDKit WASM to initialize (singleton)
├─ 2. querySelectorAll("code.language-mol") — block molecules
├─ 3. parseMolBlock() — extract SMILES + optional {width, height}
├─ 4. RDKit.get_mol(smiles).get_svg(w, h) — generate SVG
├─ 5. Replace <pre><code> with <div class="mol-container">
│
└─ 6. querySelectorAll("code") where text starts with "mol:"
└─ Same flow → replace <code> with <span class="mol-inline">
Key implementation details:
- WASM Singleton: RDKit is loaded only once. Subsequent calls to
getRDKit()return the cached instance. - Memory Safety: Every
molobject is explicitly freed withmol.delete()to prevent WASM memory leaks. - Error Handling: Invalid SMILES strings are caught and displayed as error messages instead of crashing the page.
- DOM-safe: Uses
element.replaceWith()— noinnerHTMLmutations on parent elements, so existing event listeners are preserved.
Full Source Code
/**
* auto-render-mol.js
* KaTeX-style auto-renderer for chemical structures using RDKit.js
*
* Usage:
* <script src="RDKit_minimal.js"></script>
* <script src="auto-render-mol.js"
* onload="renderMolInElement(document.body)"></script>
*/
(function (global) {
// ========================================
// 1. RDKit WASM Singleton
// ========================================
let _rdkitModule = null;
let _rdkitPromise = null;
function getRDKit() {
if (_rdkitModule) return Promise.resolve(_rdkitModule);
if (_rdkitPromise) return _rdkitPromise;
if (typeof global.initRDKitModule !== "function") {
return Promise.reject(
new Error(
"[auto-render-mol] initRDKitModule not found. " +
"Make sure RDKit_minimal.js is loaded before this script."
)
);
}
_rdkitPromise = global.initRDKitModule().then(function (mod) {
_rdkitModule = mod;
console.log("✅ [auto-render-mol] RDKit WASM ready");
return mod;
});
return _rdkitPromise;
}
// ========================================
// 2. Content Parser
// ========================================
/**
* Extract SMILES and optional {width, height} from raw text.
*
* Supported formats:
* CCCC → smiles="CCCC", size=default
* CCCC\n{300, 400} → smiles="CCCC", size=300×400
* {300, 400}\nCCCC → smiles="CCCC", size=300×400
* CCCC\n{500} → smiles="CCCC", size=500×defaultH
* CCCC {300, 400} → smiles="CCCC", size=300×400
*/
function parseMolBlock(raw, defW, defH) {
var width = defW;
var height = defH;
var sizeRegex = /\{\s*(\d+)\s*(?:,\s*(\d+)\s*)?\}/;
var match = raw.match(sizeRegex);
if (match) {
width = parseInt(match[1], 10) || width;
if (match[2]) {
height = parseInt(match[2], 10) || height;
}
raw = raw.replace(sizeRegex, "");
}
return {
smiles: raw.trim(),
width: width,
height: height,
};
}
// ========================================
// 3. Inline Molecule Renderer
// ========================================
async function renderInlineMol(rootElement, RDKit, config) {
var inlineCodes = rootElement.querySelectorAll("code");
var prefix = "mol:";
for (var i = 0; i < inlineCodes.length; i++) {
var codeEl = inlineCodes[i];
// Skip block-level codes (inside <pre>)
if (codeEl.parentElement && codeEl.parentElement.tagName === "PRE") {
continue;
}
var text = codeEl.textContent.trim();
if (!text.startsWith(prefix)) continue;
var body = text.slice(prefix.length).trim();
var parsed = parseMolBlock(
body,
config.inlineWidth || 80,
config.inlineHeight || 60
);
if (!parsed.smiles) continue;
try {
var mol = RDKit.get_mol(parsed.smiles);
if (!mol) throw new Error("Invalid SMILES");
var svg = mol.get_svg(parsed.width, parsed.height);
mol.delete();
var span = document.createElement("span");
span.className = "mol-inline";
span.innerHTML = svg;
span.setAttribute("data-smiles", parsed.smiles);
span.title = "SMILES: " + parsed.smiles;
codeEl.replaceWith(span);
} catch (err) {
var errSpan = document.createElement("span");
errSpan.className = "mol-error";
errSpan.textContent = "❌" + parsed.smiles;
codeEl.replaceWith(errSpan);
}
}
}
// ========================================
// 4. Core Render Function
// ========================================
/**
* Scan rootElement for mol code blocks and inline codes,
* replace them with rendered SVG structures.
*
* @param {HTMLElement} rootElement Root node to scan
* @param {Object} options Configuration
* @param {string} options.selector CSS selector (default: "code.language-mol")
* @param {number} options.width Block SVG width (default: 300)
* @param {number} options.height Block SVG height (default: 200)
* @param {number} options.inlineWidth Inline SVG width (default: 80)
* @param {number} options.inlineHeight Inline SVG height (default: 60)
* @param {boolean} options.removePre Remove parent <pre> tag (default: true)
*/
async function renderMolInElement(rootElement, options) {
if (!rootElement) {
console.warn("[auto-render-mol] rootElement is null, skipping");
return;
}
var config = Object.assign(
{
selector: "code.language-mol",
width: 300,
height: 200,
inlineWidth: 80,
inlineHeight: 60,
removePre: true,
},
options || {}
);
var RDKit;
try {
RDKit = await getRDKit();
} catch (err) {
console.error(err);
return;
}
// --- Block-level rendering ---
var molBlocks = rootElement.querySelectorAll(config.selector);
if (molBlocks.length > 0) {
console.log(
"🔬 [auto-render-mol] Found " + molBlocks.length + " block molecule(s)"
);
for (var i = 0; i < molBlocks.length; i++) {
var codeEl = molBlocks[i];
var rawText = codeEl.textContent;
if (!rawText.trim()) continue;
var parsed = parseMolBlock(rawText, config.width, config.height);
if (!parsed.smiles) continue;
var targetEl =
config.removePre &&
codeEl.parentElement &&
codeEl.parentElement.tagName === "PRE"
? codeEl.parentElement
: codeEl;
try {
var mol = RDKit.get_mol(parsed.smiles);
if (!mol) throw new Error("Invalid SMILES");
var svg = mol.get_svg(parsed.width, parsed.height);
mol.delete();
var container = document.createElement("div");
container.className = "mol-container";
container.innerHTML = svg;
container.setAttribute("data-smiles", parsed.smiles);
container.title = "SMILES: " + parsed.smiles;
targetEl.replaceWith(container);
} catch (err) {
var errorDiv = document.createElement("div");
errorDiv.className = "mol-error";
errorDiv.textContent = "❌ " + parsed.smiles + " → " + err.message;
targetEl.replaceWith(errorDiv);
}
}
}
// --- Inline rendering ---
await renderInlineMol(rootElement, RDKit, config);
}
// ========================================
// 5. Export to global scope
// ========================================
global.renderMolInElement = renderMolInElement;
})(typeof window !== "undefined" ? window : this);
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
selector |
string |
"code.language-mol" |
CSS selector for block-level mol elements |
width |
number |
300 |
Default SVG width for block molecules |
height |
number |
200 |
Default SVG height for block molecules |
inlineWidth |
number |
80 |
Default SVG width for inline molecules |
inlineHeight |
number |
60 |
Default SVG height for inline molecules |
removePre |
boolean |
true |
Whether to remove the parent <pre> when replacing |
Example with custom options:
<script>
renderMolInElement(document.body, {
width: 400,
height: 300,
inlineWidth: 100,
inlineHeight: 80,
});
</script>
Recommended CSS
/* Block molecule container */
.mol-container {
display: inline-flex;
justify-content: center;
align-items: center;
padding: 8px;
margin: 4px 0;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.mol-container svg {
display: block;
max-width: 100%;
}
/* Inline molecule */
.mol-inline {
display: inline-block;
vertical-align: middle;
margin: 0 2px;
padding: 2px 4px;
background: #fafafa;
border: 1px solid #eee;
border-radius: 3px;
line-height: 0;
}
.mol-inline svg {
display: inline-block;
vertical-align: middle;
}
/* Error state */
.mol-error {
color: #c00;
font-family: monospace;
background: #fff0f0;
padding: 6px 10px;
border-radius: 4px;
}
Complete Test Page
The following HTML tests renderMolInElement across every common DOM context — tables, blockquotes, nested blockquotes, lists, mixed nesting, inline molecules, and negative cases.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>auto-render-mol Compatibility Test</title>
<style>
body {
font-family: -apple-system, "Segoe UI", sans-serif;
max-width: 1000px;
margin: 40px auto;
padding: 0 20px;
line-height: 1.6;
color: #333;
}
h1 { border-bottom: 2px solid #333; padding-bottom: 8px; }
h2 { color: #0066cc; margin-top: 40px; }
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
table th, table td {
border: 1px solid #ccc;
padding: 12px;
text-align: left;
vertical-align: middle;
}
table th { background: #f0f4f8; }
blockquote {
border-left: 4px solid #0066cc;
background: #f7faff;
padding: 12px 20px;
margin: 16px 0;
border-radius: 0 6px 6px 0;
}
.mol-container {
display: inline-flex;
justify-content: center;
align-items: center;
padding: 8px;
margin: 4px 0;
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.mol-container svg { display: block; max-width: 100%; }
.mol-inline {
display: inline-block;
vertical-align: middle;
margin: 0 2px;
padding: 2px 4px;
background: #fafafa;
border: 1px solid #eee;
border-radius: 3px;
line-height: 0;
}
.mol-inline svg { display: inline-block; vertical-align: middle; }
.mol-error {
color: #c00;
font-family: monospace;
background: #fff0f0;
padding: 6px 10px;
border-radius: 4px;
}
pre {
background: #f5f5f5;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
code.language-mol { color: #0066cc; }
</style>
</head>
<body>
<h1>🧪 auto-render-mol Compatibility Test</h1>
<p>Testing <code>renderMolInElement</code> across various DOM contexts.</p>
<!-- ===== Test 1: Basic top-level block ===== -->
<h2>1. Basic Block (Control Group)</h2>
<pre><code class="language-mol">CCCC
{300, 200}</code></pre>
<!-- ===== Test 2: Inside a table ===== -->
<h2>2. Inside a Table</h2>
<table>
<thead>
<tr>
<th>Compound</th>
<th>SMILES</th>
<th>Structure</th>
</tr>
</thead>
<tbody>
<tr>
<td>Butane</td>
<td><code>CCCC</code></td>
<td><pre><code class="language-mol">CCCC
{200, 150}</code></pre></td>
</tr>
<tr>
<td>Benzene</td>
<td><code>c1ccccc1</code></td>
<td><pre><code class="language-mol">c1ccccc1
{200, 150}</code></pre></td>
</tr>
<tr>
<td>Aspirin</td>
<td><code>CC(=O)Oc1ccccc1C(=O)O</code></td>
<td><pre><code class="language-mol">CC(=O)Oc1ccccc1C(=O)O
{300, 200}</code></pre></td>
</tr>
<tr>
<td>Caffeine</td>
<td><code>CN1C=NC2=C1C(=O)N(C(=O)N2C)C</code></td>
<td><pre><code class="language-mol">CN1C=NC2=C1C(=O)N(C(=O)N2C)C
{300, 200}</code></pre></td>
</tr>
<tr>
<td>Invalid (error test)</td>
<td><code>XYZ_INVALID</code></td>
<td><pre><code class="language-mol">XYZ_INVALID
{200, 150}</code></pre></td>
</tr>
</tbody>
</table>
<!-- ===== Test 3: Inside a blockquote ===== -->
<h2>3. Inside a Blockquote</h2>
<blockquote>
<p><strong>Quote:</strong> Butane is one of the simplest alkanes.</p>
<pre><code class="language-mol">CCCC
{300, 200}</code></pre>
<p>— Chemistry Textbook</p>
</blockquote>
<blockquote>
<p>Another quote featuring <em>benzene</em>:</p>
<pre><code class="language-mol">c1ccccc1</code></pre>
</blockquote>
<!-- ===== Test 4: Nested blockquotes ===== -->
<h2>4. Nested Blockquotes</h2>
<blockquote>
<p>Level 1</p>
<blockquote>
<p>Level 2 — Ethanol:</p>
<pre><code class="language-mol">CCO
{250, 180}</code></pre>
<blockquote>
<p>Level 3 — Aspirin:</p>
<pre><code class="language-mol">CC(=O)Oc1ccccc1C(=O)O
{300, 200}</code></pre>
</blockquote>
</blockquote>
</blockquote>
<!-- ===== Test 5: Table + Blockquote mixed ===== -->
<h2>5. Table + Blockquote Mixed Nesting</h2>
<table>
<thead>
<tr><th>Category</th><th>Description & Structure</th></tr>
</thead>
<tbody>
<tr>
<td>Alcohols</td>
<td>
<blockquote>
<p>Ethanol is the most common alcohol:</p>
<pre><code class="language-mol">CCO
{200, 150}</code></pre>
</blockquote>
</td>
</tr>
<tr>
<td>Aromatics</td>
<td>
<blockquote>
<p>Benzene is the simplest aromatic:</p>
<pre><code class="language-mol">c1ccccc1
{200, 150}</code></pre>
</blockquote>
</td>
</tr>
</tbody>
</table>
<!-- ===== Test 6: Inside lists ===== -->
<h2>6. Inside Lists</h2>
<ul>
<li>
<p>Methane:</p>
<pre><code class="language-mol">C
{150, 120}</code></pre>
</li>
<li>
<p>Ethanol:</p>
<pre><code class="language-mol">CCO
{200, 150}</code></pre>
</li>
</ul>
<ol>
<li>
<p>Step 1 — Benzene:</p>
<pre><code class="language-mol">c1ccccc1
{200, 150}</code></pre>
</li>
<li>
<p>Step 2 — Aspirin:</p>
<pre><code class="language-mol">CC(=O)Oc1ccccc1C(=O)O
{300, 200}</code></pre>
</li>
</ol>
<!-- ===== Test 7: Inline molecules ===== -->
<h2>7. Inline Molecules</h2>
<p>Ethanol <code>mol:CCO</code> is a common solvent, and benzene <code>mol:c1ccccc1</code> is the simplest aromatic.</p>
<p>Inline with custom size: aspirin <code>mol:CC(=O)Oc1ccccc1C(=O)O {100, 80}</code> is an analgesic.</p>
<p>A complex molecule inline: <code>mol:Cn1c(=O)n2n(c1=O)[C@@H]1CC3=C(C(=O)C=C(Br)C3=O)[C@@H](c3ccc(CO)o3)C1=CC2 {300, 200}</code></p>
<!-- ===== Test 8: Negative cases (should NOT render) ===== -->
<h2>8. Negative Cases (Should NOT Render)</h2>
<p>These should remain as-is:</p>
<pre><code class="language-javascript">console.log("CCCC"); // This is JS, not mol</code></pre>
<pre><code>CCCC (no language-mol class — should be ignored)</code></pre>
<p>Regular inline code: <code>let x = 42;</code> — untouched.</p>
<p>Inline code without mol prefix: <code>CCCC</code> — untouched.</p>
<!-- ===== Scripts ===== -->
<script src="https://unpkg.com/@rdkit/rdkit/dist/RDKit_minimal.js"></script>
<script src="./auto-render-mol.js"
onload="renderMolInElement(document.body)"></script>
</body>
</html>
Test Coverage Matrix
| # | Test | Context | Expected |
|---|---|---|---|
| 1 | Basic block | Top-level <pre><code> |
✅ Renders |
| 2 | Table cell | <td> containing code block |
✅ Renders |
| 3 | Blockquote | <blockquote> containing code block |
✅ Renders |
| 4 | Nested blockquotes | 3 levels deep | ✅ Renders |
| 5 | Table + Blockquote | <td> → <blockquote> → code block |
✅ Renders |
| 6 | Lists | <ul> / <ol> containing code block |
✅ Renders |
| 7 | Inline molecules | <code>mol:SMILES</code> in paragraph |
✅ Renders |
| 8a | Wrong language | language-javascript |
❌ Ignored |
| 8b | No class | Plain <code> without class |
❌ Ignored |
| 8c | No mol: prefix | Inline <code> without mol: |
❌ Ignored |
| 2e | Invalid SMILES | XYZ_INVALID |
⚠️ Error message |
All positive cases work because querySelectorAll() recursively searches all descendant nodes regardless of nesting depth.
Compatibility
- Markdown Parsers: Works with any parser that follows the CommonMark spec for fenced code blocks — marked.js, markdown-it, Markdig (.NET), remark, etc.
- Browsers: Any browser that supports WebAssembly (Chrome 57+, Firefox 52+, Safari 11+, Edge 16+).
- Frameworks: Call
renderMolInElement(container)in Vue'snextTick, React'suseEffect, or after any dynamic DOM update.
License
MIT
Building Custom Nodes in Tiptap/ProseMirror: A Deep Dive with Mermaid Diagrams
Tiptap is a headless, framework-agnostic rich text editor built on top of ProseMirror. One of its most powerful features is the ability to create custom nodes—content types that go beyond simple paragraphs and headings. In this post, we'll build a complete Mermaid diagram node from scratch, covering the schema definition, NodeView implementation, commands, and rendering logic.
By the end, you'll understand not just how to build a Mermaid node, but the underlying concepts that let you build any custom node.
Table of Contents
- Understanding the ProseMirror Node Model
- Setting Up the Node Schema
- Building the NodeView
- Rendering Mermaid Diagrams
- Adding Commands and Input Rules
- Handling Node Updates
- Full Working Example
- Common Pitfalls
1. Understanding the ProseMirror Node Model
Before writing code, it's important to understand three core concepts:
- Schema: Defines what kinds of nodes/marks exist in the document, their attributes, and how they can be nested. This is the "grammar" of your document.
- NodeView: A bridge between ProseMirror's abstract document model and actual DOM rendering. It gives you full control over how a node is rendered and how it responds to updates.
- Commands: Functions that describe transactions—ways to modify the document (insert, delete, update attributes, etc.).
Tiptap wraps these concepts in a friendlier API via Node.create(), but under the hood, it's all ProseMirror.
For our Mermaid node, we want:
- A block-level, atomic node (users edit it as a single unit, not character-by-character)
- An attribute to store the raw Mermaid syntax (e.g.,
graph TD; A-->B;) - A NodeView that renders the diagram using the
mermaidlibrary - The ability to click to edit the raw text and see it re-render
2. Setting Up the Node Schema
Let's start with the basic schema definition using Tiptap's Node.create():
import { Node, mergeAttributes } from '@tiptap/core'
export interface MermaidOptions {
HTMLAttributes: Record<string, any>
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mermaid: {
setMermaid: (content: string) => ReturnType
}
}
}
export const Mermaid = Node.create<MermaidOptions>({
name: 'mermaid',
group: 'block',
atom: true, // treat as a single, non-editable-by-default unit
addOptions() {
return {
HTMLAttributes: {},
}
},
addAttributes() {
return {
content: {
default: 'graph TD;\n A-->B;',
parseHTML: (element) => element.getAttribute('data-content'),
renderHTML: (attributes) => {
return {
'data-content': attributes.content,
}
},
},
}
},
parseHTML() {
return [
{
tag: 'div[data-type="mermaid"]',
},
]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'mermaid',
}),
]
},
})
Key Points
group: 'block': This tells the schema that our node behaves like a paragraph or heading—it takes up a block-level slot in the document.atom: true: This is crucial. It tells ProseMirror to treat the entire node as a single, indivisible unit for cursor movement and selection. Users can't place a cursor inside the rendered diagram the way they would inside a paragraph.addAttributes(): We store the raw Mermaid syntax as acontentattribute, which gets serialized todata-contentin the HTML.parseHTML/renderHTML: These define the (de)serialization to/from HTML, important for copy-paste and for the initial document import.
At this point, we have a valid schema, but nothing renders as an actual diagram—we're just outputting a <div> with a data attribute. That's where NodeViews come in.
3. Building the NodeView
A NodeView gives us imperative control over the DOM for a specific node type. Tiptap exposes this through addNodeView(), which typically wraps @tiptap/vue-3's VueNodeViewRenderer, @tiptap/react's ReactNodeViewRenderer, or a plain vanilla NodeView.
Let's build this with the vanilla JS approach first, since it best exposes what's happening under the hood, and then show the React version.
Vanilla NodeView
import { Node, mergeAttributes } from '@tiptap/core'
import { NodeViewRenderer } from '@tiptap/pm/view'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false })
export const Mermaid = Node.create<MermaidOptions>({
// ... schema definition from before ...
addNodeView() {
return ({ node, editor, getPos }) => {
const container = document.createElement('div')
container.classList.add('mermaid-wrapper')
const preview = document.createElement('div')
preview.classList.add('mermaid-preview')
const textarea = document.createElement('textarea')
textarea.classList.add('mermaid-source')
textarea.style.display = 'none'
textarea.value = node.attrs.content
container.append(preview, textarea)
const renderDiagram = async (source: string) => {
try {
const id = `mermaid-${Math.random().toString(36).slice(2)}`
const { svg } = await mermaid.render(id, source)
preview.innerHTML = svg
} catch (err) {
preview.innerHTML = `<pre class="mermaid-error">${String(err)}</pre>`
}
}
renderDiagram(node.attrs.content)
// Click to toggle edit mode
preview.addEventListener('dblclick', () => {
preview.style.display = 'none'
textarea.style.display = 'block'
textarea.focus()
})
textarea.addEventListener('blur', () => {
const newContent = textarea.value
if (typeof getPos === 'function') {
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(getPos(), undefined, {
...node.attrs,
content: newContent,
})
)
}
textarea.style.display = 'none'
preview.style.display = 'block'
})
return {
dom: container,
update: (updatedNode) => {
if (updatedNode.type.name !== 'mermaid') return false
if (updatedNode.attrs.content !== node.attrs.content) {
renderDiagram(updatedNode.attrs.content)
textarea.value = updatedNode.attrs.content
}
node = updatedNode
return true
},
}
}
},
})
What's happening here?
getPos: A function that returns the current position of this node in the document. We need this to dispatch transactions that update this specific node's attributes.editor.view.dispatch(...): This is how we commit changes back to the ProseMirror state.setNodeMarkupis the standard way to update a node's attributes without replacing the whole node.- The
updatemethod: This is called whenever ProseMirror decides the node might need to re-render (e.g., due to collaborative editing or undo/redo). Returningtruetells ProseMirror "I've handled the update myself, don't tear down and rebuild the DOM." Returningfalseforces ProseMirror to destroy and recreate the NodeView. - Toggling between preview and edit mode: We use a simple double-click-to-edit pattern, since directly editing SVG content isn't meaningful—we want to edit the source.
4. Rendering Mermaid Diagrams
The core rendering logic uses the mermaid npm package:
npm install mermaid
import mermaid from 'mermaid'
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose', // needed if you allow click events inside diagrams
})
async function renderMermaidToSVG(source: string): Promise<string> {
const id = `mermaid-svg-${Date.now()}`
const { svg } = await mermaid.render(id, source)
return svg
}
A few gotchas worth knowing:
mermaid.renderis async and returns a Promise, so your NodeView update logic must handle asynchronous rendering gracefully (e.g., avoid race conditions if the user types quickly).- Unique IDs matter. Mermaid uses the
idinternally to generate SVG element IDs. If you reuse IDs across multiple diagrams, you'll get rendering conflicts. - Error handling is essential. Invalid Mermaid syntax throws, and you don't want that error to crash your whole editor—catch it and display a friendly error state instead.
5. Adding Commands and Input Rules
Now let's make it easy to insert a Mermaid node. We add a command:
addCommands() {
return {
setMermaid:
(content: string) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: { content },
})
},
}
},
Usage in your editor:
editor.commands.setMermaid('graph TD;\n A-->B;\n B-->C;')
You could also add an input rule so that typing a shortcut like ```mermaidon its own line auto-inserts a diagram node:
import { textblockTypeInputRule } from '@tiptap/core'
addInputRules() {
return [
textblockTypeInputRule({
find: /^```mermaid[\s\n]$/,
type: this.type,
}),
]
},
Note: for atom nodes, you'll typically want a custom
InputRulerather thantextblockTypeInputRule, since the latter is designed for textblock-type nodes with editable content. For an atomic node, a simpler approach is to use aPluginwith a regex-based text match that replaces the trigger text withinsertContent.
6. Handling Node Updates
One subtlety with NodeViews for atomic/complex nodes is understanding when ProseMirror calls update() vs. when it destroys and recreates the view.
Key rules:
- If the node's
typechanges, ProseMirror always recreates the view. - If only
attrschange (like ourcontentattribute), ProseMirror callsupdate(node, decorations)on the existing view, giving you a chance to patch the DOM instead of a full re-render. - Returning
falsefromupdate()forces a full teardown/rebuild—useful if the update is too complex to patch incrementally, but generally more expensive.
For our Mermaid node, since re-rendering the SVG is relatively cheap, we always return true from update() and let our internal renderDiagram() function handle applying the new SVG string.
If you're using React or Vue node views, this update-diffing is handled somewhat automatically via component re-renders when props/attrs change, which simplifies things considerably.
React Version (using @tiptap/react)
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import { useEffect, useRef, useState } from 'react'
import mermaid from 'mermaid'
const MermaidComponent = ({ node, updateAttributes }: any) => {
const [editing, setEditing] = useState(false)
const [svg, setSvg] = useState('')
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
mermaid
.render(`mermaid-${Math.random().toString(36).slice(2)}`, node.attrs.content)
.then(({ svg }) => {
if (!cancelled) {
setSvg(svg)
setError(null)
}
})
.catch((err) => !cancelled && setError(String(err)))
return () => {
cancelled = true
}
}, [node.attrs.content])
if (editing) {
return (
<NodeViewWrapper>
<textarea
autoFocus
defaultValue={node.attrs.content}
onBlur={(e) => {
updateAttributes({ content: e.target.value })
setEditing(false)
}}
/>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper>
{error ? (
<pre className="mermaid-error">{error}</pre>
) : (
<div onDoubleClick={() => setEditing(true)} dangerouslySetInnerHTML={{ __html: svg }} />
)}
</NodeViewWrapper>
)
}
// In your Node.create({...}):
addNodeView() {
return ReactNodeViewRenderer(MermaidComponent)
},
Notice how much simpler this is: updateAttributes is provided directly by Tiptap's React integration, abstracting away the manual getPos + dispatch dance.
7. Full Working Example (Vanilla)
Putting it all together:
import { Node, mergeAttributes } from '@tiptap/core'
import mermaid from 'mermaid'
mermaid.initialize({ startOnLoad: false })
export const Mermaid = Node.create({
name: 'mermaid',
group: 'block',
atom: true,
addAttributes() {
return {
content: {
default: 'graph TD;\nA-->B;',
parseHTML: (el) => el.getAttribute('data-content'),
renderHTML: (attrs) => ({ 'data-content': attrs.content }),
},
}
},
parseHTML() {
return [{ tag: 'div[data-type="mermaid"]' }]
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'mermaid' })]
},
addCommands() {
return {
setMermaid:
(content: string) =>
({ commands }) =>
commands.insertContent({ type: this.name, attrs: { content } }),
}
},
addNodeView() {
return ({ node, editor, getPos }) => {
const dom = document.createElement('div')
dom.className = 'mermaid-node'
const preview = document.createElement('div')
const textarea = document.createElement('textarea')
textarea.style.display = 'none'
dom.append(preview, textarea)
const render = async (src: string) => {
try {
const { svg } = await mermaid.render(`m-${Date.now()}`, src)
preview.innerHTML = svg
} catch (e) {
preview.innerHTML = `<pre>${e}</pre>`
}
}
render(node.attrs.content)
textarea.value = node.attrs.content
preview.ondblclick = () => {
preview.style.display = 'none'
textarea.style.display = 'block'
textarea.focus()
}
textarea.onblur = () => {
if (typeof getPos === 'function') {
editor.view.dispatch(
editor.view.state.tr.setNodeMarkup(getPos(), undefined, {
content: textarea.value,
})
)
}
preview.style.display = 'block'
textarea.style.display = 'none'
}
return {
dom,
update: (updated) => {
if (updated.type.name !== 'mermaid') return false
render(updated.attrs.content)
textarea.value = updated.attrs.content
node = updated
return true
},
}
}
},
})
Register it with your editor:
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Mermaid } from './mermaid-extension'
const editor = new Editor({
extensions: [StarterKit, Mermaid],
content: '<p>Hello world</p>',
})
editor.commands.setMermaid('graph TD;\nA-->B;\nB-->C;')
8. Common Pitfalls
Forgetting
atom: truefor non-text content. Without it, ProseMirror expects the node to have editable text content matching itscontentschema expression, which conflicts with rendering an SVG diagram.Not handling async rendering races. If a user rapidly edits the Mermaid source, multiple
mermaid.render()calls can resolve out of order. Use a request ID or cancellation flag to ensure only the latest result is applied.Missing
getPosin newer Tiptap versions. In Tiptap 2.x,getPosis a function you must call (getPos()), not a static value—forgetting the parentheses is a very common bug.SSR/hydration issues. If you use Tiptap with server-side rendering (e.g., Next.js), calling
mermaid.renderon the server will fail since it depends on browser DOM APIs. Guard NodeView rendering logic to only run client-side.Copy-paste losing your diagram. Make sure your
parseHTML/renderHTML(orparseDOMin raw ProseMirror) correctly round-trips thecontentattribute, otherwise copy-pasting Mermaid blocks between documents will silently drop the diagram source.
Conclusion
Custom nodes are where Tiptap and ProseMirror really shine, letting you embed arbitrarily rich, interactive content—diagrams, code sandboxes, polls, embeds—directly into a structured document model. The Mermaid node we built here demonstrates the essential pattern:
- Define a schema with the attributes you need.
- Use a NodeView to bridge the abstract node to real DOM/rendering logic.
- Provide commands for programmatic insertion.
- Handle updates gracefully to keep the editor responsive.
Once you understand this pattern, you can apply it to virtually any embeddable content type—it's just a matter of swapping out the rendering library (Mermaid, KaTeX, D3, a video player, etc.) while reusing the same schema/NodeView/command scaffolding.
Happy building! 🎨