Renderers walk the AST and produce output. Markdig ships with an HtmlRenderer (HTML output), a NormalizeRenderer (canonical Markdown output), and supports fully custom renderers for any output format.
When you call document.ToHtml(pipeline):
HtmlRenderer is created (internally pooled for performance).pipeline.Setup(renderer) is called — each extension's Setup(MarkdownPipeline, IMarkdownRenderer) runs, registering per-type ObjectRenderers.ObjectRenderer registered for its runtime type.TextWriter.public interface IMarkdownRenderer
{
event Action<IMarkdownRenderer, MarkdownObject> ObjectWriteBefore;
event Action<IMarkdownRenderer, MarkdownObject> ObjectWriteAfter;
ObjectRendererCollection ObjectRenderers { get; }
object Render(MarkdownObject markdownObject);
}
The most common way to render custom AST nodes to HTML is to create a class inheriting from HtmlObjectRenderer<T>:
using Markdig.Renderers;
using Markdig.Renderers.Html;
public class HtmlNoteBlockRenderer : HtmlObjectRenderer<NoteBlock>
{
protected override void Write(HtmlRenderer renderer, NoteBlock obj)
{
// Open the div with attributes from the AST node
renderer.Write("<div class=\"note note-")
.Write(obj.NoteType ?? "info")
.Write("\"");
// Write any HTML attributes attached to the node
renderer.WriteAttributes(obj);
renderer.WriteLine(">");
// Write the title
if (!string.IsNullOrEmpty(obj.Title))
{
renderer.Write("<p class=\"note-title\">")
.WriteEscape(obj.Title)
.WriteLine("</p>");
}
// Write inline content (for LeafBlocks)
renderer.WriteLeafInline(obj);
renderer.WriteLine("</div>");
}
}
The generic parameter T automatically registers this renderer for all NoteBlock nodes.
The HtmlRenderer provides these commonly used methods:
| Method | Description |
|---|---|
Write(string) |
Write raw text |
Write(char) |
Write a single character |
WriteEscape(string) |
Write HTML-escaped text |
WriteEscape(StringSlice) |
Write HTML-escaped slice |
WriteLeafInline(LeafBlock) |
Render all inlines of a leaf block |
WriteLeafRawLines(LeafBlock) |
Write raw line content (for code blocks) |
WriteAttributes(MarkdownObject) |
Write attached HTML attributes |
WriteLine() |
Write a newline |
EnsureLine() |
Write a newline only if not already at line start |
PushIndent(string) |
Push indentation for nested content |
PopIndent() |
Pop indentation |
For custom ContainerBlock nodes, render children with WriteChildren:
public class HtmlMyContainerRenderer : HtmlObjectRenderer<MyContainerBlock>
{
protected override void Write(HtmlRenderer renderer, MyContainerBlock obj)
{
renderer.Write("<div class=\"my-container\"");
renderer.WriteAttributes(obj);
renderer.WriteLine(">");
// Render all child blocks
renderer.WriteChildren(obj);
renderer.WriteLine("</div>");
}
}
For custom Inline nodes:
public class HtmlKeyboardRenderer : HtmlObjectRenderer<KeyboardInline>
{
protected override void Write(HtmlRenderer renderer, KeyboardInline obj)
{
renderer.Write("<kbd");
renderer.WriteAttributes(obj);
renderer.Write(">");
renderer.WriteEscape(obj.Shortcut ?? "");
renderer.Write("</kbd>");
}
}
For ContainerInline types, render children inline:
public class HtmlHighlightRenderer : HtmlObjectRenderer<HighlightInline>
{
protected override void Write(HtmlRenderer renderer, HighlightInline obj)
{
renderer.Write("<mark");
renderer.WriteAttributes(obj);
renderer.Write(">");
// Render child inlines
renderer.WriteChildren(obj);
renderer.Write("</mark>");
}
}
Renderers are registered in the extension's Setup(MarkdownPipeline, IMarkdownRenderer) method:
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
if (renderer is HtmlRenderer htmlRenderer)
{
// Add if not already present
htmlRenderer.ObjectRenderers.AddIfNotAlready<HtmlNoteBlockRenderer>();
htmlRenderer.ObjectRenderers.AddIfNotAlready<HtmlKeyboardRenderer>();
}
}
Like parsers, renderers can be inserted at specific positions:
// Insert at the beginning (highest priority)
htmlRenderer.ObjectRenderers.Insert(0, new HtmlNoteBlockRenderer());
// Insert before a specific type
htmlRenderer.ObjectRenderers.InsertBefore<HtmlCodeBlockRenderer>(
new HtmlNoteBlockRenderer());
You can modify built-in renderers without replacing them:
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
if (renderer is HtmlRenderer htmlRenderer)
{
// Modify the CodeBlockRenderer to output divs for specific languages
var codeRenderer = htmlRenderer.ObjectRenderers.FindExact<CodeBlockRenderer>();
if (codeRenderer != null)
{
codeRenderer.BlocksAsDiv.Add("mermaid");
codeRenderer.BlocksAsDiv.Add("nomnoml");
}
}
}
The HtmlRenderer has several useful properties:
| Property | Type | Default | Description |
|---|---|---|---|
EnableHtmlForInline |
bool |
true |
Emit HTML tags for inlines |
EnableHtmlForBlock |
bool |
true |
Emit HTML tags for blocks |
EnableHtmlEscape |
bool |
true |
HTML-escape special characters |
When all three are false, the renderer produces plain text — this is how Markdown.ToPlainText works.
You can hook into the rendering pipeline with events:
renderer.ObjectWriteBefore += (r, obj) =>
{
if (obj is HeadingBlock heading)
{
Console.WriteLine($"About to render H{heading.Level}");
}
};
renderer.ObjectWriteAfter += (r, obj) =>
{
// Post-processing after each node is rendered
};
For non-HTML output (LaTeX, XAML, JSON, etc.), you can implement a full custom renderer:
For text-based output formats:
using Markdig.Renderers;
public class LatexRenderer : TextRendererBase<LatexRenderer>
{
public LatexRenderer(TextWriter writer) : base(writer)
{
// Register per-type renderers
ObjectRenderers.Add(new LatexHeadingRenderer());
ObjectRenderers.Add(new LatexParagraphRenderer());
ObjectRenderers.Add(new LatexCodeBlockRenderer());
// ... register all needed renderers
}
}
// Per-type renderer
public class LatexHeadingRenderer : MarkdownObjectRenderer<LatexRenderer, HeadingBlock>
{
protected override void Write(LatexRenderer renderer, HeadingBlock obj)
{
var command = obj.Level switch
{
1 => "section",
2 => "subsection",
3 => "subsubsection",
_ => "paragraph"
};
renderer.Write($"\\{command}{{");
renderer.WriteLeafInline(obj);
renderer.WriteLine("}");
}
}
For fully custom output (JSON, binary, etc.):
public class JsonRenderer : IMarkdownRenderer
{
public event Action<IMarkdownRenderer, MarkdownObject>? ObjectWriteBefore;
public event Action<IMarkdownRenderer, MarkdownObject>? ObjectWriteAfter;
public ObjectRendererCollection ObjectRenderers { get; } = new();
public object Render(MarkdownObject markdownObject)
{
// Walk the AST and produce JSON
// ...
return jsonBuilder.ToString();
}
}
Pass your renderer to Markdown.Convert:
var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
var renderer = new LatexRenderer(writer);
// Setup extensions for the custom renderer
pipeline.Setup(renderer);
// Render
Markdown.Convert(markdownText, renderer, pipeline);
Putting it all together — the block parser, renderer, and extension:
// Extension
public sealed class NoteExtension : IMarkdownExtension
{
public void Setup(MarkdownPipelineBuilder pipeline)
{
pipeline.BlockParsers.AddIfNotAlready<NoteBlockParser>();
}
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
if (renderer is HtmlRenderer htmlRenderer)
{
htmlRenderer.ObjectRenderers.AddIfNotAlready<HtmlNoteBlockRenderer>();
}
}
}
// Fluent API
public static class NoteExtensionMethods
{
public static MarkdownPipelineBuilder UseNotes(
this MarkdownPipelineBuilder pipeline)
{
pipeline.Extensions.AddIfNotAlready<NoteExtension>();
return pipeline;
}
}
Usage:
var pipeline = new MarkdownPipelineBuilder()
.UseNotes()
.Build();
var html = Markdown.ToHtml("!!! warning \"Be careful\"\n", pipeline);