How To Make Eleventy Understand Obsidian-style Callouts
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.
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:
- Adding Github-Style Markdown Alerts to Eleventy (this uses https://github.com/antfu/markdown-it-github-alerts)
- Eleventy Notes
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:
- if the callout marker is just
[!NOTE]
we replace it with adiv
tag - if it is
[!NOTE]-
we replace it with adetails
tag - if it is
[!NOTE]+
we replace it with adetails
tag with theopen
attribute
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:
- the regex helps us find the specific kind of block quote we're trying to replace (
[!NOTE]
; although we ignore capitalization as [!note] also works in Obsidian) md.core.ruler.after("block", "note_callout", ...)
this specifies when our new rule is run. After the block rules (including blockquotes)- we use a
for
loop as we want to iterate over the tokens, but we need to jump ahead as soon as we find theblockquote_open
token - as soon as we do find the
blockquote_open
token we search for the closing token (findBlockquoteClose
) and we search for an inline token (findInlineTokenInBlockquote
), so we can clean it up slightly - we don't do anything if it's an empty blockquote
- we check - using our regex - if we are in the kind of blockquote we want to alter or not
- we check if it's a
[!NOTE]
,[!NOTE]+
or[!NOTE]-
and replace tags/add attributes accordingly - we do parse the summary/title of the callout ourselves as it is part of the
html_block
- finally, we overwrite the starting token, closing token and strip out the
[!NOTE]
part from the content token
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!
> [!NOTE] And we can even do Titled Callouts!
> Hooray!
> [!NOTE]+ _Foldable_ Titled Callouts!
> Hooray!
> [!NOTE]- _Folded_ Titled Callouts!
> Hooray!
"Callouts" is the name in Obsidian. In Github flavored Markdown these are called "Alerts". ↩︎
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). ↩︎
that's the markdown parse/renderer used by eleventy ↩︎
You can see the complete array, by using their demo tool, here. ↩︎
In theory, other plugins could alter these tokens, but it is not done by default. ↩︎
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. ↩︎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. ↩︎
-
← Previous
DailyDogo 1261 🐶 -
Next →
DailyDogo 1262 🐶