Writing extensions

edit

Choose the simplest way that does what you need:

You want toUse
Change the elements of every document, such as adding a class to the imagesThe DocumentProcessed event
Change how an element is written to HTMLAn extension that replaces an object renderer
Add a syntax that Markdown does not haveAn extension with a parser

Process the document after parsing

DocumentProcessed runs after each parse, before the document is returned or rendered. Change the tree there, for every document that the pipeline parses:

var builder = new MarkdownPipelineBuilder();
builder.DocumentProcessed += document =>
{
    // Runs after each parse, before the document is returned or rendered
    foreach (var link in document.Descendants<LinkInline>())
    {
        if (link.IsImage)
        {
            link.GetAttributes().AddProperty("loading", "lazy");
        }
    }
};
var pipeline = builder.Build();

var html = MarkdownConverter.ToHtml("![Logo](logo.png)", pipeline);
Assert.Equal("<p><img src=\"logo.png\" loading=\"lazy\" alt=\"Logo\" /></p>\n", html);

Subscribe to the event before Build. The handler runs for each parse: it must be thread-safe when the pipeline is shared between threads.

Write an extension

An extension implements IMarkdownExtension, which has two Setup methods:

  • Setup(MarkdownPipelineBuilder) runs once, in Build. Add or change parsers there (BlockParsers, InlineParsers), or subscribe to DocumentProcessed.
  • Setup(MarkdownPipeline, IMarkdownRenderer) runs for each renderer that the pipeline sets up: the renderers of ToHtml, ToPlainText and Normalize, and the renderers you pass to pipeline.Setup. Add or replace object renderers there, after checking the type of the renderer.

Enable it with Use<TExtension>(), or Use(extension) for an instance with options.

Change how an element is rendered

Each element type has an object renderer in ObjectRenderers. Replace it to write the element differently. For instance, to start the headings at <h2> in a page that already has a <h1>:

internal sealed class ShiftHeadingsExtension : IMarkdownExtension
{
    public void Setup(MarkdownPipelineBuilder pipeline)
    {
    }

    public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
    {
        // Called for each renderer: only change the HTML one
        if (renderer is HtmlRenderer)
        {
            renderer.ObjectRenderers.Replace<HeadingRenderer>(new ShiftedHeadingRenderer());
        }
    }
}

internal sealed class ShiftedHeadingRenderer : HtmlObjectRenderer<HeadingBlock>
{
    private static readonly string[] Tags = ["h2", "h3", "h4", "h5", "h6", "h6"];

    protected override void Write(HtmlRenderer renderer, HeadingBlock obj)
    {
        var tag = Tags[Math.Clamp(obj.Level, 1, 6) - 1];

        // ToPlainText uses the HTML renderer without the tags
        if (renderer.EnableHtmlForBlock)
        {
            renderer.Write('<').Write(tag).WriteAttributes(obj).Write('>');
        }

        renderer.WriteLeafInline(obj);

        if (renderer.EnableHtmlForBlock)
        {
            renderer.Write("</").Write(tag).Write('>');
        }

        renderer.EnsureLine();
    }
}
var pipeline = new MarkdownPipelineBuilder()
    .Use<ShiftHeadingsExtension>()
    .Build();

// The page already has a <h1>: the headings of the content start at <h2>
var html = MarkdownConverter.ToHtml("# Title\n\n## Section", pipeline);
Assert.Equal("<h2>Title</h2>\n<h3>Section</h3>\n", html);
Assert.Equal("Title\nSection\n", MarkdownConverter.ToPlainText("# Title\n\n## Section", pipeline));

Add a syntax

A parser reads your syntax and creates elements in the tree. An inline parser declares the characters that can start its syntax in OpeningCharacters; Match is called at each of them, reads the text from the StringSlice, and returns true after creating an inline. This extension turns @name into a link to a GitHub profile:

// Turns "@name" into a link to the GitHub profile of the user
internal sealed class MentionExtension : IMarkdownExtension
{
    public void Setup(MarkdownPipelineBuilder pipeline)
    {
        pipeline.InlineParsers.AddIfNotAlready(new MentionParser());
    }

    public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
    {
        // Nothing to do: a mention is a LinkInline, and every renderer already writes links
    }
}

internal sealed class MentionParser : InlineParser
{
    public MentionParser()
    {
        OpeningCharacters = ['@'];
    }

    public override bool Match(InlineProcessor processor, ref StringSlice slice)
    {
        // "user@example.com" is not a mention: the @ must start a word
        if (slice.PeekCharExtra(-1).IsAlphaNumeric())
            return false;

        var start = slice.Start;
        var current = slice.NextChar(); // Skips the @
        while (current.IsAlphaNumeric() || current == '-')
        {
            current = slice.NextChar();
        }

        var name = slice.Text[(start + 1)..slice.Start];
        if (name.Length == 0)
            return false;

        // The position of the inline in the source, when the pipeline computes them
        var position = processor.GetSourcePosition(start, out var line, out var column);
        var link = new LinkInline("https://github.com/" + name, title: "")
        {
            Span = new SourceSpan(position, position + name.Length),
            Line = line,
            Column = column,
            IsClosed = true, // The parser must not add the next inlines into the link
        };
        link.AppendChild(new LiteralInline("@" + name));
        processor.Inline = link;
        return true;
    }
}
var pipeline = new MarkdownPipelineBuilder()
    .Use<MentionExtension>()
    .Build();

var html = MarkdownConverter.ToHtml("Thanks @meziantou! Write to user@example.com.", pipeline);
Assert.Equal("<p>Thanks <a href=\"https://github.com/meziantou\">@meziantou</a>! Write to user@example.com.</p>\n", html);
  • The mention is a LinkInline: every renderer already writes links. A new element type needs an object renderer for each renderer you use.
  • processor.Inline receives the new inline. Set IsClosed on a container inline so that the next inlines are not added into it.
  • Set the position of the inline from processor.GetSourcePosition; it is only computed when the pipeline uses precise source locations.
  • Parsers run in the order of the list: InsertBefore<T> gives yours priority over an existing parser that uses the same opening character.

Block parsers derive from BlockParser, and fenced blocks from FencedBlockParserBase<T>. The extensions of the library, in the Meziantou.Framework.Markdown.Extensions namespaces, are complete examples of both.