---
title: Add a custom fence syntax
description: "Implement ICodeBlockPreprocessor to claim a fence language or :modifier suffix and return pre-rendered HTML before the default highlighter chain runs."
canonical_url: https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor/
sidecar_url: https://usepennington.net/how-to/markdown-pipeline/code-block-preprocessor.md
content_hash: sha256:818977e931fafb95108ea90bd48e4a90ed26898d95c9a8d69f69e7e00c62a725
tokens: 1821
uid: how-to.markdown-pipeline.code-block-preprocessor
reading_time_minutes: 3
---

Guides
# Add a custom fence syntax

Implement ICodeBlockPreprocessor to claim a fence language or :modifier suffix and return pre-rendered HTML before the default highlighter chain runs.

 
To intercept a fence language or `:modifier` suffix — a chart block, a plaintext wrapper, an xmldocid resolver — implement `ICodeBlockPreprocessor`. The preprocessor returns pre-rendered HTML before the default highlighter chain runs, including the rendered `<pre><code>...</code></pre>`. For line-level CSS classes on an otherwise normal code block, trailing-comment directives are the lighter-weight choice — see [Annotate specific lines in a code block](https://usepennington.net/how-to/code-samples/code-annotations.md).

 
The recipe references `examples/ExtensibilityLabExample/LineCountPreprocessor.cs`, which claims the `linecount` fence.

 
## 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 chosen fence identifier — either a full `languageId` (`linecount`) or a `:modifier` suffix (`csharp:symbol`).
 
 
## Write the preprocessor

 
Implement [Pennington.Markdown.Extensions.ICodeBlockPreprocessor](https://usepennington.net/reference/api/i-code-block-preprocessor.md) as a sealed class. `TryProcess(code, languageId)` receives the full fence info string unchanged. Compare it case-insensitively against the claimed language id or modifier, return `null` for anything else so the next preprocessor or the default highlighter can handle it, and otherwise build the wrapper HTML around the encoded source.

 
```csharp:symbol
namespace ExtensibilityLabExample;
  
using System.Net;
using Pennington.Markdown.Extensions;
  
/// <summary>
/// Implements <see cref="ICodeBlockPreprocessor"/>. Intercepts fenced
/// code blocks tagged <c>linecount</c> and renders them inside a
/// <c>&lt;figure class="linecount"&gt;</c> wrapper that reports how many
/// lines the snippet spans. Returns <see langword="null"/> for any
/// other language so the default highlighter chain runs.
/// <para>
/// <see cref="CodeBlockPreprocessResult.SkipTransform"/> is <c>true</c>
/// because the output already contains the line count badge we want and
/// should not be touched by <c>CodeTransformer</c>'s <c>// [!code]</c>
/// annotation pass.
/// </para>
/// <para>
/// Backs how-to 2.3.20 <c>/how-to/extensibility/code-block-preprocessor</c>.
/// </para>
/// </summary>
public sealed class LineCountPreprocessor : ICodeBlockPreprocessor
{
    /// <summary>
    /// 500 — higher than the shipped code-fragment preprocessors so
    /// <c>linecount</c> wins over any language-modifier preprocessor
    /// that might claim the same fence info string.
    /// </summary>
    public int Priority => 500;
  
    public CodeBlockPreprocessResult? TryProcess(string code, string languageId)
    {
        if (!string.Equals(languageId, "linecount", StringComparison.OrdinalIgnoreCase))
        {
            return null;
        }
  
        var lineCount = CountLines(code);
        var encoded = WebUtility.HtmlEncode(code);
  
        var html = $"""
            <figure class="linecount" data-extensibility-lab="line-count-preprocessor">
              <figcaption>Line count: <strong>{lineCount}</strong></figcaption>
              <pre><code>{encoded}</code></pre>
            </figure>
            """;
  
        return new CodeBlockPreprocessResult(
            HighlightedHtml: html,
            BaseLanguage: "linecount",
            SkipTransform: true);
    }
  
    private static int CountLines(string code)
    {
        if (string.IsNullOrEmpty(code))
        {
            return 0;
        }
  
        var count = 1;
        foreach (var ch in code)
        {
            if (ch == '\n')
            {
                count++;
            }
        }
        // Trim trailing newline so "one\ntwo\n" counts as 2.
        if (code.EndsWith('\n'))
        {
            count--;
        }
  
        return count;
    }
}
```

 
The returned `CodeBlockPreprocessResult` carries the pre-rendered HTML, the `BaseLanguage` CSS class Pennington stamps on the block, and `SkipTransform`. Set `SkipTransform` to `true` when the output is final and the `[!code ...]` annotation pass should not re-process it.

 
## Pick a Priority value

 
`CodeBlockRenderingService` sorts preprocessors by `Priority` descending and returns the first non-null result. The only shipped preprocessor is the tree-sitter one that claims `:symbol` and `:symbol-diff`, at `100`. `LineCountPreprocessor` uses `500` so its `linecount` fence runs ahead of the tree-sitter preprocessor — relevant only if both could claim the same info string. Pick above `100` to beat the shipped `:symbol` preprocessor on a contested `:modifier`, or below it to let `:symbol` resolve first.

 
## Register the implementation

 
Pennington collects every `ICodeBlockPreprocessor` from DI. Register anywhere after `AddPennington` — there is no `PenningtonOptions` knob. `AddTreeSitter` performs the equivalent registration for its `:symbol` preprocessor.

 
```csharp
builder.Services.AddSingleton<ICodeBlockPreprocessor, LineCountPreprocessor>();
```

 
## Verify

 
On your own site, add a fence tagged with the language your preprocessor claims, then run `dotnet run` and view source on the page that holds it. A claimed `linecount` fence renders inside a `<figure>` with the line-count badge instead of going through the default highlighter, while adjacent fences with other languages keep flowing through the highlighter chain:

 
```html
<figure class="linecount" data-extensibility-lab="line-count-preprocessor">
  <figcaption>Line count: <strong>3</strong></figcaption>
  <pre><code>first line
second line
third line</code></pre>
</figure>
```

 
The wrapper markup proves `TryProcess` returned a result rather than the default highlighter rendering the block. Confirm too that a static build picks the preprocessor up: `dotnet run -- build output`, then grep the emitted HTML for the same wrapper.

 
To see the shipped example instead, run `dotnet run --project examples/ExtensibilityLabExample` and visit `/line-count-demo/` — the `linecount` fence renders the figure above while the adjacent `text` fence highlights through the default chain.

 
## Related

 
 - Reference: [Highlighting interfaces](https://usepennington.net/reference/api/i-code-highlighter.md) — full signatures for `ICodeHighlighter`, `ICodeBlockPreprocessor`, `HighlightingService`, and `TextMateLanguageRegistry`
 - How-to: [Annotate code blocks](https://usepennington.net/how-to/code-samples/code-annotations.md) — trailing-comment directives when only line classes are needed
 - Background: [The syntax-highlighting cascade](https://usepennington.net/explanation/rendering/highlighting.md) — why preprocessors run before the highlighter and how `CodeTransformer` interacts with `SkipTransform`
 
 
[Previous
                
                Add a Markdig extension or inline parser](https://usepennington.net/how-to/markdown-pipeline/markdig-extension.md)[Next
                    
                Add a custom syntax highlighter](https://usepennington.net/how-to/markdown-pipeline/custom-highlighter.md)