The Asp.Net System.Web.UI.WebControls.HtmlHead class is clever, especially when it comes to stylesheet <link> tags in master pages, since it magically rebases them (using Control.ResolveClientUrl) to be relative to the path of the page that is executing instead of the master page. This is especially necessary when a content page is declared in a different folder to its master page, because any CSS references are unlikely to work except by pure fluke.
With the advent of Visual Studio 2008, it has become common practise to have a content placeholder in a master page’s head section, which allows the page designer to add extra meta data, script references and CSS links on a content page as well. To be fair, this process started almost as soon as master pages came into being – except Visual Studio 2005 did not have design-time support for it, a deficit that was much bemoaned by K. Scott Allen at the time.
This website uses that ability so that our master page contains our common CSS references, but some of our content pages then have their own script references and styles. In order to get around the url-rebasing issue, we use a <base /> tag at the top of every page, so that we can use root-relative links for our urls, css and javascript references, then the browser will happily get them from the correct place. A recent article detailing a fix for a small bug with the Atom Feed link on one of our pages details this process a bit more.
Now we’re working on a new site, that will be using the Asp.Net MVC 1.0 Framework (and possibly 2.0 if it goes live before we’re finished). This brings even more potential problems with relative URL links, since every page URL is actually a folder. Although the <base/> tag is a solution, it’s not perfect since you then have to define a way to actually format out that base url. Many people use configuration, but it royally sucks for this, as it means that every time you move the site to a different machine (different developer boxes, QA box, staging box etc) or from a virtual directory to a website, you have to update the configuration.
As mentioned in the introduction, any <link> tag that is placed directly inside the Head control of an Asp.Net page will be turned into an HtmlLink control – the magic for this is done in the HtmlHeadBuilder’s GetChildControlType method:
public override Type GetChildControlType(string tagName, IDictionary attribs){if (string.Equals(tagName, "title", StringComparison.OrdinalIgnoreCase)){return typeof(HtmlTitle);}if (string.Equals(tagName, "link", StringComparison.OrdinalIgnoreCase)){return typeof(HtmlLink);}if (string.Equals(tagName, "meta", StringComparison.OrdinalIgnoreCase)){return typeof(HtmlMeta);}return null;}
Very simple indeed. As you can see, in order to get an HtmlLink created in place of a standard <link> tag within the head, you have to do, well, nothing at all (not even make it runat=server). When the HtmlLink control renders it’s output, instead of dumbly rendering the attributes (i.e. the href, rel etc) it does this:
protected override void RenderAttributes(HtmlTextWriter writer){if (!string.IsNullOrEmpty(this.Href)){base.Attributes["href"] = base.ResolveClientUrl(this.Href);}base.RenderAttributes(writer);}
The ResolveClientUrl method is used to calculate the actual relative path to a resource, given a template-relative path (e.g. “~/Pages/page.aspx”, “~/Controls/control.ascx”, a master page virtual path or whatever) – which is retrieved from the first control up the parent-child hierarchy of the page that has one of these paths.
There is one tag in particular that is missing from the logic in the above code - “script”. Yes, that’s right, whilst <link> tags will be magically rebased, <script> references will not – which is indeed a stark omission from the Asp.Net framework. From the master page point of view, this means that you cannot really have <script> references in your master page’s header, unless all content pages will be in the same relative location from the target javascript file.
This problem can be solved in a couple of different ways. The first would be to pre-process the page content before it finally gets rendered, searching for any <script> references, parsing them manually, using Control.ResolveClientUrl to get the target script reference and then writing the fixed URL back to the content (indeed, this approach has been taken with a fix adopted by one frustrated Asp.Net user; see later).
However, the other approach is to write a simple server-side control to do it:
/// <summary>/// Mimics the HtmlLink control - i.e. calling ResolveClientUrl at render time -/// except it's done on the 'src' attribute value. Since HtmlLink writes the resolved/// path back to it's Href member, this one does the same./// </summary>public class HtmlScript : HtmlGenericControl{public HtmlScript() : base("script"){}public HtmlScript(string tag) : base(tag){}public string Src{get{return this.Attributes["src"];}set{this.Attributes["src"] = value;}}protected override void RenderAttributes(HtmlTextWriter writer){Src = ResolveClientUrl(Src);base.RenderAttributes(writer);}}
With this in place, you simply use the <#@ Register directive, or register a site-wide prefix via the web.config, to bring in a tag-prefix (e.g. “asp2”) for the control and then, in your ASPX source you change your <script> to:
<asp2:HtmlScript src="../myscript.js" type="text/javascript" language="javascript"></asp2:HtmlScript>
Okay, so this isn’t a perfect solution – a site-wide refactoring like this could take some time! However, the best it yet to come…
This is possibly the biggest problem, when you use a content control to inject extra links, references or whatever into a page head section from a content form, the magic CSS <link> rebasing no longer works. Why? The clue is in the above code snippet from HtmlHeadBuilder.GetChildControlType. Consider the following master page, and content page:
Master Page <head> section:
<head runat="server"><title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title><link href="../../Content/Site.css" rel="stylesheet" type="text/css" /><asp:ContentPlaceHolder ID="CustomHeadContent" runat="server" /></head>
Content Page Content Placeholder:
<asp:Content ID="customHead" ContentPlaceHolderID="CustomHeadContent" runat="server"><link href="../../Content/Site.css" rel="stylesheet" type="text/css" /></asp:Content>
Okay, so in this example, both pages are referencing the same stylesheet, so it’s slightly contrived. However, assuming that the css files are in ~/Content/, the master page is in the ~/pages/masters/ folder, and the content page is in the ~/pages/content/ folder, this will work fine when it is run.
However, if we’re using something like MVC (or using the HttpRequest.PathInfo property like we do for our articles, projects etc) in order to beautify your urls, then we will run into problems. For example, if the content page in question is actually displayed in the browser with the url “/home/”, then the CSS link rendered from the head on the master page will correctly come out as “../content/site.css”, however the CSS link from the page’s content control will be left untouched (indeed it will be rendered as a literal), and will generate a 404 when it hits the browser.
This is because in order for the <link> markup in the content control to be turned into an HtmlLink, instead of being left as raw markup, the control builder that parses the <asp:Content> control (ContentBuilderInternal – no MSDN link because it’s understandably undocumented!) would have to have the same logic in it that the head section does – since the ASP.Net build engine sees the <link> tag as a child of the Content Control, not the HtmlHead that the content eventually gets merged into.
This is clearly a bug, or at the very least an oversight, in the Asp.Net framework – and has been reported on MS Connect by Nathanael Jones, who came up with the aforementioned page-parsing solution on his blog. However, this solution does not fix design-time problems (since the VS designer doesn’t run all of the page code when rendering it in the designer), and it is also quite a weighty solution which will inevitably slow down page rendering; our sites get tens of thousands of hits per day, and adding extra load on the servers is just not an option.
Our solution fixes rebasing of all css <link> tags in the head of content pages, and it also automatically picks up and rebases all <script> references as well. You’ll need the HtmlScript control, whose source code was presented earlier on; then add this code:
/// <summary>/// It's a placeholder, really - it simply tells the framework to use the RebasingContainerBuilder to/// create the controls that will emit the html. Then, the designer is set to ControlDesigner -/// the same one that the ContentControl uses./// That ensures design-time support for the CSS and Javascript links on content pages./// </summary>[ControlBuilder(typeof(RebasingContainerBuilder)),Designer("System.Web.UI.Design.ControlDesigner, System.Design, " +"Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"),ConstructorNeedsTag(false)]public class RebasingContainer : HtmlGenericControl{public RebasingContainer(){}protected override void RenderBeginTag(System.Web.UI.HtmlTextWriter writer){ /*doesn't render it's own tag*/ }protected override void RenderEndTag(System.Web.UI.HtmlTextWriter writer){/*doesn't render it's own tag*/}}/// <summary>/// This class is almost identical to HtmlHeadBuilder. It's purpose to ensure that embedded/// links and script tags are always rebased for the client during rendering./// The HtmlHead control does this for CSS link tags only, and the functionality is lost/// on content controls on content forms./// This builder is used by the <see cref="RebasingContainer"/> control/// </summary>public class RebasingContainerBuilder : ControlBuilder{public override bool AllowWhitespaceLiterals(){return false;}public override Type GetChildControlType(string tagName, System.Collections.IDictionary attribs){/* copied this code from System.Web.UI.HtmlControls.HtmlHeadBuilder */if (string.Equals(tagName, "link", StringComparison.OrdinalIgnoreCase)){return typeof(HtmlLink);}if (string.Equals(tagName, "script", StringComparison.OrdinalIgnoreCase)&& attribs.Contains("src")){//only rebase script tags that have a src attribute!return typeof(HtmlScript);}return null;}}
If you’ve placed this code inside the same assembly and namespace as the aforementioned HtmlScript class, then register a tag-prefix for that assembly/namespace (as before – the best place is in the web.config so it’s available on all pages) and you can start using it.
In order for content page script and css references to get rebased you simply change this:
<asp:Content ID="indexCustomHead" ContentPlaceHolderID="CustomHeadContent" runat="server"><link href="/Content/TestSite.css" rel="stylesheet" type="text/css" /><script src="/Content/script.js" type="text/javascript" language="javascript" /></asp:Content>
To this:
<asp:Content ID="indexCustomHead" ContentPlaceHolderID="CustomHeadContent" runat="server"><asp2:RebasingContainer runat="server"><link href="/Content/TestSite.css" rel="stylesheet" type="text/css" /><script src="/Content/script.js" type="text/javascript" language="javascript" /></asp2:RebasingContainer></asp:Content>
Note that the asp2: prefix here depends on what prefix you actually register with Asp.Net. At run-time and design time, the RebasingContainer control doesn’t render it’s own tags – it simply acts as a placeholder to instruct Asp.Net to delegate to RebasingContainerBuilder which, in turn, contains the logic to map <link> and <script> tags to the server side controls that will perform the magic rebasing – just like the HtmlHead control.
Similarly, you can use the rebasing container to rebase <script> tags in the master page’s <head> content (or indeed any page’s head content) – simply move all your existing script tags into a new RebasingContainer, and it will work (don’t worry about the <link> tags – since the HtmlHeadBuilder will take care of those for you).
When you first do this, the designer might get upset and tell you that it doesn’t recognise the RebasingContainer – simply do a full build, and it will update. Now flip to design view on one of your content pages that previously didn’t work and smile at the fact that any extra stylesheets are now working in design view! Notice also that inline javascript will pick up intellisense from any script references that have had to be rebased in order to be picked up.
One solution that has been suggested by the community is to sub-class HtmlHead and it’s HtmlHeadBuilder in order to force <link> tags to be always mapped to HtmlLink controls. There are numerous problems with this, the first and most crucial is that HtmlHead is a sealed class, so that’s impossible. Even it were possible, however, it wouldn’t really help because, as explained earlier, the HtmlHeadBuilder is only used to parse the direct content of a runat=”server” <head> control, and would never actually get a chance to process the ASPX markup for a page’s Content control.
Another solution that we researched was to do a similar thing, but for the Content control; that is, sub-class the Content control and it’s associated control builder class, making it <link> and <script> aware. This would have meant simply changing the control tag used on a content page to inject content in the page’s header. The big problem with this, however, is that the Content control uses an control builder that’s internal to Asp.Net: ContentBuilderInternal, which by definition cannot be subclassed. It’s possible to call into internal classes and methods (using dynamic code generation – e.g. via Linq Expression Trees or DynamicMethod), but we didn’t have the time to build a proxy and all the code generation logic.
In the meantime, we hope that this explains the problem and provides you with an easy-to-use solution!
Connect: Headhunter (1)
Connect: iPhone Job Search (12)
Connect: Mobile Job Search (1)
General (5)
Google Desktop Job Search (2)
iPhone (2)
Technical (7)
Web Browser OpenSearch Provider (1)
Windows Vista Sidebar Gadget (3)
Your Voice (1)