---
title: Add a custom syntax highlighter
description: "Implement ICodeHighlighter for a fence language TextMateSharp doesn't cover and register it with HighlightingOptions.AddHighlighter."
canonical_url: https://usepennington.net/how-to/markdown-pipeline/custom-highlighter/
sidecar_url: https://usepennington.net/how-to/markdown-pipeline/custom-highlighter.md
content_hash: sha256:1fb6a227353221fe29183674e67331971b27cb4761b22e569ef8cc8d83d4acb0
tokens: 2599
uid: how-to.markdown-pipeline.custom-highlighter
reading_time_minutes: 4
---

Guides
# Add a custom syntax highlighter

Implement ICodeHighlighter for a fence language TextMateSharp doesn't cover and register it with HighlightingOptions.AddHighlighter.

 
To color a fence language TextMateSharp does not cover — a DSL, config format, or domain notation — implement `ICodeHighlighter`. For line-level callouts on a language already supported, see [Annotate specific lines in a code block](https://usepennington.net/how-to/code-samples/code-annotations.md). For transforming the fence body rather than coloring its tokens, see [Add a custom fence syntax](https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor.md).

 
The recipe below references `examples/ExtensibilityLabExample/PipelineHighlighter.cs`, which stakes out a fictional `pipeline` DSL against a bare `AddPennington` host.

 
## Before you begin

 
 - An existing Pennington site rendering markdown fences (see [Create your first Pennington site](https://usepennington.net/tutorials/getting-started/first-site.md) if not).
 - A target language not already served by `TextMateHighlighter` (priority 50) or `ShellHighlighter` (priority 75) — render a fence and inspect the emitted HTML for built-in token spans to confirm. `PlainTextHighlighter` is the hardcoded final fallback inside `HighlightingService`, reached only when no registered highlighter matches; it is not on the priority chain.
 
 
## Write the highlighter

 
Implement [Pennington.Highlighting.ICodeHighlighter](https://usepennington.net/reference/api/i-code-highlighter.md) as a sealed class. `Highlight(code, language)` returns the full HTML for the block, including the outer `<pre><code>` wrapper — the implementation owns escaping. Use `WebUtility.HtmlEncode` on every literal not wrapped in a span; anything missed becomes an injection vector.

 
```csharp:symbol
namespace ExtensibilityLabExample;
  
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Pennington.Highlighting;
  
/// <summary>
/// Implements <see cref="ICodeHighlighter"/> for a fictional <c>pipeline</c>
/// DSL — pipelines of the form
/// <c>source "name" -&gt; filter where=paid | transform total=sum | sink "name"</c>.
/// <para>
/// Keywords (<c>source</c>, <c>filter</c>, <c>transform</c>, <c>sink</c>)
/// and arrows (<c>-&gt;</c>, <c>|</c>) get wrapped in spans with CSS
/// classes so the stylesheet can theme them. Unrecognized tokens are
/// HTML-encoded and left alone.
/// </para>
/// <para>
/// Priority 100 — above <see cref="TextMateHighlighter"/>'s default (50)
/// and below <see cref="ShellHighlighter"/>'s 75 so this highlighter only
/// owns the <c>pipeline</c> language and nothing else.
/// </para>
/// <para>
/// Backs how-to 2.3.30 <c>/how-to/extensibility/custom-highlighter</c>.
/// </para>
/// </summary>
public sealed partial class PipelineHighlighter : ICodeHighlighter
{
    private static readonly HashSet<string> _keywords = new(StringComparer.OrdinalIgnoreCase)
        { "source", "filter", "transform", "sink", "where" };
  
    /// <summary>The languages this highlighter claims.</summary>
    public IReadOnlySet<string> SupportedLanguages { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "pipeline" };
  
    /// <summary>Priority for highlighter dispatch — higher wins.</summary>
    public int Priority => 100;
  
    /// <summary>Produce the highlighted HTML for one fence's body.</summary>
    public string Highlight(string code, string language)
    {
        if (string.IsNullOrEmpty(code))
        {
            return string.Empty;
        }
  
        var sb = new StringBuilder();
        sb.Append("<pre><code data-extensibility-lab=\"pipeline-highlighter\">");
  
        foreach (var rawLine in code.Split('\n'))
        {
            var line = rawLine.TrimEnd('\r');
            var position = 0;
  
            while (position < line.Length)
            {
                // Arrow `->`
                if (position + 1 < line.Length && line[position] == '-' && line[position + 1] == '>')
                {
                    sb.Append("<span class=\"pipeline-arrow\">-&gt;</span>");
                    position += 2;
                    continue;
                }
  
                // Pipe `|`
                if (line[position] == '|')
                {
                    sb.Append("<span class=\"pipeline-pipe\">|</span>");
                    position++;
                    continue;
                }
  
                // String literal "..."
                if (line[position] == '"')
                {
                    var end = line.IndexOf('"', position + 1);
                    if (end > 0)
                    {
                        var literal = line[position..(end + 1)];
                        sb.Append("<span class=\"pipeline-string\">");
                        sb.Append(WebUtility.HtmlEncode(literal));
                        sb.Append("</span>");
                        position = end + 1;
                        continue;
                    }
                }
  
                // Identifier / keyword
                var identMatch = IdentifierRegex().Match(line, position);
                if (identMatch.Success && identMatch.Index == position)
                {
                    var word = identMatch.Value;
                    if (_keywords.Contains(word))
                    {
                        sb.Append("<span class=\"pipeline-keyword\">");
                        sb.Append(WebUtility.HtmlEncode(word));
                        sb.Append("</span>");
                    }
                    else
                    {
                        sb.Append(WebUtility.HtmlEncode(word));
                    }
                    position += word.Length;
                    continue;
                }
  
                // Fallback: encode one character and continue.
                sb.Append(WebUtility.HtmlEncode(line[position].ToString()));
                position++;
            }
  
            sb.Append('\n');
        }
  
        sb.Append("</code></pre>");
        return sb.ToString();
    }
  
    [GeneratedRegex(@"[A-Za-z_][A-Za-z0-9_\-]*")]
    private static partial Regex IdentifierRegex();
}
```

 
Two values shape how the highlighter slots into the chain:

 
 - `SupportedLanguages` — every token returned here maps to a fence language (```` ```pipeline ````) that routes to this implementation. Use `StringComparer.OrdinalIgnoreCase` so `Pipeline` and `PIPELINE` match too.
 - `Priority` — higher wins when two highlighters claim the same language. For a brand-new language like `pipeline` that nothing else touches, the value is irrelevant — any number routes the fence to your implementation. Priority matters only when you override a language a shipped highlighter already owns: pick above `75` to beat `ShellHighlighter` (`bash`/`shell`/`sh`), or above `50` to beat `TextMateHighlighter` (every grammar it can load). `PipelineHighlighter` uses `100` purely to make the intent — "this wins outright" — legible.
 
 
## Register the highlighter

 
`PenningtonOptions.Highlighting.AddHighlighter` inserts the instance into the priority-sorted chain resolved by `HighlightingService`. Call it inside the `AddPennington` delegate so the highlighter is active for both `dotnet run` and `dotnet run -- build output`.

 
```csharp
builder.Services.AddPennington(penn =>
{
    penn.Highlighting.AddHighlighter(new PipelineHighlighter());
});
```

 
A markdown fence tagged with one of the strings from `SupportedLanguages` now routes to the custom highlighter instead of the fallback chain.

 
````markdown
```pipeline
source "orders" -> filter where=paid | transform total=sum | sink "warehouse"
```
````

 
## Style the emitted classes

 
The highlighter only wraps tokens in spans — `pipeline-keyword`, `pipeline-arrow`, `pipeline-pipe`, `pipeline-string`. Until a stylesheet colors those classes the block renders in the surrounding body color, so the fence looks no different from the unstyled `text` fallback. Built-in languages look colored out of the box because the shipped theme already styles TextMate's `hljs-*` classes; your custom classes are new, so the theme says nothing about them. Add the rules to the stylesheet the site already serves:

 
```css
.pipeline-keyword { color: #c678dd; font-weight: 600; }
.pipeline-arrow   { color: #56b6c2; }
.pipeline-pipe    { color: #56b6c2; }
.pipeline-string  { color: #98c379; }
```

 
The class names are whatever `Highlight` emits — keep the CSS and the span classes in sync. Reuse the theme's existing token colors (or its CSS custom properties) so the new language matches the rest of the site instead of introducing a fourth palette.

 
## Verify

 
On your own site, render a page with a `pipeline` fence next to a `text` fence and load it in a browser:

 
 - The `pipeline` fence shows colored keywords, arrows, and string literals; the `text` fence stays a single color. If both blocks look identical, the highlighter is running but the CSS rules above are missing or not loaded.
 - View source: the `pipeline` block carries `<span class="pipeline-keyword">` tokens. If it does not, the fence never reached your highlighter — confirm the fence tag matches a string in `SupportedLanguages` and the registration runs inside the `AddPennington` delegate.
 - Static build: run your build (`dotnet run -- build output`) and search the emitted HTML for `class="pipeline-keyword"` to confirm the highlighter runs during publish, not only under `dotnet run`.
 
 
For a complete worked highlighter and a demo fence that emits the `pipeline-*` spans, run `dotnet run --project examples/ExtensibilityLabExample` and visit `/pipeline-demo/`.

 
## Related

 
 - Reference: [Highlighting interfaces](https://usepennington.net/reference/api/i-code-highlighter.md)
 - Background: [The syntax-highlighting cascade](https://usepennington.net/explanation/rendering/highlighting.md)
 - Related how-to: [Register a code-block preprocessor](https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor.md)
 
 
[Previous
                
                Add a custom fence syntax](https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor.md)[Next
                    
                Expand a directive before Markdig parses](https://usepennington.net/how-to/markdown-pipeline/shortcodes.md)