---
title: Add a custom content format
description: "Register a non-markdown file format — here Cooklang .cook recipes — so its files are discovered, parsed, and rendered as pages through the same pipeline as markdown: supply a front-matter type, an IContentParser, an IContentRenderer, and one AddContentFormat call."
canonical_url: https://usepennington.net/how-to/content-services/custom-content-format/
sidecar_url: https://usepennington.net/how-to/content-services/custom-content-format.md
content_hash: sha256:3cd2e7a583b58b506e2fda33e59895c54f847373b89ff16d994bed8a52bd282a
tokens: 4076
uid: how-to.content-services.custom-content-format
reading_time_minutes: 4
---

Guides
# Add a custom content format

Register a non-markdown file format — here Cooklang .cook recipes — so its files are discovered, parsed, and rendered as pages through the same pipeline as markdown: supply a front-matter type, an IContentParser, an IContentRenderer, and one AddContentFormat call.

 
To serve a file format Pennington doesn't parse natively — a recipe format, an org-mode file, a bespoke DSL — register it as a *content format*. Pennington discovers the files and tracks them for navigation, search, and resolution; a parser and a renderer you supply turn each file into a page. The pipeline dispatches by the format's key, so your format and markdown coexist on one site.

 
Reach for this when your content is files with a consistent front-matter-plus-body shape. To source content that isn't file-backed — a remote API, a database — implement `IContentService` directly instead (see [Source content from outside the markdown pipeline](https://usepennington.net/how-to/content-services/custom-content-service.md)).

 
This guide follows `examples/BeyondCookFormatExample`, which registers [Cooklang](https://cooklang.org/) `.cook` recipes at `/recipes/{slug}/` next to a markdown landing page.

 
## Before you begin

 
 - A bare `AddPennington` host with a catch-all that resolves through `IPageResolver` (see [Create your first Pennington site](https://usepennington.net/tutorials/getting-started/first-site.md)).
 - A file format with a `---` YAML front-matter block and a body your code can turn into HTML. The example parses recipe bodies with the `CooklangSharp` NuGet package.
 
 
## Define the front-matter type

 
Every page carries typed front matter implementing `IFrontMatter` (the contract guarantees a `Title`). Add capability interfaces — `ITaggable`, `IOrderable`, `ISectionable` — for the fields navigation and search should pick up.

 
```csharp:symbol
namespace BeyondCookFormatExample;
  
using Pennington.FrontMatter;
  
/// <summary>
/// Front matter for a <c>.cook</c> recipe. Implements <see cref="IFrontMatter"/> (every page needs a
/// <c>Title</c>) and <see cref="ITaggable"/> (recipe tags feed navigation and the search facet). The
/// property names are camelCase-matched to the YAML keys (<c>prepTime</c>, <c>cookTime</c>, …), so they
/// bind without extra attributes and don't trip the build's strict unknown-key check.
/// </summary>
public sealed record CookFrontMatter : IFrontMatter, ITaggable
{
    /// <inheritdoc/>
    public string Title { get; init; } = "";
  
    /// <summary>Short recipe description, shown under the title.</summary>
    public string? Description { get; init; }
  
    /// <summary>Number of servings the recipe yields.</summary>
    public string? Servings { get; init; }
  
    /// <summary>Preparation time (e.g. "15 minutes").</summary>
    public string? PrepTime { get; init; }
  
    /// <summary>Active cooking time (e.g. "25 minutes").</summary>
    public string? CookTime { get; init; }
  
    /// <summary>Total time from start to finish.</summary>
    public string? TotalTime { get; init; }
  
    /// <summary>Resting time, when the recipe calls for it.</summary>
    public string? RestTime { get; init; }
  
    /// <inheritdoc/>
    public string[] Tags { get; init; } = [];
}
```

 
> [!IMPORTANT]
> Front-matter keys bind to camelCased property names, and a build (`-- build`) throws on any key no property matches. Name your YAML keys to match — `prepTime`, not `prep time` — or author the property to a single word. A multi-word key with a space never binds and fails the build.

 
## Write the parser

 
An `IContentParser` reads a discovered file and returns a `ParsedItem` — the typed front matter plus the raw body. Inject the framework's `FrontMatterParser` to split the YAML exactly as the markdown parser does, and hand the body on untouched for the renderer.

 
```csharp:symbol
namespace BeyondCookFormatExample;
  
using System.IO.Abstractions;
using Pennington.FrontMatter;
using Pennington.Pipeline;
  
/// <summary>
/// Parses a discovered <c>.cook</c> file into a <see cref="ParsedItem"/>: it reads the file, splits the
/// YAML front matter into a typed <see cref="CookFrontMatter"/>, and hands the Cooklang body on as the
/// parsed body. The Cooklang markup itself is parsed later by <see cref="CookContentRenderer"/>. The
/// dispatching pipeline stamps the <c>"cook"</c> format onto the returned item so the matching renderer
/// is selected.
/// </summary>
public sealed class CookContentParser : IContentParser
{
    private readonly FrontMatterParser _frontMatter;
    private readonly IFileSystem _fileSystem;
  
    /// <summary>Creates the parser. Both dependencies are registered by <c>AddPennington</c>.</summary>
    public CookContentParser(FrontMatterParser frontMatter, IFileSystem fileSystem)
    {
        _frontMatter = frontMatter;
        _fileSystem = fileSystem;
    }
  
    /// <inheritdoc/>
    public async Task<ContentItem> ParseAsync(DiscoveredItem item)
    {
        if (item.Source.Value is not FileSource file)
        {
            return new FailedItem(item.Route, new ContentError("CookContentParser: unsupported content source."));
        }
  
        try
        {
            var content = await _fileSystem.File.ReadAllTextAsync(file.Path.Value);
            var result = _frontMatter.Parse<CookFrontMatter>(content, file.Path.Value);
            var metadata = result.Metadata ?? new CookFrontMatter();
            return new ParsedItem(item.Route, metadata, result.Body);
        }
        catch (Exception ex)
        {
            return new FailedItem(item.Route, new ContentError($"Failed to parse {file.Path}: {ex.Message}", ex));
        }
    }
}
```

 
The discovered item's source is a `FileSource` carrying the file path and the format key. You don't stamp the format onto the `ParsedItem` yourself — the dispatcher does that from the source, so the matching renderer is selected downstream.

 
## Write the renderer

 
Markdown renders through a text pipeline; a structured format like a recipe renders through a **Razor component**. Subclass `RazorContentRenderer<TComponent>` (in `Pennington.Pipeline`): the base owns the Blazor `HtmlRenderer` dispatch, heading anchors, and outline extraction, so you write only a component and a `BuildParameters` that projects the parsed body into the component's parameters.

 
The component binds the parsed model and emits the markup. The page structure is Razor; the tight inline token run within a step is built as a string (inline HTML is whitespace-sensitive — a stray space would land before a `.` or `(`):

 
```razor:symbol
@namespace BeyondCookFormatExample
@using System.Net
@using System.Text
@using CooklangSharp.Models
  
@* Renders a parsed Cooklang recipe. The page structure is Razor markup; only the tight inline
   token run within a step is built as an HTML string (the PhilsRecipes Method.razor pattern),
   because inline flow is whitespace-sensitive. *@
  
<article class="recipe">
    @if (!string.IsNullOrWhiteSpace(FrontMatter.Title))
    {
        <h1>@FrontMatter.Title</h1>
    }
    @if (!string.IsNullOrWhiteSpace(FrontMatter.Description))
    {
        <p class="description">@FrontMatter.Description</p>
    }
    @if (_meta.Count > 0)
    {
        <p class="meta">@string.Join(" · ", _meta)</p>
    }
  
    @if (_ingredients.Count > 0)
    {
        <h2>Ingredients</h2>
        <ul class="ingredients">
            @foreach (var ingredient in _ingredients)
            {
                <li>@if (ingredient.Qty.Length > 0){<span class="qty">@ingredient.Qty</span> }@ingredient.Name</li>
            }
        </ul>
    }
  
    <h2>Method</h2>
    @foreach (var section in Recipe.Sections)
    {
        <section class="step-section">
            @if (!string.IsNullOrWhiteSpace(section.Name))
            {
                <h3>@section.Name</h3>
            }
            <ol class="steps">
                @foreach (var block in section.Content)
                {
                    if (block is StepContent step)
                    {
                        <li class="step">@((MarkupString)StepHtml(step.Step.Items))</li>
                    }
                    else if (block is NoteContent note)
                    {
                        <li class="note">@note.Value.Trim()</li>
                    }
                }
            </ol>
        </section>
    }
</article>
  
@code {
    /// <summary>The parsed Cooklang recipe to render.</summary>
    [Parameter] public required Recipe Recipe { get; set; }
  
    /// <summary>The recipe's typed front matter.</summary>
    [Parameter] public required CookFrontMatter FrontMatter { get; set; }
  
    private readonly List<string> _meta = [];
    private readonly List<(string Name, string Qty)> _ingredients = [];
  
    protected override void OnParametersSet()
    {
        _meta.Clear();
        AddMeta("Serves", FrontMatter.Servings);
        AddMeta("Prep", FrontMatter.PrepTime);
        AddMeta("Cook", FrontMatter.CookTime);
        AddMeta("Total", FrontMatter.TotalTime);
        AddMeta("Rest", FrontMatter.RestTime);
  
        _ingredients.Clear();
        var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        foreach (var section in Recipe.Sections)
        {
            foreach (var block in section.Content)
            {
                if (block is not StepContent step)
                {
                    continue;
                }
  
                foreach (var item in step.Step.Items)
                {
                    if (item is IngredientItem ingredient && seen.Add(ingredient.Name))
                    {
                        _ingredients.Add((ingredient.Name, Qty(ingredient.Quantity, ingredient.Units)));
                    }
                }
            }
        }
    }
  
    private void AddMeta(string label, string? value)
    {
        if (!string.IsNullOrWhiteSpace(value))
        {
            _meta.Add($"{label} {value}");
        }
    }
  
    // The inline token run: text interleaved with ingredient/cookware/timer spans, built as a
    // string so adjacent tokens carry no stray whitespace (a space before a '.' or '(' would show).
    private static string StepHtml(IReadOnlyList<Item> items)
    {
        var sb = new StringBuilder();
        foreach (var item in items)
        {
            switch (item)
            {
                case TextItem text:
                    sb.Append(Enc(text.Value));
                    break;
                case IngredientItem ingredient:
                    var quantity = Qty(ingredient.Quantity, ingredient.Units);
                    sb.Append($"<span class=\"ingredient\">{Enc(ingredient.Name)}");
                    if (quantity.Length > 0)
                    {
                        sb.Append($" <span class=\"qty\">{Enc(quantity)}</span>");
                    }
                    sb.Append("</span>");
                    break;
                case CookwareItem cookware:
                    sb.Append($"<span class=\"cookware\">{Enc(cookware.Name)}</span>");
                    break;
                case TimerItem timer:
                    sb.Append($"<span class=\"timer\">{Enc(Qty(timer.Quantity, timer.Units))}</span>");
                    break;
            }
        }
        return sb.ToString();
    }
  
    private static string Qty(QuantityValue? quantity, string units)
        => string.Join(" ", new[] { quantity?.ToString() ?? "", units }.Where(part => !string.IsNullOrWhiteSpace(part)));
  
    private static string Enc(string value) => WebUtility.HtmlEncode(value);
}
```

 
The renderer parses the Cooklang body and hands the model to the component:

 
```csharp:symbol
namespace BeyondCookFormatExample;
  
using CooklangSharp;
using Microsoft.AspNetCore.Components.Web;
using Pennington.Pipeline;
  
/// <summary>
/// Renders a parsed <c>.cook</c> recipe by binding the CooklangSharp model to the <see cref="RecipeView"/> Razor
/// component. All markup lives in the component; this renderer only parses the body and supplies the parameters.
/// The <see cref="RazorContentRenderer{TComponent}"/> base owns the Blazor <c>HtmlRenderer</c> dispatch, heading
/// anchors, and outline extraction.
/// </summary>
public sealed class CookContentRenderer : RazorContentRenderer<RecipeView>
{
    /// <summary>Creates the renderer over the Blazor <c>HtmlRenderer</c> resolved from DI.</summary>
    public CookContentRenderer(HtmlRenderer renderer) : base(renderer)
    {
    }
  
    /// <inheritdoc/>
    protected override IReadOnlyDictionary<string, object?> BuildParameters(ParsedItem item)
    {
        var parsed = CooklangParser.Parse(item.RawMarkdown);
        if (parsed.Recipe is not { } recipe)
        {
            throw new InvalidOperationException(
                $"Cooklang parse failed: {string.Join("; ", parsed.Diagnostics.Select(d => d.Message))}");
        }
  
        return new Dictionary<string, object?>
        {
            [nameof(RecipeView.Recipe)] = recipe,
            [nameof(RecipeView.FrontMatter)] = (CookFrontMatter)item.Metadata,
        };
    }
}
```

 
Throwing from `BuildParameters` (here, when the body won't parse) is captured as a `FailedItem` — it lands in the build report and the dev overlay like any markdown failure. The base produces the page-body HTML and its outline; the host or layout supplies the surrounding chrome, the same way it wraps a rendered markdown body.

 
## Register the format

 
`AddContentFormat` ties the pieces together — a content directory, a file glob, the format key, and the parser and renderer types (resolved from DI). Call it on the `penn` options inside your `AddPennington` callback, alongside `AddMarkdownContent`, so prose and recipes share the host. Because the renderer dispatches through Blazor's `HtmlRenderer`, the host also needs Razor's component services — register `AddRazorComponents()` on the service collection first:

 
```csharp
builder.Services.AddRazorComponents();
  
builder.Services.AddPennington(penn =>
{
    penn.AddMarkdownContent<DocFrontMatter>(md => md.BasePageUrl = "/");
  
    penn.AddContentFormat<CookFrontMatter>("cook", cook =>
    {
        cook.ContentPath = "recipes";
        cook.FilePattern = "*.cook";
        cook.BasePageUrl = "/recipes";
        cook.SectionLabel = "Recipes";
    })
    .UseParser<CookContentParser>()
    .UseRenderer<CookContentRenderer>();
});
```

 
That's the whole wiring. The pipeline routes each URL to the parser and renderer registered for its format, so `IPageResolver`, the build crawler, navigation, search, and the sitemap treat cook pages exactly like markdown ones — the catch-all `MapGet("/{*path}", IPageResolver resolver)` resolves both without changes.

 
## Verify

 
 - Run `dotnet run --project examples/BeyondCookFormatExample` and open `/` (the markdown landing page) and `/recipes/chicken-piccata/` (a recipe rendered to HTML — title, ingredient list, and method steps).
 - Run `dotnet run --project examples/BeyondCookFormatExample -- diag routes`. Each `/recipes/{slug}/` is listed with the `cook` kind next to the markdown `/`.
 - Run `dotnet run --project examples/BeyondCookFormatExample -- build output`. Confirm `output/recipes/{slug}/index.html` exists for every recipe and that `output/sitemap.xml` lists each `/recipes/{slug}/` URL.
 
 
## Related

 
 - How-to: [Source content from outside the file system](https://usepennington.net/how-to/content-services/custom-content-service.md) — implement `IContentService` directly when content isn't file-backed.
 - Background: [Why ContentSource is a union](https://usepennington.net/explanation/core/content-source.md) — what `FileSource` is and how the dispatcher routes a format to its parser and renderer.
 - Background: [The content pipeline](https://usepennington.net/explanation/core/content-pipeline.md) — the discover → parse → render path your format plugs into.
 
 
[Previous
                
                Build browse-by-{field} pages with AddTaxonomy](https://usepennington.net/how-to/content-services/taxonomy.md)[Next
                    
                Add a Markdig extension or inline parser](https://usepennington.net/how-to/markdown-pipeline/markdig-extension.md)