Adding Compiled .ResX Resources To NuGet Packages
Join the DZone community and get the full member experience.
Join For FreeAt my current workplace, we are using NuGet internally for managing internal ASP.NET MVC 3 project templates. If you open up Visual Studio’s Tools -> Options dialog window and expand Package Manager, you will find a “Package Sources” section where you can define locations for acquiring NuGet packages. We have a second location defined in this section with the path being a directory on our LAN. There are several advantages of taking this approach to managing team resources, not the least of which is the fact that updating our template source code repositories over SVN will not, and should not, update (read: break) the development workflows for people already working on their projects unless those individuals manually invoke an Update-Package command from the Package Manager Console. This scenario is obviously not ideal for many teams but it is quite useful for ours since each project instance has its own lifecycle and is short-lived.
One of the dependencies of our ASP.NET MVC templates is the utilization of .resx files for managing various pieces of content such as the labels on the forms. Have a resource file gives specific members of the organization a very specific and anticipated location to update the content to suit the needs of the project instance. The content items are also programmatically accessible when the Access Modifier is set to public; a .Designer.cs file is generated and injected into the project automatically by Visual Studio, appearing as a generated code-behind file for the .resx file, which exposes the API required to programmatically read content from this file by item name so that the developer does not have to stream the .resx out manually as an embedded resource stream. Microsoft .NET also automatically pulls the content from the suitable resource file when the culture is added to the filename, based on the user’s culture of the current thread; in other words, if a resource file is called FormFields.resx, but the user is French-Canadian (fr-CA), then the content in FormFields.fr-CA.resx is automatically used. Of course, we have to manually synchronize the user’s culture with the thread, that is a separate discussion, but the point is that it can be beneficial, and in our case it is, to utilize .resx resources in a project.
Unfortunately, when installing a NuGet project that contains .resx file content, the generated designer C# file that exposes programmatic access to the items in the resources file does not get added correctly, nor is the metadata on the .resx file that declares the resource’s Access Modifier to be “Public”, which is how Visual Studio knows to inject that generated file. Our template’s C# codebase depends upon the presence of the generated C# object members for the .resx, so in the absence of the generated code-behind file the project importing this template will not compile. Our workaround had been to open up the .resx file, change the Access Modifier back to “Public”, save it, and Rebuild. This worked fine, but it has been a huge annoyance.
So we decided to look into automatically fixing this within the Install.ps1 PowerShell script which NuGet invokes upon installing a package. Visual Studio’s DTE automation object and its Project object are both injected to the Install.ps1 script that PowerShell invokes.
param($installPath, $toolsPath, $package, $project)
We store our resources in a Resources directory in the project, so iterating through the project items to identify our .resx files was straightforward enough.
$resitems = @{} foreach ($item in $project.ProjectItems) { if ($item.Name -eq "Resources") { foreach ($resitem in $resources.ProjectItems) { if ($resitem.Name.ToLower().EndsWith(".resx")) { $resitems.Add("Resources\" + $resitem.Name, $resitem.FileNames(0)) } } } }
Unfortunately, I found no way within EnvDTE automation to modify the properties in the .resx file that pertain to the generated file! At best, the ProjectItem object exposes a Properties member that lists out various bits of metadata, but I found nothing here that can be changed to cause the .resx file to use a generated file.
The best I could find and tried to play with was a property called “IsDesignTimeBuildInput” that I thought I could apply to the .Designer.cs file, but attempting to set this value to true proved unfruitful:
# where $cb2 is the .Designer.cs file $cb2.Properties.Item("IsDesignTimeBuildInput").Value = $TRUE
.. results in ..
Exception setting "Value": "Invalid number of parameters. (Exception from HRESULT: 0x8002000E (DISP_E_BADPARAMCOUNT))"
I did manage to get a code-behind file added to the .resx file, however.
switch ($item.ProjectItems) { default { if ($_.Name.ToLower() -eq $resitem.Name.ToLower().Replace(".resx", ".designer.cs")) { $hasCodeBehind = $TRUE $codebehinditem = $_ } }} if ($hasCodeBehind -eq $TRUE) { $fn = $resitem.FileNames(0) $cbfn = $codebehinditem.FileNames(0) $codebehinditem.Remove() $cb2 = $resitem.ProjectItems.AddFromFile($cbfn) }
At this point, it would prove obviously beneficial to use a comparison tool such as Beyond Compare (which I used) to compare the contents of the .resx file, the .Designer.cs file, and the .csproj file (the Visual Studio project file) between my half-restored NuGet injection and a properly working project instance. Doing this, I found that there are absolutely no changes made to the .resx file to toggle its code-behind / generator behavior, and of course the .Designer.cs is just the output of the generator so it has no flags, either. All of this metadata is therefore made to the project (.csproj) file.
And since there do not seem to be any EnvDTE interfaces to support these project file changes, it seems that the change must be made in the project XML directly. This can cause all kinds of unpredictable problems, the least of which is an ugly dialog box for the user, “Project has changed, reload?” Nonetheless, this is what’s working for us, and it’s better than a broken build that requires us to manually open the .resx file.
The full solution:
param($installPath, $toolsPath, $package, $project) #script to fix code-behind for resx set-alias Write-Host -Name whecho whecho "Restoring resource code-behinds (this may cause the project to be reloaded with a dialog) ..." $resitems = @{} foreach ($item in $project.ProjectItems) { if ($item.Name -eq "Resources") { $resources = $item foreach ($resitem in $resources.ProjectItems) { $codebehinditem = $NULL if ($resitem.Name.ToLower().EndsWith(".resx")) { $hasCodeBehind = $FALSE switch ($item.ProjectItems) { default { if ($_.Name.ToLower() -eq $resitem.Name.ToLower().Replace(".resx", ".designer.cs")) { $hasCodeBehind = $TRUE $codebehinditem = $_ } }} if ($hasCodeBehind -eq $TRUE) { $fn = $resitem.FileNames(0) $cbfn = $codebehinditem.FileNames(0) $codebehinditem.Remove() $cb2 = $resitem.ProjectItems.AddFromFile($cbfn) } $resitems.Add("Resources\" + $resitem.Name, $resitem.FileNames(0)) whecho $resitem.Name } } } } $project.Save($project.FullName) $projxml = [xml](get-content $project.FullName) $ns = New-Object System.Xml.XmlNamespaceManager $projxml.NameTable $defns = "http://schemas.microsoft.com/developer/msbuild/2003" $ns.AddNamespace("csproj", $defns) foreach ($item in $resitems.GetEnumerator()) { $xpath = "//csproj:Project/csproj:ItemGroup/csproj:Compile[@Include=`"" + $item.Name.Replace(".resx", ".Designer.cs") + "`"]" $resxDesignerNode = $projxml.SelectSingleNode($xpath, $ns) if ($resxDesignerNode -ne $NULL) { $autogen = $projxml.CreateElement('AutoGen', $defns) $autogen.InnerText = 'True' $resxDesignerNode.AppendChild($autogen) $designtime = $projxml.CreateElement('DesignTime', $defns) $designtime.InnerText = 'True' $resxDesignerNode.AppendChild($designtime) } $xpath = "//csproj:Project/csproj:ItemGroup/csproj:EmbeddedResource[@Include=`"" + $item.Name + "`"]" $resxNode = $projxml.SelectSingleNode($xpath, $ns) $generator = $projxml.CreateElement('Generator', $defns) $generator.InnerText = 'PublicResXFileCodeGenerator' $resxNode.AppendChild($generator) if ($resxDesignerNode -ne $NULL) { $lastGenOutput = $projxml.CreateElement('LastGenOutput', $defns) $lastGenOutput.InnerText = $item.Name.Replace("Resources\", "").Replace(".resx", ".Designer.cs") $resxNode.AppendChild($lastGenOutput) } } $projxml.Save($project.FullName)
UPDATE: Just an update on this, we have abandoned this approach to editing the XML. The project XML can be manipulated in-memory using the MSBuild automation object. Hints of what to do are found here:
Published at DZone with permission of Jon Davis, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Trending
-
Build a Simple Chat Server With gRPC in .Net Core
-
Microservices With Apache Camel and Quarkus
-
AI Technology Is Drastically Disrupting the Background Screening Industry
-
Web Development Checklist
Comments