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<IContent>().ExcludeField(x => x.SiteId());
SearchClient.Instance.Conventions.ForInstancesOf<IContent>().IncludeField(x => x.SiteId(true));
SearchClient.Instance.Conventions.ForInstancesOf<IContent>().ExcludeField(x => x.PublishedInLanguage());
SearchClient.Instance.Conventions.ForInstancesOf<IContent>().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" ]
Comments