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).
This guide follows examples/BeyondCookFormatExample, which registers Cooklang .cook recipes at /recipes/{slug}/ next to a markdown landing page.
Before you begin
- A bare
AddPenningtonhost with a catch-all that resolves throughIPageResolver(see Create your first Pennington site). - A file format with a
---YAML front-matter block and a body your code can turn into HTML. The example parses recipe bodies with theCooklangSharpNuGet 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.
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.
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 ():
@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:
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:
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/BeyondCookFormatExampleand 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 thecookkind next to the markdown/. - Run
dotnet run --project examples/BeyondCookFormatExample -- build output. Confirmoutput/recipes/{slug}/index.htmlexists for every recipe and thatoutput/sitemap.xmllists each/recipes/{slug}/URL.
Related
- How-to: Source content from outside the file system — implement
IContentServicedirectly when content isn't file-backed. - Background: Why ContentSource is a union — what
FileSourceis and how the dispatcher routes a format to its parser and renderer. - Background: The content pipeline — the discover → parse → render path your format plugs into.