Strongly-typed resources with .NET 6, 8, Blazor and beyond

Updated 2024-10-29

Copilot (in VS Code) helping me write posts is 🤯

In the era of source generators and .NET 6+, it would seem that adding a .resx file to your project should be enough to get you started with strongly-typed resources. I’d just expect some new source generator would pick .resx files (if they don’t have %(GenerateResource) metadata value set to false) and generate the corresponding code.

Despite years going by, this still doesn’t work as-is out of the box, which is quite annoying. I just spent a while figuring out why and found this excelent blog post on how to enable it. It’s almost there as a generic solution.

Nowadays (.NET 8), when you add a new .resx file to your project and you get the old experience of having a .Designer.cs which you check into your repo. This is so 2000s!

My solution involves:

  1. Deleting the .Designer.cs file from the project
  2. Deleting the MSBuild item emitted for the .resx itself in the project file, which looks like the following (polluting your project file):
  <ItemGroup>
    <Compile Update="Resources.Designer.cs">
      <DesignTime>True</DesignTime>
      <AutoGen>True</AutoGen>
      <DependentUpon>Resources.resx</DependentUpon>
    </Compile>
  </ItemGroup>

  <ItemGroup>
    <EmbeddedResource Update="Resources.resx">
      <Generator>ResXFileCodeGenerator</Generator>
      <LastGenOutput>Resources.Designer.cs</LastGenOutput>
    </EmbeddedResource>
  </ItemGroup>
  1. Setting the Custom Tool property of the .resx file to MSBuild:Compile in the properties window.

  2. Add a Directory.Build.targets file to the root of my solution/repo with the following content:

<Project>
  <PropertyGroup>
    <!-- Required for intellisense -->
    <CoreCompileDependsOn>CoreResGen;$(CoreCompileDependsOn)</CoreCompileDependsOn>
  </PropertyGroup>

  <ItemGroup>
    <EmbeddedResource Update="@(EmbeddedResource -> WithMetadataValue('Generator', 'MSBuild:Compile'))" Type="Resx">
      <StronglyTypedFileName>$(IntermediateOutputPath)\$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.'))%(Filename).g$(DefaultLanguageSourceExtension)</StronglyTypedFileName>
      <StronglyTypedLanguage>$(Language)</StronglyTypedLanguage>
      <StronglyTypedNamespace Condition="'%(RelativeDir)' == ''">$(RootNamespace)</StronglyTypedNamespace>
      <StronglyTypedNamespace Condition="'%(RelativeDir)' != ''">$(RootNamespace).$([MSBuild]::ValueOrDefault('%(RelativeDir)', '').Replace('\', '.').Replace('/', '.').TrimEnd('.'))</StronglyTypedNamespace>
      <StronglyTypedClassName>%(Filename)</StronglyTypedClassName>
    </EmbeddedResource>
  </ItemGroup>
</Project>

This triggers the generation of the typed resource class just as if it had the (legacy?) ResXFileCodeGenerator (or PublicResXFileCodeGenerator) custom tool set in the .resx file properties. The benefit of this approach is that you don’t get the .Designer.cs file checked into your repo. In order to trigger the code generation, you instead set the custom tool to MSBuild:Compile in the .resx file properties.

The reason to keep a custom tool (and not assigning it automatically to all .resx files) is that you only need the strongly-typed resource class for the root/neutral .resx, not the per-locale ones. You might also have other resource files you don’t want to generate code for.

Some notes on the implementation of item metadata above:

  1. The RelativeDir built-in metadata is used to generate the namespace and unique target file name. We cannot use it without replacing path separators with dots because the resgen tool will not create the directory structure for us.
  2. The StronglyTypedLanguage is set to the current project $(Language) which should work for the supported languages by the resgen tool.

Enjoy!