Using Source Generators to create a Blazor icon library

In this article, you'll learn how to utilize Source Generators to create an icon library for Blazor apps. Source Generators are a powerful tool, which can be used to generate additional C# code during compilation. We won't be designing the icons ourselves, instead, we'll use Lucide, an awesome open-source project with hundreds of beautiful icons. Let's get started!

Setting up the solution

First, we'll create the directory structure below. In the icons folder, we'll put a couple of SVG files from the Lucide library.

.
├── icons
│   ├── file-1.svg
│   ├── file-2.svg
│   ├── ...
│   └── file-n.svg
└── src
    ├── Lucide.Blazor.sln
    ├── Lucide.Blazor
    │   └── Lucide.Blazor.csproj
    ├── Lucide.Blazor.Generators
    │   └── Lucide.Blazor.Generators.csproj
    └── Lucide.Blazor.App
        └── Lucide.Blazor.App.csproj

With the commands below, you can create the necessary source files we'll need for development. Make sure that the Lucide.Blazor.Generators.csproj project is targeting the netstandard2.0 framework. This is a requirement to use Source Generators.

# ⬇ creates a blank solution
dotnet new sln -n "Lucide.Blazor"

# ⬇ creates a class library
dotnet new classlib -n "Lucide.Blazor.Generators" -f "netstandard2.0"

# ⬇ creates a razor class library 
dotnet new razorclasslib -n "Lucide.Blazor" -f "net7.0"

# ⬇ creates an empty Blazor WASM project
dotnet new blazorwasm-empty -n "Lucide.Blazor.App" -f "net7.0"

# ⬇ adds the generator project to the solution
dotnet sln "Lucide.Blazor.sln" add "Lucide.Blazor.Generators/Lucide.Blazor.Generators.csproj"

# ⬇ add the component project to the solution
dotnet sln "Lucide.Blazor.sln" add "Lucide.Blazor/Lucide.Blazor.csproj"

# ⬇ add the Blazor app to the solution
dotnet sln "Lucide.Blazor.sln" add "Lucide.Blazor.App/Lucide.Blazor.App.csproj"

# ⬇ add a project reference from the generator to the component library
dotnet add "Lucide.Blazor/Lucide.Blazor.csproj" reference "Lucide.Blazor.Generators/Lucide.Blazor.Generators.csproj"

# ⬇ add a project reference from the component library to the app
dotnet add "Lucide.Blazor.App/Lucide.Blazor.App.csproj" reference "Lucide.Blazor/Lucide.Blazor.csproj"

Once all projects are created, open the Lucide.Blazor.Generators.csproj file and make the following adjustments:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <!-- ⬇ Helpful to debug the generator -->
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>
  <!-- ⬇ Necessary packages  -->
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.5.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
  </ItemGroup>
</Project>

Then edit the Lucide.Blazor.csproj file and make these changes:

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <SupportedPlatform Include="browser" />
  </ItemGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="7.0.4" />
  </ItemGroup>
  <ItemGroup>
    <!-- ⬇ Need to add "ReferenceOutputAssembly" and "OutputitemType" attributes -->
    <ProjectReference Include="..\Lucide.Blazor.Generators\Lucide.Blazor.Generators.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" />
  </ItemGroup>
  <ItemGroup>
    <!-- ⬇ Adds the svg files as additional analyzer files to the project -->
    <AdditionalFiles Include="..\..\icons\*.svg" />
  </ItemGroup>
</Project>

Creating the Source Generator

The concept of the generator is to loop over each SVG file found in the icons folder and build a static dictionary of icon data. The file name will be used as a key and the file contents will be used as the value.

Add a new class to the project and ensure it has a [Generator] attribute and implements the IIncrementalGenerator interface.

[Generator]
public class Generator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext ctx)
    {
    }
}

Then, we'll implement the Initialize method, by fetching all the SVG files and turning them into an array of key-value tuples.

// fetch all SVG files 
var files = ctx.AdditionalTextsProvider.Where(file => file.Path?.EndsWith(".svg") == true);

// create key-value tuples and convert to an array with Collect()
var iconsProvider = files.Select((file, cancel)
    => (
        Name: Path.GetFileNameWithoutExtension(file.Path),
        Svg: file.GetText(cancel)?.ToString()
    )).Collect();

Next, we'll register the source output. This is the generation part, where we'll build the dictionary of icon names and svg contents in a static C# class. To do so, we'll use a StringBuilder using the following templates:

// Template for creating a key value pair in C# on dictionary initialization
$$"""{ "{{Name}}", {{"\"\"\""}}{{value}}{{"\"\"\""}
// Template for creating a static class with prefilled icon key-value pairs
$$"""
namespace Lucide.Blazor.Data;

public static class IconSet
{
    public static IReadOnlyDictionary<string, string> Icons = new Dictionary<string, string>()
    {
        {{sb}}
    };
}
""";
ctx.RegisterSourceOutput(iconsProvider, (spc, iconsArray) =>
{
    var sb = new StringBuilder();

    foreach (var (Name, Svg) in iconsArray)
    {
        var value = Extract(Name, Svg);
        // Add individual key value pairs to the StringBuilder
        sb.AppendLine("<keyvaluepair_template>");
    }

    // compose the whole dictionary
    var fileTemplate = "<class_template>"

    // Adds the file template with specific name
    spc.AddSource("IconSet.g.cs", fileTemplate);
});

We are also applying some additional logic to the file contents, using the Extract method. The reason for this is that we don't want the whole SVG file as part of the dictionary, as we will reconstruct the SVG element using Blazor later on. All we need is the child elements of the SVG file, which we can obtain like this:

private string Extract(string name, string? value)
{
    var svg = XDocument.Parse(value);
    var elements = svg.Root.Descendants()
        .Select(element =>
        {
            element.Name = element.Name.LocalName;
            return element.ToString(SaveOptions.DisableFormatting);
        });

    var paths = string.Concat(elements);
    return paths;
}

When you compile the project now, you should be able to see a generated IconSet.g.csfile in the Lucide.Blazor.csproj project.

Creating the Blazor component

Now that our data is generated, we can start building the UI component. Head over to the Lucide.Blazor.csproj project and add a new class Icon.cs and ensure it implements ComponentBase.

public class Icon : ComponentBase
{
}

First, we'll add a couple of parameters that we want to make configurable, which are related to the SVG structure.

[Parameter] public string Name { get; set; } = "";
[Parameter] public string Css { get; set; } = "";
[Parameter] public string Width { get; set; } = "24";
[Parameter] public string Height { get; set; } = "24";
[Parameter] public string ViewBox { get; set; } = "0 0 24 24";
[Parameter] public string Fill { get; set; } = "none";
[Parameter] public string Stroke { get; set; } = "currentColor";
[Parameter] public string StrokeWidth { get; set; } = "2";
[Parameter] public string StrokeLinecap { get; set; } = "round";
[Parameter] public string StrokeLinejoin { get; set; } = "round";
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object> Attributes { get; set; } = new Dictionary<string, object>();

Next, we'll override the BuildRenderTree method. The concept is that we look up an icon by its key in the generated dictionary and then we use a RenderTreeBuilder to reconstruct an SVG element, using the provided parameters.

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
    base.BuildRenderTree(builder);

    var icon = IconSet.Icons.FirstOrDefault(i => i.Key.Equals(Name, StringComparison.OrdinalIgnoreCase));

    builder.OpenElement(0, "svg");
    builder.AddAttribute(1, "xmlns", "http://www.w3.org/2000/svg");
    builder.AddAttribute(2, "width", Width);
    builder.AddAttribute(3, "height", Height);
    builder.AddAttribute(4, "viewBox", ViewBox);
    builder.AddAttribute(5, "fill", Fill);
    builder.AddAttribute(6, "stroke", Stroke);
    builder.AddAttribute(7, "stroke-width", StrokeWidth);
    builder.AddAttribute(8, "stroke-linecap", StrokeLinecap);
    builder.AddAttribute(9, "stroke-linejoin", StrokeLinejoin);

    if (Attributes?.Any() == true)
    {
        builder.AddMultipleAttributes(10, Attributes);
    }

    builder.AddMarkupContent(11, icon.Value);
    builder.CloseElement();
}

The last method we'll override is OnParametersSet, where we will do some validation of the parameters.

protected override void OnParametersSet()
{
    base.OnParametersSet();

    if (string.IsNullOrWhiteSpace(Name)) throw new ArgumentNullException(nameof(Name));

    if (!string.IsNullOrWhiteSpace(Css))
    {
        Attributes["class"] = Css;
    }
}

Trying it out

With the coding out of the way, we can now try out the library in the Blazor WASM project. Navigate to the Index.razor page and add the code below to show an icon on the page.

@page "/"
<Icon Name="bug" Stroke="red" Width="250" Height="250" />

When you launch the app you'll see a successfully rendered icon on your screen.

Source code and more

Check out the full source code or Nuget package on GitHub. Where you can also find out how to unit test Source Generators and Blazor components using the Verify library.

References