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 %}
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'sRenderedMarkdownScrubber - 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
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
Verify it works:
mmdc --version
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)
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>
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
}
}
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
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
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/, "")
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]
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 %}
{% mermaid %}
sequenceDiagram
Gandhi->>British: Quit India
British-->>Gandhi: Arrested
Bose->>INA: Give me blood
INA->>British: Armed resistance
{% endmermaid %}
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)