Jay Wilson developer. creator. coffee drinker.

Callout support

I added support for callouts to my blog.

With the help of Cursor, I think I've added support for callouts similar to GitHub's alerts.

Here are examples of how mine render:

NOTE

That has a broken line in it

TIP

This is a tip

IMPORTANT

This is important

WARNING

This is a warning

CAUTION

This is a caution

NOTE

This is a note

With a second paragraph and some inline styles and a link.

Here's how it works under the hood:

> [!NOTE]
> This is a note
>
> With a second paragraph and some inline **styles** and a [link](https://example.com).

The > [!NOTE] is the callout type which is picked up by a plugin that looks for the token and created the HTML structure of the callout.

That structure looks like

<blockquote data-callout="note">
  <p class="callout-label"><span class="callout-icon"></span>NOTE</p>
  <p>This is a note</p>
  <p>With a second paragraph and some inline <strong>styles</strong> and a <a href="https://example.com">link</a>.</p>
</blockquote>

The hard part was getting the tokens and the content right underneath the callout type to render correctly.

Here's the final javascript that does the trick:

import MarkdownIt from "markdown-it";

export function calloutsPlugin(eleventyConfig) {
  const md = eleventyConfig.markdownLibrary || new MarkdownIt({ html: true });
  eleventyConfig.setLibrary("md", md);

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

    for (let i = 0; i < tokens.length; i++) {
      const token = tokens[i];
      if (token.type === "blockquote_open") {
        const text = tokens[i + 2];
        const match = text?.content?.match(
          /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/
        );

        if (match) {
          // Add data-callout attribute
          token.attrs = token.attrs || [];
          token.attrs.push(["data-callout", match[1].toLowerCase()]);

          // Add class to the first paragraph
          const para = tokens[i + 1];
          if (para && para.type === "paragraph_open") {
            para.attrs = para.attrs || [];
            para.attrs.push(["class", "callout-label"]);
          }

          // Get cleaned content
          const content = text.content.replace(match[0], "").trim();

          // Set the label
          text.content = `<span class="callout-icon"></span>${match[1]}`;

          // Add paragraph with content
          const paraOpen = new state.Token("paragraph_open", "p", 1);
          const contentToken = new state.Token("text", "", 0);
          contentToken.content = content;
          const paraClose = new state.Token("paragraph_close", "p", -1);
          tokens.splice(i + 4, 0, paraOpen, contentToken, paraClose);
        }
      }
    }
  });
}

This plugin is then added to my 11ty config file and runs on build.


This was quite the exercise to get it right even with the help of Cursor. It's hard to get Cursor to think of different ways to do the same thing if you don't prompt it first. I was stuck in a few loops of getting the same answers over and over when I should have been prompting for different ways to do the same thing. I tried going a mainly CSS route, but it wasn't working so then I had to prompt it to think about how to do it with standard HTML layout which eventually got me to the solution.

If you liked this post, consider buying me a coffee.

Or donate to Lambda Legal to help fight for trans and LGBTQIA+ rights.

Subscribe to my posts via your favorite feed reader.