Every development career has milestone moments. One we all likely share is building a custom content management system, or CMS, as developers like to refer to it. A common approach to melding metadata and content is utilizing the old reliable Markdown format, which fuses YAML frontmatter with a simple content format. While YAML is flexible, it can be less than ideal when wanting to use that embedded data in your ASP.NET Core applications.

In this post, I’ll show you a quick experiment around processing Markdown files and their YAML into a strongly-type C# object. The example allows you to easily modify content while still having access to instances of data that you can strongly type.

The Magnificent Markdown

Markdown is a very flexible format whose strength comes from its simplicity. Let’s examine a document that defines a person’s profile.

---
name: "Khalid Abuhakmeh"
profession: "Software Developer"
hobbies: ["video games", "movies", "boxing"]
---

## Summary

I am writing a little about myself here and this should appear
in the page. Cool! Check me out at my [personal blog](https://khalidabuhakmeh.com).
Markdown

The top of the document defines a data model with properties for Name, Profession, and Hobbies. The C# data model for this YAML would consist of three properties.

public class Asset
{
    public string Name { get; set; } = "";
    public string Profession { get; set; } = "";
    public string[] Hobbies { get; set; } = [];
}
C#

Let’s build an object that will parse the Markdown file’s front matter while helping us render the content into HTML for use on a Razor page.

The MarkdownObject and Parsing Files

For my experiment, I created a MarkdownObject<T> class that takes a content string and parses the document into its parts. The T argument is up to the developer to determine.

To continue with the code, you must add the Markdig package and the YamlDotNet package.

<ItemGroup>
  <PackageReference Include="Markdig" Version="0.40.0" />
  <PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
XML

Let’s look at the implementation next.

using Markdig;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
using Microsoft.AspNetCore.Html;
using YamlDotNet.Serialization;
using Md = Markdig.Markdown;

namespace SuperContent.Models;

public class MarkdownObject<T>
{
    private static readonly MarkdownPipeline MarkdownPipeline 
        = new MarkdownPipelineBuilder()
            .UseYamlFrontMatter()
            .UseAdvancedExtensions()
            .Build();
    private static readonly IDeserializer Deserializer 
        = new DeserializerBuilder()
            .WithYamlFormatter(new YamlFormatter())
            .WithCaseInsensitivePropertyMatching()
            .Build();

    public MarkdownObject(string content)
    {
        var doc = Md.Parse(content, MarkdownPipeline);
        FrontMatter = default;
        
        if (doc.Descendants<YamlFrontMatterBlock>().FirstOrDefault() is { } fm)
        {
            var yaml = fm.Lines.ToSlice();
            FrontMatter = Deserializer.Deserialize<T>(yaml.Text);
            
            // we don't want front matter after it's processed
            doc.Remove(fm);
        }
        
        // turn it into HTML once
        Html = new HtmlString(doc.ToHtml());
    }

    public T? FrontMatter { get; private set; }
    
    public IHtmlContent Html { get; private set; }
}
C#

In the case of this demo, we’ll create an instance of MarkdownObject<Asset>. Let’s see how to use this type in a Razor Page.

MarkdownObject in a Razor Page

In my demo, I have all my Markdown files in a Data directory. Each file in the data directory has a unique file name that we’ll use in our Razor Page as a slug. We’ll also use the Model to output the data and the processed HTML into a structured layout.

@page "/profile/{slug}"
@model SuperContent.Pages.Profile

<div class="row">
    <div class="col-12">
        <h1>@Model.Asset.FrontMatter?.Name</h1>
    </div>
</div>

<div class="row">
    <div class="col-3">
        <dl>
            <dt>Profession</dt>
            <dd>@Model.Asset.FrontMatter?.Profession</dd>
            <dt>Hobbies</dt>
            <dd>
                <ul>
                    @if (Model.Asset is { FrontMatter.Hobbies : { } hobbies })
                    {
                        @foreach (var hobby in hobbies)
                        {
                            <li>@hobby</li>
                        }
                    }
                </ul>
            </dd>
        </dl>
    </div>
    <div class="col-9">
        @Model.Asset.Html
    </div>
</div>
Razor C#

So, what does the page’s model look like?

using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using SuperContent.Models;

namespace SuperContent.Pages;

public partial class Profile : PageModel
{
    [BindProperty(SupportsGet = true)]
    public string Slug { get; set; } = "";
    
    public MarkdownObject<Asset> Asset { get; set; } 
        = null!;
    
    public IActionResult OnGet()
    {
        // read a file from the Data directory based on the slug
        // sanitize the slug first because people are mean
        var sanitizedSlug = SlugRegex.Replace(Slug, "");
        var path = Path.Combine("Data", $"{sanitizedSlug}.md");

        if (System.IO.File.Exists(path))
        {
            var content = System.IO.File.ReadAllText($"Data/{sanitizedSlug}.md");
            Asset = new(content);
            return Page();
        }

        return NotFound();
    }

    [GeneratedRegex("[^a-zA-Z0-9_-]")]
    private static partial Regex SlugRegex { get; }
}

public class Asset
{
    public string Name { get; set; } = "";
    public string Profession { get; set; } = "";
    public string[] Hobbies { get; set; } = [];
}
C#

The OnGet method contains some protective code to prevent access to other files, but it’s ultimately pretty straightforward. When you go to /profile/Khalid, you’ll see a nicely formatted page that mixes data and content into predetermined HTML because we use the new MarkdownObject class. Sweet!

I’ve pushed the code to my GitHub repository so you can try this sample for yourself. Please give it a try and let me know what you think. As always, thanks for reading and sharing my posts. Cheers.