---
title: Add a Markdig extension or inline parser
description: "Register any Markdig extension, inline parser, or block parser through ConfigureMarkdownPipeline — wiki-links as the worked example — and check what the default pipeline already enables before you add."
canonical_url: https://usepennington.net/how-to/markdown-pipeline/markdig-extension/
sidecar_url: https://usepennington.net/how-to/markdown-pipeline/markdig-extension.md
content_hash: sha256:4193582a0de809a2d689a972906ae48885761131721b3f767ca46291a16dbd02
tokens: 4004
uid: how-to.markdown-pipeline.markdig-extension
reading_time_minutes: 6
---

Guides
# Add a Markdig extension or inline parser

Register any Markdig extension, inline parser, or block parser through ConfigureMarkdownPipeline — wiki-links as the worked example — and check what the default pipeline already enables before you add.

 
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](https://usepennington.net/how-to/markdown-pipeline/shortcodes.md) is lighter; to claim a whole fenced block, use a [code-block preprocessor](https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor.md). 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](https://usepennington.net/tutorials/getting-started/first-site.md) 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](https://usepennington.net/how-to/rich-content/content-tabs.md), [custom alerts](https://usepennington.net/how-to/rich-content/alerts.md), and Mdazor rendering on top. The [extensions catalog](https://usepennington.net/reference/markdown/extensions.md) 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](https://usepennington.net/how-to/rich-content/client-side-widget.md).

 
## 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>`.

 
```csharp:symbol
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>&lt;a class="wikilink" href="/notes/&lt;slug&gt;/"&gt;Label&lt;/a&gt;</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/&lt;slug&gt;/</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:

 
 - **`OpeningCharacters` triggers `Match`.** The parser registers `[`, then returns `false` immediately 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 `LinkInline` and tag it.** Setting `Url`, appending a `LiteralInline` label, and calling `GetAttributes().AddClass("wikilink")` produces a normal anchor with a class downstream code can target. Restore the saved `StringSlice` and return `false` on 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 `_`:

 
```csharp
penn.ConfigureMarkdownPipeline = (pipeline, _) =>
    pipeline.Extensions.AddIfNotAlready(new WikiLinkExtension());
```

 
## Result

 
The demo page uses both wiki-link forms and a math block:

 
```markdown:symbol
---
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">`:

 
```html
<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](https://usepennington.net/how-to/discovery/search.md) and [Make the site discoverable to LLM crawlers](https://usepennington.net/how-to/feeds/llms-txt.md).

 
**Watch the word-break rewriter if you emit text-bearing `<span>`s.** The shipped rewriters rewrite the internal `href`s 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](https://usepennington.net/how-to/response-pipeline/html-rewriter.md)'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](https://usepennington.net/how-to/markdown-pipeline/shortcodes.md) — string expansion before the parser runs, for stamping values rather than new syntax.
 - How-to: [Add a custom fence syntax](https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor.md) — claim a fenced block instead of an inline token.
 - How-to: [Ship a custom client-side widget](https://usepennington.net/how-to/rich-content/client-side-widget.md) — the head-content + `spa:commit` pattern the math example uses.
 - Reference: [Markdown extensions catalog](https://usepennington.net/reference/markdown/extensions.md) — the syntax the default pipeline already provides.
 - Background: [The response-processing pipeline](https://usepennington.net/explanation/core/response-processing.md) — where the rewriters and projection run.
 
 
[Previous
                
                Add a custom content format](https://usepennington.net/how-to/content-services/custom-content-format.md)[Next
                    
                Add a custom fence syntax](https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor.md)