This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

Style the site with MonorailCSS

By the end of this tutorial a three-page Pennington site has its HTML layout styled with MonorailCSS utility classes, served from a /styles.css endpoint that regenerates whenever a new class appears in the response HTML.

The tutorial covers how to register AddMonorailCss, point it at a NamedColorScheme, mount the generated stylesheet with UseMonorailCss, and rely on the class-collector to keep the CSS file in sync with whatever utility classes the HTML emits.

Prerequisites

This tutorial picks up where Add your first markdown page left off. It needs the same three-markdown-page scaffold and bare AddPennington host from that tutorial — start there first if that tutorial hasn't been completed.

  • .NET 11 SDK installed
  • Completed Add your first markdown page (or a Pennington project with three markdown pages and a working MapGet endpoint)

The finished code for this tutorial lives in examples/GettingStartedStylingExample.


1. See the starting point

Let's confirm the baseline before touching anything. The starting point is an unstyled three-page site — the same shape built in the previous tutorial.

  1. 1

    Run the pre-styling host

    Here's the host as it stands at the start of this tutorial. Run it and load / to see the bare, unstyled HTML — no class-based styling has entered the picture yet.

    var builder = WebApplication.CreateBuilder(args);
      
    builder.Services.AddPennington(penn =>
    {
        penn.SiteTitle = "My Styled Pennington Site";
        penn.ContentRootPath = "Content";
      
        penn.AddMarkdownContent<DocFrontMatter>(md =>
        {
            md.ContentPath = "Content";
            md.BasePageUrl = "/";
        });
    });
      
    var app = builder.Build();
      
    app.UsePennington();
      
    app.MapGet("/{*path}", async (
        string? path,
        IEnumerable<IContentService> services,
        IContentParser parser,
        IContentRenderer renderer,
        NavigationBuilder navigation) =>
    {
        var requested = new UrlPath("/" + (path ?? string.Empty).Trim('/'));
      
        var tocItems = new List<ContentTocItem>();
        foreach (var service in services)
        {
            var entries = await service.GetIndexableEntriesAsync();
            tocItems.AddRange(entries);
        }
        var navTree = navigation.BuildTree(tocItems);
        var navHtml = string.Join(
            "",
            navTree.Select(i =>
                $"<li><a href=\"{i.Route.CanonicalPath.Value}\">{i.Title}</a></li>"));
      
        foreach (var service in services)
        {
            await foreach (var discovered in service.DiscoverAsync())
            {
                if (!discovered.Route.CanonicalPath.Matches(requested)) continue;
      
                var parsed = await parser.ParseAsync(discovered);
                if (parsed is not ParsedItem parsedItem) continue;
      
                var rendered = await renderer.RenderAsync(parsedItem);
                if (rendered is not RenderedItem renderedItem) continue;
      
                var html = $"""
                    <!DOCTYPE html>
                    <html lang="en">
                    <head>
                      <meta charset="utf-8" />
                      <title>{renderedItem.Metadata.Title}</title>
                    </head>
                    <body>
                      <nav><ul>{navHtml}</ul></nav>
                      <article>
                        <h1>{renderedItem.Metadata.Title}</h1>
                        {renderedItem.Content.Html}
                      </article>
                    </body>
                    </html>
                    """;
                return Results.Content(html, "text/html");
            }
        }
      
        return Results.NotFound();
    });
      
    await app.RunOrBuildAsync(args);

Checkpoint — Unstyled pages render

  • Run dotnet run and visit http://localhost:5000/, /about, and /contact
  • Three pages render with plain browser defaults — black text on white, browser-default link underlines, and no layout chrome beyond <nav>/<article>

2. Add a utility-class layout

Before wiring MonorailCSS itself, let's create the shared Layout.Render helper. Its HTML carries the utility classes the class-collector reads on each response, and MonorailCSS styles them once it's registered.

  1. 1

    Create the layout helper

    Drop the Layout class next to Program.cs. The full type shows both the signature and the utility-class shell it emits.

    /// <summary>
    /// Minimal HTML layout helper — the bare-host equivalent of a DocSite/Razor
    /// layout component. It wraps every rendered markdown page in a consistent
    /// shell of utility-class-styled elements. Because the HTML returned here is
    /// what flows through the ASP.NET response pipeline,
    /// <c>CssClassCollectorProcessor</c> sees every utility token on its way to
    /// the browser and the generated stylesheet stays in sync.
    /// </summary>
    /// <remarks>
    /// Teaching points:
    /// <list type="bullet">
    /// <item><description>The <c>&lt;link rel="stylesheet" href="/styles.css"&gt;</c>
    ///   tag points at the endpoint registered by <c>UseMonorailCss()</c>.</description></item>
    /// <item><description>Utility classes on <c>&lt;body&gt;</c>,
    ///   <c>&lt;header&gt;</c>, <c>&lt;nav&gt;</c>, <c>&lt;article&gt;</c>, and
    ///   <c>&lt;footer&gt;</c> all feed the collector once the page is rendered.</description></item>
    /// <item><description>Swap <c>PrimaryColorName</c> in <c>Program.cs</c> and
    ///   the color behind every <c>text-primary-*</c>/<c>bg-primary-*</c>
    ///   token changes on the next request.</description></item>
    /// </list>
    /// </remarks>
    public static class Layout
    {
        /// <summary>
        /// Render the shared page shell around a pre-rendered markdown body.
        /// </summary>
        /// <param name="title">Front-matter title used in <c>&lt;title&gt;</c> and the H1.</param>
        /// <param name="navTree">Nav entries produced by <see cref="NavigationBuilder"/>.</param>
        /// <param name="bodyHtml">The markdown pipeline's rendered HTML.</param>
        public static string Render(string title, IReadOnlyList<NavigationTreeItem> navTree, string bodyHtml)
        {
            var navHtml = string.Join(
                "",
                navTree.Select(item =>
                    $"<li><a class=\"text-primary-700 hover:text-primary-900 font-medium\" href=\"{item.Route.CanonicalPath.Value}\">{item.Title}</a></li>"));
      
            return $"""
                <!DOCTYPE html>
                <html lang="en">
                <head>
                  <meta charset="utf-8" />
                  <meta name="viewport" content="width=device-width, initial-scale=1" />
                  <title>{title}</title>
                  <link rel="stylesheet" href="/styles.css" />
                </head>
                <body class="bg-base-50 text-base-900 min-h-screen">
                  <div class="max-w-3xl mx-auto px-6 py-10">
                    <header class="mb-8 border-b border-base-200 pb-4">
                      <a class="text-lg font-bold text-primary-700" href="/">My Styled Pennington Site</a>
                      <nav class="mt-2">
                        <ul class="flex gap-4 text-sm">{navHtml}</ul>
                      </nav>
                    </header>
                    <article class="prose">
                      <h1 class="text-3xl font-bold text-base-900 mb-4">{title}</h1>
                      {bodyHtml}
                    </article>
                    <footer class="mt-12 pt-4 border-t border-base-200 text-xs text-base-500">
                      Styled with MonorailCSS.
                    </footer>
                  </div>
                </body>
                </html>
                """;
        }
    }

    Notice that <link rel="stylesheet" href="/styles.css"> points at an endpoint that doesn't exist yet — that's intentional. UseMonorailCss mounts it in section 4. Classes like text-primary-700, bg-base-50, and border-base-200 come from the named color palette configured in the next section.

Checkpoint — Layout file compiles

  • The project builds with dotnet build
  • Layout.Render is visible from Program.cs; pages still render unstyled because the route handler hasn't been updated to call it yet

3. Register MonorailCSS in DI

Now let's add the MonorailCSS service registration, pick a named color scheme, and update the route handler to wrap every response in Layout.Render. The stylesheet endpoint still isn't mounted, so pages stay unstyled — that's deliberate. Keeping DI wiring separate from endpoint wiring makes it easier to pinpoint problems.

  1. 1

    Call AddMonorailCss with a NamedColorScheme

    Here's the updated host. Each of PrimaryColorName, AccentColorName, and BaseColorName takes a ColorName value. This tutorial uses indigo/pink/slate, but any ColorName constant works — swap freely.

    var builder = WebApplication.CreateBuilder(args);
      
    builder.Services.AddPennington(penn =>
    {
        penn.SiteTitle = "My Styled Pennington Site";
        penn.ContentRootPath = "Content";
      
        penn.AddMarkdownContent<DocFrontMatter>(md =>
        {
            md.ContentPath = "Content";
            md.BasePageUrl = "/";
        });
    });
      
    builder.Services.AddMonorailCss(_ => new MonorailCssOptions
    {
        ColorScheme = new NamedColorScheme
        {
            PrimaryColorName = ColorName.Indigo,
            AccentColorName = ColorName.Pink,
            BaseColorName = ColorName.Slate,
        },
    });
      
    var app = builder.Build();
      
    app.UsePennington();
      
    app.MapGet("/{*path}", async (
        string? path,
        IEnumerable<IContentService> services,
        IContentParser parser,
        IContentRenderer renderer,
        NavigationBuilder navigation) =>
    {
        var requested = new UrlPath("/" + (path ?? string.Empty).Trim('/'));
      
        var tocItems = new List<ContentTocItem>();
        foreach (var service in services)
        {
            var entries = await service.GetIndexableEntriesAsync();
            tocItems.AddRange(entries);
        }
        var navTree = navigation.BuildTree(tocItems);
      
        foreach (var service in services)
        {
            await foreach (var discovered in service.DiscoverAsync())
            {
                if (!discovered.Route.CanonicalPath.Matches(requested)) continue;
      
                var parsed = await parser.ParseAsync(discovered);
                if (parsed is not ParsedItem parsedItem) continue;
      
                var rendered = await renderer.RenderAsync(parsedItem);
                if (rendered is not RenderedItem renderedItem) continue;
      
                var html = Layout.Render(renderedItem.Metadata.Title, navTree, renderedItem.Content.Html);
                return Results.Content(html, "text/html");
            }
        }
      
        return Results.NotFound();
    });
      
    await app.RunOrBuildAsync(args);

Checkpoint — Services registered, pages still unstyled

  • Run dotnet run and visit http://localhost:5000/ — the page now has the layout's <header>, <nav>, <article>, and <footer> shell, but no styles apply
  • Visit http://localhost:5000/styles.css and the response is a 404; the endpoint isn't mounted yet

4. Mount the stylesheet with UseMonorailCss

One line stands between here and a live stylesheet. Adding UseMonorailCss to the middleware pipeline turns /styles.css into a real endpoint backed by the class-collector.

  1. 1

    Call app.UseMonorailCss()

    The updated host is Stage 2 with one line added: app.UseMonorailCss(). The default path is /styles.css, which already matches the <link> tag in Layout.Render.

    var builder = WebApplication.CreateBuilder(args);
      
    builder.Services.AddPennington(penn =>
    {
        penn.SiteTitle = "My Styled Pennington Site";
        penn.ContentRootPath = "Content";
      
        penn.AddMarkdownContent<DocFrontMatter>(md =>
        {
            md.ContentPath = "Content";
            md.BasePageUrl = "/";
        });
    });
      
    builder.Services.AddMonorailCss(_ => new MonorailCssOptions
    {
        ColorScheme = new NamedColorScheme
        {
            PrimaryColorName = ColorName.Indigo,
            AccentColorName = ColorName.Pink,
            BaseColorName = ColorName.Slate,
        },
    });
      
    var app = builder.Build();
      
    app.UsePennington();
    app.UseMonorailCss();
      
    app.MapGet("/{*path}", async (
        string? path,
        IEnumerable<IContentService> services,
        IContentParser parser,
        IContentRenderer renderer,
        NavigationBuilder navigation) =>
    {
        var requested = new UrlPath("/" + (path ?? string.Empty).Trim('/'));
      
        var tocItems = new List<ContentTocItem>();
        foreach (var service in services)
        {
            var entries = await service.GetIndexableEntriesAsync();
            tocItems.AddRange(entries);
        }
        var navTree = navigation.BuildTree(tocItems);
      
        foreach (var service in services)
        {
            await foreach (var discovered in service.DiscoverAsync())
            {
                if (!discovered.Route.CanonicalPath.Matches(requested)) continue;
      
                var parsed = await parser.ParseAsync(discovered);
                if (parsed is not ParsedItem parsedItem) continue;
      
                var rendered = await renderer.RenderAsync(parsedItem);
                if (rendered is not RenderedItem renderedItem) continue;
      
                var html = Layout.Render(renderedItem.Metadata.Title, navTree, renderedItem.Content.Html);
                return Results.Content(html, "text/html");
            }
        }
      
        return Results.NotFound();
    });
      
    await app.RunOrBuildAsync(args);

Checkpoint — Styled pages and a live stylesheet

  • Run dotnet run and visit http://localhost:5000/ — the header, nav, article, and footer now render with indigo accents, slate neutrals, and the layout spacing from the utility classes
  • Visit http://localhost:5000/styles.css and a populated stylesheet appears, containing rules for every utility class the layout emits
  • Visit http://localhost:5000/contact — the inline <p class="text-primary-700 font-semibold"> in contact.md picks up the indigo color because the collector observed the class on its way through the response pipeline

5. Watch the stylesheet regenerate

Let's prove the class-collector is live. Adding a new utility class to a markdown file and reloading the browser produces a new CSS rule without a server restart.

  1. 1

    Add a new utility class to a page

    Open Content/about.md and add the following line anywhere in the body:

    <p class="text-accent-600 italic">Hello MonorailCSS</p>

    The class text-accent-600 wasn't in the layout, so it doesn't yet exist in the stylesheet.

  2. 2

    Reload and confirm the new rule

    Now reload /about in the browser. The paragraph renders in pink italic because MonorailCSS regenerated the stylesheet on the next /styles.css request after the new class flowed through the collector. Reload /styles.css directly and the text-accent-600 rule is present.

Checkpoint — New class, new rule, no restart

  • http://localhost:5000/about renders the new paragraph in pink italic
  • http://localhost:5000/styles.css now contains a rule for text-accent-600 that wasn't there before the markdown edit
  • No server restart was required — the collector picked up the class the first time the page was served

Summary

  • MonorailCSS is registered with AddMonorailCss and a five-color NamedColorScheme.
  • The generated stylesheet is mounted at /styles.css with UseMonorailCss.
  • A utility-class layout feeds the class-collector, which discovers every token on its way through the response pipeline.
  • A new utility class added at runtime regenerates the stylesheet without a restart.