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 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:
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:
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.
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:
@* 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.mdin the output — its front-matter block carries bothgit_last_modifiedandreading_time_minutes, the two keysDerivedaccumulated for that page. - Render
/metadata-demo/and confirm the<LastModified />component prints the date, proving a component readContext["Derived"].
Related
- Reference:
IMetadataEnricherandReadingTimeEnricher - How-to: Generate an llms.txt index — a built-in consumer of
Derived