When we talk about Site Clusters — as you hopefully read in Part 1 and Part 2 of this series — we’re referring to the localization strategy for translating a website from one localized language to many other localized languages. Let’s imagine you have a website which you want to distribute to all of North America. For this case, you may introduce language localizations for:
Localization | Language | Country |
---|---|---|
en-CA | English | Canada |
en-US | English | United States |
es-CR | Spanish | Costa Rica |
es-MX | Spanish | Mexico |
fr-CA | French | Canada |
This works fairly well, and Sitecore is able to handle this pretty natively out of the box. You just need to make sure you develop your back-end code to be language friendly, and pay special attention to which fields you mark “Shared.”
There’s one minor issue with this strategy, however, and it relates to the authoring impact. Imagine I have a new set of pages which need to go out as soon as possible. In this scenario, I need to build those pages 5 times to have them distributed to my 5 countries. I’m also entering content twice for English, and twice for Spanish. When a page changes for one country, it probably should change for all others that have the same language, and so on. It’s really an authoring nightmare.
So what’s special about a Site Cluster?
Really, a site cluster is about owning your language strategy. In the case I listed above, we’re looking at building content in 5 separate languages. In reality, what we really need is 3 separate languages. Imagine a scenario where we could impose this strategy:
Localization | Language | Country |
---|---|---|
en | English | United States |
en | English | Canada |
fr | French | Canada |
es | Spanish | Mexico |
es | Spanish | Costa Rica |
By simply having my countries share the same language under the hood, I’m able to reduce the amount of content which needs to be managed. This is amplified tremendously when we look at introducing a global rollout for our website. Imagine entering content on a per-language basis, and not a per-country basis.
Solving problems introduces problems
Unfortunately, it’s never quite this easy. In practice, you’ll usually end up with bits and pieces of information which need to change across your country sites. For instance, you may have contact information for each country, which becomes untranslatable in this scenario. You may also have a situation where a page may be available for one country but not available for another country, even though those countries are sharing the same language.
Removing Pages for Countries
Part of the Site Cluster solution involves the ability to remove pages for specific countries. Not only do we need to have them 404 a user if they try to access them, but functionality like SCORE’s Navigational Spiders and Search Crawlers need to behave as well, with minimal coding intervention. To solve this, we can use the Security Provider pattern (which I introduced earlier in this post…).
First, we need a structure that represents our global presence.
Within this implementation, we’ve created a Region and Country concept. This also can drive features like a Country Selector component if needed:
We’ve also introduced a site definition per country, which looks like so:
<?xml version="1.0" encoding="utf-8"?> <configuration> <sitecore> <sites> <site name="United States" language="en" ... country="{12835590-98e2-4cfe-b092-c0673a0abc57}" /> <site name="Canada" language="en" ... country="{11fe4021-5058-416f-8c51-6f0775281f1f}" /> <site name="Mexico" language="es" ... country="{b0c15165-b249-4a04-a52d-8161b8e7f179}" /> </sites> </sitecore> </configuration>
Notice that we’ve introduced an extra attribute to each website. This attribute is “country,” and it’s assigned to the GUID of the country item within the content tree that represents this particular country site.
Next, we need to introduce page-level settings for content authors.
I added a “Country Availability” field to a base page template, and pointed it to the Regions section in my content tree. I also extended all of my pages to inherit from this base template, so that this field is available on every page. Pretty standard, right?
Just as a tip, you can actually apply this technique to ALL items within your tree if you really want to (Datasources, settings, etc). You need to be careful when you do this, but it’s technically possible.
And finally, some code to make this work.
Remember when I blogged about the Security Item Provider pattern? We’ll use the same technique here to “trick” Sitecore into believing that items don’t exist based off of the value of this Country Availability field. Slick, huh?
What I’m really looking to do, at a very foundational level within Sitecore, is create logic that looks something like this:
// cast the current item to a cluster page base to make logic checks easier // I happened to name my base template "Cluster Page Base" var clusterPage = new ClusterPageBase(item); // If the page is not available for the country, then trick sitecore into thinking that the page doesn't exist if (!clusterPage.IsAvailableFor(Context.Site.SiteInfo)) { return null; } return item;
Before we get to where I can plug that logic in, let’s take a look at that ClusterPageBase.cs class. It’s really pretty straightforward — just a class that represents this template:
public class ClusterPageBase : CustomItem { public static readonly ID TemplateId = ID.Parse("{8168F6EB-D9AF-4CBC-9910-916391E63B87}"); public static string CountryAvailabilityFieldName = "Country Availability"; public ClusterPageBase(Item item) : base(item) { } public bool IsAvailableFor(SiteInfo site) { var siteCountry = site?.Properties["country"]; if (this.InnerItem.Fields[CountryAvailabilityFieldName].HasValue && !string.IsNullOrEmpty(siteCountry)) { var rawValue = this.InnerItem.Fields[CountryAvailabilityFieldName].Value; return string.IsNullOrEmpty(rawValue) || rawValue.ToLower().Contains(siteCountry.ToLower()); } return true; } // implicit conversions allow me to cast too and from this class public static implicit operator ClusterPageBase(Item innerItem) { return innerItem != null && innerItem.IsDerived(TemplateId) ? new ClusterPageBase(innerItem) : null; } // the opposite of the implicit conversion above, you really should use these everywhere public static implicit operator Item(ClusterPageBase customItem) { return customItem != null ? customItem.InnerItem : null; } // you also should add a TryParse to your custom items public static bool TryParse(Item item, out ClusterPageBase parsedItem) { parsedItem = item == null || item.IsDerived(TemplateId) == false ? null : new ClusterPageBase(item); return parsedItem != null; } }
Plugging in the Security Item Provider
To plug in a new item provider, it starts with a Patch file. Here’s mine:
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/"> <sitecore> <!-- change the default item provider to a custom one which understands country restriction concepts --> <itemManager defaultProvider="pipelineBased"> <providers> <add name="pipelineBased" type="Sitecore.Data.Managers.PipelineBasedItemProvider, Sitecore.Kernel" fallbackProvider="default"> <patch:attribute name="fallbackProvider">countryItemProvider</patch:attribute> </add> <add name="countryItemProvider" type="MyTenant.Custom.Providers.CountryItemProvider, MyTenant.Custom" /> </providers> </itemManager> </sitecore> </configuration>
After that, we can then wrap our desired logic into the item provider:
public class CountryItemProvider : Sitecore.Data.Managers.ItemProvider { // templates have this template ID private const string STANDARD_TEMPLATE_ID = "{AB86861A-6030-46C5-B394-E8F99E8B87DB}"; private string[] sites = { "shell", "login", "admin", "service", "modules_shell", "modules_website", "website", "schedule", "system", "publisher" }; protected override Item ApplySecurity(Item item, Sitecore.SecurityModel.SecurityCheck securityCheck) { if (item == null) return base.ApplySecurity(item, securityCheck); // if this item's a template, or one of the country items, just return standard security // for this case, I have a class called "Country" that represents my country items. The implementation // of this class is pretty straightforward, so I'll leave those details out for brevity. if (item.TemplateID == ID.Parse(STANDARD_TEMPLATE_ID) || item.TemplateID == Country.TemplateId) { return base.ApplySecurity(item, securityCheck); } // make sure it's not one of the sitecore sites // we only want this code to execute for one of our country sites if (Context.Site == null || sites.Contains(Context.Site.Name.ToLower())) { return base.ApplySecurity(item, securityCheck); } // make sure we're in normal or preview mode. don't do this for experience editor if (!Context.PageMode.IsNormal && !Context.PageMode.IsPreview) { return base.ApplySecurity(item, securityCheck); } // sometimes PageMode is normal even if we are in EE - probably because the context site is resolved incorrectly // we are checking if there is sc_mode param in queryString to check if we are in EE in edit mode var queryScMode = Sitecore.Web.WebUtil.GetQueryString("sc_mode"); // if a query string sc_mode was found and if it equals 'edit', don't check country restrictions if (!string.IsNullOrEmpty(queryScMode) && queryScMode.Equals("edit")) { return base.ApplySecurity(item, securityCheck); } // here's the good part. ClusterPageBase is a template that represents my Base Template that // has my "Country Availability" field. I only want to run this logic for those pages if (item.IsDerivedFrom(ID.Parse(ClusterPageBase.TemplateId))) { // only execute this code for content within my tenant's root if (!item.Paths.Path.ToLower().StartsWith("/sitecore/content/my-tenant")) { return base.ApplySecurity(item, securityCheck); } // cast the current item to a cluster page base to make logic checks easier var clusterPage = new ClusterPageBase(item); // the real magic right here. If the page is not available for the country, then // trick sitecore into thinking that the page doesn't exist if (!clusterPage.IsAvailableFor(Context.Site.SiteInfo)) { return null; } } return base.ApplySecurity(item, securityCheck); } }
So what have we done, and what does it do?
In this example, we’ve done these things:
- Create a country hierarchy in Sitecore to represent our countries.
- Create a separate site definition for each country, and point our site definitions back into the countries within our content tree.
- Provide a field on all pages which will allow our content authors to pick and choose which pages should be available for which countries.
- Inject into the default Item Provider to enrich its security checks to include checking our Country restriction settings.
This results in the following behavior: If a page does not have any Country Availability set, then the page will be available for all countries. If one or more Country Availabilities are set, then the page will only be available for those country sites. And believe me when I say “Available”; Sitecore doesn’t even recognize that the item exists when under the context of a site for which the item is not available. This has tremendous benefit to any sort of Crawling component (such as a SCORE Section Menu Spider), to your search indexing, to your 404 processing and more.
Next time I’ll discuss how we can utilize the personalization engine to further customize the output of renderings on a per-country basis.