Extending Redcarpet for Admonition Blocks

I've been doing a lot of writing in Obsidian, and they recently released a version with these lovely "callout" blocks. I like them a lot, and since I want my Creativity System to feed straight through to my writing, time to hack my blog!

The "callouts" take this form:

> [!tldr] If you hack the base templater for your blog, you will cry.

I like this format because it’s still meaningful as plain text. I'm rendering them like this:

If you hack the base templater for your blog, you will cry.

Pretty straightforward SCSS:

.admonition {
  padding: 1em;
  margin-top: 2em;

  background: transparentize($accent, 0.9);
  border-left: 1em solid $accent;
  border-radius: .25em;

.admonition.tldr {
  @media #{$desktop} {
    display: grid;

    grid-template-columns: auto auto;
    grid-column-gap: 1em;
    align-items: center;

  &:before {
    content: 'tl;dr:';

    font-size: 2em;
    font-weight: 800;

  > p {
    margin: 0;

The really tricky part was ruby. Y'all ever seen the gif where Hal tries to change a lightbulb?

hal changes a lightbulb

This was one of those changes.

Let's go learn what all I did wrong! 🏃

A tale of woe.

  • I found a couple posts about extending markdown in redcarpet. Looks easy enough… right?
  • I first got stuck because I wasn't using the right regex globally to fix all instances of the regex. 🤦
  • I wanted to render markdown markup like _italics_ or [links](url) in the admonition body, so I had to run a markdown renderer inside my markdown renderer. It's heinous, but I don't know how to get around it.
  • I realized that I could use the same format to display youtube videos, instead of my previous .erb based format. Pure markdown for everything!
  • All this worked beautifully, but while writing this post I realized my changes destroyed syntax highlighting. I believe the middleman-syntax was doing some monkeypatching that I sidestepped by creating my own preprocess method… so I just removed the gem and used Rouge directly.
  • I broke footnotes and header anchors because I wasn't passing options to the new renderer properly.
  • emboldened by my success, I decided to strip the .html.markdown extension from all my blog posts (I'd like them all to just be .md files someday!). The rename broke my git changelogs.

Phew! It was a lot of bug chasing. I'm pleased with the result.

Here are the finished changes:

Git Changelog

ruby-git doesn't support git log --follow yet, so I had to drop into git and handle it myself.

I tried really hard to pull all the information in one git command using --format: I tried JSON, YML, even Marshall.dump in ruby… but calling for each commit was easier and doesn't seem to add to my Github CI times.

    @git = `git log --follow --format='%H' #{file}`
        date: `git show #{sha} --no-patch --format='format:%ai'`,
        title: `git show #{sha} --no-patch --format='format:%s'`,
        body: `git show #{sha} --no-patch --format='format:%b'`
  if @git.size > 1
  <details class="blog__changes">
      <% @git.each do |commit| %>
          <div class="blog__changes__date"><%= commit[:date] %></div>
          <div class="blog__changes__title"><%= commit[:title] %></div>
          <%= simple_format(commit[:body]) %>
      <% end %>
<% end %>

Custom Markdown Elements

That's the changelog fixed, what about the actual markdown renderer? Let's overly document some hacky ruby code:

# Get our gems
require 'middleman-core/renderers/redcarpet'
require 'rouge/plugins/redcarpet'

# This is the same template from the previous blog post.
<div class="youtube-container">
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"

# This regex describes the `> [!name] content` Admonition block. We have to use
# it twice (I got really stuck trying to make it one call!)
CALLOUT = /^> \[!(?<name>\w+)\] (?<str>.*)$/.freeze

# Renderer: This is going to be our custom renderer. Gotta initialize it with
# some options enabled to make sure we keep our footnotes and TOC anchors.
class Renderer < Redcarpet::Render::HTML
  def initialize(extensions = {})
    super extensions.merge(
      footnotes: true,
      with_toc_data: true

  include Rouge::Plugins::Redcarpet

  # Here's the heart of the hack
  def preprocess(document)
    # Instantiate _another_ renderer inside this renderer to render while you
    # render. (Eat your heart out Xzibit.)
    markdown = Redcarpet::Markdown.new(self, {})

    # change every instance of the CALLOUT regex in place.
    document.gsub!(CALLOUT) do |match|
      # because `match` contains the matching string, not capture groups (and
      # `$~` didn't always return the last match, maybe thread-local?!) I'm
      # going to use CALLOUT again to grab the named captures.
      m = CALLOUT.match(match).named_captures
      if m['name'] == 'youtube'
        @video = URI.decode_www_form(URI(m['str']).query).to_h['v']
        format(YOUTUBE_TEMPLATE, @video)
        # Render out our little component, with nested markdown render 🤷‍♂️
        "<div class='admonition  #{m['name']}'>#{markdown.render m['str']}</div>"


set :markdown_engine, :redcarpet
set :markdown,
    fenced_code_blocks: true,
    smartypants: true,
    with_toc_data: true,
    highlights: true,
    footnotes: true,
    renderer: Renderer

What a time. I do not recommend this… unless like me you are dead set on having a simple markdown file represent your writing output, and you want to have your Creativity System hooked directly into your blogging system. More to come on that.

  • 2022-07-12 09:37:50 -0500
    Update blog

  • 2022-06-17 15:13:15 -0500
    Change some words

  • 2022-06-17 11:18:59 -0500

  • 2022-06-17 11:17:06 -0500
    Rename and change date

  • 2022-06-17 11:15:35 -0500
    Finish blog post

  • 2022-06-09 14:01:49 -0500
    updating draft on phone while waiting

  • 2022-06-08 11:31:29 -0500
    Rename articles

  • 2022-06-08 11:27:18 -0500
    Working on new post