How to add Google PageMaps support to Kentico

   —   

A basic implementation of Google PageMaps metadata support in Kentico 9 using the ASPX page template development model. Simple proof-of-concept implementation.

The Google PageMaps is a way of expressing schema-less metadata (tags) for your pages. It helps the Google crawler in indexing the contents. In its basic form the tags should be rendered in the <head> section of your pages in XML format. The whole XML then is commented-out so that it doesn’t interfere with the page rendering process.

If you’ve ever thought about whether PageMaps metadata could be put into the page <head> markup, here’s the answer. It can be accomplished with little effort and it will allow content editors to maintain some PageMaps data easily via ordinary page type fields in the ‘Form’ tab in the Kentico administrative interface. Product (SKU) data or any other Kentico data can also be rendered that way.

Among the ‘traditional’ development models of Kentico, the most appropriate would be the ‘ASPX template’ model. It gives the developer freedom with customization efforts, yet it allows user-friendly administration via the web interface.

Preparations in the administrative UI

We can start by importing the out-of-box ‘ASPX Blank Site’ site. We can change the template type to ‘ASPX + Portal page’ right away. Now it is time to customize the template and its master page in your favorite editor. You can create more such page templates or you can also combine these with Kentico portal engine templates.

To demonstrate the wide variety of possible scenarios, let’s create one page type that inherits from the ‘Article’ page type.

It will have three non-inherited fields named ‘My Field’, ‘Product Brand’ and ‘Product Friendly Name’ …

… and it will represent a product.

Finally, let’s create a page instance of that type with some values in it.

Code customizations

You can now open your solution in Visual Studio and go to your CMSApp_AppCode project (or the App_Code folder, if you happen to have a web site project type).

Create a new class file with the following contents. The code renders the PageMaps data from three basic sources: Page field data (the AddPageFieldAttributes method), SKU field data (the AddSkuAttributes method) and other Kentico data (the AddCustomAttributes method).

Please note that this is just a proof of concept code. I’ve commented the code so you can get a glimpse of what it does.

using System; using System.Collections.Generic; using System.Xml.Linq; using CMS.Controls; using CMS.DocumentEngine; using CMS.Ecommerce; using CMS.Helpers; using CMS.UIControls; using CMS.SiteProvider; namespace CMSAppAppCode { /// <summary> /// Enum that distinguishes the way data is loaded from page fields. /// </summary> public enum PageFieldDataType { Text, Image, } /// <summary> /// Enum that distinguishes various members SKU-only fields. /// </summary> public enum SkuField { SKUName, SKUShortDescription, SKUDescription, SKUPrice, SKUImagePath, MainCurrency, SKUValidUntil } public static class GooglePageMapsGenerator { public static string GetPageMaps(TemplateMasterPage currentMasterPage) { if (currentMasterPage.CurrentPage.ClassName == "custom.MyType") { SiteInfo currentSite = currentMasterPage.CurrentSite; PageInfo currentPage = currentMasterPage.CurrentPage; // Creating an XML element "PageMap". XElement pageMaps = new XElement("PageMap"); // Adding a "WebPage" child element through a method defined below. XElement webPageElement = AddDataObject("WebPage", ref pageMaps); // Creating a dictionary of attribute names and their respective custom Kentico data to be rendered. As the output data is being referenced here, there can be no caching logic in the AddCustomAttributes method. Dictionary<string, object> customAttributeItems = new Dictionary<string, object>() { { "title", currentMasterPage.Title }, { "description", currentMasterPage.Description }, { "keywords", currentMasterPage.KeyWords }, { "content-type", currentMasterPage.CurrentPage.ClassName }, { "site", currentMasterPage.CurrentSite.DisplayName } }; // Rendering the child XML elements through a method defined below. AddCustomAttributes(customAttributeItems, ref webPageElement); // Creating a dictionary of page field names and their respective data types. Dictionary<string, PageFieldDataType> webPageAttributeItems = new Dictionary<string, PageFieldDataType>() { { "MyField", PageFieldDataType.Text }, { "ArticleText", PageFieldDataType.Text } }; // Rendering the child XML elements through a method defined below. The cache key defined by the CacheSettings constructor parameters should be different from other pages' ones and should differ from other keys created during processing of the current page. List<XElement> pageFieldsElements = CacheHelper.Cache(cs => AddPageFieldAttributes(webPageAttributeItems, currentSite.SiteName, currentPage.NodeAliasPath, cs), new CacheSettings(10, "googlepagemaps", "node", currentSite.SiteName, currentPage.NodeAliasPath, "pagefields")); webPageElement.Add(pageFieldsElements); // Adding a "Teaser" child element through a method defined below. XElement teaserElement = AddDataObject("Teaser", ref pageMaps); // Creating a dictionary of page field names and their respective data types. Dictionary<string, PageFieldDataType> teaserAttributeItems = new Dictionary<string, PageFieldDataType>() { { "ArticleTeaserText", PageFieldDataType.Text }, { "ArticleTeaserImage", PageFieldDataType.Image } }; // Rendering the child XML elements through a method defined below. List<XElement> teaserElements = CacheHelper.Cache(cs => AddPageFieldAttributes(teaserAttributeItems, currentSite.SiteName, currentPage.NodeAliasPath, cs), new CacheSettings(10, "googlepagemaps", "node", currentSite.SiteName, currentPage.NodeAliasPath, "teaser")); teaserElement.Add(teaserElements); // Let's render another XML element "Product". The logic can be made conditional using 'if (DocumentContext.CurrentDocument.HasSKU)'. XElement productElement = AddDataObject("Product", ref pageMaps); // Creating a dictionary of PageMaps element names and their respective product-specific Kentico objects. Dictionary<string, SkuField> productSkuAttributeItems = new Dictionary<string, SkuField>() { { "Name", SkuField.SKUName }, { "Description", SkuField.SKUShortDescription }, { "PriceCurrency", SkuField.MainCurrency }, { "Price", SkuField.SKUPrice }, { "Image", SkuField.SKUImagePath }, { "ValidUntil", SkuField.SKUValidUntil } }; // Rendering the child XML elements through a method defined below. List<XElement> productSkuElements = CacheHelper.Cache(cs => AddSkuAttributes(productSkuAttributeItems, currentSite.SiteID, currentSite.SiteName, currentPage.NodeAliasPath, cs), new CacheSettings(10, "googlepagemaps", "node", currentSite.SiteName, currentPage.NodeAliasPath, "productsku")); productElement.Add(productSkuElements); //AddSkuAttributes(productSkuAttributeItems, ref productElement, currentMasterPage); // If the product page types also contain custom fields, they will get rendered in the "Product" element. Dictionary<string, PageFieldDataType> productCustomAttributeItems = new Dictionary<string, PageFieldDataType>() { { "ProductBrand", PageFieldDataType.Text }, { "ProductFriendlyName", PageFieldDataType.Text } }; // Rendering the child XML elements through a method defined below. List<XElement> productCustomElements = CacheHelper.Cache(cs => AddPageFieldAttributes(productCustomAttributeItems, currentSite.SiteName, currentPage.NodeAliasPath, cs), new CacheSettings(10, "googlepagemaps", "node", currentSite.SiteName, currentPage.NodeAliasPath, "productcustom")); productElement.Add(productCustomElements); // Returning the whole XML (to be used in the page's <head> section). return "\n<!--\n" + pageMaps.ToString() + "\n-->\n"; } else { return string.Empty; } } /// <summary> /// Renders "Attribute" XML elements from page fields. /// </summary> /// <param name="items">The dictionary containing Kentico page field names and their types.</param> /// <param name="parent">The parent element (typically the "DataObject" element).</param> private static List<XElement> AddPageFieldAttributes(Dictionary<string, PageFieldDataType> items, string siteName, string nodeAliasPath, CacheSettings cs) { string result; List<XElement> childElements = new List<XElement>(); foreach (KeyValuePair<string, PageFieldDataType> item in items) { object value; // If the current page has a field of a specific name, it will be added to XML. if (DocumentContext.CurrentDocument.TryGetValue(item.Key, out value)) { XElement element = new XElement("Attribute", new XAttribute("name", item.Key)); result = HTMLHelper.StripTags(ValidationHelper.GetValue<string>(value, string.Empty)); // If the type of the field is an image, the absolute URL of the image will be rendered. if (item.Value == PageFieldDataType.Image && result != string.Empty) { try { result = TransformationHelper.HelperObject.GetAbsoluteUrl(TransformationHelper.HelperObject.GetFileUrlFromAlias(result, DocumentContext.CurrentDocument.NodeAlias)); } catch (Exception ex) { // Put your exception handling logic here. } } element.Add(result); childElements.Add(element); } } if (cs.Cached) { cs.CacheDependency = CacheHelper.GetCacheDependency(string.Format("node|{0}|{1}", siteName, nodeAliasPath)); //"node|dancinggoat|/|childnodes" } return childElements; } /// <summary> /// Renders "Attribute" XML elements from product-specific objects (eg. SKUInfo). /// </summary> /// <param name="items">The dictionary containing PageMaps elements' names and their respective product-specific Kentico objects.</param> /// <param name="parent">The parent element (typically the "DataObject" element).</param> private static List<XElement> AddSkuAttributes(Dictionary<string, SkuField> items, int siteId, string siteName, string nodeAliasPath, CacheSettings cs) { List<XElement> childElements = new List<XElement>(); if (DocumentContext.CurrentDocument.HasSKU) { SKUInfo sku = SKUInfoProvider.GetSKUInfo(DocumentContext.CurrentDocument.NodeSKUID); string result; foreach (KeyValuePair<string, SkuField> item in items) { XElement element = new XElement("Attribute", new XAttribute("name", item.Key)); switch (item.Value) { case SkuField.SKUName: default: result = ValidationHelper.GetValue<string>(sku.SKUName, string.Empty); break; case SkuField.SKUShortDescription: result = HTMLHelper.StripTags(ValidationHelper.GetValue<string>(sku.SKUShortDescription, string.Empty)); break; case SkuField.SKUDescription: result = HTMLHelper.StripTags(ValidationHelper.GetValue<string>(sku.SKUDescription, string.Empty)); break; case SkuField.SKUPrice: // If you need a price in a specific currency, invoke GetSKUPrice() or other methods (http://devnet.kentico.com/docs/9_0/api/html/Methods_T_CMS_Ecommerce_SKUInfoProvider.htm). result = ValidationHelper.GetValue<string>(sku.SKUPrice, string.Empty); break; case SkuField.SKUImagePath: result = string.Empty; try { result = TransformationHelper.HelperObject.GetAbsoluteUrl(sku.SKUImagePath, string.Empty); } catch (Exception ex) { // Put your exception handling logic here. } break; case SkuField.SKUValidUntil: result = ValidationHelper.GetValue<string>(sku.SKUValidUntil, string.Empty); break; case SkuField.MainCurrency: result = ValidationHelper.GetValue<string>(CurrencyInfoProvider.GetMainCurrencyCode(siteId), string.Empty); break; } element.Add(result); childElements.Add(element); } if (cs.Cached) { cs.CacheDependency = CacheHelper.GetCacheDependency(string.Format("node|{0}|{1}", siteName, nodeAliasPath)); // The cache item is also dependent on SKU cache items of the respective pages. string cacheKey = string.Format("ecommerce.sku|byid|{0}|children", sku.SKUID); string cacheKeyMetafile = cacheKey + "|cms.metafile"; cs.CacheDependency = CacheHelper.GetCacheDependency(cacheKey); cs.CacheDependency = CacheHelper.GetCacheDependency(cacheKeyMetafile); } } return childElements; } /// <summary> /// Renders "Attribute" XML elements from other Kentico objects. /// </summary> /// <param name="items">The dictionary containing PageMaps elements' names and references to existing Kentico objects.</param> /// <param name="parent">The parent element (typically the "DataObject" element).</param> /// <remarks>This method does not set any cache item dependencies.</remarks> private static void AddCustomAttributes(Dictionary<string, object> items, ref XElement parent) { foreach (var item in items) { XElement element = new XElement("Attribute", new XAttribute("name", item.Key)); string value = string.Empty; try { value = ValidationHelper.GetValue<string>(item.Value, string.Empty); element.Add(); } catch (Exception ex) { // Put your exception handling logic here. } element.Add(value); parent.Add(element); } } /// <summary> /// Adds an XML element named "DataObject" into the parent "PageMaps" element (or any other element). /// </summary> /// <param name="type">The "type" attribute of the "DataObject" element.</param> /// <param name="parent">The parent element (typically the "PageMaps" element).</param> /// <returns>An empty "DataObject" XML element.</returns> private static XElement AddDataObject(string type, ref XElement parent) { XElement webPageElement = new XElement("DataObject"); webPageElement.Add(new XAttribute("type", type)); parent.Add(webPageElement); return webPageElement; } } }

You might need to add a reference to the System.Xml.Linq assembly to the CMSApp_AppCode project.

Please note that the code is made to as simple as possible. Although it makes use of caching, it still cannot be considered production-ready.

Now you can open the ‘~/CMS/CMSTemplates/BlankSiteASPX/Blank.master.cs’ file and add the using statement:

using CMSAppAppCode;

Now you can add the following line to the OnPreRender() method:

ltlTags.Text += GooglePageMapsGenerator.GetPageMaps(this);

You should back up your Blank.master.cs file before you apply any hotfix. It may get overwritten during the hotfixing process.

Once you create a page of the ‘My Type’ page type, its fields and other properties will get rendered into the <head> section of the page.

Final thoughts

I should stress again the fact that the solution is just a proof of concept. The main method supports just one page type and lots of things are hard-coded for simplicity. It of course can be improved in many ways. But in this form it allows the content editors to maintain the PageMaps data in ordinary page/SKU fields.

There are also some specifics of Kentico that should be aware of:

  • While th SKUName, SKUShortDescription and SKUDescription properties of the SKUInfo class are permanent, their DocumentSKUName, DocumentSKUShortDescription and DocumentSKUDescription counterparts in the SKUTreeNode class change over time. They can differ from one version of a product page to another. They are also different if multiple language versions of a page are created. If you wish to retrieve language-specific values, you should make use of the SKUTreeNode class instead.

  • At the moment there is no Kentico function to retrieve image dimensions.

Share this article on   LinkedIn

Jan Lenoch

I'm a developer trainer at Kentico. I create hands-on developer trainings of ASP.NET and Kentico Xperience. I listen to the voice of our customers and I improve our training courses on a continuous basis. I also give feedback to the internal teams about developers' experience with Xperience.