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 under /_llms/. 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

Attach derived metadata to every page

Implement IMetadataEnricher to merge derived values like reading time or git timestamps into ParsedItem.Derived, kept separate from authored front matter.

To compute values from a page rather than have an author type them — reading time, a git last-modified date, a word count — implement IMetadataEnricher. Each enricher contributes a dictionary that MetadataEnrichmentService merges into ParsedItem.Derived. Derived values land in their own bag, not in the strongly-typed Metadata, so authored front matter stays the single source of truth and computed values can change between builds without rewriting any page.

Before you begin

Reading time ships built in

AddPennington registers ReadingTimeEnricher by default, so every page with body text carries an estimate under the reading_time_minutes key. The estimate divides the word count by 200 words per minute and rounds up, with a floor of one minute. A page with no words contributes no key, so consumers guard the read with TryGetValue. The shipped enricher is a pure function of ParsedItem.RawMarkdown — no file access:

csharp
namespace Pennington.Pipeline;
  
/// <summary>
/// Estimates reading time from the markdown body and contributes it as
/// <c>reading_time_minutes</c>. A pure function of <see cref="ParsedItem.RawMarkdown"/>
/// — no file access, no external dependencies.
/// </summary>
public sealed class ReadingTimeEnricher : IMetadataEnricher
{
    /// <summary>Words read per minute used to derive the estimate.</summary>
    private const int WordsPerMinute = 200;
  
    /// <summary>Key written into <see cref="ParsedItem.Derived"/>.</summary>
    public const string Key = "reading_time_minutes";
  
    /// <inheritdoc/>
    public Task<IReadOnlyDictionary<string, object?>> EnrichAsync(ParsedItem item)
    {
        var words = CountWords(item.RawMarkdown);
        if (words == 0)
        {
            return Task.FromResult<IReadOnlyDictionary<string, object?>>(
                new Dictionary<string, object?>());
        }
  
        var minutes = Math.Max(1, (int)Math.Ceiling(words / (double)WordsPerMinute));
        return Task.FromResult<IReadOnlyDictionary<string, object?>>(
            new Dictionary<string, object?> { [Key] = minutes });
    }
  
    private static int CountWords(string text)
    {
        if (string.IsNullOrWhiteSpace(text))
        {
            return 0;
        }
  
        var count = 0;
        var inWord = false;
        foreach (var c in text)
        {
            if (char.IsWhiteSpace(c))
            {
                inWord = false;
            }
            else if (!inWord)
            {
                inWord = true;
                count++;
            }
        }
  
        return count;
    }
}

Expose the key as a const (as ReadingTimeEnricher.Key does) so consumers reference it without retyping the string.

Write an enricher

Implement IMetadataEnricher and return the keys you contribute. EnrichAsync receives the parsed item and returns an IReadOnlyDictionary<string, object?>; return an empty dictionary to contribute nothing for a given page. GitTimestampEnricher reads the source file's timestamp from ParsedItem.Route.SourceFile and contributes a git_last_modified date — the value a real enricher would instead pull from git log -1. Pages with no file on disk (generated content) contribute nothing:

csharp
public sealed class GitTimestampEnricher : IMetadataEnricher
{
    /// <summary>Key written into <see cref="ParsedItem.Derived"/>.</summary>
    public const string Key = "git_last_modified";
  
    /// <inheritdoc />
    public Task<IReadOnlyDictionary<string, object?>> EnrichAsync(ParsedItem item)
    {
        var path = item.Route.SourceFile?.Value;
        if (path is null || !File.Exists(path))
        {
            return Task.FromResult<IReadOnlyDictionary<string, object?>>(
                new Dictionary<string, object?>());
        }
  
        var modified = File.GetLastWriteTimeUtc(path).ToString("yyyy-MM-dd");
        return Task.FromResult<IReadOnlyDictionary<string, object?>>(
            new Dictionary<string, object?> { [Key] = modified });
    }
}

Register your enricher

Register the implementation after AddPennington — there is no PenningtonOptions knob. MetadataEnrichmentService runs every registered enricher in registration order and merges each contribution into Derived, so a later enricher overrides an earlier one on a key collision.

csharp
builder.Services.AddTransient<IMetadataEnricher, GitTimestampEnricher>();

Read derived metadata in a component

The renderer exposes the merged Derived dictionary to every Mdazor component under the Derived context key. A component reads it through [CascadingParameter] public MdazorContext? Context — no tag attributes, the dictionary cascades in from the page being rendered. LastModified.razor reads the git_last_modified key and renders the date:

razor
@* LastModified — reads the ambient MdazorContext that Pennington populates for each
   page and renders the git_last_modified date contributed by GitTimestampEnricher.
   The enricher merges into ParsedItem.Derived; the renderer exposes that dictionary
   under the "Derived" context key. Registered in Program.cs with
   services.AddMdazorComponent<LastModified>(). *@
@using Mdazor
@using Microsoft.AspNetCore.Components
  
@if (Date is not null)
{
    <p class="last-modified" data-extensibility-lab="last-modified">Last modified: @Date</p>
}
  
@code {
    // Pennington cascades the page's facts in per render — no tag attributes needed.
    [CascadingParameter] public MdazorContext? Context { get; set; }
  
    // The "Derived" key carries the IMetadataEnricher contributions for this page.
    private string? Date =>
        Context?["Derived"] is IReadOnlyDictionary<string, object?> derived
        && derived.TryGetValue(GitTimestampEnricher.Key, out var value)
            ? value?.ToString()
            : null;
}

Register the component with AddMdazorComponent<LastModified>(), then drop <LastModified /> into any page body.

Verify

  • Build the lab (dotnet run --project examples/ExtensibilityLabExample -- build) and open /_llms/metadata-demo.md in the output — its front-matter block carries both git_last_modified and reading_time_minutes, the two keys Derived accumulated for that page.
  • Render /metadata-demo/ and confirm the <LastModified /> component prints the date, proving a component read Context["Derived"].