MergeDefaultStyles build task improves control development (w/source)
January 13, 2009
When we first introduced the Silverlight Toolkit, we highlighted our agile, customer feedback-focused, transparent way of working. We talked about our open source model. And we’ve made a commitment to share what we’ve learned while developing the toolkit, whether that is knowledge, guides, blog posts, or sharing code. Today, I offer infrastructure in blog post format!
The task presented here is used (along with others) to help improve developer efficiency, cut down on simple coding mistakes, and specialize a number of functions to automation and tasks. This post is kind of like learning about how sausage is made: this is for power users and control developers who have an interest in geek’n out with this.
This particular post helps improve the development process for controls by letting us separate out the actual styles, so we don’t spend so much time worrying about merge conflicts and diffs.
Merging resources into generic.xaml
As recently noted on the Silverlight.net forums, the source code download for the Silverlight Toolkit sheds some light on an interesting “MergeDefaultStyles” task (and DefaultStyle item type) used to merge all the different control .Xaml files into one build file.
This allows us to bundle several controls in a single library, but not worry about merging source code changes for several templates in a single generic.xaml: TreeView, AutoCompleteBox, and other controls each have their own XAML resource dictionaries that contain their default styles and template: AutoCompleteBox.xaml is merged at build time into the generic.xaml, and so on.
I am assuming that you’re already familiar enough with msbuild to create your own tasks… here goes. Also, do download the toolkit source code package – although the package does not include the custom targets to use this task, it does include the individual resource dictionaries used by the controls.
The MergeDefaultStylesTask
The task has a few inputs:
- ProjectDirectory, required; sets the directory of the project where the generic.xaml resides.
- DefaultStyles array of ITaskItem’s; represents the items that are marked with the DefaultStyle build action.
And the eventual output is the updating of the generic.xaml file.
The task references the typical MsBuild libraries, including Microsoft.Build.Framework, engine, and utilities. In our implementation, we also interact with our Visual Studio Team Foundation Server (TFS): however I’ve stripped that from this example, for simplicity sake, and instead just removed the read-only flag on the generic.xaml file when writing it.
Here’s MergeDefaultStylesTask.cs:
// (c) Copyright Microsoft Corporation. // This source is subject to the Microsoft Public License (Ms-PL). // Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. // All other rights reserved. using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Text; using Microsoft.Build.Framework; using Microsoft.Build.Utilities; namespace Engineering.Build.Tasks { /// <summary> /// Build task to automatically merge the default styles for controls into /// a single generic.xaml file. /// </summary> public class MergeDefaultStylesTask : Task { /// <summary> /// Gets or sets the root directory of the project where the /// generic.xaml file resides. /// </summary> [Required] public string ProjectDirectory { get; set; } /// <summary> /// Gets or sets the project items marked with the "DefaultStyle" build /// action. /// </summary> [Required] public ITaskItem[] DefaultStyles { get; set; } /// <summary> /// Initializes a new instance of the MergeDefaultStylesTask class. /// </summary> public MergeDefaultStylesTask() { } /// <summary> /// Merge the project items marked with the "DefaultStyle" build action /// into a single generic.xaml file. /// </summary> /// <returns> /// A value indicating whether or not the task succeeded. /// </returns> [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Task should not throw exceptions.")] public override bool Execute() { Log.LogMessage(MessageImportance.Low, "Merging default styles into generic.xaml."); // Get the original generic.xaml string originalPath = Path.Combine(ProjectDirectory, Path.Combine("themes", "generic.xaml")); if (!File.Exists(originalPath)) { Log.LogError("{0} does not exist!", originalPath); return false; } Log.LogMessage(MessageImportance.Low, "Found original generic.xaml at {0}.", originalPath); string original = null; Encoding encoding = Encoding.Default; try { using (StreamReader reader = new StreamReader(File.Open(originalPath, FileMode.Open, FileAccess.Read))) { original = reader.ReadToEnd(); encoding = reader.CurrentEncoding; } } catch (Exception ex) { Log.LogErrorFromException(ex); return false; } // Create the merged generic.xaml List<DefaultStyle> styles = new List<DefaultStyle>(); foreach (ITaskItem item in DefaultStyles) { string path = Path.Combine(ProjectDirectory, item.ItemSpec); if (!File.Exists(path)) { Log.LogWarning("Ignoring missing DefaultStyle {0}.", path); continue; } try { Log.LogMessage(MessageImportance.Low, "Processing file {0}.", item.ItemSpec); styles.Add(DefaultStyle.Load(path)); } catch (Exception ex) { Log.LogErrorFromException(ex); } } string merged = null; try { merged = DefaultStyle.Merge(styles).GenerateXaml(); } catch (InvalidOperationException ex) { Log.LogErrorFromException(ex); return false; } // Write the new generic.xaml if (original != merged) { Log.LogMessage(MessageImportance.Low, "Writing merged generic.xaml."); try { // Could interact with the source control system / TFS here File.SetAttributes(originalPath, FileAttributes.Normal); Log.LogMessage("Removed any read-only flag for generic.xaml."); File.WriteAllText(originalPath, merged, encoding); Log.LogMessage("Successfully merged generic.xaml."); } catch (Exception ex) { Log.LogErrorFromException(ex); return false; } } else { Log.LogMessage("Existing generic.xaml was up to date."); } return true; } } }
With the task in place, now we just need to add the DefaultStyle implementation and build the task assembly.
DefaultStyle does the heavy lifting
The type DefaultStyle is very LINQ-y and uses XLinq to handle parsing XAML, managing namespaces, and also the merging of multiple instances. Here’s DefaultStyle.cs, that should be included in the project (author: Ted Glaza):
// (c) Copyright Microsoft Corporation. // This source is subject to the Microsoft Public License (Ms-PL). // Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details. // All other rights reserved. using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Xml.Linq; namespace Engineering.Build { /// <summary> /// DefaultStyle represents the XAML of an individual Control's default /// style (in particular its ControlTemplate) which can be merged with other /// default styles). The XAML must have a ResourceDictionary as its root /// element and be marked with a DefaultStyle build action in Visual Studio. /// </summary> public partial class DefaultStyle { /// <summary> /// Root element of both the default styles and the merged generic.xaml. /// </summary> private const string RootElement = "ResourceDictionary"; /// <summary> /// Gets or sets the file path of the default style. /// </summary> public string DefaultStylePath { get; set; } /// <summary> /// Gets the namespaces imposed on the root element of a default style /// (including explicitly declared namespaces as well as those inherited /// from the root ResourceDictionary element). /// </summary> public SortedDictionary<string, string> Namespaces { get; private set; } /// <summary> /// Gets the elements in the XAML that include both styles and shared /// resources. /// </summary> public SortedDictionary<string, XElement> Resources { get; private set; } /// <summary> /// Gets or sets the history tracking which resources originated from /// which files. /// </summary> private Dictionary<string, string> MergeHistory { get; set; } /// <summary> /// Initializes a new instance of the DefaultStyle class. /// </summary> protected DefaultStyle() { Namespaces = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase); Resources = new SortedDictionary<string, XElement>(StringComparer.OrdinalIgnoreCase); MergeHistory = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); } /// <summary> /// Load a DefaultStyle from the a project item. /// </summary> /// <param name="path"> /// Path of the default style which is used for reporting errors. /// </param> /// <returns>The DefaultStyle.</returns> public static DefaultStyle Load(string path) { DefaultStyle style = new DefaultStyle(); style.DefaultStylePath = path; string xaml = File.ReadAllText(path); XElement root = XElement.Parse(xaml, LoadOptions.PreserveWhitespace); if (root.Name.LocalName == RootElement) { // Get the namespaces foreach (XAttribute attribute in root.Attributes()) { if (attribute.Name.LocalName == "xmlns") { style.Namespaces.Add("", attribute.Value); } else if (attribute.Name.NamespaceName == XNamespace.Xmlns.NamespaceName) { style.Namespaces.Add(attribute.Name.LocalName, attribute.Value); } } // Get the styles and shared resources foreach (XElement element in root.Elements()) { string name = (element.Name.LocalName == "Style") ? GetAttribute(element, "TargetType", "Key", "Name") : GetAttribute(element, "Key", "Name"); if (style.Resources.ContainsKey(name)) { throw new InvalidOperationException(string.Format( CultureInfo.InvariantCulture, "Resource \"{0}\" is used multiple times in {1} (possibly as a Key, Name, or TargetType)!", name, path)); } style.Resources.Add(name, element); style.MergeHistory[name] = path; } } return style; } /// <summary> /// Get the value of the first attribute that is defined. /// </summary> /// <param name="element">Element with the attributes defined.</param> /// <param name="attributes"> /// Local names of the attributes to find. /// </param> /// <returns>Value of the first attribute found.</returns> private static string GetAttribute(XElement element, params string[] attributes) { foreach (string name in attributes) { string value = (from a in element.Attributes() where a.Name.LocalName == name select a.Value) .FirstOrDefault(); if (name != null) { return value; } } return ""; } /// <summary> /// Merge a sequence of DefaultStyles into a single style. /// </summary> /// <param name="styles">Sequence of DefaultStyles.</param> /// <returns>Merged DefaultStyle.</returns> public static DefaultStyle Merge(IEnumerable<DefaultStyle> styles) { DefaultStyle combined = new DefaultStyle(); if (styles != null) { foreach (DefaultStyle style in styles) { combined.Merge(style); } } return combined; } /// <summary> /// Merge with another DefaultStyle. /// </summary> /// <param name="other">Other DefaultStyle to merge.</param> private void Merge(DefaultStyle other) { // Merge or lower namespaces foreach (KeyValuePair<string, string> ns in other.Namespaces) { string value = null; if (!Namespaces.TryGetValue(ns.Key, out value)) { Namespaces.Add(ns.Key, ns.Value); } else if (value != ns.Value) { other.LowerNamespace(ns.Key); } } // Merge the resources foreach (KeyValuePair<string, XElement> resource in other.Resources) { if (Resources.ContainsKey(resource.Key)) { throw new InvalidOperationException(string.Format( CultureInfo.InvariantCulture, "Resource \"{0}\" is used by both {1} and {2}!", resource.Key, MergeHistory[resource.Key], other.DefaultStylePath)); } Resources[resource.Key] = resource.Value; MergeHistory[resource.Key] = other.DefaultStylePath; } } /// <summary> /// Lower a namespace from the root ResourceDictionary to its child /// resources. /// </summary> /// <param name="prefix">Prefix of the namespace to lower.</param> private void LowerNamespace(string prefix) { // Get the value of the namespace string @namespace; if (!Namespaces.TryGetValue(prefix, out @namespace)) { return; } // Push the value into each resource foreach (KeyValuePair<string, XElement> resource in Resources) { // Don't push the value down if it was overridden locally or if // it's the default namespace (as it will be lowered // automatically) if (((from e in resource.Value.Attributes() where e.Name.LocalName == prefix select e).Count() == 0) && !string.IsNullOrEmpty(prefix)) { resource.Value.Add(new XAttribute(XName.Get(prefix, XNamespace.Xmlns.NamespaceName), @namespace)); } } } /// <summary> /// Generate the XAML markup for the default style. /// </summary> /// <returns>Generated XAML markup.</returns> public string GenerateXaml() { // Create the ResourceDictionary string defaultNamespace = XNamespace.Xml.NamespaceName; Namespaces.TryGetValue("", out defaultNamespace); XElement resources = new XElement(XName.Get(RootElement, defaultNamespace)); // Add the shared namespaces foreach (KeyValuePair<string, string> @namespace in Namespaces) { // The default namespace will be added automatically if (string.IsNullOrEmpty(@namespace.Key)) { continue; } resources.Add(new XAttribute( XName.Get(@namespace.Key, XNamespace.Xmlns.NamespaceName), @namespace.Value)); } // Add the resources foreach (KeyValuePair<string, XElement> element in Resources) { resources.Add( new XText(Environment.NewLine + Environment.NewLine + " "), new XComment(" " + element.Key + " "), new XText(Environment.NewLine + " "), element.Value); } resources.Add(new XText(Environment.NewLine + Environment.NewLine)); // Create the document XDocument document = new XDocument( // TODO: Pull this copyright header from some shared location new XComment(Environment.NewLine + "// (c) Copyright Microsoft Corporation." + Environment.NewLine + "// This source is subject to the Microsoft Public License (Ms-PL)." + Environment.NewLine + "// Please see http://go.microsoft.com/fwlink/?LinkID=131993 for details." + Environment.NewLine + "// All other rights reserved." + Environment.NewLine), new XText(Environment.NewLine + Environment.NewLine), new XComment(Environment.NewLine + "// WARNING:" + Environment.NewLine + "// " + Environment.NewLine + "// This XAML was automatically generated by merging the individual default" + Environment.NewLine + "// styles. Changes to this file may cause incorrect behavior and will be lost" + Environment.NewLine + "// if the XAML is regenerated." + Environment.NewLine), new XText(Environment.NewLine + Environment.NewLine), resources); return document.ToString(); } /// <summary> /// Generate the XAML markup for the default style. /// </summary> /// <returns>Generated XAML markup.</returns> public override string ToString() { return GenerateXaml(); } } }
Reference the task in your project or targets file
Now, with your task assembly in hand (and available in your source tree), add a UsingTask element to your project.
<!-- // // Define our custom build tasks // --> <UsingTask TaskName="Engineering.Build.Tasks.MergeDefaultStylesTask" AssemblyFile="$(EngineeringResources)\Engineering.Build.dll" />
Note: We’ve already defined the EngineeringResources property value elsewhere. You can substitute it with your own relative path as need be.
Next up, add an item group that Visual Studio recognizes to add the DefaultStyle item to the property grid:
<!-- Add "DefaultStyle" as a Build Action in Visual Studio --> <ItemGroup Condition="'$(BuildingInsideVisualStudio)'=='true'"> <AvailableItemName Include="DefaultStyle" /> </ItemGroup>
Finally, we have two overridden (and custom) targets for merging the default styles, and for “touching” the default styles:
<!-- Merge the default styles of controls (only if any of the DefaultStyle files is more recent than the project's generic.xaml file) before compilation dependencies are processed. --> <PropertyGroup> <PrepareResourcesDependsOn> MergeDefaultStyles; $(PrepareResourcesDependsOn); </PrepareResourcesDependsOn> </PropertyGroup> <Target Name="MergeDefaultStyles" Inputs="@(DefaultStyle)" Outputs="$(MSBuildProjectDirectory)\generic.xaml"> <MergeDefaultStylesTask DefaultStyles="@(DefaultStyle)" ProjectDirectory="$(MSBuildProjectDirectory)" /> </Target> <!-- Touch DefaultStyles on Rebuild to force generation of generic.xaml. --> <PropertyGroup> <RebuildDependsOn> TouchDefaultStyles; $(RebuildDependsOn); </RebuildDependsOn> </PropertyGroup> <Target Name="TouchDefaultStyles"> <Touch Files="@(DefaultStyle)" ForceTouch="true" /> </Target>
Use the MergeDefaultStyles task in the project
Now, you can change the build actions of the appropriate control .Xaml files (that are resource dictionaries) to use this new task:
When building, you should see the Generic.xaml file update! (Themes\generic.xaml should probably exist before using this task, btw).
This code is offered through the Ms-PL license, but no support from the Silverlight Toolkit is implied. Hope this helps you!