Using source generators with a custom attribute to generate a menu component in a Blazor app

In my previous post, I showed how to create a source generator that finds all the routeable components in a Blazor application at build time, and uses that to build a nav control, without having to manually update the control every time a new page is added.

In this post, I extend that approach further, using a custom attribute (added using a source generator) to specify the icon and title that should be used for a page in the menu. This allows you to generate a List<> of details at build time, that we can then use to build the list of pages.

In the previous post, we wrote a source generator that would find all the pages in the application, but we couldn't control the order they appeared in the menu control, or provide icons:

In this post, the custom attribute means we can add those features to the nav control:

In the previous post I talked about why you might want to use a source generator like this, so in this post I'm just going to focus on the code and the end result.

The end result: using the source generator

In our app we'll use the source generator in two ways:

  • Decorate pages with the [MenuItem] attribute to indicate they should be included in the NavMenu
  • Use the static PageDetails.MenuPages collection to generate the NavMenu component, based on the decorated pages.

Let's start with the MenuItemAttribute. This is a simple attribute, as shown below:

public class MenuItemAttribute : System.Attribute
{
    public string Icon { get; }
    public string Description { get; }
    public int Order { get; }

    public MenuItemAttribute(string icon, string description, int order = 0)
    {
        Icon = icon;
        Description = description;
        Order = order;
    }
}

We can use this attribute on pages that we want to appear in the menu as follows:

@page "/"
@attribute [MenuItem(icon: "oi-home", description: "Home", order: -1)]

<h1>Hello, world!</h1>

We use these [MenuItem] attributes to create PageDetail components:

public record PageDetail(string Route, string Title, string Icon);

These are exposed as the static MenuPages property on PageDetails, which will look something like this:

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

We can then use the PageDetails in the NavMenu component:

<ul class="nav flex-column">
  @foreach (var pageDetail in PageDetails.MenuPages)
  {
    <li class="nav-item px-3">
      <NavLink class="nav-link" href="@pageDetail.Route" Match="NavLinkMatch.All">
        <span class="oi @pageDetail.Icon" aria-hidden="true"></span> @pageDetail.Title
      </NavLink>
    </li>    
  }
</ul>

So that covers everything we want to generate, how do we get there?

Creating the source generator

Source generator projects are .NET Standard class libraries—create a new library using dotnet new classlib, and update the project file to the following:

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

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <PropertyGroup>
    <RestoreAdditionalProjectSources>https://dotnet.myget.org/F/roslyn/api/v3/index.json;$(RestoreAdditionalProjectSources)</storeAdditionalProjectSources>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.8.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2" PrivateAssets="all" />
  </ItemGroup>

</Project>

We'll start by defining the templates for the components we're going to generate. We have three templates to define:

  • The MenuItemAttribute—is always added to the compilation, and is used by the Blazor pages.
  • The PageDetail record—is always added to the compilation and is used in the NavMenu
  • The PageDetails list class—this contains the list of pages. We pass the details of the pages to the MenuPages method to build the final result.
internal static class Templates
{
    public static string MenuItemAttribute()
    {
        return @"
namespace BlazorApp1
{
    public class MenuItemAttribute : System.Attribute
    {
        public string Icon { get; }
        public string Description { get; }
        public int Order { get; }

        public MenuItemAttribute(
            string icon,
            string description,
            int order = 0
        )
        {
            Icon = icon;
            Description = description;
            Order = order;
        }
    }
}";
    }

    public static string PageDetail()
    {
        // hard code the namespace for now
        return @"
namespace BlazorApp1
{
    public record PageDetail(string Route, string Title, string Icon);
}";
    }

    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}\", \"{page.Icon}\"),");
        }

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

Finally, we come to the source generator class itself. This generator is very similar to the one in my previous post. In the previous post, I used the [Description] attribute to decide which pages to include; in this post we'll use the MenuItemAttribute instead.

The source generator is shown below. It has certain limitations (which I'll discuss later), and may not be the most efficient approach, but it works!

using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace SourceGenerators
{
    [Generator]
    public class MenuPagesGenerator : ISourceGenerator
    {
        private const string RouteAttributeName = "Microsoft.AspNetCore.Components.RouteAttribute";
        private const string MenuItemAttributeName = "MenuItem";

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

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

        public void Initialize(GeneratorInitializationContext context)
        {
            //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
                .OrderBy(x => x.Order)
                .ThenBy(x => x.Title)
                .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() == MenuItemAttributeName)
                .ToList();

            var routeAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == RouteAttributeName);
            var menuItemAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == MenuItemAttributeName);

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

            if (
                routeAttribute.ArgumentList?.Arguments.Count != 1 ||
                menuItemAttribute.ArgumentList?.Arguments.Count < 2)
            {
                // 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 iconArg = menuItemAttribute.ArgumentList.Arguments[0];
            var iconExpr = iconArg.Expression;
            var icon = semanticModel.GetConstantValue(iconExpr).ToString();

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

            var order = 0;
            if (menuItemAttribute.ArgumentList?.Arguments.Count == 3)
            {
                var orderArg = menuItemAttribute.ArgumentList.Arguments[2];
                var orderExpr = orderArg.Expression;
                var maybeOrder = semanticModel.GetConstantValue(orderExpr);
                if (maybeOrder.HasValue)
                {
                    order = (int) maybeOrder.Value;
                }
            }


            return new RouteableComponent(routeTemplate, title, icon, order);
        }
    }
}

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);

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

First we call the GetMenuComponents() method, which is where we find all the Razor components that have both a @page directive and a [MenuItem] attribute. This method 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 and the [MenuItem] attribute to the compilation.

The RouteableComponent class used in the source generator is a simple DTO as shown below:

public class RouteableComponent
{
    public string Route { get; }
    public string Title { get; }
    public string Icon { get; }
    public int Order { get; }

    public RouteableComponent(string route, string title, string icon, int order)
    {
        Title = title;
        Icon = icon;
        Order = order;
        Route = route;
    }
}

The GetMenuComponents() function, is very similar to the one from the previous post. It starts by finding all the nodes in the compilation, filters to the class declarations, and filters that list to pages decorated with the [MenuItem] attribute. The list is then ordered according the to the optoional Order property of the [MenuItem] attribute (falling back to the page title)

private static ImmutableArray<RouteableComponent> GetMenuComponents(Compilation compilation)
{
    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>()
        .OrderBy(x => x.Order) // Order by optional Order attribute first
        .ThenBy(x => x.Title) // Then by title
        .ToImmutableArray();
}

TryGetMenuComponent() is where we determine whether a class is a routeable component decorated with the [MenuItem] attribute.

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() == MenuItemAttributeName)
        .ToList();

    var routeAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == RouteAttributeName);
    var menuItemAttribute = attributes.FirstOrDefault(attr => attr.Name.ToString() == MenuItemAttributeName);

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

    if (
        routeAttribute.ArgumentList?.Arguments.Count != 1 ||
        menuItemAttribute.ArgumentList?.Arguments.Count < 2)
    {
        // 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 iconArg = menuItemAttribute.ArgumentList.Arguments[0];
    var iconExpr = iconArg.Expression;
    var icon = semanticModel.GetConstantValue(iconExpr).ToString();

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

    var order = 0;
    if (menuItemAttribute.ArgumentList?.Arguments.Count == 3)
    {
        var orderArg = menuItemAttribute.ArgumentList.Arguments[2];
        var orderExpr = orderArg.Expression;
        var maybeOrder = semanticModel.GetConstantValue(orderExpr);
        if (maybeOrder.HasValue)
        {
            order = (int) maybeOrder.Value;
        }
    }

    return new RouteableComponent(routeTemplate, title, icon, order);
}

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 [MenuItem] attribute.

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

Once we've found the attributes, we use the SemanticModel to extract the values from the attributes—the route templates, the icon to use, the menu item title, and the optional order. 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 [MenuItem] 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 [MenuItem] attribute with a title and icon for the page:

@page "/Example"
@attribute [MenuItem("oi-list-rich", "Example")]

<h3>Example</h3>

After adding this new page, it immediately appears in the menu, without us having to touch the NavMenu.razor component, with an icon, and at an appropriate place in the menu:

Limitations

The example source generator in this post is still rather simplistic. It's very particular about the way you add the [MenuItem] attribute (you can't use the namespace-qualified name, e.g. [BlazorApp1.MenuItem]), otherwise the source generator won't find the attribute. There's ways around that using the SemanticModel, but I chose not to worry about that here.

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, to dynamically generate a menu component without the overhead of using Reflection at runtime. In this post I showed how to use a custom attribute, to associate each page with an icon, and to control the order of the menu items in the NavMenu component.