This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy at the same URL with .md appended. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

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 AddPennington host, or an existing AddDocSite/AddBlogSite host with room for an additional markdown source. AddBlogSite registers one source against BlogSiteFrontMatter; AddDocSite registers two sources (DocSiteFrontMatter and BlogPostFrontMatter). Adding a third custom-record source on top of the template is done by chaining another AddMarkdownContent<T>() call after AddDocSite/AddBlogSite, or by falling back to bare AddPennington (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.

csharp
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 CamelCaseNamingConventionNamespace 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:

csharp
// 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:

yaml
---
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:

razor
@* 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, run dotnet run and visit /symbols/highlighting-service/. The page renders Stability: preview from that page's stability: key — proof the YAML deserialized into the typed ApiFrontMatter.Stability property and ResolveAsync<ApiFrontMatter> returned the typed page.
  • The build report contains no FrontMatterParseError diagnostics for pages under the new source.