Take the community feedback survey now.

dada
Jun 26, 2025
  97
(0 votes)

Make Global Assets Site- and Language-Aware at Indexing Time

I had a support case the other day with a question around search on global assets on a multisite. This is the result of that investigation.
This code is provided as-is but please let me know if you have any feedback.

In Episerver (Optimizely) CMS, global assets—such as images, documents, and blocks stored in the Global Assets folder—are not tied to a specific site or language. 
Yet, the content that references typically is.

You might have noticed a common issue:
When implementing a search on Site A, assets referenced only by content on Site B still appear in the results and vice versa. That’s maybe not what we want .

Let’s say you have:

  • Assets used exclusively on Site A, or

  • Assets shared between Site A and Site B.

When a user searches on Site A, you want results that are relevant only to that site—including global assets referenced by that site’s content. Likewise, if an asset is shared between both sites, it should appear in both result sets.

To support this, your indexing process must make global assets aware of which sites and languages reference them.

This Is the Way

If you're using UnifiedSearch, this filtering on Site ID and language typically works out of the box via:

* FilterOnCurrentSite() / FilterOnSite()
* FilterForVisitor() / PublishedInLanguage()

And for typed searches, you can easily apply these filters yourself.

Update the Initialization Module

To get this working for global assets, we override the default indexing behavior for SiteId() and PublishedInLanguage() by excluding the built-in fields and replacing them with our own logic.

SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().ExcludeField(x => x.SiteId());
SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().IncludeField(x => x.SiteId(true));
SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().ExcludeField(x => x.PublishedInLanguage());
SearchClient.Instance.Conventions.ForInstancesOf<IContentMedia>().IncludeField(x => x.PublishedInLanguage(true));

Extension Methods

We define custom logic in a static class to determine which sites and languages reference a given global asset. 

using EPiServer.Find.Cms;
using EPiServer.Framework.Cache;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using EPiServer.Find.Helpers;

public static class GlobalAssetsExtensions
{
    private static readonly Lazy<ISiteDefinitionResolver> _siteDefinitionResolver =
        new(() => ServiceLocator.Current.GetInstance<ISiteDefinitionResolver>());

    private static readonly Lazy<IContentLoader> _contentLoader =
        new(() => ServiceLocator.Current.GetInstance<IContentLoader>());

    private static readonly Lazy<IContentRepository> _contentRepository =
        new(() => ServiceLocator.Current.GetInstance<IContentRepository>());

    private static readonly Lazy<IContentVersionRepository> _versionContentRepository =
        new(() => ServiceLocator.Current.GetInstance<IContentVersionRepository>());

    private static readonly Lazy<IObjectInstanceCache> _objectCache =
        new(() => ServiceLocator.Current.GetInstance<IObjectInstanceCache>());

    private static readonly Lazy<IContentCacheKeyCreator> _contentCacheKeyCreator =
        new(() => ServiceLocator.Current.GetInstance<IContentCacheKeyCreator>());

    public static IEnumerable<string> SiteId(this IContentMedia content, bool foobar)
    {        
        content.ValidateNotNullArgument(nameof(content));

        var siteDefinitionResolver = _siteDefinitionResolver.Value;
        var contentLoader = _contentLoader.Value;
        var contentRepository = _contentRepository.Value;
        var objectCache = _objectCache.Value;
        var contentCacheKeyCreator = _contentCacheKeyCreator.Value;

        if (siteDefinitionResolver.GetByContent(content.ContentLink, false) == null)
        {
            if (ContentReference.IsNullOrEmpty(content.ParentLink) ||
                !contentLoader.TryGet<IContent>(content.ParentLink, out _))
            {
                return Enumerable.Empty<string>();
            }

            if (contentLoader.GetAncestors(content.ContentLink)
                             .Any(x => x.ContentLink == ContentReference.GlobalBlockFolder))
            {
                var cacheKey = $"SiteIds:{content.ContentLink.ID}";
                var cachedSiteIds = objectCache.Get(cacheKey) as IEnumerable<string>;
                if (cachedSiteIds != null)
                {
                    return cachedSiteIds;
                }

                var contentRefs = new HashSet<ContentReference>();

                foreach (var link in contentRepository.GetReferencesToContent(content.ContentLink, false))
                {
                    if (!ContentReference.IsNullOrEmpty(link.OwnerID))
                    {
                        contentRefs.Add(link.OwnerID);
                    }
                }

                var siteIds = new HashSet<string>();
                foreach (var contentRef in contentRefs)
                {
                    var site = siteDefinitionResolver.GetByContent(contentRef, false);
                    if (site != null)
                    {
                        siteIds.Add(site.Id.ToString());
                    }
                }

                objectCache.Insert(cacheKey, siteIds, new CacheEvictionPolicy(
                    TimeSpan.FromMinutes(120), CacheTimeoutType.Sliding,
                    [contentCacheKeyCreator.VersionKey]));

                return siteIds;
            }
        }

        return [content.SiteId()];
    }

    public static Dictionary<string, LanguagePublicationStatus> PublishedInLanguage(this IContentMedia content, bool foobar)
    {
        content.ValidateNotNullArgument(nameof(content));

        if (ContentReference.IsNullOrEmpty(content?.ContentLink))
        {
            return null;
        }

        var contentLoader = _contentLoader.Value;
        var contentRepository = _contentRepository.Value;
        var versionRepository = _versionContentRepository.Value;
        var objectCache = _objectCache.Value;
        var contentCacheKeyCreator = _contentCacheKeyCreator.Value;

        if (contentLoader.GetAncestors(content.ContentLink)
                         .Any(x => x.ContentLink == ContentReference.GlobalBlockFolder))
        {
            // Should we need to check whether entire ancestor chain are resolvable?

            var cacheKey = $"Languages:{content.ContentLink.ID}";
            var cachedLanguages = objectCache.Get(cacheKey) as Dictionary<string, LanguagePublicationStatus>;
            if (cachedLanguages != null)
            {
                return cachedLanguages;
            }

            var contentRefs = new HashSet<ContentReference>();

            foreach (var link in contentRepository.GetReferencesToContent(content.ContentLink, false))
            {
                if (!ContentReference.IsNullOrEmpty(link.OwnerID))
                {
                    contentRefs.Add(link.OwnerID);
                }
            }

            var languages = new Dictionary<string, LanguagePublicationStatus>(StringComparer.OrdinalIgnoreCase);

            foreach (var contentRef in contentRefs)
            {
                var publishedLanguages = versionRepository
                    .ListPublished(contentRef)
                    .Select(x => x.LanguageBranch);
                
                foreach (var lang in publishedLanguages)
                {
                    if (contentLoader.TryGet<IContent>(contentRef, new LanguageSelector(lang), out var contentInLang))
                    {
                        var langStatus = contentInLang.PublishedInLanguage();
                        if (langStatus != null)
                        {
                            foreach (var kvp in langStatus)
                            {
                                languages.TryAdd(kvp.Key, kvp.Value);
                            }
                        }
                    }
                }
            }

            objectCache.Insert(cacheKey, languages, new CacheEvictionPolicy(
                TimeSpan.FromMinutes(120), CacheTimeoutType.Sliding,
                [contentCacheKeyCreator.VersionKey]));

            return languages;
        }

        return content.PublishedInLanguage();
    }
}

What It Looks Like in the Index

After reindexing a global asset referenced across different sites and languages, it will now include enriched metadata, like this

"PublishedInLanguage": { "sv": { "StartPublish$$date": "2017-02-22T11:24:00Z" },
 "fi": { "StartPublish$$date": "2025-06-18T11:03:00Z" },
 "en": { "StartPublish$$date": "2012-10-04T11:53:00Z" },
 "en-GB": { "StartPublish$$date": "2025-05-21T06:24:00Z" } }, 
"SiteId": [ "site-a-guid", "site-b-guid" ]
Jun 26, 2025

Comments

Please login to comment.
Latest blogs
A day in the life of an Optimizely OMVP - Opticon London 2025

This installment of a day in the life of an Optimizely OMVP gives an in-depth coverage of my trip down to London to attend Opticon London 2025 held...

Graham Carr | Oct 2, 2025

Optimizely Web Experimentation Using Real-Time Segments: A Step-by-Step Guide

  Introduction Personalization has become de facto standard for any digital channel to improve the user's engagement KPI’s.  Personalization uses...

Ratish | Oct 1, 2025 |

Trigger DXP Warmup Locally to Catch Bugs & Performance Issues Early

Here’s our documentation on warmup in DXP : 🔗 https://docs.developers.optimizely.com/digital-experience-platform/docs/warming-up-sites What I didn...

dada | Sep 29, 2025

Creating Opal Tools for Stott Robots Handler

This summer, the Netcel Development team and I took part in Optimizely’s Opal Hackathon. The challenge from Optimizely was to extend Opal’s abiliti...

Mark Stott | Sep 28, 2025

Integrating Commerce Search v3 (Vertex AI) with Optimizely Configured Commerce

Introduction This blog provides a technical guide for integrating Commerce Search v3, which leverages Google Cloud's Vertex AI Search, into an...

Vaibhav | Sep 27, 2025

A day in the life of an Optimizely MVP - Opti Graph Extensions add-on v1.0.0 released

I am pleased to announce that the official v1.0.0 of the Opti Graph Extensions add-on has now been released and is generally available. Refer to my...

Graham Carr | Sep 25, 2025