MergeDefaultStyles build task improves control development (w/source)

Posted 13 January 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:

DefaultStyleBuildAction[1]

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!

Jeff Wilcox is a Principal Software Engineer at Microsoft in the Open Source Programs Office, helping Microsoft scale to 10,000+ engineers using, contributing to and releasing open source.

comments powered by Disqus