Adding "spoiler" support to Markdown with Markdig

According to its docs, Markdig is "a fast, powerful, CommonMark compliant, extensible Markdown processor for .NET." I've chosen it as the renderer for my Book Club app project.

Surprisingly, it took me and my testers ~3 months to realize we might want a way of marking spoilers - i.e. designating some text to be hidden, until a user deliberately reveals it. The revealing itself is easy - just some styling with the :hover and :active pseudo classes makes it work both on PC and phone. Adding Markdown support (so that the appropriate class, like spoiler, is rendered to HTML) was going to be the more difficult bit.

Looking at the documentation, the Custom Container Markdown extension looked promising. In fact, spoilers is exactly what the docs show as an example. This would work out of the box:

:::spoiler
This is a *spoiler*
:::

This is a text with an ::inline spoiler::{.spoiler}

However, I need the Book club app to be as easy to use as possible; I'll be happy if our non-technical users will be able to handle basic MD; there's no way they would write that monstrosity. Heck, even I don't wanna do it. 😀

The solution I ended up going for is: if there is no class explicitly specified, use spoiler by default; otherwise, just use the specified class. This will do just what we need, and still leave room for other special usage if required later.

That will let us write text simply like this:

:::spoiler
This is a *spoiler*
:::

This is a text with an ::inline spoiler::

I guess that's as simple as we can get. Without further ado, here is the code.

First, we need our Renderers.  One to handle the block spoilers:

public class SpoilerContainerRenderer : HtmlObjectRenderer<CustomContainer>
{
    protected override void Write(HtmlRenderer renderer, CustomContainer obj)
    {
        renderer.EnsureLine();
        if (string.IsNullOrWhiteSpace(obj.Info))
        {
            var attr = obj.GetAttributes();
            attr.AddClass("spoiler");
            obj.SetAttributes(attr);
        }
        if (renderer.EnableHtmlForBlock)
        {
            renderer.Write("<div").WriteAttributes(obj).Write('>');
        }
        // We don't escape a CustomContainer
        renderer.WriteChildren(obj);
        if (renderer.EnableHtmlForBlock)
        {
            renderer.WriteLine("</div>");
        }
    }
}

A second one to handle inline spoilers:

public class SpoilerContainerInlineRenderer : HtmlObjectRenderer<CustomContainerInline>
{
    protected override void Write(HtmlRenderer renderer, CustomContainerInline obj)
    {
        var attr = obj.TryGetAttributes() ?? new ();
        if ((attr.Classes?.Count ?? 0) == 0)
        {
            attr.AddClass("spoiler");
            obj.SetAttributes(attr);
        }
        renderer.Write("<span").WriteAttributes(obj).Write('>');
        renderer.WriteChildren(obj);
        renderer.Write("</span>");
    }
}

Next bit: the extension itself. A similar one is in Markdig's source code. I've modified it to register the two renderers if needed.

public class SpoilerContainerExtension : IMarkdownExtension
{
    public void Setup(MarkdownPipelineBuilder pipeline)
    {
        if (!pipeline.BlockParsers.Contains<CustomContainerParser>())
        {
            // Insert the parser before any other parsers
            pipeline.BlockParsers.Insert(0, new CustomContainerParser());
        }

        // Plug the inline parser for CustomContainerInline
        var inlineParser = pipeline.InlineParsers.Find<EmphasisInlineParser>();
        if (inlineParser != null && !inlineParser.HasEmphasisChar(':'))
        {
            inlineParser.EmphasisDescriptors.Add(new EmphasisDescriptor(':', 2, 2, true));
            inlineParser.TryCreateEmphasisInlineList.Add((emphasisChar, delimiterCount) =>
            {
                if (delimiterCount == 2 && emphasisChar == ':')
                {
                    return new CustomContainerInline();
                }
                return null;
            });
        }
    }

    public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
    {
        if (renderer is HtmlRenderer htmlRenderer)
        {
            if (!htmlRenderer.ObjectRenderers.Contains<SpoilerContainerRenderer>())
            {
                // Must be inserted before CodeBlockRenderer
                htmlRenderer.ObjectRenderers.Insert(0, new SpoilerContainerRenderer());
            }
            if (!htmlRenderer.ObjectRenderers.Contains<SpoilerContainerInlineRenderer>())
            {
                // Must be inserted before EmphasisRenderer
                htmlRenderer.ObjectRenderers.Insert(0, new SpoilerContainerInlineRenderer());
            }
        }
    }
}

That's it. The only thing left to do is registering the extension. By default, when you just want to use Markdig with all the extensions with no hassle (which is what I'd been doing up until this point), you just need to create a MarkdownPipeline object like this:

var mdPipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();

That registers all of them, including the default CustomContainer extension, which should no longer be present. Too lazy to write an extension method I would just call once, I changed the code above to:

var builder = new MarkdownPipelineBuilder();
builder.Extensions.Insert(0, new SpoilerContainerExtension());
builder.UseAbbreviations()
    .UseAutoIdentifiers()
    .UseCitations()
    .UseDefinitionLists()
    .UseEmphasisExtras()
    .UseFigures()
    .UseFooters()
    .UseFootnotes()
    .UseGridTables()
    .UseMathematics()
    .UseMediaLinks()
    .UsePipeTables()
    .UseListExtras()
    .UseTaskLists()
    .UseDiagrams()
    .UseAutoLinks()
    .UseGenericAttributes();
var mdPipeline = builder.Build();

This piece of code registers all the extensions used previously, but instead of the Custom Container extension, the new Spoiler extension is used. Rendering Markdown with the spoiler tags (by calling .ToHtml) now generates HTML with the spoiler class applied to the appropriate divs and spans.

Enjoy.