DEV Community

Murari Kumar
Murari Kumar

Posted on

Adding Mermaid Diagram Support to Forem Articles — A Custom Liquid Tag

Forem's article editor supports Markdown and a rich set of Liquid tags ({% youtube %}, {% codepen %}, {% katex %}, etc.) — but no native diagram support.

I wanted authors on my Forem instance to write flowcharts and sequence diagrams using Mermaid syntax directly in their articles, like this:

{% mermaid %}
graph TD
  A[Lahore Resolution 1940] --> B[Quit India 1942]
  B --> C[Independence 1947]
{% endmermaid %}
Enter fullscreen mode Exit fullscreen mode

Here's how I built it.


The Approach: Server-Side Rendering

Two options exist for rendering Mermaid in a web app:

Approach Pros Cons
Client-side (load mermaid.js) Simple, always latest renderer Extra JS payload, flash of unstyled content
Server-side (render at save-time) Zero client JS, instant display Requires mermaid-cli on server

I chose server-side because:

  • Forem's sanitizer strips <script> and <iframe> from article HTML — so client-side mermaid.js can't run inside article content
  • SVG elements (<svg>, <path>, <rect>, etc.) are already whitelisted in Forem's RenderedMarkdownScrubber
  • Pre-rendered SVG means zero layout shift and faster page loads

Architecture

The implementation follows Forem's existing Liquid::Block pattern (same as KatexTag, DetailsTag, QuoteTag):

Author writes           Save triggers           Article displays
{% mermaid %}    →    MermaidTag#render    →    inline <svg> in HTML
  graph TD             calls mmdc CLI
  A --> B              writes .mmd file
{% endmermaid %}       reads .svg output
Enter fullscreen mode Exit fullscreen mode

Files Created

File Purpose
app/liquid_tags/mermaid_tag.rb Liquid block tag — the core logic
app/views/liquids/_mermaid.html.erb ERB partial for the output HTML
config/mermaid.json Mermaid theme configuration
spec/liquid_tags/mermaid_tag_spec.rb RSpec tests

Prerequisites

Install mermaid-cli on your server:

npm install -g @mermaid-js/mermaid-cli
Enter fullscreen mode Exit fullscreen mode

Verify it works:

mmdc --version
Enter fullscreen mode Exit fullscreen mode

File 1: app/liquid_tags/mermaid_tag.rb

# Renders Mermaid diagrams to inline SVG at save-time using mermaid-cli (mmdc).
#
# Usage in articles:
#   {% mermaid %}
#   graph TD
#     A[Start] --> B[End]
#   {% endmermaid %}
#
# Requires: @mermaid-js/mermaid-cli installed globally or via npx.
#   npm install -g @mermaid-js/mermaid-cli
#
class MermaidTag < Liquid::Block
  include ActionView::Helpers::SanitizeHelper

  PARTIAL = "liquids/mermaid".freeze

  def render(_context)
    raw_definition = Nokogiri::HTML.parse(super).at("body").text.strip

    svg_output = render_mermaid_svg(raw_definition)

    html = ApplicationController.render(
      partial: PARTIAL,
      locals: {
        svg_content: svg_output,
        raw_definition: raw_definition,
      },
    )
    # Collapse whitespace between HTML tags so the second Redcarpet pass in
    # MarkdownProcessor::Parser#finalize treats the output as one HTML block
    # instead of interpreting indented SVG paths as code blocks and blank
    # lines as block breaks.
    html.gsub(/>\s*\n\s*</, "> <").gsub(/\A\s+/, "").gsub(/\s+\z/, "")
  end

  private

  def render_mermaid_svg(definition)
    require "open3"
    require "tempfile"

    input_file = Tempfile.new(["mermaid", ".mmd"])
    output_file = Tempfile.new(["mermaid", ".svg"])

    begin
      input_file.write(definition)
      input_file.close

      mmdc_path = find_mmdc
      raise StandardError, "mermaid-cli (mmdc) not found. Install with: npm install -g @mermaid-js/mermaid-cli" unless mmdc_path

      config_path = mermaid_config_path

      cmd = [
        mmdc_path,
        "-i", input_file.path,
        "-o", output_file.path,
        "-e", "svg",
        "--quiet",
      ]
      cmd += ["-c", config_path] if config_path && File.exist?(config_path)

      _stdout, stderr, status = Open3.capture3(*cmd)

      unless status.success?
        Rails.logger.error("MermaidTag render failed: #{stderr}")
        return fallback_html(definition, stderr)
      end

      svg = File.read(output_file.path)
      sanitize_svg(svg)
    rescue StandardError => e
      Rails.logger.error("MermaidTag error: #{e.message}")
      fallback_html(definition, e.message)
    ensure
      input_file.close unless input_file.closed?
      output_file.close unless output_file.closed?
      input_file.unlink rescue nil
      output_file.unlink rescue nil
    end
  end

  def find_mmdc
    require "open3"

    candidates = if Gem.win_platform?
                   %w[mmdc.cmd mmdc]
                 else
                   %w[mmdc]
                 end

    candidates.each do |candidate|
      _output, status = Open3.capture2e("#{candidate} --version")
      return candidate if status.success?
    rescue Errno::ENOENT
      next
    end

    nil
  end

  def mermaid_config_path
    config = Rails.root.join("config", "mermaid.json")
    config.exist? ? config.to_s : nil
  end

  def sanitize_svg(svg)
    # Extract just the <svg>...</svg> content, strip any XML declarations
    svg_match = svg.match(%r{<svg[\s\S]*</svg>}m)
    return svg unless svg_match

    svg_content = svg_match[0]
    # Add responsive class
    svg_content.sub("<svg", '<svg class="mermaid-diagram" style="max-width: 100%; height: auto;"')
  end

  def fallback_html(definition, error_message = nil)
    escaped = ERB::Util.html_escape(definition)
    error_note = error_message ? "<p class=\"mermaid-error\">Render error: #{ERB::Util.html_escape(error_message)}</p>" : ""
    <<~HTML
      <div class="mermaid-fallback">
        #{error_note}
        <pre><code>#{escaped}</code></pre>
      </div>
    HTML
  end
end

Liquid::Template.register_tag("mermaid", MermaidTag)
Enter fullscreen mode Exit fullscreen mode

File 2: app/views/liquids/_mermaid.html.erb

<div class="mermaid-container" data-mermaid-source="<%= ERB::Util.html_escape(raw_definition) %>">
  <%= svg_content.html_safe %>
</div>
Enter fullscreen mode Exit fullscreen mode

File 3: config/mermaid.json

This theme config is optional but gives you control over how diagrams look. Customize to match your Forem's design:

{
  "theme": "base",
  "themeVariables": {
    "fontFamily": "Inter, system-ui, sans-serif",
    "fontSize": "14px",
    "primaryColor": "#e8d5b7",
    "primaryBorderColor": "#b8860b",
    "lineColor": "#888",
    "secondaryColor": "#f5f5f5",
    "tertiaryColor": "#fff"
  },
  "flowchart": {
    "htmlLabels": true,
    "curve": "basis",
    "nodeSpacing": 25,
    "rankSpacing": 45,
    "useMaxWidth": true
  }
}
Enter fullscreen mode Exit fullscreen mode

File 4: spec/liquid_tags/mermaid_tag_spec.rb

require "rails_helper"

RSpec.describe MermaidTag, type: :liquid_tag do
  def generate_mermaid_liquid(definition)
    Liquid::Template.register_tag("mermaid", described_class)
    Liquid::Template.parse("{% mermaid %}#{definition}{% endmermaid %}")
  end

  describe "#render" do
    context "when mermaid-cli is available" do
      let(:sample_svg) do
        '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect width="50" height="50"/></svg>'
      end

      before do
        allow_any_instance_of(described_class).to receive(:render_mermaid_svg).and_return(sample_svg)
      end

      it "renders SVG content inside a mermaid-container div" do
        rendered = generate_mermaid_liquid("graph TD\n  A --> B").render
        expect(rendered).to include("mermaid-container")
        expect(rendered).to include("<svg")
      end

      it "includes the raw definition as a data attribute" do
        rendered = generate_mermaid_liquid("graph TD\n  A --> B").render
        expect(rendered).to include("data-mermaid-source")
      end

      it "collapses whitespace to survive Redcarpet second pass" do
        rendered = generate_mermaid_liquid("graph TD\n  A --> B").render
        expect(rendered).not_to match(/>\s*\n\s*</)
      end
    end

    context "when mermaid-cli is not available" do
      before do
        allow_any_instance_of(described_class).to receive(:find_mmdc).and_return(nil)
      end

      it "renders a fallback code block with error message" do
        rendered = generate_mermaid_liquid("graph TD\n  A --> B").render
        expect(rendered).to include("mermaid-fallback")
        expect(rendered).to include("mmdc")
      end
    end
  end

  describe "#find_mmdc" do
    it "returns nil when mmdc is not installed" do
      tag_instance = described_class.allocate
      allow(Open3).to receive(:capture2e).and_raise(Errno::ENOENT)
      result = tag_instance.send(:find_mmdc)
      expect(result).to be_nil
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

How It Works — The Pipeline

Understanding Forem's markdown pipeline is critical. Here's the flow in MarkdownProcessor::Parser#finalize:

1. Markdown → HTML (Redcarpet)
2. Sanitize HTML (RenderedMarkdownScrubber)
3. Parse Liquid tags (Liquid::Template.parse)
4. Render Liquid tags → output HTML        ← MermaidTag runs here
5. Markdown → HTML again (Redcarpet)       ← SVG must survive this
Enter fullscreen mode Exit fullscreen mode

The Redcarpet Trap

Step 5 is the critical gotcha. The liquid tag output goes through Redcarpet again. Redcarpet will:

  • Treat indented SVG <path> elements as code blocks (4+ spaces = code)
  • Interpret blank lines between tags as paragraph breaks

The fix (borrowed from QuoteTag):

html.gsub(/>\s*\n\s*</, "> <").gsub(/\A\s+/, "").gsub(/\s+\z/, "")
Enter fullscreen mode Exit fullscreen mode

This collapses all whitespace between tags into a single line, so Redcarpet treats the entire output as one HTML block and passes it through untouched.

Why SVG Survives the Sanitizer

Forem's RenderedMarkdownScrubber runs at step 2 (before liquid tags render), not after. So the SVG output from step 4 is never sanitized. But even if it were, Forem already whitelists SVG elements:

# From MarkdownProcessor::AllowedTags::RENDERED_MARKDOWN_SCRUBBER
%w[svg circle clipPath defs ellipse feBlend feColorMatrix
   feComposite feFlood feOffset filter g line linearGradient
   path polygon polyline rect stop title use]
Enter fullscreen mode Exit fullscreen mode

Graceful Fallback

If mmdc is not installed, the tag renders a <pre><code> block showing the raw Mermaid definition with an error message — so the article is never broken.


Usage

Once deployed, authors can write:

{% mermaid %}
graph LR
  A[British Raj] -->|1947| B[Independence]
  B --> C[Republic 1950]
  B --> D[Pakistan]
  D -->|1971| E[Bangladesh]
{% endmermaid %}
Enter fullscreen mode Exit fullscreen mode
{% mermaid %}
sequenceDiagram
  Gandhi->>British: Quit India
  British-->>Gandhi: Arrested
  Bose->>INA: Give me blood
  INA->>British: Armed resistance
{% endmermaid %}
Enter fullscreen mode Exit fullscreen mode

All Mermaid diagram types are supported: flowcharts, sequence diagrams, Gantt charts, class diagrams, state diagrams, pie charts, etc.


Pattern Compliance

The implementation follows Forem's established block tag pattern:

Pattern KatexTag DetailsTag QuoteTag MermaidTag
Extends Liquid::Block
PARTIAL constant
Nokogiri body extraction
ApplicationController.render
Whitespace collapse
register_tag at EOF

Built for a self-hosted Forem powering an India history knowledge map. The theme config matches the era timeline pages that already use Mermaid for interactive flowcharts.

Top comments (0)