Handling Site Metadata with SXA Modules: Part 1 | Perficient Digital

Handling Site Metadata with SXA Modules: Part 1

If you’re using Sitecore 9.0+, chances are that you’ve heard of, or are currently utilizing, Sitecore Experience Accelerator to accessorize your instance. In my opinion, one of the better things to come out of SXA is the use of modules to add discrete amounts of functionality to a tenant or site by leveraging the scaffolding actions.

Use Case

One of the first use cases I encountered for building a custom module came after a review of our Google Lighthouse page scores:

Image result for google lighthouse sample report

Now obviously this isn’t the exact Lighthouse report that was generated. But as part of the Best Practices audit, Google looks for the manifest.json file which contains information about a site that will tell it how to behave for mobile and desktop users.

Here are the contents of a typical file:

As you can probably tell, this is mostly intended for mobile users who install the web app on their home screen. Normally, this lives as a static file in the application’s root directory, and gets linked to the page using a snippet in the <head> element like so:

<link rel="manifest" href="/manifest.json">

If nothing is going to ever change with your application, this is the extent of your implementation efforts. Hooray! You now have a web app manifest, your mobile users are happy, and the Best Practices score on your Lighthouse report has gone up. Unfortunately, things are never this simple. There are several variables that we haven’t considered, especially when you add Sitecore to the equation:

  1. What if there are multiple sites/tenants?
  2. What if my theme color/icons/background color changes?
  3. What if the manifest file is in source control and gets overridden when deploying to my site?
  4. Can I set these variables from within Sitecore?

Lucky for you, if any of the above sounded familiar, you are a good candidate for the following information. In the next section we will cover how to implement a dynamic and cached manifest file that can be independently controlled by tenants and won’t get overridden on deployments. As an added bonus, this module will handle adding two more custom <meta> tags for the theme color and meta description, both of which are helpful for improving the Lighthouse report scores.

Implementation

Before we get started, you’ll have to have the following (or a very similar setup):

  • Sitecore Experience Accelerator 1.7
  • Sitecore Powershell Extensions 4.7.2
  • Unicorn 4.0+

You can create your own project, or reference any of the code snippets demonstrated, by viewing them on github. Keep in mind that this structure conforms to Helix conventions, as should any good Sitecore project. Since this functionality will be added as an SXA module, I’ve decided that it should live in the Feature layer.

The secret of this module lies in the HttpRequestBegin pipeline. This pipeline executes with every request and makes a great candidate for creating or retrieving a dynamically-generated manifest file. We’ll add a custom processor called WebAppManifestHandler to our project and add it into the pipeline using a config patch.

<httpRequestBegin>
    <processor type="YourProject.Feature.SiteMetadata.Pipelines.HttpRequestBegin.WebAppManifestHandler, YourProject.Feature.SiteMetadata"
               resolve="true"
               patch:after="processor[@type='Sitecore.Pipelines.HttpRequest.DatabaseResolver, Sitecore.Kernel']">
        <CacheExpiration>720</CacheExpiration>
    </processor>
</httpRequestBegin>

This will pass in a CacheExpiration value as well, which will be used later when caching the manifest file. Now on to the important bits of the handler:

public class WebAppManifestHandler : HttpRequestProcessor
{
    private readonly IContext _context;
    private readonly BaseCorePipelineManager _pipelineManager;

    public int CacheExpiration { get; set; }

    public WebAppManifestHandler(IContext context, BaseCorePipelineManager pipelineManager)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _pipelineManager = pipelineManager ?? throw new ArgumentNullException(nameof(pipelineManager));
    }

    public override void Process(HttpRequestArgs args)
    {
        var url = args.HttpContext.Request.Url;
        if (AbortProcessor(url)) return;

        var manifest = GetManifestFromCache(url?.ToString());
        if (manifest != null)
        {
            RenderManifest(args, manifest);
            return;
        }

        var webAppManifestArgs = new GetWebAppManifestContentArgs();
        _pipelineManager.Run("getWebAppManifestContent", webAppManifestArgs);

        manifest = JsonConvert.SerializeObject(webAppManifestArgs.Result, Formatting.Indented);         
        CacheManifestContent(url?.ToString(), manifest);
        RenderManifest(args, manifest);
    }

    private bool AbortProcessor(Uri url)
    {
        if (!url.PathAndQuery.EndsWith("/manifest.json", StringComparison.OrdinalIgnoreCase))
            return true;
        var siteInfo = _context.Site.SiteInfo;
        return siteInfo == null || !UrlUtils.IsUrlValidForFile(url, siteInfo, "/manifest.json");
    }

    private string GetManifestFromCache(string requestUrl)
    {
        var cacheKey = GetCacheKey(requestUrl);
        var manifest = HttpRuntime.Cache[cacheKey] as string;
        return manifest;
    }

    private static void RenderManifest(HttpRequestArgs args, string manifest)
    {
        args.HttpContext.Response.ContentType = "application/json";
        args.HttpContext.Response.ContentEncoding = Encoding.UTF8;
        args.HttpContext.Response.Write(manifest);
        args.HttpContext.Response.End();
        args.AbortPipeline();
    }

    protected virtual void CacheManifestContent(string requestUrl, string manifest)
    {
        var cacheKey = GetCacheKey(requestUrl);
        HttpRuntime.Cache.Insert(cacheKey, manifest, null, DateTime.UtcNow.AddMinutes(CacheExpiration), Cache.NoSlidingExpiration);
    }

    private string GetCacheKey(string requestUrl)
    {
        return $@"{Constants.ManifestCacheKey}/{_context.Database?.Name}/{_context.Site?.Name}/{requestUrl}";
    }
}

Since this handler inherits from HttpRequestProcessor, we can override the Process method to retrieve the manifest. First, it checks for the manifest in the cache. And if it isn’t found, it runs the GetWebAppManifestContent pipeline and caches the result from a serialized JSON object using a custom cache key and the CacheExpiration variable that was passed in from the config patch.

Once we have the manifest and have cached it, we can render the contents with the RenderManifest method. That means that any requests to https://sitecore.site/manifest.json will render the generated manifest, including references via the link tag that was mentioned above (i.e. <link rel="manifest" href="/manifest.json">).

Now we need to tie the manifest content into Sitecore. For this we are going to create a custom pipeline called getWebAppManifestContent and add it into our config patch file.

<getWebAppManifestContent>
    <processor type="YourProject.Feature.SiteMetadata.Pipelines.GetWebAppManifestContent.GetWebAppManifestContent, YourProject.Feature.SiteMetadata"
               resolve="true" />
</getWebAppManifestContent>

As shown in the documentation, we need a custom processor and custom processor arguments in order to override the Process method in the actual pipeline class. This is as simple as making an arguments class inherit from PipelineArgs and adding any properties that we need (in this case a JSON model for rendering and caching the manifest content). This class then gets used in an abstract Processor class, which is then overridden.

public class GetWebAppManifestContent : GetWebAppManifestContentProcessor
{
    private readonly IMultisiteContext _multisiteContext;
    private readonly IContext _context;
    private readonly BaseMediaManager _mediaManager;
    private readonly BaseLinkManager _linkManager;

    public GetWebAppManifestContent(IMultisiteContext multisiteContext, IContext context, BaseMediaManager mediaManager, BaseLinkManager linkManager)
    {
        _multisiteContext = multisiteContext ?? throw new ArgumentNullException(nameof(multisiteContext));
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _mediaManager = mediaManager ?? throw new ArgumentNullException(nameof(mediaManager));
        _linkManager = linkManager ?? throw new ArgumentNullException(nameof(linkManager));
    }

    public override void Process(GetWebAppManifestContentArgs args)
    {
        if (args.Result != null) return;
        var settingsItem = GetSiteSettings();
        if (settingsItem == null) return;
        if (settingsItem.InheritsFrom(new ID(Templates.WebAppManifest.Id)))
        {
            args.Result = GetManifestContent(settingsItem);
        }
    }

    private Item GetSiteSettings()
    {
        return _multisiteContext.GetSettingsItem(_context.Database.GetItem(_context.Site.StartPath));
    }

    private WebAppManifestJsonModel GetManifestContent(Item settingsItem)
    {
        var manifest = new WebAppManifestJsonModel
        {
            ShortName = settingsItem.Fields[Templates.WebAppManifest.Fields.ShortName]?.Value ?? string.Empty,
            Name = settingsItem.Fields[Templates.WebAppManifest.Fields.FullName]?.Value ?? string.Empty,
            Description = settingsItem.Fields[Templates.WebAppManifest.Fields.Description]?.Value ?? string.Empty,
            Icons = new[]
            {
                GetIcon(settingsItem, Templates.WebAppManifest.Fields.LargeIcon, "512x512"),
                GetIcon(settingsItem, Templates.WebAppManifest.Fields.SmallIcon, "192x192")
            },
            ThemeColor = settingsItem.Fields[Templates.WebAppManifest.Fields.ThemeColor]?.Value ?? "#FFFFFF",
            BackgroundColor = settingsItem.Fields[Templates.WebAppManifest.Fields.BackgroundColor]?.Value ?? "#FFFFFF",
            StartUrl = GetRelativeUrl(settingsItem.Fields[Templates.WebAppManifest.Fields.StartUrl]),
            Display = GetDisplayType(settingsItem.Fields[Templates.WebAppManifest.Fields.Display]) ?? "standalone",
            Scope = GetRelativeUrl(settingsItem.Fields[Templates.WebAppManifest.Fields.Scope])
        };

        return manifest;
    }

    private object GetIcon(Item settingsItem, string fieldId, string size)
    {
        var iconImage = (ImageField)settingsItem.Fields[fieldId];
        var iconMedia = iconImage?.MediaItem;
        if (iconMedia == null)
        {
            return new
            {
                src = string.Empty,
                type = string.Empty,
                sizes = size
            };
        }

        return new
        {
            src =  _mediaManager.GetMediaUrl(iconMedia),
            type = _mediaManager.MimeResolver.GetMimeType(iconMedia) ?? string.Empty,
            sizes = size
        };
    }

    private string GetRelativeUrl(Field field)
    {
        if (field == null) return null;
        var item = _context.Database.GetItem(field.Value);
        return item != null ? _linkManager.GetItemUrl(item) : _context.Site.VirtualFolder;
    }

    private static string GetDisplayType(Field field)
    {
        if (field == null) return null;
        var displayItem = ((ReferenceField)field).TargetItem;
        return displayItem?.Fields[Sitecore.XA.Foundation.Common.Templates.Enum.Fields.Value]?.Value;
    }
}

This class will first and foremost check if the context site’s settings item inherits from the Web App Manifest template (Check out follow up posts). This partial template will hold the content of the manifest in Sitecore so that content authors can control the various values of the manifest. And since it is added as a partial template to the Settings item, the manifest content can be separated on a site by site basis, meaning that there is no interference with other sites.

If the class finds the partial template, it will attempt to create a new JSON model from the field values. Be sure that you have set the field values correctly in the Templates file after creating the partial template, or the values won’t resolve correctly. Finally, there are a few helper methods to get display types, icons, and URLs. At this point, we can generate the manifest content in Sitecore, render it, and cache it if necessary.

The next piece of this puzzle involves publishing. What happens if you decide to change one of the values in Sitecore? Because the manifest is cached, the updates won’t propagate on a publish. Luckily, Sitecore includes several event handlers that we can augment in a similar way as the HttpRequestBegin pipeline. In this case, we’ll need to clear the cache once publishing is finished, which is easily done through the config patch.

<events>
    <event name="publish:end">
        <handler type="YourProject.Feature.SiteMetadata.EventHandlers.WebAppManifestCacheClearer, YourProject.Feature.SiteMetadata"
                 method="ClearManifestCache"
                 resolve="true" />
    </event>
    <event name="publish:end:remote">
        <handler type="YourProject.Feature.SiteMetadata.EventHandlers.WebAppManifestCacheClearer, YourProject.Feature.SiteMetadata"
                 method="ClearManifestCache"
                 resolve="true" />
    </event>
</events>

This will cover local and remote publishes of a site. The class itself is very straightforward.

public class WebAppManifestCacheClearer : CacheClearerBase
{
    private readonly BaseLog _baseLog;

    public WebAppManifestCacheClearer(BaseLog baseLog)
    {
        _baseLog = baseLog ?? throw new ArgumentNullException(nameof(baseLog));
    }
    public void ClearManifestCache(object sender, EventArgs args)
    {
        Assert.ArgumentNotNull(sender, nameof(sender));
        Assert.ArgumentNotNull(args, nameof(args));
        _baseLog.Info("ManifestCacheClearer clearing HTTP cache for all sites.", this);
        DeleteCachesWithKeyPrefix(Constants.ManifestCacheKey);
    }
}

The main method uses the ManifestCacheKey to delete the cached manifest content once publishing was finished. Because of this, the content will need to be cached again, but this isn’t a very expensive or lengthy operation, and the updated content will be available for any requests made to the manifest. Another quirk to take note of is SXA’s PreProcessRequest pipeline includes a filter to block certain file extensions from being loaded, except for files like a sitemap or robots file. We can add a new exception in our config patch file like so:

<experienceAccelerator>
    <sitecoreExtensions>
        <filterUrlFilesAndExtensions>
          <file desc="Allowed file - Feature.WebAppManifest">manifest.json</file>
        </filterUrlFilesAndExtensions>
    </sitecoreExtensions>
</experienceAccelerator>

Now the manifest file won’t be blocked by the pipeline and can be loaded successfully.

We’re almost done, and the final piece that remains is generating the link tag to render it on a page, as well as those other bonus tags I mentioned previously. Using the repository pattern, we’ll generate a few link tags by overriding SXA’s own meta tag functionality. The repository looks like this:

public class WebAppManifestRepository : MetadataBaseRepository<WebAppManifestRenderingModel>
{
    private readonly IContext _context;
    private readonly IMultisiteContext _multisiteContext;
    private readonly IPageMode _pageMode;

    public WebAppManifestRepository(IContext context, IMultisiteContext multisiteContext, IPageMode pageMode)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _multisiteContext = multisiteContext ?? throw new ArgumentNullException(nameof(multisiteContext));
        _pageMode = pageMode ?? throw new ArgumentNullException(nameof(pageMode));
        Mapping = new Dictionary<string, ID>();       
    }

    public override WebAppManifestRenderingModel GetModel()
    {
        var instance = base.GetModel();
        instance.ManifestPath = GetManifestPath();
        return _pageMode.IsExperienceEditorEditing ? MockEmptyValues(instance) : FilterEmptyValues(instance);
    }

    private WebAppManifestRenderingModel MockEmptyValues(WebAppManifestRenderingModel model)
    {
        var metaTags = model as IList ?? model.MetaTags.ToList();
        foreach (var metaTagModel in metaTags.Where(tagModel => string.IsNullOrEmpty(tagModel.Content)))
        {
            var name = Rendering.Item.Database.GetItem(metaTagModel.Id).Name;
            metaTagModel.Content = $@"[{name} field is empty or is missing on the context item]";
        }
        var source = new WebAppManifestRenderingModel
        {
            MetaTags = metaTags,
            ManifestPath = "~/manifest.json"
        };
        return source;
    }

    private static WebAppManifestRenderingModel FilterEmptyValues(WebAppManifestRenderingModel model)
    {
        var obj = new WebAppManifestRenderingModel
        {
            MetaTags = model.MetaTags.Where(tagModel => !string.IsNullOrEmpty(tagModel.Content)),
            ManifestPath = model.ManifestPath
        };
        return obj;
    }

    private string GetManifestPath()
    {
        if (!_context.Site.VirtualFolder.Equals("/"))
            return $@"{_context.Site.VirtualFolder}manifest.json";
        return "/manifest.json";
    }

    protected override IEnumerable GetMetaTags()
    {
        var list = BuildModelMapping().ToList();
        list.Add(new MetaTagModel
        {
            Name = "theme-color",
            Content = GetThemeColor()
        });
        list.Add(new MetaTagModel
        {
            Name = "description",
            Content = GetDescription()
        });
        return list.Where(metaTagModel => metaTagModel != null);
    }

    private string GetThemeColor()
    {
        var settingsItem = GetSiteSettings();
        var themeColor = settingsItem[Templates.WebAppManifest.Fields.ThemeColor];
        return !string.IsNullOrEmpty(themeColor) ? themeColor : "#FFFFFF";
    }

    private string GetDescription()
    {
        var settingsItem = GetSiteSettings();
        var description = settingsItem[Templates.WebAppManifest.Fields.Description];
        return !string.IsNullOrEmpty(description) ? description : "description";
    }

    private Item GetSiteSettings()
    {
        return _multisiteContext.GetSettingsItem(_context.Database.GetItem(_context.Site.StartPath));
    }
}

This repository will be consumed by a controller class used in a Sitecore controller rendering. The rendering model, in this case, inherits the stock SXA MetaTagRenderingModel and adds a new property called ManifestPath. This is needed because some sites might have different values set for their virtual folders than others.

Before this property is set, the model’s base properties are filled, and another method called GetMetaTags is overridden so that we can add the meta description and theme color tags to the base model. This process is done using helper methods that look at the context site’s Settings item fields for their values. The ManifestPath is then set and, finally, the values are either mocked up or filtered depending on whether the page is in the Experience Editor. The model is then passed into the view and rendered on a page:

@using Sitecore.XA.Feature.SiteMetadata.Models
@model YourProject.Feature.SiteMetadata.Models.WebAppManifestRenderingModel

@{
    Layout = Sitecore.Configuration.Settings.GetSetting("XA.Foundation.Presentation.MetaComponentLayoutPath", "~/Views/SXA/Meta Component Layout.cshtml");
}

@functions {
    private static string BuildAttribute(string attribute, string value)
    {
        return (!string.IsNullOrWhiteSpace(value) ? $@"{attribute}=""{value}""" : string.Empty);
    }
}

<link rel="manifest" href="@Model.ManifestPath" />

@foreach (var tag in Model.MetaTags)
{
    if (!Sitecore.Context.PageMode.IsExperienceEditorEditing && string.IsNullOrWhiteSpace(tag.Content))
    {
        continue;
    }
    @Html.Raw($@"<meta {BuildAttribute("name", tag.Name)} {BuildAttribute("content", tag.Content)} />");
}

Conclusion

That’s it! Once you have created the rendering and added it to a page, you’ll have a dynamically generated and cached manifest file that’s updated on publishing, as well as some bonus tags to improve your Lighthouse report scores. Let’s take a look at the concerns raised before and how we addressed them.

  • What if there are multiple sites/tenants?
    • Since the manifest is based on the settings item, each site can have its own manifest
  • What if my theme color/icons/background color changes?
    • Simply publishing the settings item will clear the cache and render the new values
  • What if the manifest file is in source control and gets overridden when deploying to my site?
    • Now you don’t have to have a static file since it isn’t in source control
  • Can I set these variables from within Sitecore?
    • Yes! Using a partial template that is added to the settings

Stay tuned for my next post where I will cover SXA modules, scaffolding, PowerShell, and much more!

Leave a Reply