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.
The BlockProcessor orchestrates block parsing. For each line in the source:
TryContinue). Blocks that don't continue are closed.TryOpen on block parsers whose OpeningCharacters match the current character.OpeningCharacters array — a parser is only tried when one of its opening characters matches the current position.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;
}
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) |
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; }
}
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
}
}
processor.IsCodeIndent — If the line is indented 4+ spaces, it's a code block, not your custom syntax.Parser property — The Block constructor automatically sets it from the argument.Span, Line, and Column — These enable precise source location tracking.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).processor.NewBlocks — This tells the processor a new block was found.BlockState — Break for a single-line block, Continue for multi-line blocks.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;
}
}
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:
TryContinue automaticallyThis is the pattern used by CustomContainerParser (3+ colons :::) — it's extremely concise.
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 |
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) |
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;
}
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
}
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;
}