Define custom front-matter keys
Declare a record implementing IFrontMatter with extra YAML keys and register it through AddMarkdownContent so a markdown source deserializes into the custom type.
To parse YAML keys the shipped front-matter records do not expose — a namespace, a stability badge, a productName — declare a custom record implementing IFrontMatter and the capability interfaces relevant to the keys, then register it as a markdown source with AddMarkdownContent<T>. For the full catalog of built-in keys, see Front matter key reference; for the design rationale behind the capability interfaces, see The front-matter capability system.
The recipe declares and registers the record in examples/DocSiteKitchenSinkExample, which adds namespace and stability keys on top of the built-in front-matter records, then reads those keys from a regular Razor page in examples/CustomFrontMatterRazorPageExample.
Before you begin
- An existing Pennington site with markdown content under a
Content/folder (see Create your first Pennington site if not). - A bare
AddPenningtonhost, or an existingAddDocSite/AddBlogSitehost with room for an additional markdown source.AddBlogSiteregisters one source againstBlogSiteFrontMatter;AddDocSiteregisters two sources (DocSiteFrontMatterandBlogPostFrontMatter). Adding a third custom-record source on top of the template is done by chaining anotherAddMarkdownContent<T>()call afterAddDocSite/AddBlogSite, or by falling back to bareAddPennington(see Serve docs and a blog from separate content roots).
Declare the record
Implement Pennington.FrontMatter.IFrontMatter as a record and add only the capability interfaces the new keys need — ITaggable, IOrderable, ISectionable, IRedirectable. Unimplemented capabilities pick up their default-member values, so a minimal record is short.
namespace DocSiteKitchenSinkExample;
using Pennington.FrontMatter;
/// <summary>
/// Custom front-matter record used by the "multiple content sources" how-to.
/// Implements the same capability interfaces as <c>DocSiteFrontMatter</c>
/// plus an API-specific <see cref="Namespace"/> and <see cref="Stability"/>
/// pair so reference pages can expose a per-API namespace and stability
/// badge.
/// </summary>
/// <remarks>
/// Kept as a standalone record so tutorials can target it with
/// <c>T:DocSiteKitchenSinkExample.ApiFrontMatter</c>. Declaring a record
/// that implements <see cref="IFrontMatter"/> with a small handful of
/// capability interfaces is the canonical "write your own front matter"
/// pattern referenced by the front-matter how-to.
/// </remarks>
public record ApiFrontMatter : IFrontMatter, ITaggable, ISectionable, IOrderable, IRedirectable
{
public string Title { get; init; } = "";
public string? Description { get; init; }
public bool IsDraft { get; init; }
public string[] Tags { get; init; } = [];
public int Order { get; init; } = int.MaxValue;
public string? RedirectUrl { get; init; }
public string? SectionLabel { get; init; }
public string? Uid { get; init; }
public bool Search { get; init; } = true;
public bool Llms { get; init; } = true;
/// <summary>API namespace (e.g. <c>Pennington.Highlighting</c>).</summary>
public string? Namespace { get; init; }
/// <summary>Stability classification — <c>stable</c>, <c>preview</c>, or <c>experimental</c>.</summary>
public string Stability { get; init; } = "stable";
}
Property names map to YAML keys under CamelCaseNamingConvention — Namespace reads namespace:; Stability reads stability:. Unknown keys are dropped with a warning in lenient mode (dev) and rejected in strict mode (the build default), so a typo on a custom key is flagged — as a dev warning or a build failure — rather than silently taking effect as a default.
Register the record
Pass the record type to AddMarkdownContent<T> so the pipeline deserializes the YAML into that type. The configure delegate selects the content root the source reads from and the URL prefix its pages serve under. On a bare host this call goes inside the AddPennington lambda; on a DocSite or BlogSite host, chain it through the ConfigurePennington escape hatch so the extra source sits alongside the template's own. The kitchen-sink example registers its ApiFrontMatter source this way:
// DocSite's default source serves all of Content/ at /. Carve out the
// symbols subtree so the custom-typed source below owns it without an
// overlap warning.
penn.MarkdownSources[0].ExcludePaths = ["symbols"];
penn.AddMarkdownContent<ApiFrontMatter>(o =>
{
o.ContentPath = "Content/symbols";
o.BasePageUrl = "/symbols";
o.SectionLabel = "Symbols";
});
ExcludePaths on the template's own doc source carves the subtree out so exactly one source owns those pages — drop that line on a bare AddPennington host where no template source claims the folder.
Read the key in a Razor page
The lede promised a stability value — here is what consumes it. The ApiFrontMatter record is portable across hosts; only its registration differs, so this section uses a bare AddPennington host where a Razor page owns rendering. A page under the registered source authors the custom keys at the top of its YAML block:
---
title: "Highlighting service"
namespace: "Pennington.Highlighting"
stability: "preview"
---
A regular Razor @page reads those keys by injecting IPageResolver and calling the generic ResolveAsync<ApiFrontMatter>. That overload resolves the requested URL and hands back the front matter already typed as the custom record, so Stability and Namespace are plain property reads — no cast. It returns null when nothing matches or the page is served by a source registered against a different front-matter type:
@* Catch-all that resolves a URL to a rendered page and reads the custom front
matter directly. ResolveAsync<ApiFrontMatter> hands back the front matter
already typed as the custom record, so Stability and Namespace are plain
property reads — no cast, no MdazorContext dictionary. *@
@page "/{*Path}"
@inject IPageResolver Resolver
@if (_html is not null)
{
<PageTitle>@_title</PageTitle>
<article>
<h1>@_title</h1>
@if (_stability is not null)
{
<p>Stability: <strong>@_stability</strong> · Namespace: <code>@_namespace</code></p>
}
@((MarkupString)_html)
</article>
}
else
{
<PageTitle>Not found</PageTitle>
<p>No content matches @Path.</p>
}
@code {
[Parameter] public string? Path { get; set; }
private string? _title;
private string? _html;
private string? _stability;
private string? _namespace;
protected override async Task OnInitializedAsync()
{
var requested = new UrlPath(Path ?? string.Empty).EnsureLeadingSlash();
// The generic overload resolves the page and narrows its front matter to
// ApiFrontMatter in one step. It returns null when nothing matches or the
// page is served by a source registered against a different type.
if (await Resolver.ResolveAsync<ApiFrontMatter>(requested) is { } page)
{
_title = page.Metadata.Title;
_stability = page.Metadata.Stability;
_namespace = page.Metadata.Namespace;
_html = page.Content.Html;
}
}
}
Any page served by the ApiFrontMatter source now surfaces its typed keys — the round-trip from YAML to a strongly-typed Razor page.
Verify
- In
examples/CustomFrontMatterRazorPageExample, rundotnet runand visit/symbols/highlighting-service/. The page rendersStability: previewfrom that page'sstability:key — proof the YAML deserialized into the typedApiFrontMatter.Stabilityproperty andResolveAsync<ApiFrontMatter>returned the typed page. - The build report contains no
FrontMatterParseErrordiagnostics for pages under the new source.
Related
- Reference: Front matter key reference — every built-in key, type, and default
- Reference: Built-in front-matter types —
DocFrontMatter,BlogFrontMatter,DocSiteFrontMatter,BlogSiteFrontMatter - Reference:
IFrontMatterand capability defaults — the capability interfaces available to a custom record - Background: The front-matter capability system — why the design collapsed ten interfaces into default members
- How-to: Use multiple content sources — chain a second
AddMarkdownContent<T>against a custom record