Extending Page: Adding Metadata and Stylesheet management

Adding stylesheets or changing meta tags is just slighly clunky with the default Page class provided by ASP.NET. We're going to fix that.

Here's how to add metadata and stylesheet management by making a subclass which we'll call PageBase, since all markup pages will inherit from it.

Features

  • One-line method calls for common operations like adding a stylsheet reference or changing metadata.
  • Duplicate stylsheet/meta tag prevention.
  • Only 2 instance members will be added to PageBase - .Stylesheets and .Metadata.
    Stylesheet (HtmlLink) and Metadata (HtmlMeta) management is encapsulated in separate classes to keep things clean, and reduce methods inside the page class.
  • Static PageBase.GetControlsOfType<t>(Control parent) method simplifies all kinds of hierarchy querying.

StylesheetManager methods

Complete documentation is in the XML comments in the code.

Page.Stylesheets.AddLink("~/css/effects/css","stylesheet", "text/css");
Page.Stylesheets.AddLink("~/css/effects/css"); //Shorter syntax for typcial usage
Page.Stylesheets.AddLinkIfMissing("~/css/effects/css"); //So User Controls and multi-instance objects can safely include stylesheets without worrying about duplicates.
Page.Stylesheets.RemoveLinks("~/css/effects/css"); //Removes all links matching this path (ResolveUrl() is called on all paths prior to comparison)
Page.Stylesheets.GetControls() //returns all HtmlLink controls within the page header.
Page.Stylesheets.GetHrefs() //Returns a collection of all hrefs from the link tags on the page.
Page.Stylesheets.FindLinkControl(string href); //Case-insensitive, ResolveURL-cleaned search by href value.

MetadataManager methods

Page.Metadata["description"] = "Why everybody ends up extending the Page class".
if (Page.Metadata["description"] == "Why everybody ends up extending the Page class"){}

// Returns a collection of all HtmlMeta controls in the header
// Calls  PageBase.GetControlsOfType<HtmlMeta>(_page.Header);            
Page.Metadata.GetControls(); 
Page.Metadata.GetNameContentPairs(); //Returns a NameValueCollection of the metadata name/content pairs.
Page.Metadata.GetControl("description") // Returns the first HtmlMeta instance matching the specified name attribute
Page.Metadata.RemoveControls(List<HtmlMeta>) //Detaches each item in the collection from its parent
Page.Metadata.HideControls(List<HtmlMeta>) //Sets .Visible and .EnableViewState to "false" on all items.
Page.Metadata.GetMatches(query) //Returns all HtmlMeta instances with matching Name attributes. Query can be "*" or a comma-delimited list of values like "description,keywords,test"
Page.Metadata.GetNonMatches(quer) //Returns all non-matching HtmlMeta instances

Usage

We can use this base class in any page my making a single change. If the page has a code-behind file, just change
public partial class _Default : System.Web.UI.Page

to

public partial class _Default : fbs.PageBase
If it is a standalone .aspx file, you can set Inherits="fbs.PageBase".

Internals

All of the querying operations performed only affect controls of a certain type. To greatly simplify the code for both StylesheetManager and MetdataManager, we have added GetControlsOfType<t>(Control parent) to the PageBase class.

This method efficiently builds a collection of all controls in the specified hierarchy that are of type T.

/// <summary>
/// Iterates over the control structure of the specified object and returns all elements that are
/// of the specified type
/// </summary>
/// <param name="parent"></param>
/// <returns></returns>
public static List<T> GetControlsOfType<T>(Control parent) where T : Control
{
	return GetControlsOfType<T>(parent, false,false);
}
/// <summary>
/// Iterates over the control structure of the specified object and returns all elements that are
/// of the specified type. If there are two items of the specified type, and one is a child of the other, 
/// the childrenOnly and parentOnly parameters can be used to control which is selected. If both are false, both controls are returned.
/// </summary>
/// <param name="parent">The control to search</param>
/// <param name="childrenOnly">If true, only the innermost matching children will be returned.</param>
/// <param name="parentsOnly">If true, only the outermost matching parents will be returned.</param>
/// <returns></returns>
public static List<T> GetControlsOfType<T>(Control parent, bool childrenOnly, bool parentsOnly) where T : Control
{
	if (parent == null) return null;
	if (childrenOnly && parentsOnly) throw 
		new ArgumentException("Only one of childrenOnly and parentsOnly may be true. They are mutually exclusive");

	//We are doing last-minute initialization to minimize the overhead of building one of these.
	//The List<> constructor should only be called n times, where n is the number of ContentPlaceHolder controls.
	List<T> temp = null;

	if (parent.Controls != null)
	{
		//Loop through all of the child controls
		foreach (Control child in parent.Controls)
		{
			//Recursively search them also.
			List<T> next = GetControlsOfType<T>(child,childrenOnly,parentsOnly);

			//To save on initialization costs.
			if (next != null)
			{
				if (temp == null)
				{
					temp = next; //Use existing collection from recursive call
				}
				else
				{
					//Merge the collections

					//If a the same object is the child of two different parents, this will
					//stop it.
					foreach (T c in next)
					{
						if (!temp.Contains(c)) temp.Add(c);
					}

				}
			}
		}
	}

	//If this item is of the target type, add it 
	if ((parent is T))
	{
		//If there are no children or we are trying to discard children
		if (parentsOnly || temp == null)
		{
			//Clear the list and add the parent
			T item = (T)parent;

			temp = new List<T>();

			temp.Add(item);
		}
		else if (!childrenOnly)
		{
			//Append the parent with the children
			T item = (T)parent;

			if (temp == null) temp = new List<T>();

			temp.Add(item);
		}
	}

	return temp;
}

The remainder of the PageBase class

/// 
/// Extends System.Web.UI.Page
/// Adds metadata and stylehseet management
/// 
public partial class PageBase : Page
{


/// <summary>
/// Creates a new PageBase instance. 
/// </summary>
public PageBase()
{
}


protected MetadataManager _metadata = null;
/// <summary>
/// Manages page metadata. Add, remove, and query metadata 
/// (Only meta tags with a name attribute are affected, and only those located in the head section)
/// </summary>
public MetadataManager Metadata { 
	get {
		if (_metadata == null) _metadata = new MetadataManager(this);
		return _metadata; 
	} 
}


protected LinkManager _Stylesheets = null;
/// <summary>
/// Manages all of the HtmlLink controls in the head section of the page.
/// Register, delete, and enumerate all link tags.
/// </summary>
public LinkManager Stylesheets
{
	get
	{
		if (_Stylesheets == null) _Stylesheets = new LinkManager(this);
		return _Stylesheets;
	}
}

LinkManager


using System;
using System.Data;
using System.Configuration;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Collections.Specialized;

namespace fbs
{
    public partial class PageBase
    {
        /// <summary>
        /// Manages &lt;link> tags (controls) on the current page.
        /// </summary>
        public class LinkManager
        {
            protected Page _page = null;
            /// <summary>
            /// Creates a new Link Manager attached to the specified Page instance
            /// </summary>
            /// <param name="parent"></param>
            public LinkManager(Page parent)
            {
                _page = parent;
            }
            /// <summary>
            /// Adds a CSS reference. Paths must be 1) relative to the page, 2) application-relative, or 3) absolute
            /// </summary>
            /// <param name="href"></param>
            public void AddLink(string href)
            {
                AddLink(href, "stylesheet", "text/css");
            }
            /// <summary>
            /// Adds a CSS stylsheet reference, but only if there isn't one yet for that path.
            /// Paths must be 1) relative to the page, 2) application-relative, or 3) absolute
            /// </summary>
            /// <param name="href"></param>
            /// <param name="resolveFirst">If true, compares resolved paths instead of raw paths</param>
            public void AddLinkIfMissing(string href)
            {
                if (this.FindLinkControl(href) == null)
                {
                    AddLink(href);
                }
            }
            /// <summary>
            /// Adds a link tag with the specified rel and type attributes
            /// Paths must be 1) relative to the page, 2) application-relative, or 3) absolute
            /// </summary>
            /// <param name="href"></param>
            /// <param name="rel"></param>
            /// <param name="type"></param>
            public void AddLink(string href, string rel, string type)
            {
                HtmlLink l = new HtmlLink();

                l.EnableViewState = false;
                l.Href = href;
                l.Attributes["type"] = type;
                l.Attributes["rel"] = rel;
                l.AppRelativeTemplateSourceDirectory = _page.AppRelativeTemplateSourceDirectory;
                _page.Header.Controls.Add(l);
            }
            /// <summary>
            /// Removes all meta tags with a matching href.
            /// Paths must be 1) relative to the page, 2) application-relative, or 3) absolute
            /// </summary>
            /// <param name="href"></param>
            /// <param name="resolveFirst">If true, compares resolved paths instead of raw paths</param>
            public void RemoveLinks(string href)
            {
                bool resolveFirst = false;
                List<HtmlLink> controls = GetControls();
                string searchfor = href;
                if (resolveFirst) searchfor = _page.ResolveUrl(searchfor);
                foreach (HtmlLink hl in controls)
                {
                    string thishref = hl.Href;

                    if (resolveFirst) thishref = _page.ResolveUrl(thishref);

                    if (thishref.Equals(searchfor, StringComparison.OrdinalIgnoreCase))
                    {
                        hl.Parent.Controls.Remove(hl);
                    }
                }

            }
            /// <summary>
            /// Returns a collection of all HtmlLink controls in the page header.
            /// </summary>
            /// <returns></returns>
            public List<HtmlLink> GetControls()
            {
                return PageBase.GetControlsOfType<HtmlLink>(_page.Header);
            }
            /// <summary>
            /// Returns a collection of the hrefs in each link tag in the head section.
            /// Paths are 1) relative to the page, 2) application-relative, or 3) absolute
            /// </summary>
            /// <returns></returns>
            public List<string> GetHrefs()
            {
                List<HtmlLink> list = GetControls();
                List<string> hrefs = new List<string>();
                foreach (HtmlLink l in list)
                {
                    hrefs.Add(l.Href);
                }
                return hrefs;
            }
            /// <summary>
            /// Case-insensitive. Returns the first HtmlLink control in the heirarchy that matches the href. Only scans inside the head tag.
            /// returns null if no match is found.
            /// 
            /// </summary>
            /// <param name="href">Paths must be 1) relative to the page, 2) application-relative, or 3) absolute</param>
            /// <param name="resolveFirst">If true, compares resolved paths instead of raw paths</param>
            /// <returns></returns>
            public HtmlLink FindLinkControl(string href)
            {
                bool resolveFirst = false;
                List<HtmlLink> controls = GetControls();
                string searchfor = href;
                if (resolveFirst) searchfor = _page.ResolveUrl(searchfor);
                foreach (HtmlLink hl in controls)
                {
                    string thishref = hl.Href;

                    if (resolveFirst) thishref = _page.ResolveUrl(thishref);

                    if (thishref.Equals(searchfor, StringComparison.OrdinalIgnoreCase)) return hl;
                }
                return null;
            }


        }
    }
}

MetadataManager


using System;
using System.Data;
using System.Configuration;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Diagnostics;
using System.Text.RegularExpressions;
using System.Collections.Specialized;

namespace fbs
{
    public partial class PageBase
    {
        /// <summary>
        /// Manages &lt;meta> tags (controls) on the current page. Not designed for HTTP-EQUIV tags - they are ignored and skipped unless they have a name attribute.
        /// Only deals with meta tags within the Head of the page. The page must have a server-side head tag.
        /// </summary>
        public class MetadataManager
        {
            protected Page _page = null;
            
            /// <summary>
            /// Creates a new MetadataManager and attaches it to the current page.
            /// </summary>
            /// <param name="parent"></param>
 
            public MetadataManager(Page parent)
            {
                _page = parent;
            }
            /// <summary>
            /// Gets or sets the Content attribute for the specified metadata tag.
            /// Returns null if pair does not exist.
            /// Creates a new metadata tag if it does not exist.
            /// </summary>
            /// <param name="name"></param>
            /// <returns></returns>
            public string this[string name]
            {

                get
                {
                    HtmlMeta m = FindMetaControl(name, _page.Header);
                    if (m == null) return null;
                    return m.Content;
                }
                set
                {
                    HtmlMeta m = FindMetaControl(name, _page.Header);
                    if (m != null)
                    {
                        m.Content = value;
                    }
                    else
                    {
                        HtmlMeta newm = new HtmlMeta();
                        newm.EnableViewState = false;
                        newm.Name = name;
                        newm.Content = value;
                        _page.Header.Controls.Add(newm);
                    }

                }

            }
            /// <summary>
            /// Returns a collection of all HtmlMeta controls in the header
            /// </summary>
            /// <returns></returns>
            public List<HtmlMeta> GetControls()
            {
                return PageBase.GetControlsOfType<HtmlMeta>(_page.Header);
            }
            /// <summary>
            /// Returns a name:value collection of meta name:content pairs from the page.
            /// If there are multiple meta tags with the same name, the contents are comma-delimited (NameValueCollection.Add behavior)
            /// </summary>
            /// <returns></returns>
            public NameValueCollection GetNameContentPairs()
            {
                List<HtmlMeta> list = PageBase.GetControlsOfType<HtmlMeta>(_page.Header);
                NameValueCollection pairs = new NameValueCollection();
                foreach (HtmlMeta m in list)
                {
                    pairs.Add(m.Name, m.Content);
                }
                return pairs;
            }
            /// <summary>
            /// Returns the first HtmlMeta control with the specified name
            /// </summary>
            /// <param name="name"></param>
            /// <returns></returns>
            public HtmlMeta GetControl(string name)
            {
                return FindMetaControl(name, this._page.Header);
            }
            /// <summary>
            /// Whether to include or exclude matches
            /// </summary>
            public enum FilterType
            {
                ReturnMatches = 1,
                ReturnNonMatches = 2
            }
            /// <summary>
            /// Returns all meta tags that don't match 'pattern' 
            /// To exclude all, specify "*". Otherwise, specify a list of exclusions: "date,expires,description,flags".
            /// Not regex, but case-insensitive.
            /// </summary>
            /// <param name="pattern">To exclude all, specify "*". Otherwise, specify a list of exclusions: "date,expires,description,flags".</param>
            /// <returns></returns>
            public List<HtmlMeta> GetNonMatches(string pattern)
            {
                return GetMatches(pattern, FilterType.ReturnNonMatches);
            }
            /// <summary>
            /// Returns all meta tags with a name that matches 'pattern' 
            /// To match all, specify "*". Otherwise, specify a list of possibilities: "date,expires,description,flags".
            /// Not regex, but case-insensitive.
            /// </summary>
            /// <param name="pattern">To match all, specify "*". Otherwise, specify a list of possibilities: "date,expires,description,flags".</param>
            /// <returns></returns>
            public List<HtmlMeta> GetMatches(string pattern)
            {
                return GetMatches(pattern, FilterType.ReturnMatches);
            }

            /// <summary>
            /// Removes the specified HtmlMeta controls from their parents.
            /// </summary>
            /// <param name="list"></param>
            public void RemoveControls(List<HtmlMeta> list)
            {
                foreach (HtmlMeta m in list)
                {
                    if (m.Parent != null)
                    {
                        m.Parent.Controls.Remove(m);
                    }
                }
            }

            /// <summary>
            /// Hides the specified HtmlMeta controls from rendering
            /// </summary>
            /// <param name="list"></param>
            public void HideControls(List<HtmlMeta> list)
            {
                foreach (HtmlMeta m in list)
                {
                    m.Visible = false;
                    m.EnableViewState = false;
                }
            }
            /// <summary>
            /// Returns a collection of HtmlMeta tags that match 'pattern' (or don't match, depending on 'filter').
            /// Pattern is not a regex, but supports alternations and is case-insensitive. if Pattern="*", then everything matches.
            /// Pattern can be a single meta name, or a list of meta names (comma or | delimited).
            /// </summary>
            /// <param name="pattern">To match all, specify "*". Otherwise, specify a list of possibilities: "date,expires,description,flags".</param>
            /// <param name="filter"></param>
            /// <returns></returns>
            public List<HtmlMeta> GetMatches(string pattern, FilterType filter)
            {

                //List of all meta controls in the head
                List<HtmlMeta> list = PageBase.GetControlsOfType<HtmlMeta>(_page.Header);

                //Parse pattern string
                bool wildcard = (pattern.Equals("*", StringComparison.OrdinalIgnoreCase));

                string[] parts = pattern.Replace(',', '|').Split('|');
                for (int i = 0; i < parts.Length; i++)
                    parts[i] = parts[i].Trim().ToLowerInvariant();

                //Index valid names in a binary tree
                List<string> names = new List<string>(parts);
                names.Sort();

                //Create collections to hold matches and non-matches.
                List<HtmlMeta> matches = new List<HtmlMeta>();
                List<HtmlMeta> nonmatches = new List<HtmlMeta>();

                //Loop throught controls and distribute to the appropriate collection.
                foreach (HtmlMeta m in list)
                {
                    //Skip meta tags with an no name (probably HTTP-EQIV)
                    if (m.Name == null) continue;

                    if (wildcard)
                    {
                        matches.Add(m);
                    }
                    else if ((names.BinarySearch(m.Name.ToLowerInvariant()) > 0))
                    {
                        matches.Add(m);
                    }
                    else
                    {
                        nonmatches.Add(m);
                    }
                }

                //Return the correct collection based upon the filter type
                if (filter == FilterType.ReturnMatches) return matches;
                else if (filter == FilterType.ReturnNonMatches) return nonmatches;
                else throw new ArgumentException("filter must be a valid enumeration value", "filter");
            }
            /// <summary>
            /// Recursively searches the hierarchy of 'parent' for the first HtmlMeta instance with the specified Name attribute.
            /// Case-insensitive. 
            /// </summary>
            /// <param name="name">Case-insensitive. </param>
            /// <param name="parent">Control tree to search</param>
            /// <returns></returns>
            protected static HtmlMeta FindMetaControl(string name, Control parent)
            {
                if (parent is HtmlMeta)
                {
                    HtmlMeta m = parent as HtmlMeta;
                    if (m.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) return m;
                }
                foreach (Control c in parent.Controls)
                {
                    HtmlMeta m = FindMetaControl(name, c);
                    if (m != null) return m;
                }
                return null;
            }

        }
    }
}

Integrating script support

If you haven't already read Referencing stylesheets and scripts from content pages, give it a glance. Download the attached code files and combine them with this one. The only file you'll have to merge is PageBase.cs.

Just add this code to the PageBase.cs included in the article, and make sure ContentPlaceHolderFixes.cs and the ScriptReference.cs files are included also.

/// <summary>
/// Calls the ContentPlaceHolderHeadRepair.Repair method
/// </summary>
/// <param name="e"></param>
protected override void OnLoad(EventArgs e)
{
	//Parses link, meta, and script tags that got missed by the Head>CPH bug.
	ContentPlaceHolderFixes.RepairPageHeader(this);

	//Fire the events later
	base.OnLoad(e);
}

This will allow you to use script tags from the head section and get proper ASP.NET URL resolution on them.

If you have any questions, please use the comments form below.

Happy coding!

Download files

Published on

About Nathanael

Nathanael Jones is a software engineer, father, consultant, and computer linguist with unreasonably high expectations of inanimate objects. He refines .NET, ruby, and javascript libraries full-time at Imazen, but can often be found on stack overflow or participating in W3C community groups.

ImageResizer

If you develop websites, and those websites have images, ImageResizer can make your life much eaiser. Find out more at imageresizing.net.

Imazen

I run Imazen, a tiny software company that specializes in web-based image processing and other difficult engineering problems. I spend most of my time writing image-processing code in C#, web apps in Ruby, and documentation in Markdown. Check out some of my current projects.

More articles