This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy under /_llms/. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
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. For transforming the fence body rather than coloring its tokens, see Add a custom fence syntax.

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 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 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
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/.