MarkView.Avalonia is designed to be extended. The three main extension points are:
All extension points are accessed through IMarkViewExtension.
To show the fenced code blocks of one language as your own control (```chart → a chart),
you don't need any of them: register a fence renderer.
A fence renderer maps a fenced code block's language to a factory that builds a control from the block's content. No Markdig renderer is involved:
using MarkView.Avalonia;
viewer.UseFenceRenderer("chart", (code, info) => new MyChart(code));
// or for every viewer of the application (App.axaml.cs):
MarkdownViewerDefaults.Extensions.AddFenceRenderer("chart", (code, info) => new MyChart(code));
```chart {height=200}
jan 12
feb 19
```
The factory is a FenceRendererFactory, Control? (string code, string info):
code is the block's content without its fences, its lines joined with \n.info is the whole info string after the opening fence: the language, then anything after it
(chart {height=200} above), for the factory to parse as it likes. With Markdig's
UseGenericAttributes() in the pipeline, a trailing {...} is parsed as
attributes instead, and is not part of info.null to render that block as an ordinary code
block, e.g. when its content isn't valid for your control.Behaviour:
UseFenceRenderer("mermaid", …) replaces the Mermaid package's diagrams.
Blocks of other languages render as they would without it, and $$ math blocks stay math.Extensions override MarkdownViewerDefaults.Extensions.
UseFenceRenderer also removes the viewer's earlier fence renderer for that language.UseFenceRenderer re-renders the viewer's document. Adding a
FenceRendererExtension to Extensions directly (or with AddFenceRenderer) takes effect on
the next render, as with any other extension.Control), so one faulty block never takes the rest of the document down.Border with the markdown-custom-fence and
markdown-fence-<language> classes (the language in lower case, e.g. markdown-fence-chart);
see Theming.Border is selected as a whole and copied as the block's fenced source
(```chart {height=200} … ```), as Mermaid diagrams are. To copy something
else, set MarkdownSelection.CopyText on the control the factory
returns; set it to null to leave the control out of selection.A fence renderer is itself an IMarkViewExtension — new FenceRendererExtension("chart", factory)
— so it can be added to an Extensions list like any other extension.
A control styled with DynamicResource follows light/dark switches on its own. When the control
bakes colours in (a chart drawn to a bitmap, say), rebuild them with
ThemeTracking.ReapplyOnThemeChange, from the factory:
viewer.UseFenceRenderer("chart", (code, info) =>
{
var image = new Image();
void Bake() => image.Source = MyChart.Render(code,
isDark: Application.Current?.ActualThemeVariant == ThemeVariant.Dark);
Bake();
ThemeTracking.ReapplyOnThemeChange(image, Bake);
return image;
});
using MarkView.Avalonia.Extensions;
using MarkView.Avalonia.Rendering;
public interface IMarkViewExtension
{
void Register(AvaloniaRenderer renderer);
}
Register is called once per render pass, before pipeline.Setup(renderer), so extensions can install renderers that override the defaults.
Register your extension on a viewer instance or globally:
viewer.Extensions.Add(new MyExtension());
// or globally:
MarkdownViewerDefaults.Extensions.Add(new MyExtension());
Use renderer.ObjectRenderers.ReplaceOrAdd<TExisting, TNew>() to swap out a built-in renderer:
public class MyCodeBlockExtension : IMarkViewExtension
{
public void Register(AvaloniaRenderer renderer)
{
renderer.ObjectRenderers.ReplaceOrAdd<CodeBlockRenderer, MyCodeBlockRenderer>();
}
}
To insert at a specific position (e.g. to intercept before the default):
renderer.ObjectRenderers.Insert(0, new MyFencedCodeInterceptor());
Extend AvaloniaObjectRenderer<TBlock>:
using Markdig.Syntax;
using MarkView.Avalonia.Rendering;
public class MyCodeBlockRenderer : AvaloniaObjectRenderer<FencedCodeBlock>
{
protected override void Write(AvaloniaRenderer renderer, FencedCodeBlock obj)
{
var language = obj.Info ?? string.Empty;
var source = obj.Lines.ToString();
var code = new TextBlock { Text = source };
renderer.ApplyTheme(code, "MarkdownCodeTheme");
var border = new Border { Child = code };
border.Classes.Add("markdown-code-block");
renderer.ApplyTheme(border, "MarkdownCodeBlockTheme");
renderer.WriteBlock(border);
}
}
The built-in look comes from control themes, not from
the markdown-* classes: renderer.ApplyTheme(control, key) gives a control the theme with that
resource key, found from the viewer, so it follows presets and is swapped when a preset is added or
removed. A class alone only matches the application's own styles. Set the class too, so those
styles and the style class reference still apply.
A custom code block renderer does not get the built-in code block toolbar, nor its line numbers, wrapping and maximum height; add your own controls if you want them.
renderer.WriteBlock(control) adds the control to the current Panel container.
renderer.WriteInline(inline) adds an inline to the current InlineCollection.
renderer.Push(container) / renderer.Pop() manage the render stack.
While a document is streamed, a renderer runs again for the block that contains the
end of the text on every update. renderer.IsProvisional(obj) says whether the block is in that
state, so a renderer can show a cheap placeholder, such as the source text, instead of building a
chart or fetching a resource from a half-written block. See
For custom renderers for a sketch. A renderer for
FencedCodeBlock registered before UseMath() receives the MathBlock (empty Info, fence
character $) in the provisional frame, so it must dispatch on Info as the built-in ones do.
Controls styled through DynamicResource follow light/dark switches on their own. Content whose
colours are baked in at render time — bitmaps, SVG, per-token brushes — has to be rebuilt instead.
Build it once, then hand the rebuild to ThemeTracking.ReapplyOnThemeChange:
using Avalonia;
using Avalonia.Styling;
using MarkView.Avalonia.Rendering;
public class ChartBlockRenderer : AvaloniaObjectRenderer<ChartBlock>
{
protected override void Write(AvaloniaRenderer renderer, ChartBlock obj)
{
var image = new Image();
var border = new Border { Child = image };
void Bake() => image.Source = MyChart.Render(obj,
isDark: Application.Current?.ActualThemeVariant == ThemeVariant.Dark);
Bake();
ThemeTracking.ReapplyOnThemeChange(border, Bake);
renderer.WriteBlock(border);
}
}
The callback runs when the theme changes while the element is shown, and when the element is
attached again after a switch that happened while it was detached (e.g. on an inactive tab). It
listens to the element's own inherited theme, so a document that is never shown, or is replaced
by a re-render, is garbage-collected normally. Subscribing to Application.Current events
directly instead keeps every rendered document alive for the lifetime of the application.
Pass an element that stays in the document for as long as the content should follow the theme —
here the Border, not the Image, so that a renderer swapping the image for an error fallback
keeps tracking the theme.
When the viewer's lazy rendering is on, its own renderers build
images, formulas, diagrams and syntax colours only once they are about to scroll into view. A custom
renderer can do the same with ViewportTracking.RunWhenNearViewport, when
renderer.IsLazyRenderingEnabled is set:
var built = false;
ThemeTracking.ReapplyOnThemeChange(border, () => { if (built) Bake(); });
if (renderer.IsLazyRenderingEnabled)
ViewportTracking.RunWhenNearViewport(border, () => { built = true; Bake(); });
else
{
built = true;
Bake();
}
renderer.WriteBlock(border);
The callback runs once, the first time the element is laid out within one viewport height of the
visible area, during that layout pass and before it is drawn: content it builds is laid out before
it is ever seen. An element that is never shown never runs it, and is garbage-collected normally.
A bare AvaloniaRenderer has the switch off by default, since nothing guarantees that its output will
be laid out in a viewport; MarkdownViewer turns it on from its IsLazyRenderingEnabled property.
Document-wide text selection only knows how to copy text. Give a non-text
control the text a selection should copy in its place with the MarkdownSelection.CopyText
attached property:
var border = new Border { Child = image };
MarkdownSelection.SetCopyText(border, obj.Lines.ToString()); // copy the chart's source
renderer.WriteBlock(border);
WriteBlock is selectable only when it has copy text. It is then
highlighted and copied as a whole; dragging past its vertical middle includes it in the selection.WriteInline(Control) is always highlighted with the surrounding text,
and copied as its copy text, or as nothing when it has none.As with theme tracking, set it on the element that stays in the document (the Border), so an
error fallback that replaces the border's content is still copied.
Implement IImageLoader:
using MarkView.Avalonia.Extensions;
public class MyImageLoader : IImageLoader
{
public bool CanLoad(string url) => url.StartsWith("myapp://images/");
public async Task<IImage?> LoadAsync(string url, CancellationToken ct = default)
{
// Return null to fall through to the next loader in the chain
var stream = await MyApp.GetImageStreamAsync(url, ct);
if (stream is null) return null;
return new Bitmap(stream);
}
}
Register it in a IMarkViewExtension:
public class MyImageExtension : IMarkViewExtension
{
public void Register(AvaloniaRenderer renderer)
{
// Insert at 0 to take priority over all built-in loaders
renderer.ImageLoaders.Insert(0, new MyImageLoader());
}
}
The loader chain is tried in order. The first loader whose CanLoad returns true and whose LoadAsync returns a non-null result wins. Returning null from LoadAsync passes control to the next loader.
When no loader returns an image, or a loader throws (other than for the cancellation it was given, which comes when the image is removed while it loads), the viewer shows a placeholder instead. A loaded image is kept in the image cache under its URL and the types of the loaders in the chain, so a loader is not asked again for an image it already returned. Its result should therefore depend only on the URL. The viewer may show the image a loader returns in several places at once, and never disposes it.
Implement ICodeHighlighter (or IThemeAwareCodeHighlighter for live theme switching):
using MarkView.Avalonia.Extensions;
using Avalonia.Media;
public class MyHighlighter : ICodeHighlighter
{
public IReadOnlyList<(string Text, IBrush? Foreground)>? Highlight(
ReadOnlyMemory<char> line, string? language)
{
// Return null to signal the language is unsupported — falls back to monochrome.
// Return an empty list to signal supported but no tokens.
if (line.IsEmpty) return [];
return [(line.ToString(), Brushes.LimeGreen)];
}
}
IThemeAwareCodeHighlighter adds HighlightVariant(line, language, isDark) — called in-place when the user switches themes, allowing code blocks to update colours without a full document re-render.
IDualThemeCodeHighlighter goes further: HighlightBoth(line, language) tokenises a line once and returns each token's colour for both variants, so a theme switch only recolours the existing text instead of highlighting it again and rebuilding it.
Each of these methods also has an overload with a ref object? state parameter. A code block calls it for its lines in turn, from a null state for the first line, each time passing on the state the previous call set, so a highlighter can carry a construct that spans lines (a block comment, a multi-line string) on to the next line. By default the overload ignores the state and calls the one without it.
Register on the renderer:
public class MyHighlightExtension : IMarkViewExtension
{
public void Register(AvaloniaRenderer renderer)
{
renderer.CodeHighlighter = new MyHighlighter();
}
}
/// <summary>Renders ::: note / ::: warning fences from a custom Markdig extension.</summary>
public class AdmonitionExtension : IMarkViewExtension
{
public void Register(AvaloniaRenderer renderer)
{
renderer.ObjectRenderers.Add(new AdmonitionBlockRenderer());
}
}
public class AdmonitionBlockRenderer : AvaloniaObjectRenderer<AdmonitionBlock>
{
protected override void Write(AvaloniaRenderer renderer, AdmonitionBlock obj)
{
var panel = new StackPanel();
var border = new Border { Child = panel };
border.Classes.Add("my-admonition");
border.Classes.Add($"my-admonition-{obj.Kind.ToLowerInvariant()}");
var header = new TextBlock { Text = obj.Kind.ToUpperInvariant() };
header.Classes.Add("my-admonition-header");
panel.Children.Add(header);
renderer.Push(panel);
renderer.WriteChildren(obj);
renderer.Pop();
renderer.WriteBlock(border);
}
}
The example above renders an AdmonitionBlock but doesn't show how that block gets parsed
in the first place — writing a MarkView.Avalonia renderer is only half of a real
extension. The other half is a Markdig IMarkdownExtension that parses your syntax into a
syntax tree node.
This example implements %[color:red]text% inline colour spans end to end. It ships as a
runnable, documented demo page in samples/MarkView.Avalonia.Demo/ColorExtension/ — open
that folder (and Assets/color-extension.md, which the page renders) for the full
tutorial; this section covers the shape of the five pieces involved.
A leaf node holding whatever your parser extracts — here, a colour name and literal text content:
public sealed class ColorSpanInline : LeafInline
{
public string Color { get; }
public string Content { get; }
public ColorSpanInline(string color, string content)
{
Color = color;
Content = content;
}
}
Extend Markdig.Parsers.InlineParser, set OpeningCharacters to the character(s) that
trigger it, and implement Match. Returning false leaves the input slice untouched and
lets Markdig fall through to the next parser (plain text) — every built-in inline parser
follows this same graceful-degradation contract, so a malformed %[color:...] just renders
as literal text instead of breaking the document:
public sealed class ColorSpanParser : InlineParser
{
public ColorSpanParser() => OpeningCharacters = ['%'];
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// ... match "[color:NAME]", scan ahead for the closing '%' ...
// On success:
processor.Inline = new ColorSpanInline(color, content)
{
Span = new SourceSpan(
processor.GetSourcePosition(startPosition, out var line, out var column),
processor.GetSourcePosition(slice.Start - 1)),
Line = line,
Column = column,
};
return true;
}
}
Setting Span/Line/Column via InlineProcessor.GetSourcePosition matters beyond
bookkeeping — MarkView.Avalonia's cross-block text selection (see
Selection and copy) relies on accurate source spans to map screen
selections back to document positions.
IMarkdownExtensionThis half is pure Markdig and knows nothing about Avalonia:
public sealed class ColorMarkdownExtension : IMarkdownExtension
{
public void Setup(MarkdownPipelineBuilder pipeline) =>
pipeline.InlineParsers.AddIfNotAlready<ColorSpanParser>();
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) { }
}
public static class ColorMarkdownExtensions
{
public static MarkdownPipelineBuilder UseColorSpans(this MarkdownPipelineBuilder builder) =>
builder.Use<ColorMarkdownExtension>();
}
IMarkViewExtensionThe MarkView.Avalonia half turns the parsed node into a control, following the same
AvaloniaObjectRenderer<T> / IMarkViewExtension shape as every example earlier in this
document:
public sealed class ColorSpanRenderer : AvaloniaObjectRenderer<ColorSpanInline>
{
protected override void Write(AvaloniaRenderer renderer, ColorSpanInline obj)
{
var run = new Run(obj.Content);
if (Color.TryParse(obj.Color, out var color))
run.Foreground = new SolidColorBrush(color);
renderer.WriteInline(run);
}
}
public sealed class ColorSpanExtension : IMarkViewExtension
{
public void Register(AvaloniaRenderer renderer) =>
renderer.ObjectRenderers.Add(new ColorSpanRenderer());
}
An unparseable colour name is treated the same way as a parse failure anywhere else in this document's examples: fall back to a sane default (here, the inherited foreground) rather than throwing.
viewer.Pipeline = new MarkdownPipelineBuilder()
.UseGitHubExtensions()
.UseColorSpans()
.Build();
viewer.Extensions.Add(new ColorSpanExtension());
Note the asymmetry if you set Pipeline on a MarkdownViewer instance instead of (or in
addition to) MarkdownViewerDefaults.Pipeline: an instance Pipeline fully replaces
the global default — none of MarkdownViewerDefaults.Pipeline's extensions apply unless
you add them again yourself. Extensions, by contrast, is additive — an instance's
Extensions list is combined with MarkdownViewerDefaults.Extensions, not a replacement
for it. See Settings for every viewer for the full precedence rules.