Leo He

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 mol object is explicitly freed with mol.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() — no innerHTML mutations 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's nextTick, React's useEffect, 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

  1. Understanding the ProseMirror Node Model
  2. Setting Up the Node Schema
  3. Building the NodeView
  4. Rendering Mermaid Diagrams
  5. Adding Commands and Input Rules
  6. Handling Node Updates
  7. Full Working Example
  8. 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 mermaid library
  • 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 a content attribute, which gets serialized to data-content in 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?

  1. 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.
  2. editor.view.dispatch(...): This is how we commit changes back to the ProseMirror state. setNodeMarkup is the standard way to update a node's attributes without replacing the whole node.
  3. The update method: This is called whenever ProseMirror decides the node might need to re-render (e.g., due to collaborative editing or undo/redo). Returning true tells ProseMirror "I've handled the update myself, don't tear down and rebuild the DOM." Returning false forces ProseMirror to destroy and recreate the NodeView.
  4. 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.render is 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 id internally 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 InputRule rather than textblockTypeInputRule, since the latter is designed for textblock-type nodes with editable content. For an atomic node, a simpler approach is to use a Plugin with a regex-based text match that replaces the trigger text with insertContent.


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 type changes, ProseMirror always recreates the view.
  • If only attrs change (like our content attribute), ProseMirror calls update(node, decorations) on the existing view, giving you a chance to patch the DOM instead of a full re-render.
  • Returning false from update() 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

  1. Forgetting atom: true for non-text content. Without it, ProseMirror expects the node to have editable text content matching its content schema expression, which conflicts with rendering an SVG diagram.

  2. 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.

  3. Missing getPos in newer Tiptap versions. In Tiptap 2.x, getPos is a function you must call (getPos()), not a static value—forgetting the parentheses is a very common bug.

  4. SSR/hydration issues. If you use Tiptap with server-side rendering (e.g., Next.js), calling mermaid.render on the server will fail since it depends on browser DOM APIs. Guard NodeView rendering logic to only run client-side.

  5. Copy-paste losing your diagram. Make sure your parseHTML/renderHTML (or parseDOM in raw ProseMirror) correctly round-trips the content attribute, 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:

  1. Define a schema with the attributes you need.
  2. Use a NodeView to bridge the abstract node to real DOM/rendering logic.
  3. Provide commands for programmatic insertion.
  4. 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! 🎨