Create a multi-site aware custom search provider using Search & Navigation
In a multisite setup using Optimizely CMS, searching for pages can be confusing. The default CMS search regardless of search provider does not clearly indicate which site a given result belongs to—especially when you have identical page names across multiple sites.
This post shows how you can create a custom search provider, using Search & Navigation, to make your search results site-aware.
Solution
By creating a custom search provider that appends site context (like the start page or site name) to each result, you can make the search output clearer and easier to work with.
In the example below, we add the start page name as a suffix to the page title in the results. This makes it obvious which site a page belongs to. Pages that are not under any site start page appear without a suffix and show up at the top of the results, since they are not associated with any specific site.
You can easily adapt this to use the site name instead of the start page name if you prefer.
This code is provided as-is, without guarantees or support. However, I’d be happy to hear your feedback or thoughts in the comments.
Implement it
-
Add new class MultiSitePageSearchProvider.cs to your solution. Attached further down.
-
In Admin mode > Settings -> Search Configuration, enable the new Find pages (Multisite) provider and disable the default Find pages provider.
-
Try a few searches in edit mode and confirm that results now show which site they belong to.
Example results before
... and after
MultiSitePageSearchProvider.cs
using EPiServer.Find;
using EPiServer.Find.Framework;
using EPiServer.Find.UI.Models;
using EPiServer.Find.UnifiedSearch;
using EPiServer.Framework;
using EPiServer.Framework.Localization;
using EPiServer.Globalization;
using EPiServer.ServiceLocation;
using EPiServer.Shell;
using EPiServer.Shell.Search;
using EPiServer.Web;
using EPiServer.Web.Routing;
using Newtonsoft.Json.Linq;
namespace EPiServer.Find.Cms.SearchProviders
{
[SearchProvider]
public class MultiSitePageSearchProvider : EnterprisePageSearchProvider
{
private readonly UIDescriptorRegistry _uiDescriptorRegistry;
private readonly ISiteDefinitionRepository _siteDefinitionRepository;
private readonly IContentLoader _contentLoader;
private readonly IClient _searchClient;
private readonly ILogger<MultiSitePageSearchProvider> _logger;
private const string AllowedTypes = "allowedTypes";
private const string RestrictedTypes = "restrictedTypes";
public override string Area => FindContentSearchProviderConstants.PageArea;
public override string Category => "Find pages (Multisite)";
public MultiSitePageSearchProvider(
LocalizationService localizationService,
ISiteDefinitionResolver siteDefinitionResolver,
IContentTypeRepository<PageType> contentTypeRepository,
UIDescriptorRegistry uiDescriptorRegistry,
EditUrlResolver editUrlResolver,
ServiceAccessor<SiteDefinition> siteDefinitionAccessor,
IContentLanguageAccessor contentLanguageAccessor,
IUrlResolver urlResolver,
ITemplateResolver templateResolver,
IContentRepository contentRepository,
ISiteDefinitionRepository siteDefinitionRepository,
IContentLoader contentLoader,
IClient searchClient,
ILogger<MultiSitePageSearchProvider> logger)
: base(localizationService, siteDefinitionResolver, contentTypeRepository, uiDescriptorRegistry, editUrlResolver, siteDefinitionAccessor, contentLanguageAccessor, urlResolver, templateResolver, contentRepository)
{
_uiDescriptorRegistry = uiDescriptorRegistry;
_siteDefinitionRepository = siteDefinitionRepository;
_contentLoader = contentLoader;
_searchClient = searchClient;
_logger = logger;
}
public override IEnumerable<SearchResult> Search(Query query)
{
Validator.ThrowIfNull("SearchProviderFactory.Instance.AccessFilter", FilterFactory.Instance.ContentAccessFilter);
Validator.ThrowIfNull("SearchProviderFactory.Instance.CultureFilter", FilterFactory.Instance.CultureFilter);
Validator.ThrowIfNull("SearchProviderFactory.Instance.RootsFilter", FilterFactory.Instance.RootsFilter);
query.MaxResults = 20;
ITypeSearch<IContentData> searchQuery = GetFieldQuery(query.SearchQuery, query.MaxResults)
.Filter(x => x.MatchTypeHierarchy(typeof(IContentData)));
var allowedTypes = GetContentTypesFromQuery(AllowedTypes, query);
var restrictedTypes = GetContentTypesFromQuery(RestrictedTypes, query);
FilterContext filterContext = FilterContext.Create<IContentData, ContentType>(query);
searchQuery = FilterFactory.Instance.AllowedTypesFilter(searchQuery, filterContext, allowedTypes);
searchQuery = FilterFactory.Instance.RestrictedTypesFilter(searchQuery, filterContext, restrictedTypes);
searchQuery = FilterFactory.Instance.ContentAccessFilter(searchQuery, filterContext);
searchQuery = FilterFactory.Instance.CultureFilter(searchQuery, filterContext);
searchQuery = FilterFactory.Instance.RootsFilter(searchQuery, filterContext);
var contentLinksWithLanguage = Enumerable.Empty<ContentInLanguageReference>();
try
{
contentLinksWithLanguage = searchQuery
.Select(x =>
new ContentInLanguageReference(
new ContentReference(((IContent)x).ContentLink.ID,
((IContent)x).ContentLink.ProviderName),
((ILocalizable)x).Language.Name))
.StaticallyCacheFor(TimeSpan.FromMinutes(1), UnifiedWeightsCache.ChangeToken)
.GetResultAsync()
.GetAwaiter()
.GetResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during CMS page search query execution: {Message}", ex.Message);
return Enumerable.Empty<SearchResult>();
}
PageData content = null;
// Check if there are multiple sites with actual start pages
var hasMultipleResolvableSites = _siteDefinitionRepository.List()
.Where(site => !ContentReference.IsNullOrEmpty(site.StartPage) &&
_contentLoader.TryGet<IContent>(site.StartPage, out _))
.Skip(1)
.Any();
return contentLinksWithLanguage
.Where(
searchResult =>
_contentLoader.TryGet<PageData>(searchResult.ContentLink,
!String.IsNullOrEmpty(searchResult.Language)
? new LanguageSelector(searchResult.Language)
: LanguageSelector.AutoDetect(true), out content))
.Select(item =>
{
var result = CreateSearchResult(content);
if (hasMultipleResolvableSites && Guid.TryParse(content.SiteId(), out var siteId))
{
var site = _siteDefinitionRepository.List().FirstOrDefault(s => s.Id == siteId);
if (site != null)
{
var startPageName = _contentLoader.Get<IContent>(site.StartPage)?.Name;
if (!string.IsNullOrEmpty(startPageName))
{
result.Metadata.Add("SortKey", startPageName);
result.Title = $"{startPageName} \\ {result.Title}"; // Suffix the page title with the startPage name
}
}
}
return result;
})
.OrderBy(x => x.Metadata.TryGetValue("SortKey", out var sortKey) ? sortKey : string.Empty);
}
private new ITypeSearch<IContentData> GetFieldQuery(string SearchQuery, int maxResults)
{
var language = ResolveSupportedLanguageBasedOnPreferredCulture();
if (String.IsNullOrEmpty(SearchQuery))
{
SearchQuery = "*";
}
var query = _searchClient.Search<IContentData>(language)
.For(SearchQuery);
if (StringExtensions.IsAbsoluteUrl(SearchQuery))
{
query = query.InField(x => ((ISearchContent)x).SearchHitUrl);
return query
.Take(maxResults);
}
query = query.InField(x => ((IContent)x).Name, 10)
.InField(x => ((IContent)x).ContentTypeName(), 0.5)
.InField(x => x.SearchText());
int parsedQuery;
if (int.TryParse(SearchQuery, out parsedQuery))
{
query = query.InField(x => ((IContent)x).ContentLink.ID, 10);
}
DateTime parsedDate;
if (DateTime.TryParse(SearchQuery, out parsedDate))
{
query = query.InField(x => ((ISearchContent)x).SearchPublishDate.ToString());
}
return query
.Take(maxResults).SetTimeout(10000);
}
private IEnumerable<Type> GetContentTypesFromQuery(string parameter, Query query)
{
if (query.Parameters.ContainsKey(parameter))
{
var array = query.Parameters[parameter] as JArray;
if (array != null)
{
return array.Values<string>().SelectMany(GetContentTypes);
}
}
return Enumerable.Empty<Type>();
}
private new IEnumerable<Type> GetContentTypes(string allowedType)
{
var uiDescriptor = _uiDescriptorRegistry.UIDescriptors.FirstOrDefault(d => d.TypeIdentifier.Equals(allowedType, StringComparison.OrdinalIgnoreCase));
if (uiDescriptor == null)
return Enumerable.Empty<Type>();
return _contentTypeRepository
.List()
.Where(c => uiDescriptor.ForType.IsAssignableFrom(c.ModelType))
.Select(c => c.ModelType);
}
private Language ResolveSupportedLanguageBasedOnPreferredCulture()
{
Language language = null;
var preferredCulture = ContentLanguage.PreferredCulture;
if (preferredCulture != null)
{
language = _searchClient.Settings.Languages.GetSupportedLanguage(preferredCulture);
}
language = language ?? Language.None;
return language;
}
}
}
Comments