Add a custom fence syntax
Implement ICode
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.
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 if not).
- A chosen fence identifier — either a full
languageId(linecount) or a:modifiersuffix (csharp:symbol).
Write the preprocessor
Implement Pennington.Markdown.Extensions.ICodeBlockPreprocessor 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.
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><figure class="linecount"></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.
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:
<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 — full signatures for
ICodeHighlighter,ICodeBlockPreprocessor,HighlightingService, andTextMateLanguageRegistry - How-to: Annotate code blocks — trailing-comment directives when only line classes are needed
- Background: The syntax-highlighting cascade — why preprocessors run before the highlighter and how
CodeTransformerinteracts withSkipTransform