---
title: Attach derived metadata to every page
description: "Implement IMetadataEnricher to merge derived values like reading time or git timestamps into ParsedItem.Derived, kept separate from authored front matter."
canonical_url: https://usepennington.net/how-to/markdown-pipeline/metadata-enrichers/
sidecar_url: https://usepennington.net/how-to/markdown-pipeline/metadata-enrichers.md
content_hash: sha256:f1715b3da3e80548d2cccdff95e2f7d50a307b462da3b969594faf61cc05c8e1
tokens: 1872
uid: how-to.markdown-pipeline.metadata-enrichers
reading_time_minutes: 3
---

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

 
 - An existing Pennington site with markdown rendering wired (see [Create your first Pennington site](https://usepennington.net/tutorials/getting-started/first-site.md) if not).
 
 
## 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:symbol
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:symbol
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:symbol
@* 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 `/metadata-demo/index.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"]`.
 
 
## Related

 
 - Reference: [IMetadataEnricher](https://usepennington.net/reference/api/i-metadata-enricher.md) and [ReadingTimeEnricher](https://usepennington.net/reference/api/reading-time-enricher.md)
 - How-to: [Generate an llms.txt index](https://usepennington.net/how-to/feeds/llms-txt.md) — a built-in consumer of `Derived`
 
 
[Previous
                
                Expand a directive before Markdig parses](https://usepennington.net/how-to/markdown-pipeline/shortcodes.md)[Next
                    
                Rewrite HTML attributes after parsing](https://usepennington.net/how-to/response-pipeline/html-rewriter.md)