Token replacement is a fairly common feature in a CMS, allowing editors to use tokens for commonly used phrases like contacts information, which can then be managed in a global dictionary. The idea is that instead of e.g. entering a office phonenumber on multiple pages, a token like {{phonenumber}}
is used. If the phone number is later changed, the dictionary value can simply be updated, and all content pages will be up to date.
There are multiple approaches to token replacement: One approach is to do the replacement in the frontend using JavaScript. This however has some SEO implications as the unreplaced tokens might not be picked up by search engines unable to process JavaScript. Sitecore offers another approach out of the box, which replaces tokens on publish using a Sitecore.Text.Replacer
. While mitigating the SEO implications of frontend replacement, the drawback is that the replacements are done on publish, requiring a republish of content if any dictionary values are changed.
Hence, a popular approach is to do token replacement in Sitecores renderField pipeline, allowing the dictionary values to be evaluated upon the rendering of the field. However, if your solution is using GlassMapper, the renderField
pipeline is bypassed when not in the the Experience Editor, as GlassMapper implements its own rendering methods.
Luckily, implementing a simliar token replacement in GlassMapper is rather straightforward: In our implementation, the actual replacement is done in a number of field replacers, all implementing this interface:
namespace TokenReplacement
{
public interface IFieldReplacer<T>;
{
T Replace(T value, out bool hasReplaced);
}
}
A really simple implementation of a field replacer, using a static dictionary could be:
namespace TokenReplacement
{
using System.Collections.Generic;
using System.Text.RegularExpressions;
public class DictionaryFieldReplacer : IFieldReplacer<string>
{
private static readonly string tokenPrefix = "{{";
private static readonly string tokenPostfix = "}}";
// The dictionary could be retrieved from some external source or from Sitecore
private static readonly Dictionary<string, string> dictionary = new Dictionary<string, string>()
{
{ "phonenumber", "+45 11111111" }
};
public string Replace(string value, out bool hasReplaced)
{
hasReplaced = false;
if (Sitecore.Context.PageMode.IsExperienceEditorEditing)
return value;
string tokenPattern = string.Concat(tokenPrefix, "(.*?)", tokenPostfix);
MatchCollection matches = Regex.Matches(value, tokenPattern);
foreach (Match match in matches)
{
if (dictionary.TryGetValue(match.Groups[1].Value, out var replacement))
{
value = value.Replace(match.Value, replacement);
hasReplaced = true;
}
}
return value;
}
}
}
In GlassMapper, All the ordinary Sitecore text fields is mapped using the SitecoreFieldStringMapper
which can be overridden to do the replacements:
In GlassMapper, All the ordinary Sitecore text fields is mapped using the SitecoreFieldStringMapper
which can be overridden to do the replacements:
namespace TokenReplacement
{
using Glass.Mapper.Configuration;
using Glass.Mapper.Sc;
using Glass.Mapper.Sc.Configuration;
using Glass.Mapper.Sc.DataMappers;
using Microsoft.Extensions.DependencyInjection;
using Sitecore.Data.Fields;
using Sitecore.DependencyInjection;
using System.Linq;
public class FieldReplacerSitecoreFieldStringMapper : SitecoreFieldStringMapper
{
private static readonly string[] TokenFieldTypes = new[] { "rich text", "multi-line text", "single-line text",
"general link" };
public override object GetField(Field field, SitecoreFieldConfiguration
config, SitecoreDataMappingContext context)
{
var value = (string)base.GetField(field, config, context);
if (!TokenFieldTypes.Contains(field.TypeKey))
return value;
return GetFieldValue(value, config, context);
}
public override object GetFieldValue(string fieldValue,
SitecoreFieldConfiguration config, SitecoreDataMappingContext context)
{
var fieldReplacers =
ServiceLocator.ServiceProvider.GetServices<IFieldReplacer<string>>();
var replaced = false;
var replacedValue = fieldValue;
foreach (var fieldReplacer in fieldReplacers)
{
replacedValue = fieldReplacer.Replace(replacedValue, out var
hasReplaced);
replaced = replaced || hasReplaced;
}
return replacedValue;
}
}
}
The final step is simply to inject the overridden SitecoreFieldStringMapper
into the Glass configuration. This can be done in a number of ways, but in our case we replaced the build in SitecoreFieldStringMapper with our implementation in the GlassMapperScCustom.cs
(added when GlassMappers is installed via NuGet). If you view this file, you will see a method called CreateResolver
with a comment saying that you should “add any changes to the standard resolver here”, which we do like this:
dependencyResolver.DataMapperFactory.Replace<SitecoreFieldStringMapper, FieldReplacerSitecoreFieldStringMapper>(() =>
new FieldReplacerSitecoreFieldStringMapper());
Using multiple dictionaries
In a situation where only a single dictionary is used, Sitecore’s build in HTML cache should generally work, and prevent token replacements from taking place on each page load.
In our solution however, different parts of the site tree could use different dictionaries (we called these dictionary contexts). This allowed different departments to have different phone numbers and addresses while using e.g. the exact same footer module. When the footer module was rendered on a page belonging to Department A, the Dictionary A would be used (containing one phone number), but when rendered on a Department B, the Dictionary B dictionary (containing another phone number).
The resolving of the correct Dictionary was done in the FieldReplacer
, but to support HTML caching we also added a dictionary context id to the Sitecore HTML cache key in a RenderRenderingProcessor
for relevant modules (e.g. the footer). This allowed us to cache a version for each dictionary in the solution.
However, as we also used GlassMappers cache (AlwaysOn) in combination with Lazy Loading, we also needed to tweak Glass Mappers cache: One option was to add the dicationary context id to the cache key used by GlassMapper. But considering that GlassMapper was used thoughout the solution, this would lead to a lot of items being cached multiple times (for each dictionary context) even if the items did not, as was not intended to contain tokens.
So what we did was to add this logic (marked with a comment) in our FieldReplacerSitecoreFieldStringMapper
, making sure that if tokens was encounted in a field, the item would not be added to GlassMappers cache:
public override object GetFieldValue(string fieldValue,
SitecoreFieldConfiguration config,
SitecoreDataMappingContext context)
{
// Inject the field replacers by a mechanism that suits your project
var fieldReplacers = ServiceLocator.ServiceProvider.GetServices<IFieldReplacer<string>>();
var replaced = false;
var replacedValue = fieldValue;
foreach (var fieldReplacer in fieldReplacers)
{
replacedValue = fieldReplacer.Replace(replacedValue, out var hasReplaced);
replaced = replaced || hasReplaced;
}
// Do not cache if replacement has taken place!
if (replaced)
context.Options.Cache = Cache.Disabled;
return replacedValue;
}
This seems like a reasonable approach, given that Sitecores HTML would sit “in front” of GlassMapper, but unfortunately, it did not solve the cache issue in regards to the lazy loaded Sitecore items. The problem is that the lazy loaded items are added to the cache before the overridden SitecoreFieldStringMapper
is executed (and the cache is potentially disabled). So we had to implement another tweak:
What I did was to create a copy of the SitecoreCacheAlwaysOnCheckTask
in the solution, and add some additional logic in the Execute
method:
namespace TokenReplacement
{
using Glass.Mapper;
using Glass.Mapper.Caching;
using Glass.Mapper.Configuration;
using Glass.Mapper.Diagnostics;
using Glass.Mapper.Pipelines.ObjectConstruction;
using Glass.Mapper.Sc;
using System;
public class CustomSitecoreCacheAlwaysOnCheckTask : AbstractObjectConstructionTask
{
protected CacheFactory CacheFactory { get; private set; }
protected ICacheKeyGenerator CacheKeyGenerator { get; private set; }
public CustomSitecoreCacheAlwaysOnCheckTask(CacheFactory cacheFactory, ICacheKeyGenerator cacheKeyGenerator)
{
CacheFactory = cacheFactory;
CacheKeyGenerator = cacheKeyGenerator;
Name = "CustomSitecoreCacheAlwaysOnCheckTask";
}
public string GetCacheName(ObjectConstructionArgs args)
{
var options = args.Options as GetOptionsSc;
if (options != null && options.Site != null)
{
return options.Site.Name;
}
return "Default";
}
public override void Execute(ObjectConstructionArgs args)
{
if (args.Result == null
&& args.Options.Cache != Cache.Disabled)
{
var cachename = GetCacheName(args);
var cacheManager = CacheFactory.GetCache(cachename);
var key = CacheKeyGenerator.Generate(args);
var cacheItem = cacheManager.Get<Tuple<object, GetOptions>>(key);
// Use result from cache ?
if (cacheItem?.Item1 != null && cacheItem.Item2.Cache != Cache.Disabled)
{
args.Result = cacheItem.Item1;
ModelCounter.Instance.CachedModels++;
}
else
{
base.Execute(args);
}
cacheManager.AddOrUpdate(key, new Tuple<object, GetOptions>(args.Result, args.Options));
}
else
{
base.Execute(args);
}
}
}
}
The point here is that with lazy loading there is actually no straight forward way to prevent a Sitecore item from being added to the cache. So, what we are doing is to add the args.Options
to the cache as well.
For the lazy loaded module, the args.Options
will always be the default setting (that is, enabled), as the overridden SitecoreFieldStringMapper
is yet to be executed. But by adding the args.Options
to the cache, we can use that on the next request (when the item is lazy loaded, and the args.Options
have potentially been set to disabled by the overridden SitecoreFieldStringMapper
). In that case we will disregard the cached value.
The obvious downside of this approach is that items are being added to the cache that will in fact never been used because tokens have been replaced in some of their fields.
However, by looking at the CacheKeyGenerator this will happens once per item/language combination, and would be an acceptable overhead.
We added this to GlassMapperScCustom.cs
:
if (config.Cache.AlwaysOn)
dependencyResolver.ObjectConstructionFactory.
Replace<SitecoreCacheAlwaysOnCheckTask, CustomSitecoreCacheAlwaysOnCheckTask>(() =>
new CustomSitecoreCacheAlwaysOnCheckTask(dependencyResolver.CacheFactory, new CacheKeyGenerator()));
Which this in place, our token replacement implementation worked and allowed of to use different dictionaries on different parts of the site.