Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

How to *correctly* map a file extension to an MSBuild item type? #9614

Open
ltrzesniewski opened this issue Dec 9, 2024 · 1 comment
Open

Comments

@ltrzesniewski
Copy link

Summary

I am trying to resolve ltrzesniewski/RazorBlade#16 but I don't know which is the best way to proceed.

This issue is about how to control what changes Visual Studio automatically introduces to a project when having custom MSBuild items.

Description

Basically, I have a library which handles .cshtml files by passing them to a Roslyn source generator. I'd like to do it the clean way by mapping those files to a custom MSBuild item (of type RazorBlade), and then passing those as AdditionalFiles items to Roslyn. Note that the Razor SDK is not supposed to be used in this scenario.

The library provides the following .props file, which maps .cshtml files in the project to RazorBlade items, unless the EnableDefaultRazorBladeItems property is set to false:

<Project>

  <PropertyGroup>
    <EnableDefaultRazorBladeItems Condition="'$(EnableDefaultRazorBladeItems)' == ''">true</EnableDefaultRazorBladeItems>
  </PropertyGroup>

  <ItemGroup>
    <AvailableItemName Include="RazorBlade" />
  </ItemGroup>

  <ItemGroup Condition="'$(EnableDefaultItems)' == 'true' and '$(EnableDefaultRazorBladeItems)' == 'true'">
    <RazorBlade Include="**/*.cshtml" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
  </ItemGroup>

</Project>

Then, the following .targets file configures them and generates AdditionalFiles items:

<Project>

  <ItemGroup>
    <RazorBlade Update="@(RazorBlade)" Namespace="$([MSBuild]::ValueOrDefault('$(RootNamespace).%(RelativeDir)', '').Replace('\', '.').Replace('/', '.').Trim('.'))" />
    <RazorBlade Update="@(RazorBlade)" IsRazorBlade="True" />

    <None Remove="@(RazorBlade)" />
    <AdditionalFiles Include="@(RazorBlade)" />
  </ItemGroup>

  <ItemGroup>
    <CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="IsRazorBlade" />
    <CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="Namespace" />
  </ItemGroup>

  <!-- Other unrelated stuff removed for clarity -->

</Project>

The goal here is to use all .cshtml files by default, or let the users customize them if needed by manipulating the RazorBlade items themselves.

Unfortunately, this code causes Visual Studio to remove RazorBlade items when adding a new file: if I create NewFile.cshtml using the UI, Visual Studio will automatically add the following to the .csproj file:

<ItemGroup>
  <RazorBlade Remove="NewFile.cshtml" />
</ItemGroup>

This is not exactly what I'd like to happen. 😅

The inital issue also reported that after removing the code that VS inserted, the "Build Action" property was inconsistent (which I can understand given that two different MSBuild item types reference the same file):

The build action was also not set correctly, sometimes getting set to Content, sometimes to C# analyzer file

Repro

  • Open the following project file:

    <Project Sdk="Microsoft.NET.Sdk">
    
      <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
      </PropertyGroup>
    
      <ItemGroup>
        <PackageReference Include="RazorBlade" Version="0.6.0" />
      </ItemGroup>
    
    </Project>
  • Add a NewFile.cshtml file using the Visual Studio UI (Add -> New item...)

  • Notice the change in the project file

Question

I'd like to know which is the correct way to handle this.

I suppose I'll need to use $(DesignTimeBuild) somewhere, or move the mapping to AdditionalFiles to an MSBuild target. But what would be the idiomatic way to proceed in order to avoid surprises with what Visual Studio does with the .csproj file? Also, how does it behave when several MSBuild items are mapped to the same file?

More generally, I think this should ideally be documented, as it is not straightforward, and there are quite a few libraries doing this kind of things, using different approaches.

Details

Reproduced with VS 17.12.3

Here are some related issues I could find, but none of them provide a clear solution to the problem:

@ltrzesniewski
Copy link
Author

I found the Custom item types page in the VSProjectSystem project which was very helpful.

I did the following:

  • Added a ProjectSchemaDefinitions XAML file referenced by a PropertyPageSchema item
  • Removed .cshtml files from the None items in the library .targets
  • Moved adding AdditionalFiles items into a MSBuild target

And this seemed to do the trick. Visual Studio no longer modifies the .csproj file when adding a .cshtml file. 🎉

For reference, here is the new .props file:

<Project>

  <PropertyGroup>
    <EnableDefaultRazorBladeItems Condition="'$(EnableDefaultRazorBladeItems)' == ''">true</EnableDefaultRazorBladeItems>
  </PropertyGroup>

  <ItemGroup Condition="'$(EnableDefaultItems)' == 'true' and '$(EnableDefaultRazorBladeItems)' == 'true'">
    <RazorBlade Include="**/*.cshtml" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)" />
  </ItemGroup>

</Project>

The new .targets file:

<Project>

  <ItemGroup>
    <AvailableItemName Include="RazorBlade" />
    <PropertyPageSchema Include="$(MSBuildThisFileDirectory)RazorBlade.xaml" Context="File;BrowseObject" />

    <CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="IsRazorBlade" />
    <CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="Namespace" />

    <None Remove="**/*.cshtml" Condition="'$(EnableDefaultItems)' == 'true' and '$(EnableDefaultRazorBladeItems)' == 'true'" />
  </ItemGroup>

  <Target Name="RazorBladePrepareAnalyzerInput" BeforeTargets="GenerateMSBuildEditorConfigFileCore">
    <ItemGroup>
      <RazorBlade Update="@(RazorBlade)" Namespace="$([MSBuild]::ValueOrDefault('$(RootNamespace).%(RelativeDir)', '').Replace('\', '.').Replace('/', '.').Trim('.'))" />
      <RazorBlade Update="@(RazorBlade)" IsRazorBlade="True" />

      <AdditionalFiles Include="@(RazorBlade)" />
    </ItemGroup>
  </Target>

  <!-- Other unrelated stuff removed for clarity -->

</Project>

And the .xaml file:

<ProjectSchemaDefinitions xmlns="http://schemas.microsoft.com/build/2009/properties">
  <ContentType Name="RazorBlade" DisplayName="RazorBlade template" ItemType="RazorBlade" />
  <ItemType Name="RazorBlade" DisplayName="RazorBlade template" />
  <FileExtension Name=".cshtml" ContentType="RazorBlade" />
</ProjectSchemaDefinitions>

All of this seemed necessary in order to get the proper behavior, but I'm leaving this issue open as I'm not sure if this is the best way to solve the issue, and would appreciate a comment on that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant