Add a custom fence syntax
To add a custom fence syntax — a chart block, a plaintext wrapper, an xmldocid resolver — implement ICodeBlockPreprocessor. The preprocessor claims a fence language or :modifier suffix and returns pre-rendered HTML before the default highlighter chain runs. For line-level CSS classes on an otherwise normal code block, trailing-comment directives are the lighter-weight choice — see Highlight, diff, focus, or flag lines inside a code block.
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:xmldocid) — along with awareness of the other preprocessors currently registered, so thePriorityslots in sensibly. - Comfort producing HTML by hand: the preprocessor owns the rendered
<pre><code>...</code></pre>when it returns a result, and the default highlighter does not run again on that block.
For a working setup, see examples/ExtensibilityLabExample — LineCountPreprocessor claims the linecount fence and is the fixture every snippet below references.
Implement the preprocessor
ICodeBlockPreprocessor has two members: a Priority int and a TryProcess(code, languageId) method returning CodeBlockPreprocessResult?. Return null for any unclaimed fence so the next preprocessor — or the default highlighter — can handle it.
/// <summary>
/// Preprocesses fenced code blocks before normal highlighting.
/// Implementations can intercept blocks with specific language modifiers
/// (e.g., "csharp:xmldocid") and provide pre-highlighted HTML.
/// </summary>
public interface ICodeBlockPreprocessor
{
/// <summary>Priority — higher runs first.</summary>
int Priority { get; }
/// <summary>
/// Attempts to preprocess a code block. Returns a result if handled, or null to pass through.
/// </summary>
CodeBlockPreprocessResult? TryProcess(string code, string languageId);
}
The full fence info string reaches the preprocessor unchanged. Compare it case-insensitively against the claimed language id or :modifier suffix, return null immediately for anything else, and otherwise build the wrapper HTML around the encoded source.
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);
The result wraps 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.
/// <summary>Result from a code block preprocessor.</summary>
/// <param name="HighlightedHtml">Fully highlighted HTML (wrapped in pre/code tags).</param>
/// <param name="BaseLanguage">The base language for CSS class purposes.</param>
/// <param name="SkipTransform">If true, skip CodeTransformer on the output.</param>
public record CodeBlockPreprocessResult(
string HighlightedHtml,
string BaseLanguage,
bool SkipTransform = false);
Pick a Priority value
CodeHighlightRenderer sorts preprocessors by Priority descending and returns the first non-null result, so higher wins. The shipped RoslynCodeBlockPreprocessor uses 100; LineCountPreprocessor uses 500 so its linecount fence is never intercepted by a lower-priority modifier preprocessor. Review the registered preprocessors before picking a value.
/// <summary>
/// 500 — higher than the shipped Roslyn preprocessor (250) so
/// <c>linecount</c> wins over any language-modifier preprocessor
/// that might claim the same fence info string.
/// </summary>
public int Priority => 500;
Register the implementation
Pennington collects every ICodeBlockPreprocessor from DI. Register with AddSingleton<ICodeBlockPreprocessor, TPreprocessor>() anywhere after AddPennington — there is no PenningtonOptions knob; the DI registration is the entire wiring step. (AddPenningtonRoslyn performs the equivalent registration for RoslynCodeBlockPreprocessor.)
builder.Services.AddSingleton<ICodeBlockPreprocessor, LineCountPreprocessor>();
Result
A markdown fence tagged linecount renders inside a <figure> with the line-count badge instead of going through the default highlighter:
<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>
Adjacent fences with other languages (text, csharp) keep flowing through the default highlighter chain.
Verify
- Run
dotnet run --project examples/ExtensibilityLabExampleand visit/line-count-demo/— thelinecountfence renders inside a<figure class="linecount">with the line-count badge while the adjacenttextfence highlights normally through the default chain. - View source on the rendered page and confirm the
linecountfigure carriesdata-extensibility-lab="line-count-preprocessor"— this showsLineCountPreprocessor.TryProcessreturned a result rather than the defaultCodeHighlightRendererpath rendering the block. - Add a second preprocessor with a lower
Prioritythat also claimslinecount, reload, and confirm the higher-priority result still wins — priority ordering is descending.
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 and the preprocessor does not need to take over rendering
- Background: The syntax-highlighting cascade — why preprocessors run before the highlighter and how
CodeTransformerinteracts withSkipTransform