Add a Markdig extension or inline parser
Register any Markdig extension, inline parser, or block parser through Configure
To add syntax Markdig doesn't parse out of the box — [[wiki-links]], a definition shortcut, a custom container — register a Markdig extension or a raw inline/block parser through PenningtonOptions.ConfigureMarkdownPipeline. The hook runs after every built-in extension with the resolved IServiceProvider, so you extend the same pipeline that renders the rest of the site rather than replacing it.
This is the hook to reach for instead of writing your own renderer. For directives that expand to a string before parsing, a shortcode is lighter; to claim a whole fenced block, use a code-block preprocessor. Everything else — new inline tokens, new block syntax, swapping a renderer — goes through ConfigureMarkdownPipeline.
The recipe references examples/ExtensibilityLabExample/WikiLinkExtension.cs, which adds a [[…]] inline parser to a bare AddPennington host.
Before you begin
- An existing Pennington site with markdown rendering wired (see Create your first Pennington site if not).
- A look at what the pipeline already enables, below — several "missing" features are already on.
Check what's already enabled
Pennington's pipeline is Markdig's UseAdvancedExtensions() plus its own renderers. Before you register anything, confirm the feature isn't already parsed — re-adding an extension that's present is a double-register that, depending on the extension, duplicates parsers, reorders them, or shadows the built-in.
UseAdvancedExtensions() already turns on the usual advanced set — auto-identifiers, footnotes, grid and pipe tables, task lists, mathematics, and the rest — and Pennington layers its own front matter, syntax highlighting, tabbed code, content tabs, custom alerts, and Mdazor rendering on top. The extensions catalog is the full list to check against.
Math already works — don't re-register it
UseAdvancedExtensions() includes the mathematics extension, so math is parsed today with no configuration. Inline $E = mc^2$ renders to <span class="math">\(E = mc^2\)</span> and a $$…$$ block to <div class="math">\[…\]</div> — already in the \(…\) / \[…\] delimiters KaTeX and MathJax expect. Registering UseMathematics() again is the double-register to avoid. Turning that markup into typeset math is a client-side step, not a Markdig one: load KaTeX (or MathJax) through the head content option and re-run its auto-render on spa:commit, exactly the head-content-plus-script pattern in Ship a custom client-side widget.
Write a custom inline parser
A genuinely new token needs a parser. WikiLinkExtension is an IMarkdownExtension whose Setup inserts one inline parser; the parser claims [[Target]] / [[Target|Label]] and emits an ordinary LinkInline so Markdig's own anchor renderer writes the <a>.
namespace ExtensibilityLabExample;
using System.Text;
using Markdig;
using Markdig.Helpers;
using Markdig.Parsers;
using Markdig.Parsers.Inlines;
using Markdig.Renderers;
using Markdig.Renderers.Html;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
/// <summary>
/// A Markdig extension that teaches the pipeline a new inline token: the
/// <c>[[Target]]</c> / <c>[[Target|Label]]</c> wiki-link. Each match renders as an
/// internal anchor — <c><a class="wikilink" href="/notes/<slug>/">Label</a></c>
/// — so digital-garden cross-references resolve like ordinary links.
/// <para>
/// Registered through <see cref="Pennington.Infrastructure.PenningtonOptions.ConfigureMarkdownPipeline"/>
/// in <c>Program.cs</c>; that hook runs after Pennington's built-in extensions, so the
/// extension only adds the one parser the built-ins don't already supply.
/// </para>
/// <para>
/// Backs how-to 2.2.65 <c>/how-to/markdown-pipeline/markdig-extension</c>.
/// </para>
/// </summary>
public sealed class WikiLinkExtension : IMarkdownExtension
{
/// <summary>Inserts the wiki-link inline parser ahead of the built-in link parser.</summary>
public void Setup(MarkdownPipelineBuilder pipeline)
{
// Run before the CommonMark link parser so a leading "[[" is claimed as a
// wiki-link instead of being read as the start of two nested "[...]" links.
if (!pipeline.InlineParsers.Contains<WikiLinkInlineParser>())
{
pipeline.InlineParsers.InsertBefore<LinkInlineParser>(new WikiLinkInlineParser());
}
}
/// <summary>No renderer wiring needed — the emitted <see cref="LinkInline"/> uses Markdig's own anchor renderer.</summary>
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer)
{
}
}
/// <summary>
/// Inline parser that claims the doubled-bracket <c>[[…]]</c> token and emits a
/// <see cref="LinkInline"/> pointing at <c>/notes/<slug>/</c>. A single <c>[</c>
/// is left for the built-in link parser.
/// </summary>
public sealed class WikiLinkInlineParser : InlineParser
{
/// <summary>Registers <c>[</c> as the trigger; <see cref="Match"/> bails unless it doubles.</summary>
public WikiLinkInlineParser()
{
OpeningCharacters = ['['];
}
/// <summary>Matches <c>[[Target]]</c> / <c>[[Target|Label]]</c> and emits the anchor inline.</summary>
public override bool Match(InlineProcessor processor, ref StringSlice slice)
{
// Only a doubled opener is a wiki-link; "[" alone belongs to the link parser.
if (slice.PeekChar() != '[')
{
return false;
}
var saved = slice;
slice.SkipChar(); // first [
slice.SkipChar(); // second [
// Scan the inner text up to the closing "]]". Wiki-links never span lines
// or nest, so a newline or a fresh "[" aborts the match.
var contentStart = slice.Start;
var contentEnd = -1;
var c = slice.CurrentChar;
while (c != '\0')
{
if (c == ']' && slice.PeekChar() == ']')
{
contentEnd = slice.Start - 1; // last char before "]]"
slice.SkipChar(); // first ]
slice.SkipChar(); // second ]
break;
}
if (c is '\n' or '\r' or '[')
{
break;
}
c = slice.NextChar();
}
// Unterminated or empty ("[[]]"): restore the slice and let other parsers try.
if (contentEnd < contentStart)
{
slice = saved;
return false;
}
var inner = new StringSlice(slice.Text, contentStart, contentEnd).AsSpan().ToString();
var (target, label) = SplitTargetAndLabel(inner);
if (target.Length == 0)
{
slice = saved;
return false;
}
var spanStart = processor.GetSourcePosition(saved.Start, out var line, out var column);
var spanEnd = processor.GetSourcePosition(slice.Start - 1);
var link = new LinkInline
{
Url = $"/notes/{Slugify(target)}/",
IsClosed = true,
Span = new SourceSpan(spanStart, spanEnd),
Line = line,
Column = column,
};
// The class is the contract downstream consumers key on. Internal hrefs like
// the one above are still rewritten by the response pipeline (locale prefixing,
// base-URL prefixing), so wiki-links stay portable across deploys and locales.
link.GetAttributes().AddClass("wikilink");
link.AppendChild(new LiteralInline(label));
processor.Inline = link;
return true;
}
// "Target|Label" → (Target, Label); "Target" → (Target, Target).
private static (string Target, string Label) SplitTargetAndLabel(string inner)
{
var pipe = inner.IndexOf('|');
if (pipe < 0)
{
var only = inner.Trim();
return (only, only);
}
var target = inner[..pipe].Trim();
var label = inner[(pipe + 1)..].Trim();
return (target, label.Length == 0 ? target : label);
}
// Lowercase, collapse runs of non-alphanumerics to single dashes, trim trailing dashes.
private static string Slugify(string value)
{
var sb = new StringBuilder(value.Length);
var prevDash = false;
foreach (var ch in value.Trim().ToLowerInvariant())
{
if (char.IsLetterOrDigit(ch))
{
sb.Append(ch);
prevDash = false;
}
else if (!prevDash && sb.Length > 0)
{
sb.Append('-');
prevDash = true;
}
}
return sb.ToString().TrimEnd('-');
}
}
Three details carry the parser:
OpeningCharacterstriggersMatch. The parser registers[, then returnsfalseimmediately unless the next character is also[, leaving single-bracket[text](url)links to the built-in parser.- Insert before the link parser.
InsertBefore<LinkInlineParser>gives the wiki-link parser first claim on[[; otherwise the CommonMark link parser reads the brackets as nested links. - Emit a
LinkInlineand tag it. SettingUrl, appending aLiteralInlinelabel, and callingGetAttributes().AddClass("wikilink")produces a normal anchor with a class downstream code can target. Restore the savedStringSliceand returnfalseon any malformed input so other parsers get their turn.
A block-level construct follows the same shape with a BlockParser inserted into pipeline.BlockParsers; to change how an existing node renders, swap its renderer in the second Setup(MarkdownPipeline, IMarkdownRenderer) overload the way Pennington's own syntax-highlighting and scrollable-tables extensions replace Markdig's default code-block and table renderers.
Register it through ConfigureMarkdownPipeline
ConfigureMarkdownPipeline is Action<MarkdownPipelineBuilder, IServiceProvider>, set inside the AddPennington lambda. It runs after the built-ins, and the second argument is the resolved service provider — resolve dependencies from it when a parser needs them (an HttpClient, options, a file-watched index). The lab discards it with _:
penn.ConfigureMarkdownPipeline = (pipeline, _) =>
pipeline.Extensions.AddIfNotAlready(new WikiLinkExtension());
Result
The demo page uses both wiki-link forms and a math block:
---
title: Wiki-links and math markup
description: A custom [[wiki-link]] inline parser registered via ConfigureMarkdownPipeline, plus the math markup the built-in pipeline already emits.
---
The `WikiLinkExtension` registered through `penn.ConfigureMarkdownPipeline`
teaches Markdig one new inline token. A bare target links to its slug — see
the [[Glossary]] — and the piped form sets its own label, like
[[content-pipeline|how rendering works]]. Each renders as
`<a class="wikilink" href="/notes/<slug>/">`, so the response pipeline prefixes
the internal href the same way it prefixes any other link.
A single bracket is untouched: an ordinary [link](https://example.com) still
parses through the built-in CommonMark link parser.
## Math is already on
No extension registration is needed for math — `UseAdvancedExtensions` (part of
Pennington's default pipeline) already parses it. Inline math like $E = mc^2$
renders to a `<span class="math">`, and a display block to a `<div class="math">`:
$$
\int_0^1 x^2 \,\mathrm{d}x = \frac{1}{3}
$$
The markup ships in KaTeX/MathJax delimiters; rendering it is a client-side step,
not a Markdig one.
The wiki-links render as anchors carrying class="wikilink", and the math block renders as the KaTeX-ready <div class="math">:
<a href="/notes/glossary/" class="wikilink">Glossary</a>
<a href="/notes/content-pipeline/" class="wikilink">how rendering works</a>
<div class="math">
\[
\int_0^1 x^2 \,\mathrm{d}x = \frac{1}{3}
\]</div>
How your custom HTML survives the response pipeline
After rendering, every page passes through the response rewriters and is harvested by the site projection. Custom markup rides through the same as built-in markup, with two things to do.
Emit your HTML into the content region. Search, llms.txt, and the link audit read the element named by PenningtonOptions.SiteProjection.ContentSelector (the lab uses article); markup placed there is captured and indexed by its visible text, while anything injected into the chrome or <head> is not. See Tune what the search box returns and Make the site discoverable to LLM crawlers.
Watch the word-break rewriter if you emit text-bearing <span>s. The shipped rewriters rewrite the internal hrefs you emit — adding the locale prefix and deploy base URL, so a wiki-link to /notes/glossary/ stays portable — but the opt-in word-break rewriter's default selector includes span. If a client script reads a <span> verbatim (a math span, say), leave word-break off for it or narrow its selector.
Verify
On your own site, register your extension through ConfigureMarkdownPipeline, put a [[Target]] (or your token) in any content page, run your site, and view source on that page: your token rendered as the markup your parser emits — <a class="wikilink" href="/notes/…/"> for the wiki-link parser. Then run your static build and grep the page's index.html in the output for the same markup. The build-time link audit treats custom anchors as real internal links, so it reports any href with no matching page as broken — which is the audit working, not a misfire.
To check against the reference implementation, run dotnet run --project examples/ExtensibilityLabExample, visit /wikilinks-demo/, and confirm the wiki-links are <a class="wikilink" href="/notes/…/"> and the math block is <div class="math">.
Related
- How-to: Expand a directive before Markdig parses — string expansion before the parser runs, for stamping values rather than new syntax.
- How-to: Add a custom fence syntax — claim a fenced block instead of an inline token.
- How-to: Ship a custom client-side widget — the head-content +
spa:commitpattern the math example uses. - Reference: Markdown extensions catalog — the syntax the default pipeline already provides.
- Background: The response-processing pipeline — where the rewriters and projection run.