Using source generators to generate a menu component in a Blazor app

In the previous post, I showed how to create a source generator that finds all the routeable components in a Blazor application at build time.

In this post, I extend that approach, collecting additional metadata about the components, and building a source generator to output this information as a List<> at build time. We can then use the list at runtime to efficiently build a nav control.

This technique is useful for automatically adding new components to a navigation menu, without having to remember to update the menu/navigation component itself:

I'll start by talking about why this is useful, show the end result we're going to build, and then look at the code for the source generator that implements it.

Why is this useful?

The default ASP.NET Core Blazor app in .NET 5.0 has three "routeable" components in the Pages directory

  • Counter.razor
  • FetchData.razor
  • Index.razor

Each of these pages is decorated with a [Route] attribute. Each routeable component is also linked in the shared NavMenu.razor component. The NavMenu component is responsible for rendering the sidebar links in the default Blazor template:

The downside to this shared menu component, is that each of those links is hard-coded. If you change the route for a component, or if you add a new routeable component, you have to remember to go back into NavMenu.razor and update it:

<!-- Each entry in the menu is hard coded to a specific copmonent-->
<li class="nav-item px-3">
    <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
        <span class="oi oi-home" aria-hidden="true"></span> Home
    </NavLink>
</li>
<li class="nav-item px-3">
    <NavLink class="nav-link" href="counter">
        <span class="oi oi-plus" aria-hidden="true"></span> Counter
    </NavLink>
</li>

Wouldn't it be nice if we could automatically build the navigation menu, instead of all that hard coding? Ideally, we want to be able to do something like the following

<!-- Iterate over a list of routeable components in the app -->
@foreach (var pageDetail in PageDetails.MenuPages)
{
    <li class="nav-item px-3">
        <!-- Render a generic link-->
        <NavLink class="nav-link" href="@pageDetail.Route" Match="NavLinkMatch.All">
            @pageDetail.Title
        </NavLink>
    </li>    
}

In this example, we build a PageDetails class, that contains all the routeable components in the application that we want to include in the menu. We then render a link in the menu for them.

In this post I'm explicitly ignoring some functionality, like adding an icon and controlling the order, but I'll show how to add that back in a later post

We don't necessarily want to render every routeable component in the app in the menu: we wouldn't want an Error page to be rendered there, for example. We also need a way to set the "title" of the component that will be shown in the menu.

We can tackle both of these requirements at once using the built-in [Description] attribute: if the attribute is present, we will include the component in the PageDetails class, using the provided value as the title of the page. If it's not present (or the component doesn't have a @page directive, so isn't routable), then we ignore the component:

@page "/"
@attribute [System.ComponentModel.Description("Home")]

<h1>Hello, world!</h1>

That covers what we want our Blazor components to look like, but what about the PageDetails list itself?

The generated classes

For the purposes of this example, we only need two pieces of information to generate the menu control: the component's route, and the title to display in the menu. We can capture this information as a simple .NET 5.0 record type, which we'll call PageDetail:

public record PageDetail(string Route, string Title);

The PageDetails class will contain the list of all components in the app. The desired compiled output is something like the example below. I chose to keep the class as a static for simplicity, but you could easily make it an instance type and register it with the DI container if you prefer.

public static class PageDetails
{
    public static List<PageDetail> MenuPages { get; } = 
        new List<PageDetail>
        {
            new PageDetail("/counter", "Counter"),
            new PageDetail("/", "Home"),
        });
}

So that's what we're going to generate, now lets look at how it works.

Creating the source generator

We'll start with the easy part, defining the templates for the components we're going to generate. We have two templates to define:

  • The PageDetail record—this is always added to the compilation
  • The PageDetails list class—this contains the list of pages. We pass the details of the pages to the method to build the final result.
internal static class Templates
{
    public static string PageDetail()
    {
        // hard code the namespace for now
        return @"
namespace BlazorApp1
{
    public record PageDetail(string Route, string Title);
}";
    }

    public static string MenuPages(IEnumerable<RouteableComponent> pages)
    {
        // hard code the namespace for now
        var sb = new StringBuilder(@"
using System.Collections.Generic;
using System.Collections.ObjectModel;

namespace BlazorApp1
{
public static class PageDetails
{
    public static List<PageDetail> MenuPages { get; } = 
        new List<PageDetail>
        {
");
        foreach (var page in pages)
        {
            sb.AppendLine($"new PageDetail(\"{page.Route}\", \"{page.Title}\"),");
        }

        sb.Append(@"
        };
}
}");
        return sb.ToString();
    }
}

Finally, lets look at the source generator class itself. This generator is very similar to the one in my previous post, but with the addition of the [Description] attribute check. See that post for creating the project itself.

As before, I'll show the source code generator initially, and walk through it subsequently:

[Generator]
public class MenuPagesGenerator : ISourceGenerator
{
    private const string RouteAttributeName = "Microsoft.AspNetCore.Components.RouteAttribute";
    private const string DescriptionAttributeName = "System.ComponentModel.Description";

    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
        try
        {
            var menuComponents = GetMenuComponents(context.Compilation);

            var pageDetailsSource = SourceText.From(Templates.MenuPages(menuComponents), Encoding.UTF8);
            context.AddSource("PageDetails", pageDetailsSource);
            context.AddSource("PageDetail", SourceText.From(Templates.PageDetail(), Encoding.UTF8));
        }
        catch (Exception)
        {
            Debugger.Launch();
        }
    }

    private static ImmutableArray<RouteableComponent> GetMenuComponents(Compilation compilation)
    {
        // Get all classes
        IEnumerable<SyntaxNode> allNodes = compilation.SyntaxTrees.SelectMany(s => s.GetRoot().DescendantNodes());
        IEnumerable<ClassDeclarationSyntax> allClasses = allNodes
            .Where(d => d.IsKind(SyntaxKind.ClassDeclaration))
            .OfType<ClassDeclarationSyntax>();

        return allClasses
            .Select(component => TryGetMenuComponent(compilation, component))
            .Where(page => page is not null)
            .Cast<RouteableComponent>()// stops the nullable lies
            .ToImmutableArray();
    }

    private static RouteableComponent? TryGetMenuComponent(Compilation compilation, ClassDeclarationSyntax component)
    {
        var attributes = component.AttributeLists
            .SelectMany(x => x.Attributes)
            .Where(attr => 
                attr.Name.ToString() == RouteAttributeName
                || attr.Name.ToString() == DescriptionAttributeName)
            .ToList();

        var routeAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == RouteAttributeName);
        var descriptionAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == DescriptionAttributeName);

        if (routeAttribute is null || descriptionAttribute is null)
        {
            return null;
        }

        if (
            routeAttribute.ArgumentList?.Arguments.Count != 1 ||
            descriptionAttribute.ArgumentList?.Arguments.Count != 1)
        {
            // no route path or description value
            return null;
        }

        var semanticModel = compilation.GetSemanticModel(component.SyntaxTree);

        var routeArg = routeAttribute.ArgumentList.Arguments[0];
        var routeExpr = routeArg.Expression;
        var routeTemplate = semanticModel.GetConstantValue(routeExpr).ToString();

        var descriptionArg = descriptionAttribute.ArgumentList.Arguments[0];
        var descriptionExpr = descriptionArg.Expression;
        var title = semanticModel.GetConstantValue(descriptionExpr).ToString();

        return new RouteableComponent(routeTemplate, title);
    }
}

As in my last post, the source generator implements the ISourceGenerator interface, which requires implementing the Initialize() function. This is called once when the source generator is first initialized: in this case, there's no initialization we need to do, so the method is empty.

The Execute() method is called whenever we need to evaluate the compilation and generate the PageDetails class.

public void Execute(GeneratorExecutionContext context)
{
    try
    {
        IEnumerable<RouteableComponent> menuComponents = GetMenuComponents(context.Compilation);

        var pageDetailsSource = SourceText.From(Templates.MenuPages(menuComponents), Encoding.UTF8);
        context.AddSource("PageDetails", pageDetailsSource);
        context.AddSource("PageDetail", SourceText.From(Templates.PageDetail(), Encoding.UTF8));
    }
    catch (Exception)
    {
        Debugger.Launch();
    }
}

First we call the GetMenuComponents() method, which is where we do the bulk of the work, as you'll see shortly. This returns a list of RouteableComponents, which we use with the Templates helpers from earlier to add the source classes to the compilation. We also add the constant PageDetail record template to the compilation.

The RouteableComponent class used in the source generator is just a simple DTO as shown below. We could use a record instead, but as this is a netstandard2.0 app we'd have to deal with the annoying IsExternalInit issue, so I stuck to the standard DTO for simplicity.

public class RouteableComponent
{
    public string Route { get; }
    public string Title { get; }

    public RouteableComponent(string route, string title)
    {
        Title = title;
        Route = route;
    }
}

Let's now take a look at the GetMenuComponents() function, where we find all the routeable components with a [Description] attribute.

private static ImmutableArray<RouteableComponent> GetMenuComponents(Compilation compilation)
{
    // Get all classes
    IEnumerable<SyntaxNode> allNodes = compilation.SyntaxTrees.SelectMany(s => s.GetRoot().DescendantNodes());

    IEnumerable<ClassDeclarationSyntax> allClasses = allNodes
        .Where(d => d.IsKind(SyntaxKind.ClassDeclaration))
        .OfType<ClassDeclarationSyntax>();

    return allClasses
        .Select(component => TryGetMenuComponent(compilation, component))
        .Where(page => page is not null)
        .Cast<RouteableComponent>() // stops the nullable lies
        .ToImmutableArray();
}

GetMenuComponents() starts by finding all nodes in the compilation, and filters to the list of class declarations. For each of those classes we call TryGetMenuComponent(), to see if it is a component we want to include in the menu: if it is, we return a RouteableComponenet, otherwise we return null. Filter out the null values, and we're left with our list of menu components.

private static RouteableComponent? TryGetMenuComponent(Compilation compilation, ClassDeclarationSyntax component)
{
    var attributes = component.AttributeLists
        .SelectMany(x => x.Attributes)
        .Where(attr => 
            attr.Name.ToString() == RouteAttributeName
            || attr.Name.ToString() == DescriptionAttributeName)
        .ToList();

    var routeAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == RouteAttributeName);
    var descriptionAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == DescriptionAttributeName);

    if (routeAttribute is null || descriptionAttribute is null)
    {
        return null;
    }

    if (
        routeAttribute.ArgumentList?.Arguments.Count != 1 ||
        descriptionAttribute.ArgumentList?.Arguments.Count != 1)
    {
        // no route path or description value
        return null;
    }

    var semanticModel = compilation.GetSemanticModel(component.SyntaxTree);

    var routeArg = routeAttribute.ArgumentList.Arguments[0];
    var routeExpr = routeArg.Expression;
    var routeTemplate = semanticModel.GetConstantValue(routeExpr).ToString();

    var descriptionArg = descriptionAttribute.ArgumentList.Arguments[0];
    var descriptionExpr = descriptionArg.Expression;
    var title = semanticModel.GetConstantValue(descriptionExpr).ToString();

    return new RouteableComponent(routeTemplate, title);
}

TryGetMenuComponent() is where we analyse a component to see if it should be included in the menu. We start by finding all the attributes decorating the class, selecting only those components decorated with both a [Route] attribute (courtesy of the @page directive), and a [Description] attribute.

This allows you to have routeable components that aren't included in the menu by omitting the [Description] attribute

Once we've found the attributes, we use the SemanticModel to extract the values from the attributes—the route templates and the description name. These are returned as the RouteableComponent.

With the source generator complete, all that remains is to reference it from the main Blazor app:

<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="5.0.1" />
    <PackageReference Include="System.Net.Http.Json" Version="5.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\SourceGenerators\SourceGenerators.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
  </ItemGroup>

</Project>

And with that, we're done. The source generator will find all components with a @page directive and a [Description] attribute, and create the PageDetails.MenuPages properties. The NavMenu.razor component uses this class to populate the side menu. To add a new menu entry, simply create the routeable component, and add the [Description] attribute with a title for the page:

@page "/Example"
@attribute [System.ComponentModel.Description("Example")]

<h3>Example</h3>

After adding this new page, it immediately appears in the menu, without us having to touch the NavMenu.razor component:

Limitations

The example I've shown here is rather simplistic. It's very particular about the way you add the [Description] attribute (you need to use the full namespace-qualified name, e.g. [System.ComponentModel.Description]), otherwise the source generator won't find the attribute.

With this setup, there's also no way to choose the order of the components in the menu. Working around this limitation could be easily solved with a custom attribute, instead of repurposing the [Description] attribute.

You could obviously extend this even further, depending on how you design your menu component: you could add a hierarchy to the menu for example. All of this is relatively easily done with a custom attribute.

Another possible change would be to generate the NavMenu.razor component itself, instead of the PageDetails class.

Summary

In this post I showed how you could use a source generator to create a list of routeable components in your Blazor application at build time. You can use this to dynamically generate a menu component without the overhead of using Reflection at runtime. In this post I showed a simple implementation that uses the [Description] attribute, but you could also create a custom attribute to control the order components are listed in the menu.