This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

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 :modifier suffix (csharp:xmldocid) — along with awareness of the other preprocessors currently registered, so the Priority slots 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/ExtensibilityLabExampleLineCountPreprocessor 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/ExtensibilityLabExample and visit /line-count-demo/ — the linecount fence renders inside a <figure class="linecount"> with the line-count badge while the adjacent text fence highlights normally through the default chain.
  • View source on the rendered page and confirm the linecount figure carries data-extensibility-lab="line-count-preprocessor" — this shows LineCountPreprocessor.TryProcess returned a result rather than the default CodeHighlightRenderer path rendering the block.
  • Add a second preprocessor with a lower Priority that also claims linecount, reload, and confirm the higher-priority result still wins — priority ordering is descending.
  • Reference: Highlighting interfaces — full signatures for ICodeHighlighter, ICodeBlockPreprocessor, HighlightingService, and TextMateLanguageRegistry
  • 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 CodeTransformer interacts with SkipTransform