Skip to main content
Martin Hähnel

How To Make Eleventy Understand Obsidian-style Callouts

Changelog

  • 2025-05-11 - Created this note
  • 2025-05-12 - Fixed a misleading note about putting the content in between the html_blocks. The content tokens stay in their place. They are just getting cleaned up. Also added a couple more links and linked those solutions in the beginning correctly. I added codeblocks so that people can see how the markdown looks that leads to the callout examples in the end. I also fixed a couple of typos.

I really like callouts as a concept.[1] If you're unfamiliar, they are a way to make a part of a note or post stand out.

Like this!

Because Obsidian is my blog editor, I wanted to make callouts work with eleventy - which is the static site generator I use.

Now, there are a handful of solutions out there that do this already:

Eleventy notes had the most complete implementation of callouts, but it's not a plugin you can just use and the licensing was unclear when I tried to solve this.[2]

So I made my own markdown-it[3] plugin. It's just one file and it re-uses (mostly) the message-box css file that comes with the eleventy base blog.

A Mini-Tour Of markdown-its Parsing Architecture

Not only because I don't really understand it, but because we don't need super-deep understanding, we'll make this quick:

markdown-it parses any text it is fed in multiple passes. And creates a token stream - an array of objects - that is then finally fed to the renderer to create HTML.

Take this as an example:

> [!NOTE]+
> Hello *World*

The array created by vanilla markdown-it looks something like this:[4]

[
  { "type": "blockquote_open" },
  { "type": "paragraph_open" },
  {
    "type": "inline",
    "children": [
      { "type": "text", "content": "[!NOTE]+" },
      { "type": "softbreak" },
      { "type": "text", "content": "Hello " },
      { "type": "em_open" },
      { "type": "text", "content": "World" },
      { "type": "em_close" }
    ],
    "content": "[!NOTE]+\nHello *World*"
  },
  { "type": "paragraph_close" },
  { "type": "blockquote_close" }
]

Now, plugins can hook into this parsing cascade at any point and change the resultant array. You can, for example, add your own rule after all the blocks (e.g., block quotes, code blocks, ...) have been parsed. The array at this point in the parsing process looks therefore slightly simpler:

[
  { "type": "blockquote_open" },
  { "type": "paragraph_open" },
  {
    "type": "inline",
    "children": [
      { "type": "text", "content": "[!NOTE]+" },
      { "type": "softbreak" },
      { "type": "text", "content": "Hello *World*" }
    ],
    "content": "[!NOTE]+\nHello World"
  },
  { "type": "paragraph_close" },
  { "type": "blockquote_close" }
]

Note how the string *World* is not yet parsed further.

In our case, we want to replace these special blockquote tokens with HTML ourselves, so there is no need for any other rules to parse this block. We can do so by replacing the blockquote_open/blockquote_close tokens by using a token type called html_block which skips all other rules and is fed to the renderer as is.[5]

So we turn the above into something like this:

[
  {
    "type": "html_block",
    "content": "<details class=\"message-box\" open><summary>Note</summary>\n"
  },
  { "type": "paragraph_open" },
  {
    "type": "inline",
    "children": [
      { "type": "text", "content": "[!NOTE]+" },
      { "type": "softbreak" },
      { "type": "text", "content": "Hello *World*" }
    ],
    "content": "[!NOTE]+\nHello *World*"
  },
  { "type": "paragraph_close" },
  {
    "type": "html_block",
    "content": "</details>\n"
  }
]

Note that we are not parsing the markdown within the block - although we're going to strip the [!NOTE]+ part.

Alright. So that's the plan. We are going to do exactly what I described in code. On top of what I have described here, we will also have to decide how we are going to replace the block exactly:

Our little plugin

So here's the plugin note-callout-plugin.js (which I just put in the repo root):

export const noteCalloutPlugin = (md) => {
  const regex = /^\[\!note\]([+-]?)( +[^\n\r]+)?/i;

  md.core.ruler.after("block", "note_callout", function (state) {
    const tokens = state.tokens;

    for (let idx = 0; idx < tokens.length; idx++) {
      if (tokens[idx].type !== "blockquote_open") continue;

      const openIdx = idx;
      const closeIdx = findBlockquoteClose(tokens, idx);
      const contentToken = findInlineTokenInBlockquote(tokens, openIdx, closeIdx);

      if (!contentToken) continue;

      const match = contentToken.content.match(regex);
      if (!match) continue;

      const modifier = match[1]; // "+" or "-" or ""
      const title = (match[2] || "Note").trim();
      const parsedTitle = md.renderInline(title);

      let wrapperTag = "div";
      let openAttr = "";
      let summary = "";

      if (modifier === "+" || modifier === "-") {
        wrapperTag = "details";
        if (modifier === "+") openAttr = " open";
        summary = `<summary>${parsedTitle}</summary>\n`;
      } else {
        summary = `<p class="callout-title">${parsedTitle}</p>\n`;
      }

      tokens[openIdx].tag = wrapperTag;
      tokens[openIdx].type = "html_block";
      tokens[openIdx].content = `<${wrapperTag} class="message-box"${openAttr}>${summary}`;

      tokens[closeIdx].tag = wrapperTag;
      tokens[closeIdx].type = "html_block";
      tokens[closeIdx].content = `</${wrapperTag}>\n`;

      contentToken.content = contentToken.content.replace(regex, "").trim();
    }
  });

  function findBlockquoteClose(tokens, idx) {
    for (let i = idx + 1; i < tokens.length; i++) {
      if (tokens[i].type === "blockquote_close") return i;
    }
    return idx;
  }

  function findInlineTokenInBlockquote(tokens, startIdx, endIdx) {
    for (let i = startIdx + 1; i < endIdx; i++) {
      if (tokens[i].type === "inline") return tokens[i];
    }
    return null;
  }
};

Some notes:

Limitations

It's important to note that this is not really meant (yet) to handle nested callouts:

> [!NOTE]
> Like
>
> > [!NOTE]
> > This

...but that's fine for now. We also only care for [!NOTE] callouts (not [!QUOTE] or whatever).

Configuring The Plugin

In our eleventy.config.js we do the following[6]:

//...
import {noteCalloutPlugin} from "./note-callout-plugin.js";
//...

export default async function(eleventyConfig) {

  //...

  // markdown-it plugins
  eleventyConfig.amendLibrary("md", (mdLib) => {
    // callout plugin
    mdLib.use(noteCalloutPlugin)
  });

  // ...
};

You may have noticed that we set class="message-box" on the opening callout tag (details or div). This class is styled in an extra css file named message-box.css. This message box is meant to show off eleventy-plugin-bundle, but as per my strategy of reusing the defaults, we will just reuse the css for our own use. We also don't need to change anything in it, we just need to make sure that we actually always include the css per default instead of only sometimes.[7]

For this we update our base.njk:

...
{%- css %}{% include "public/css/index.css" %}{% endcss %}
{%- css %}{% include "public/css/message-box.css" %}{% endcss %} <- add this
...

Using the plugin

Using the plugin is as easy as writing obsidian flavored markdown:

> [!NOTE]
> Callouts are cool! Hooray!

Callouts are cool! Hooray!

> [!NOTE] And we can even do Titled Callouts!
> Hooray!

And we can even do Titled Callouts!

Hooray!

> [!NOTE]+ _Foldable_ Titled Callouts!
> Hooray!
Foldable Titled Callouts!

Hooray!

> [!NOTE]- _Folded_ Titled Callouts!
> Hooray!
Folded Titled Callouts!

Hooray!


  1. "Callouts" is the name in Obsidian. In Github flavored Markdown these are called "Alerts". ↩︎

  2. There is an mit licensed package inspired by this called markdown-it-callouts, but this lacks the possibility to fold callouts, which I use for my changelogs (see here for example). ↩︎

  3. that's the markdown parse/renderer used by eleventy ↩︎

  4. You can see the complete array, by using their demo tool, here. ↩︎

  5. In theory, other plugins could alter these tokens, but it is not done by default. ↩︎

  6. You may already have a markdown-it plugin activated (f.x. footnotes). If that is the case, there is no need to add another amendLibrary block just put them all inside those curly braces after mdLib. ↩︎

  7. Two notes here: 1. You could add .message-box + .message-box { margin-top: 1.5em;} so that callouts have some distance between each other if you happen to use more than one, like I do here and below. 2. This is such a small amount of CSS that performance should not be an issue. It just makes working with callouts so much easier to include this file by default. ↩︎