T4 Templates Updated for Visual Studio 2019

Several years ago I published a series of articles on how to use T4 to generate code. As part of that series I showed how you can, at build time, get Visual Studio to run environmental transforms on any project. To get this to work we relied on a custom .targets file being installed. The .targets file was installed as part of a Visual Studio extension that also installed the T4 templates. As newer versions of Visual Studio have been released the extension has been updated through Visual Studio 2017.

This process still works but as you start moving to build servers in the cloud or that don’t have Visual Studio installed you cannot rely on extensions being available. This article will discuss the process of moving the transform process to a NuGet package that can be used in any build system. This is consistent with how popular packages are now injecting .targets files into the build process as well and reduces the dependencies needed to build a solution.

Who This Isn’t For

If you are using Azure or another cloud provider then please note that they generally provide a cloud-specific approach to storing per-environment settings. You may not need to use config transforms anymore. There are plusses and minuses to transforms. On the plus side you can ensure at build time that everything is transforming correctly. On the negative side you are exposing your settings to all your developers.

If you are building .NET Core applications then realize that XML-based configs are out and JSON files (options) are in. The configuration system in .NET Core allows you to have per-environment settings files that override specific settings in the base file. You don’t need environmental transforms in these environments anymore.

What Is Changing

The base change that will be made is that the .targets file that is shipped as part of the VS extension needs to move to a NuGet package. The need for a VS extension at build time has to be eliminated. The original solution has several components: item template, config transform files and .targets file. The config transform files containing the transformation rules remain unchanged. You can continue to use them as is.

The item template needs to be adjusted to handle the move from an extension-specific .targets file to a NuGet package. This is a minor change.

The .targets file needs to be moved from the extension to a NuGet package. At the same time we can update the file to rely on a pre-build assembly that contains the transformation logic. This helps resolve issues with trying to get MSBuild to load an assembly from an inline task like we had in previous versions of the extension.

Creating the NuGet Package

To get started we need to create a new class library to hold the transformation task the .targets file will call.

  1. Create a new class library (e.g. P3Net.BuildExtensions.TransformConfigs) targeting .NET 4.6.1.
  2. Copy the TransformXmlFiles.cs file from the old build extension project into the new project.
  3. Fix up the namespace name.
  4. Add NuGet packages for the required MSBuild packages.
  5. Microsoft.Build.Framework
  6. Microsoft.Build.Utilities.Core
  7. Microsoft.Web.Xdt

In the sample implementation I’m using the SDK project format to make things easier. Edit the project file or update it via the UI.

  1. Set the title, description and package tags appropriately.
  2. Set the version of the package. Note: I’m using a directory.build.props to share the versioning across projects.
  3. Set the GeneratePackageOnBuild to true.
  4. Optionally configure the package and publish information.
  5. Set the NuspecFile attribute to the path to the .nuspec file below.

XDT Package Changes

The original version of the code used the Microsoft.Web.Publishing.Tasks.TransformXml task from Microsoft.Web.Publishing.Tasks. But this assembly is not shipped in NuGet so we have switched to Microsoft.Web.Xdt. This required some changes to the TransformXmlFiles task to use the new type. Here are the highlights, the code has the full version.

foreach (var inputFile in TransformFiles)
   var transform = new XmlTransformation(inputFile.ItemSpec);
   var sourceDocument = new XmlTransformableDocument() { PreserveWhitespace = true };

   if (transform.Apply(sourceDocument))

Defining the NuSpec File

The SDK project format supports generating packages without a .nuspec file. However I was unable to get it to place the .props and .targets files into the correct locations so we are going to use an external file for now.

<!--?xml version="1.0" encoding="utf-8"?-->
<package xmlns="http://schemas.microsoft.com/packaging/2011/08/nuspec.xsd">
    <title>P3Net Build Extensions - Transform Configs</title>
    <summary>Provides a task for running environmental config transforms at build time.</summary>
    <description>Provides a task for running environmental config transforms at build time.</description>
    <copyright>Copyright 2018 Michael Taylor</copyright>
    <tags>p3net build</tags>
    <license type="expression">MIT</license>
    <releaseNotes>Moved from Visual Studio extension to NuGet package. Added support for VS 2019.</releaseNotes>
    <repository type="git" url="https://github.com/CoolDadTx/p3net-t4-templates"></repository>
    <file target="build\" src="build\">
    <file target="tools\" src="tools\">

Packing the Right Files

The .props files are put into the build folder which will cause them to be injected into the build file. The .targets file in the tools folder is part of the tools that will be referenced by the build. To be found at build time the generated DLL needs to be copied to the tools folder. Normally we would do this using a post-build event. The problem is that we’re relying on the built in packaging feature of the project system and that runs before post build events. Hence when the package is created the binaries haven’t been copied yet. To resolve this we need to copy the files before packing occurs.

As of Visual Studio 2017 15.9 I am unable to get the binary files to appear in the package file. The pack target determines what files to include before the build runs. Because the binaries haven’t been built yet it won’t recognize them as inputs to Nuspec no matter what I’ve tried to do. Here’s some of the things I’ve tried.

Copy Binaries During Buld

Currently the PackDependsOn target can be used to run a task before packaging occurs. We just need to add a target that runs before this.


<target name="CopyBinaries">
   <exec command='xcopy "$(TargetDir)Microsoft.Web.XmlTransform.dll" "$(TargetDir)_Packaging\tools\" /Y /R
xcopy "$(TargetPath)" "$(TargetDir)_Packaging\tools\" /Y /R'>

However the pack target determines the files to include before the build step so it won’t see these files even if they are there.

Update Package Files Item Group

The pack target uses _PackageFiles to identify the files to include. In theory updating this item group to include the binaries should include them. But I was unable to get the item group to recognize the new files.

<target name="CopyBinaries">
   <exec command='xcopy "$(TargetDir)Microsoft.Web.XmlTransform.dll" "$(ProjectDir)Bin\" /Y /R
xcopy "$(TargetPath)" "$(ProjectDir)Bin\" /Y /R'></exec>
      <_PackageFiles Include="$(ProjectDir)Bin\Microsoft.Web.XmlTransform.dll"><!--_PackageFiles-->

Use a wildcard in Nuspec

Nuspec supports wildcards so you can do something like bin\**\*.dll to include binary files. The problem is that the folder structure is rebuilt under the target folder. The binaries have to be at the root for the .targets file to work so the structure would need to be flattened. Unfortunately this isn’t supported in the Nuspec file outside content files.

You could also simply hard code the path to the output. For example the following would copy the files correctly.

   <file target="build\" src="_packaging\build\" />
   <file target="tools\" src="_packaging\tools\" />
   <file target="tools" src="bin\release\net461\Microsoft.Web.XmlTransform.dll" />
   <file target="tools" src="bin\release\net461\P3Net.BuildExtensions.TransformConfigs.dll" />

The downside to this approach is the hard coded configuration and platform information. This is the approach I have gone with.

Use Manual Packaging

The last option is to not use the automated system at all but instead either do it via a post build event or manually.

Updating the Targets File

With the build task complete and wrapped in a package we just need to get it called during a build. That is where the .targets file comes in. In the previous version the file was copied to a location under MSBuild but it is now going to be part of the package.

  1. Create a new Tools folder in the package project.
  2. Copy the .targets file into the new folder and rename to P3Net.BuildExtensions.TransformConfigs.targets.

The .targets file needs a couple of adjustments to play nice with the rest of the build system and use the new assembly where the custom task is defined.

<!--?xml version="1.0" encoding="utf-8"?-->
<project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" toolsversion="15.0">

   <usingtask assemblyfile="P3Net.BuildExtensions.TransformConfigs.dll" taskname="TransformXmlFiles"></usingtask>

   <!-- Get the config transform files -->
      <webconfigtransformfiles exclude="web.config" include="web.*.config" />
      <appconfigtransformfiles exclude="app.config" include="app.*.config" />

   <!-- Runs after a successful build -->
   <target name="TransformConfigurationFiles" aftertargets="AfterBuild">
      <transformxmlfiles condition="Exists('web.config')" projectname="$(MSBuildProjectName)" outputdirectory="$([System.IO.Path]::Combine($(OutDir), 'Configs'))" targetfile="web.config" sourcefile="web.config" transformfiles="@(WebConfigTransformFiles)"></transformxmlfiles>
      <transformxmlfiles condition="Exists('app.config')" projectname="$(MSBuildProjectName)" outputdirectory="$([System.IO.Path]::Combine($(OutDir), 'Configs'))" targetfile="$(TargetFileName).config" sourcefile="$([System.IO.Path]::Combine($(TargetDir), '$(TargetFileName).config'))" transformfiles="@(AppConfigTransformFiles)">

We need to make sure the .targets file gets copied to the output directory so right-click the file in Solution Explorer and set its Build Action to Content.

Handling Binding Redirects

One change from the original version is around looking for the app.config file. For newer projects using the SDK format the build system can auto-generate binding redirects. This reduces the amount of configuration that goes into the config file and helps ensure the application will use the correct versions. However this transformation occurs at build time and the modified file is stored directly in the output.

To account for this the .targets file has been modified to use the generated config file instead of the version in source. This will ensure the transforms include any generated binding redirects.

Adding the Props File

The final step is to add a .props file that will get added to the project file when the package is installed. The property file will import the .targets file into the build. Create the P3Net.BuildExtensions.TransformConfigs.props file in the package project. Then paste the following code.

<!--?xml version="1.0" encoding="utf-8"?-->
<project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" toolsversion="4.0">

   <import project="$(P3NetTargetsPath)"></import>

Like the .targets file it needs to be copied to the output at build time.

Updating the Item Template

The environment config item template (actually the template wizard) currently looks for an import of the targets file so that it can report a warning if a project is using the template but hasn’t installed the extension yet. Instead we will look for the NuGet package. Instead of trying to handle both approaaches to package references we’ll just look for the props file that the package installs.

private void EnsureStandardTargetIsImported ( EnvDTE.Project project )
   var buildProject = ProjectCollection.GlobalProjectCollection.GetLoadedProjects(project.FullName).First();

   //Check for the import
   var hasImport = (from i in buildProject.Xml.Imports
                    where String.Compare(Path.GetFileName(i.Project), SharedTargetsFileName, true) == 0
                    select i).Any();
   if (hasImport)

   ReportErrorAndConfirm($"This item requires that the NuGet package {PackageName} be installed in the project. Do you want to add the template anyway?");

Testing the Changes

Go ahead and build the solution. Then find the generated .nupkg file and open it. If everything is correct then the .props file will be under the build folder and the .targets file under the tools folder. When the package is installed the .props file will get added to the project file. Since it references the .targets file that file will get loaded at build time which will trigger the generation of the config transforms.

Now copy the package file to your local NuGet store so you can add it to a project with a set of config transforms. We’ll use a simple web app as a test.

  1. Create a new ASP.NET Web Application in Visual Studio.
  2. Using Package Manager add the new package to the project.
  3. Rebuild.
  4. The existing web config transforms should run during the build and, as in the original article, you should have the transformed files ready for use.

Visual Studio 2019 Support

As a final touch we will update the extension to support VS 2019. This just requires a couple of changes to the vsixmanifest file as discussed here.

  1. Update the InstallationTarget to [15.0,17.0)].
  2. Update any Prerequisite elements from Visual Studio to [15.0,].
  3. Update the extension version information.
  4. Rebuild

Note: The templates require T4 Toolbox which has not been updated to VS 2019 as of yet.

Cleaning Up the Code

The build extension project (P3Net.BuildExtensions) and VSIX (P3Net.BuildExtensions.Setup) are no longer needed and can be removed.

The installation script and original .targets files can be removed as they are no longer needed.

The text template VSIX (T4TemplatesSetup) may have a dependency on the build extension project. This can be removed from the manifest editor now that it is gone.

The code is available on GitHub.