>
Blog
Book
Portfolio
Search

4/20/2011

22497 Views // 0 Comments // Not Rated

How To Have Multiple Publishing “Pages” Libraries In A Single Web

So this is not supported.

Neither was creating folders in a Page Library, but 2010 is cool with it. However, it's still not recommended.

Despite all these warnings, I decided to write this story anyway. Unfortunately, it turned into quite the epic saga: another bombastic battle hymn of me against SharePoint. Feel free to click here and jump down to the code; you won't hurt my feelings if you're not interested in all the backstory and rationalizations of my hackery.

This all came about back in the 2007 days, and is untested in 2010. It should still work. I'm writing this now because the problem came up again in my current 2010 project. We decided to oblige the OOTB publishing paradigm of a single Page Library per web and the havoc it reeks on information architecture. Why take the time to discuss this when I decided not to use it? Because it brings up some interesting nuances in SharePoint publishing, and some cool code that might be useful to you in some other scenarios.

<Rant>

I completely understand when a software company doesn't want to support something that could potentially allow its code to enter an inconsistent state; we do it all the time. However, I still sometimes get into tiffs with my PMs over stuff like this; I want to build the Bentley when the budget only allows for the Buick. But it is of course more important to make sure what you build is robust than to have a sexy yet flimsy feature list.

And when you consider something like SharePoint (or any CMS) that developers will have their hands all over, there has to be a lot of diligence with support cases. If something is, well, shady, and the team doesn't have the time or money (When do you have one and not the other?) to test all the ramifications of including the feature, it'll get cut.

That's why, for example, the Windows Phone 7 API is so locked down. Crappy apps will kill the entire platform. So instead of supporting a wide-open Android-like API (which, despite what I'm saying, would be awesome) and therefore having to support all kinds of hardware and firmware scenarios, Microsoft simply won't allow us to break anything. It's like putting your child in one of those vests connected to a bungee cord instead of baby proofing your house. (I clearly don't have children.)

Now what I don't like (but still understand) is when features are unsupported, but perhaps in reality are merely untested. Back to the phone: Zune doesn't work on Windows Server 2008. There's an easy workaround: hack the install config file that simply precludes the installer from thinking it's not in a supported environment. There is no reason why Zune can't work on a server OS; there is no reason why Microsoft should have to be bothered to test that scenario. So instead of sinking all kinds of resources into supporting a use case that consists of probably only a few hundred SharePoint developers, it's unsupported; hack at your own risk.

Speaking of SharePoint, and getting back to the point (which I haven't really even made yet), I ran into just such a scenario with the Publishing Infrastructure, where you can't have multiple "Pages" document libraries in the same SPWeb. Unlike developing against the Windows Phone 7 API or using Windows Server 2008 as a client OS, this is something that should be supported. Of course, I want every hair-brained scheme I come up with to be legit, (even though sometimes it's more fun when it's not).

</Rant>

The Problem

Page libraries, in the SharePoint Publishing Infrastructure, are special. I feel it would have been perfectly reasonable to assume that they were nothing more than document libraries containing content types that had their own create and edit forms. But, unfortunately, that's not the case; they are, like I said, special. There is some black-boxiness about them that disallows you from creating multiple instances in the same web. I have tried:

  • Creating a Content Type that inherits from "Page" (and "Article Page") and basing a new list off of it: you get an ASP.NET error when a new page is created.
  • Creating a new document library and attaching the "publishing" content types (essentially manually creating a clone of the "Pages" library): new pages blow up as above.
  • Saving the OOTB Pages library as a template and then creating a new list from that: you get an exception that states "Only one instance of this template is allowed per web."

I'm sure there are valid reasons for this: the testing and support challenges I mentioned previously, making the PublishingWeb and PublishingPage classes work, dependencies on publishing workflows, etc. Regardless, I hate when something has to be imbued with magic to force it to work in a certain scenario. There are so many architecturally elegant pieces of SharePoint: Content Type inheritance, the search API, the web part infrastructure, and the client object models; publishing, however, the CMS arm of the platform, is still a bit of kludge. The fact that we have to create sub sites to encapsulate page libraries is egregious; we have to augment our information architecture to make publishing work.

The Solution

The idea came to me when contemplating the issue of being able to provision additional quasi-page libraries with my own publishing content types, but getting that ASP.NET exception when I tried to actually create a new page. Why would SharePoint allow me to create the library and set everything up, only to slap my hand when I tried to use it? So it must be something in the OOTB create page logic that's amiss.

The idea is to use a custom application page to handle the creation of content, which will place it in the OOTB pages library. When that's done, our create page simply moves the newly created item to the proper library. And guess what? This totally works! It shows that the kludge Microsoft put into disallowing multiple page libraries in a single web exists in the create page logic. So circumvent it.

You might be asking: "Why not just attach event handlers to the Pages library and move the content that way?" I tried that first. It almost works. Other than the main problem of not really knowing where the pages belong, the OOTB creation logic redirects you to the newly-provisioned URL, which actually no longer exists after the move, resulting in 404's. I also ran into some weirdness with check in/check out, so I abandoned this idea (which could possibly be beaten into working) and came up with the proposed solution of using a custom creation page to provision content.

Pages created in this manner behave completely normal once they are immigrated to their new home. (These new homes are simply Page libraries associated with content types that inherit from the base publishing pages.) Workflows, editing, check in/check out, publishing, page layouts, etc. all work just fine. Like I said: it's creating the pages that doesn't work...so skip creation! We'll be taking a deeper look at the create page that does all this goodness.

The first thing we need to do is inject our own create page into the beginning of the process. This is advantageous for many reasons:

  • Does not interfere with OOTB publishing functionality (since it's a best practice to create your own application pages verses modifying farm-wide pages located on the web front end's file systems in _layouts).
  • Allows us to pass information to the page (via query string, session state, etc.) - this is how we can tell it where to move our newly-created items.
  • Streamlines the amount of clicks required by content authors to create pages. They will love you for this!

To do so, we first need to create a feature that installs the custom create page (implemented as an application page) along with our content types (that all inherit from the publishing "Page" base). This is all easy enough with Visual Studio 2010, so I won't bore you with those details. Next we need to wire our page into the creation process. Since we require a destination Page library passed to us, (probably via query string) we can't simply set SPContentType.NewFormUrl on our content types. So what we need is a mechanism to allow content authors to do their thing using our page.

There are a couple different ways to do this. One method is to have a master page that contains a button which redirects to our create page, passing in the current URL as a query string. This will give us the "context" we need in order to know where (in terms of sub site, document library, and possibly even folder) our page should be provisioned. The page would also contain logic to hide the button if the current user doesn't have contribute permissions on the site.

Or, the create page itself could contain a control exposing the entire hierarchy of the site collection as a tree. The user selects a node in the tree corresponding, again, to the sub site, library, or folder where the page should go. This requires more input from the user, but doesn't force them to navigate to where they want the page to live before clicking the create button. This really just comes down to a usability decision. In fact, both of these approaches could be combined.

There's a third way. And it's a hack. It's disgusting. But it's beautiful too, even elegant in a way, like a dive bar. I know it's bad, bad, bad practice to touch the OOTB .ASPX files that SharePoint deploys to _layouts. However, why is that rule there? Because, in my opinion, it precludes inexperienced people from making unintended farm-wide changes to circumvent a particular problem. In order to keep everyone on the same page, so to speak, Microsoft won't support it, hoping that'll be a logical enough reason not to do it.

But don't worry; I know what I'm doing. Promise.

To ensure that I'm doing my due diligence to this end, I've created solutions with features that programmatically backed up the page I'm modifying, and copied my version over it. Uninstalling the feature rolls everything back nice and tidy. And by using a solution, I am guaranteed that this will happen on all front end web servers. Once again: if you understand and proactively account for all the ramifications of a hack, it is that much less of a hack.

The page creation paradigm is actually a great example. It would be a lot of messy effort to not only hide all of the OOTB ways to create pages within the publishing paradigm, but then to come up with a new way to provision content via our custom create page that won't annoy the hell out of your contributors. I pointed out some reasons earlier why conventional methods won't work. And if you have several logical entry points to a piece of functionality, it makes more sense to change the target functionality than each of the entry points individually. Think of this like refactoring commonly-used code into an extension method.

The Code

If you have to hack OOTB files in the _layouts directory, do so in a way that doesn't put your farm in an indeterminant state. My general approach to this is to add markup to the page that uses ASP.NET "spaghetti code" syntax to call a utility method that checks if a certain feature is installed and activated in a certain scope. If so, the page redirects to a custom page that contains the required logic. If not, the OOTB functionality "falls through" and works just fine.

First, here's the utility method:

Code Listing 1

  1. namespace WebSite.Helpers
  2. {
  3. public static class FeatureHelpers
  4. {
  5. #region Public Methods
  6. public static bool IsFeatureInstalled(string featureName)
  7. {
  8. //check for site collection features (could be made more generic to check any scope)
  9. foreach(SPFeature feature in SPContext.Current.Site.Features)
  10. {
  11. //check by name
  12. if (feature.Definition.DisplayName.Equals(featureName, StringComparison.InvariantCultureIgnoreCase))
  13. return true;
  14. }
  15. //not found
  16. return false;
  17. }
  18. #endregion
  19. }
  20. }

Very straightforward. Just make sure that the class and method are defined with the static access modifier and the DLL is in the GAC. Next we need to discuss what I added to CreatePage.aspx, which is the page that the publishing infrastructure calls when new content is being created. This is where you type in the name and URL for the page, and pick the layout. You can find it, along with all other pages we shouldn't be messing with, at \<12 or 14>\template\layouts\CreatePage.aspx.

First, we need to make the page aware of our helper assembly (which is of course deployed to the GAC):

Code Listing 2

  1. <%@ Assembly Name="WebSite.Helpers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=xxx" %>
  2. <%@ Import Namespace="WebSite.Helpers.FeatureHelpers" %>

This enables the following ugly JavaScript / spaghetti code to work:

Code Listing 3

  1. if ("<% =FeatureHelpers.IsFeatureInstalled('AllowMultiplePageLibrariesInAWebFeature') %>" == "True")
  2. {
  3. window.location.href = "<% =SPContext.Current.Web.Url %>" + "/_layouts/1033/AllowMultiplePageLibrariesInAWebFeature/Pages/CreateCustomPage.aspx" + "<% =Request.Url.Query %>";
  4. }

The above statement is located inside an existing JavaScript block on the page. The spaghetti located inside the "<%" and "%>" characters is evaluated on the server, and the string responses are rendered statically in the JavaScript code along with the rest of page's HTML. What you're left with is innocent-looking JavaScript that appears as though it has hard-coded strings and URLs.

So the static method in the helper class is called, and the JavaScript will be string-comparing "True" to "True" or "False" to "True" (since IsFeatureInstalled returns a Boolean that will have ToString called on it). If true is indeed returned, the redirect will happen. Otherwise, page operation proceeds normally. The redirect builds a URL to the custom create page, in the scope of the current site, and maintains the query string.

However you get there, the next step is to talk about what this page actually does. A lot of the implementation depends on your requirements, but at a minimum, we'll need the name of the page from the user. In some instances, we had a one-to-one mapping of content types and page layouts to sub sites, so I was able to infer that information from the context. In others, I had to create folders, build a drop down of content types, or capture several pieces of metadata about the page. It all depends.

Once your UI is done, and the user clicks "OK," we can look at some code. Again, there's a lot of different things you might need to do, so I'll stick to the core logic. Make sure you consider the following in your page:

  • Handle security
  • Validate user input, especially in case of removing illegal URL characters from textboxes that capture the names of pages or folders
  • Ensure the new page's URL doesn't already exist
  • Set any metadata on the page

Here's the bread and butter:

Code Listing 4

  1. private void SavePage(bool isPagesLibrary, string webOrFolderRelativePath, string pageName, PageLayout pageLayout)
  2. {
  3. //initialization
  4. SPWeb web = SPContext.Current.Web;
  5. string url = string.Format("{0}{1}/{2}.aspx", web.Site.Url, webOrFolderRelativePath, pageName);
  6. //add page via the publishing infrastructure
  7. PublishingWeb pub = PublishingWeb.GetPublishingWeb(web);
  8. PublishingPage page = pub.GetPublishingPages().Add(isPagesLibrary ? pageName : string.Format("{0}.aspx", Guid.NewGuid()), pageLayout);
  9. //save
  10. page.ListItem.Update();
  11. //redirect to new page
  12. if (isPagesLibrary)
  13. url = string.Format("{0}/{1}?DisplayMode=edit", SPContext.Current.Web.Url, page.Url);
  14. else
  15. {
  16. //save
  17. page.CheckIn(string.Empty);
  18. page.ListItem.File.Publish(string.Empty);
  19. url = string.Format("{0}?DisplayMode=edit", url);
  20. //move to new location
  21. page.ListItem.File.MoveTo(url);
  22. page.ListItem.Delete();
  23. }
  24. //redirect
  25. this.Response.Redirect(url);
  26. }

So however you design your page provisioning user experience, you'll end up calling a method like this. Line #8 might look strange, as we're creating our pages in the default Page Library with Guids as names. This ensures uniqueness at provision time. Before moving the page, you need to manually check if the destination exists. When you call SPFile.MoveTo, the supplied URL becomes the URL of the page (since it technically is creating a new SPListItem).

Let's talk about what the parameters mean. The first one, isPagesLibrary, is a flag that skips the move logic in Lines #17 - 22 in case the user wants to use the OOTB Pages library. This is determined by checking if the target URL passed to the page is indeed /pages/. Remember: OOTB functionality has to work precisely. Next is webOrFolderRelativePath. This is either taken from the context or passed to the page as well. It will be in the format of something like web/sub web/document library/folder/sub folder - the relative path between the URL of the SPSite and the page itself. Third is pageName. This is user input, and should be taken from a textbox on the page itself.

Finally we have pageLayout, the cornerstone of publishing. The following method will get a PageLayout from a passed-in SPContentTypeId:

Code Listing 5

  1. private PageLayout GetPageLayout(SPContentTypeId id)
  2. {
  3. //initialization
  4. PageLayout pageLayout = null;
  5. PublishingSite ps = new PublishingSite(SPContext.Current.Site);
  6. //get all layouts
  7. foreach (PageLayout pl in ps.PageLayouts)
  8. {
  9. //search by content type
  10. if (id.ToString().StartsWith(pl.AssociatedContentType.Id.ToString()))
  11. {
  12. //layout found
  13. if (pageLayout == null)
  14. {
  15. //always capture the first match
  16. pageLayout = pl;
  17. }
  18. else
  19. {
  20. //if more than one is found, only keep the one with the longest content type
  21. if (pl.AssociatedContentType.Id.ToString().Length > pageLayout.AssociatedContentType.Id.ToString().Length)
  22. pageLayout = pl;
  23. }
  24. }
  25. }
  26. //return
  27. return pageLayout;
  28. }

Basically, this method loops through the site collection's publishing page layouts, and matches the selected one by the passed-in id. But you'll notice in Line #10 that I don't do an exact match; rather, I use StartsWith. And then, in Line #21, I keep reassigning the matched pageLayout variable to the site content type that has the longest (in terms of number of characters) id. This is because when a list is associated with a content type, it makes its own copy of it that inherits from the "main" one published to the site collection's Content Type Gallery. Why grab the longest? Because of the nature of content type inheritance: children tack their own dash-less Guid onto their parent's id. So the longest content type id that starts with the site's page layout's content type id is the one we want.

I could just spin through the content types on the list itself, but it's possible that we could be passed in the content type id from the site instead. The OOTB create page actually gets a content type id passed to it via query string, but we have no guarantee which one it is. Looping the way I did, although admittedly a bit awkward, handles all scenarios. Finally, we take the page layout that we find from this method, and that's what gets passed to the "Save Page" method above.

And there you have it: a custom create page that allows provisioning content to multiple page libraries with custom publishing content types in the same web. Again: this is unsupported, but a very interesting exercise in skirting around SharePoint black box magic. The most important thing to take away from this post is the concept of hacking SharePoint in a smart, elegant way: ensuring that not only are your requirements met, but also OOTB functionality is allowed to exist unscathed.

Have fun publishing!

No Tags

No Files

No Thoughts

Your Thoughts?

You need to login with Twitter to share a Thought on this post.


Loading...