Block parsers

Block parsers identify block-level elements (paragraphs, headings, lists, code blocks, custom containers, etc.) from the Markdown source text. They run during the first phase of parsing, processing the document line by line.

How block parsing works

The BlockProcessor orchestrates block parsing. For each line in the source:

  1. Continue — All currently open blocks are asked if they continue on the current line (TryContinue). Blocks that don't continue are closed.
  2. Open — The processor tries to open new blocks by calling TryOpen on block parsers whose OpeningCharacters match the current character.
  3. Dispatch — Parsers are dispatched based on their OpeningCharacters array — a parser is only tried when one of its opening characters matches the current position.

The BlockParser base class

All block parsers inherit from BlockParser:

public abstract class BlockParser : ParserBase<BlockProcessor>, IBlockParser<BlockProcessor>
{
    // Characters that trigger this parser
    public char[]? OpeningCharacters { get; set; }

    // Whether this parser can interrupt an open paragraph
    public virtual bool CanInterrupt(BlockProcessor processor, Block block) => true;

    // Try to open a new block at the current position
    public abstract BlockState TryOpen(BlockProcessor processor);

    // Try to continue an already-open block
    public virtual BlockState TryContinue(BlockProcessor processor, Block block)
        => BlockState.None;

    // Called when a block is closing. Return false to remove it from the AST.
    public virtual bool Close(BlockProcessor processor, Block block) => true;

    // Event fired when a block is closed
    public event ProcessBlockDelegate? Closed;
}

BlockState return values

The TryOpen and TryContinue methods return a BlockState enum:

Value Meaning
None No match — parser did not recognize anything
Skip Skip this parser for the current line, try others
Continue Block stays open; for leaf blocks, the line content is appended
ContinueDiscard Block stays open; line is consumed but not appended to the block
Break Block is closed; current line remains available for other parsers
BreakDiscard Block is closed; current line is consumed (not available to others)

Writing a custom block parser

Step 1: Define the AST node

Create a class inheriting from LeafBlock (for blocks with inline content) or ContainerBlock (for blocks containing other blocks):

using Markdig.Parsers;
using Markdig.Syntax;

/// <summary>
/// A custom note block: !!! note "Title"
/// </summary>
public class NoteBlock : LeafBlock
{
    public NoteBlock(BlockParser parser) : base(parser)
    {
    }

    /// <summary>
    /// The note title.
    /// </summary>
    public string? Title { get; set; }

    /// <summary>
    /// The note type (note, warning, etc.).
    /// </summary>
    public string? NoteType { get; set; }
}

Step 2: Implement the block parser

using Markdig.Helpers;
using Markdig.Parsers;

public class NoteBlockParser : BlockParser
{
    public NoteBlockParser()
    {
        // This parser triggers on '!' characters
        OpeningCharacters = ['!'];
    }

    public override BlockState TryOpen(BlockProcessor processor)
    {
        // Don't match if indented as code (4+ spaces)
        if (processor.IsCodeIndent)
            return BlockState.None;

        var line = processor.Line;
        var startPosition = line.Start;

        // Check for "!!!" prefix
        if (line.CurrentChar != '!' || line.PeekChar(1) != '!' || line.PeekChar(2) != '!')
            return BlockState.None;

        // Advance past "!!!"
        line.Start += 3;
        line.TrimStart(); // Skip whitespace

        // Read the note type (e.g., "note", "warning")
        var noteType = line.ToString().Trim();
        string? title = null;

        // Check for quoted title: !!! note "My Title"
        var quoteIndex = noteType.IndexOf('"');
        if (quoteIndex >= 0)
        {
            title = noteType[(quoteIndex + 1)..].TrimEnd('"').Trim();
            noteType = noteType[..quoteIndex].Trim();
        }

        // Create the block and push it
        var block = new NoteBlock(this)
        {
            NoteType = noteType,
            Title = title,
            Span = new SourceSpan(startPosition, line.End),
            Line = processor.LineIndex,
            Column = processor.Column
        };

        processor.NewBlocks.Push(block);
        return BlockState.Break; // Single-line block
    }
}

Step 3: Key points about TryOpen

  • Check processor.IsCodeIndent — If the line is indented 4+ spaces, it's a code block, not your custom syntax.
  • Set the Parser property — The Block constructor automatically sets it from the argument.
  • Set Span, Line, and Column — These enable precise source location tracking.
  • Support trivia when enabled — If processor.TrackTrivia is true, take pending leading blank/trivia lines via processor.TakeLinesBefore() and assign them to block.LinesBefore (and use processor.UseTrivia(end) when you need exact trivia slices around markers).
  • Push to processor.NewBlocks — This tells the processor a new block was found.
  • Return the right BlockStateBreak for a single-line block, Continue for multi-line blocks.

Multi-line block parsers

For blocks that span multiple lines, use TryContinue:

public class AdmonitionParser : BlockParser
{
    public AdmonitionParser()
    {
        OpeningCharacters = ['!'];
    }

    public override BlockState TryOpen(BlockProcessor processor)
    {
        if (processor.IsCodeIndent) return BlockState.None;

        var line = processor.Line;
        if (line.CurrentChar != '!' || line.PeekChar(1) != '!' || line.PeekChar(2) != '!')
            return BlockState.None;

        line.Start += 3;
        line.TrimStart();

        var block = new AdmonitionBlock(this)
        {
            // ... set properties
        };

        processor.NewBlocks.Push(block);
        return BlockState.Continue; // Expect more lines
    }

    public override BlockState TryContinue(BlockProcessor processor, Block block)
    {
        // Continue while lines are indented (part of the admonition)
        if (processor.IsBlankLine)
            return BlockState.Continue; // Blank lines are allowed

        if (processor.Indent >= 4)
        {
            // Indented content belongs to this block
            processor.GoToColumn(processor.ColumnBeforeIndent + 4);
            return BlockState.Continue;
        }

        // Not indented — close the block
        return BlockState.Break;
    }
}

Using FencedBlockParserBase

For blocks that use opening/closing fences (like ::: custom containers or ``` code blocks), inherit from FencedBlockParserBase<T> to get fencing logic for free:

using Markdig.Parsers;

/// <summary>
/// A custom "spoiler" block: |||spoiler ... |||
/// </summary>
public class SpoilerBlock : FencedCodeBlock
{
    public SpoilerBlock(BlockParser parser) : base(parser) { }
}

public class SpoilerParser : FencedBlockParserBase<SpoilerBlock>
{
    public SpoilerParser()
    {
        OpeningCharacters = ['|'];
        InfoPrefix = null; // No info string prefix required
    }

    protected override SpoilerBlock CreateFencedBlock(BlockProcessor processor)
    {
        return new SpoilerBlock(this);
    }
}

The base class handles:

  • Counting opening fence characters (minimum 3)
  • Matching the closing fence
  • Storing the info string
  • Managing TryContinue automatically

This is the pattern used by CustomContainerParser (3+ colons :::) — it's extremely concise.

The BlockProcessor state

Inside TryOpen and TryContinue, you have access to the BlockProcessor which provides:

Property Description
processor.Line Current source line as a StringSlice
processor.CurrentChar Character at current position
processor.Column Current column number
processor.Indent Columns since the last indent reference point
processor.IsCodeIndent true if indent ≥ 4
processor.IsBlankLine true if the line is empty
processor.LineIndex Zero-based line number in the source
processor.NewBlocks Stack where you push newly created blocks
processor.CurrentContainer The innermost open container block
processor.Document The root MarkdownDocument

Useful StringSlice methods

The processor.Line is a StringSlice — a window into the source text:

Method Description
CurrentChar Character at current position
PeekChar(int offset) Look ahead without advancing
NextChar() Advance by one character
SkipChar() Skip the current character
TrimStart() Skip leading whitespace
IsEmpty True if no characters remain
Start / End Start/end positions (mutable)

CanInterrupt

The CanInterrupt method controls whether your parser can interrupt an open paragraph. By default it returns true, meaning your block syntax can start in the middle of a paragraph. Override to return false if you want your syntax to only work at the start of a block context.

public override bool CanInterrupt(BlockProcessor processor, Block block)
{
    // Only allow after a blank line, not in the middle of a paragraph
    return false;
}

The Close method

Close is called when a block is being finalized. Return false to remove the block from the AST (useful if the block turned out to be invalid):

public override bool Close(BlockProcessor processor, Block block)
{
    var myBlock = (MyBlock)block;
    if (!myBlock.IsValid)
    {
        return false; // Remove from AST
    }
    return true; // Keep in AST
}

Trivia tracking

When TrackTrivia is enabled, use processor.TakeLinesBefore() in TryOpen to capture pending blank/trivia lines:

public override BlockState TryOpen(BlockProcessor processor)
{
    // ... detection logic ...

    var block = new MyBlock(this);
    block.LinesBefore = processor.TakeLinesBefore();

    processor.NewBlocks.Push(block);
    return BlockState.Continue;
}

Next steps