Showing posts with label multisite. Show all posts
Showing posts with label multisite. Show all posts

Thursday, June 17, 2021

Sugcon 2021

 Global Virtual Sitecore UserGroup 2021

The 2021 SUGCON (Sitecore UserGroup Conference) was virtual 😿 and global 🌍.  

The sessions were streamed live on YouTube - you can (re-)view all the sessions at the Sugcon Global 2021 playlist.  I was honored to be amongst the presenters this year with a session on the multisite capabilities of SXA and how to even extend those to achieve a great multisite experience.

A big thank you to all the organizers for a smooth organization and the opportunity to share this knowledge.

 

Creating websites with SXA in 15 minutes



My presentation was about a project to provide a multisite setup for (smaller) websites that need to be created smooth and fast (competing against Wordpress and such). 

We managed to do this with ootb SXA and some customizations that are shown and also documented in these blogs (including all the code):

Sharing


Template enhancements

I hope you liked the session (and all the other ones from this years Sugcon) and may it inspire you to create amazing solutions.


And if anyone gets to do something similar with JSS I would like to hear about it... 

Thursday, January 21, 2021

Sharing available renderings in multisite SXA

Sitecore SXA available renderings

We have a multisite setup in SXA to easily create (small) websites and have been sharing a lot of configuration, layout information and content between those. Check my latest SXA blog posts for more information.

We did already cover sharing placeholder settings. But if we can share those, we might as well share the available renderings configuration. This is a list of renderings that is defined per site to define the availability within the SXA Toolbox - and since SXA 1.8 we can also define the structure of the toolbox with these Available renderings. As explained in the documentation, you have to check the box "Group renderings in sections according to Available Renderings items in the site" to do so.


I believe it is a best practice to do this so that feature is always on in my projects.

But let's get back to sharing...  We have a shared site (and master site) setup, so it would be a good idea to define the available renderings in the shared site and not in every site (created from the master site). This idea was on my radar and I got triggered by Toby Gutierrez to really try this now.


Sharing available renderings

I am going to try the same principle I used for the placeholder settings: if the current site has no configuration in place -meaning no children underneath the available renderings parent node- we check the shared site(s) and get the configuration there.

The toolbox

First of all, we need to figure out how the toolbox works. There is a handler (sxa-toolbox-feed.ashx) but that is not to much use for us here as it works with data that is retrieved elsewhere. I noticed however that in case the renderings grouping is turned on, a AvailableRenderingsOrderingService is called. This service will read the configuration and use this to structure the toolbox data that was passed along. As a side affect, it will also filter (again) on the available renderings.

This means that if you are using the grouping -as I do- you will have to override this. Let's do that:
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.DependencyInjection;
using Sitecore.XA.Foundation.Editing.Models;
using Sitecore.XA.Foundation.Multisite;
using Sitecore.XA.Foundation.Presentation;
using Sitecore.XA.Foundation.SitecoreExtensions.Extensions;
using Sitecore.XA.Foundation.SitecoreExtensions.Repositories;

namespace Feature.Multisite.Editing
{
  public class AvailableRenderingsOrderingService : Sitecore.XA.Foundation.Editing.Service.AvailableRenderingsOrderingService
  {
    public AvailableRenderingsOrderingService(
      IPresentationContext presentationContext,
      IMultisiteContext multisiteContext,
      IContentRepository contentRepository) : base(presentationContext, multisiteContext, contentRepository)
    {
    }

    public override IList<AvailableRenderingEntry> GetOrderedRenderings(Item siteItem, IList<Item> renderings = null)
    {
      var orderDictionary = new Dictionary<ID, ValueTuple<int, string>>();
      var presentationItem = PresentationContext.GetPresentationItem(siteItem);
      var folder = presentationItem?.FirstChildInheritingFrom(Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.ID);
      if (folder != null)
      {
        if (!folder.Children.Any())
        {
          var shared = ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSitesWithoutCurrent(siteItem);
          foreach (var site in shared)
          {
            presentationItem = PresentationContext.GetPresentationItem(site);
            folder = presentationItem?.FirstChildInheritingFrom(Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.ID);
            if (folder != null && folder.Children.Any())
            {
              break;
            }
          }
        }

        if (folder != null)
        {
          var sections = (CheckboxField)folder.Fields[Sitecore.XA.Foundation.Presentation.Templates.AvailableRenderingsFolder.Fields.GroupRenderingsInSections];
          if (sections != null && sections.Checked)
          {
            var array = folder.Children.Where(item => !string.IsNullOrEmpty(item[Sitecore.XA.Foundation.Presentation.Templates._RenderingsList.Fields.Renderings])).ToArray();
            for (var index1 = 0; index1 < array.Length; ++index1)
            {
              var fieldRenderings = (MultilistField)array[index1].Fields[Sitecore.XA.Foundation.Presentation.Templates._RenderingsList.Fields.Renderings];
              for (var index2 = 0; index2 < fieldRenderings.TargetIDs.Length; ++index2)
              {
                if (!orderDictionary.ContainsKey(fieldRenderings.TargetIDs[index2]))
                {
                  orderDictionary.Add(fieldRenderings.TargetIDs[index2], new ValueTuple<int, string>((index1 + 1) * 1000 + index2, fieldRenderings.InnerField.Item.DisplayName));
                }
              }
            }
          }
        }
      }

      return renderings?.Select(r => new AvailableRenderingEntry
      {
        RenderingItem = r,
        Order = orderDictionary.ContainsKey(r.ID) ? orderDictionary[r.ID].Item1 : 0,
        SectionName = orderDictionary.ContainsKey(r.ID) ? orderDictionary[r.ID].Item2 : r.Parent.DisplayName
      }).OrderBy(r => r.Order).ToList();
    }
  }
}  
This code is almost identical as the original one. The difference is in the GetOrderedRenderings function where we check the existance of a local configuration (folder.Children.Any) and try the shared sites (using the SharedSitesContext) if needed.

The service is registered in the config, so we patch that config to include our version:
<sitecore>
  <services>
    <register serviceType="Sitecore.XA.Foundation.Editing.Service.IAvailableRenderingsOrderingService, Sitecore.XA.Foundation.Editing"
              implementationType="Sitecore.XA.Foundation.Editing.Service.AvailableRenderingsOrderingService, Sitecore.XA.Foundation.Editing" lifetime="Singleton">
      <patch:attribute name="implementationType">Feature.Multisite.Editing.AvailableRenderingsOrderingService, Feature.Multisite</patch:attribute>
    </register>
  </services>
</sitecore>

That's it. Now we can safely delete all children from the Available renderings node in our sites that use our shared site. The toolbox in those sites will use the configuration defined in the shared site.


But...

Note that this only works if the grouping is enabled. As I always do this, I'm not going to try to implement a solution if it's not. This also means that the toolbox handler might be getting too much renderings as a parameter, but the result is ok so that's fine for me. 

However, if you do require such a solution without the grouping you'll need to check the getPlaceholderRenderings pipeline and especially the GetModuleRenderings processor. This has a function called GetSiteRenderingSources and you'll have to add the same logic there as I used in the AvailableRenderingsOrderingService.


Conclusion

Yet another configuration that we can share across all the created sites...  this makes it easier to handle if we add/remove components that can be used on those sites - we only have to do this on one spot from now on.


Monday, October 19, 2020

SXA tokens in a Sitecore Treelist datasource

Treelist datasource

A Treelist is a field type in Sitecore that is commonly used to select one or more items from a tree view. It is easy to use for editors and has a rather special way to set the root (datasource) for the tree that is available to those editors. 

A few related field types are available ootb (e.g. TreelistEx) and the Sitecore community has provided rather a few blogpost already on how to customize the Treelist field - and especially the way we set the datasource. 

The basics of how to set a Treelist datasource using the "datasource=" parameters syntax are described very well in this post from Mark Ursino. Or in the official Sitecore archives where setting items available for selecting within the TreeList field was posted already in 2009! Out of the box you are able to select a parent path and determine which templates can be shown and/or selected.

As I mentioned Sitecore community people have been posting blogs on how to extend the datasource based on several different requirements. 

SXA tokens

Sitecore eXperience Accelerator has a pipeline called resolveTokens. This pipeline is used to resolve tokens that can be used in queries and is (amongst other) useful to make templates multisite-friendly. 

The fields on your templates should have sources when applicable - i.e. when another item is selected. This applies to link type fields, list type fields, the image field...  Setting those sources is easy in a single site environment, but when you want to use those templates in a multisite setup you usually want the source to be relative to the site, the site's data folder... 

This is possible with the SXA tokens. You can set sources like "query:$site/..." and those will be resolved relatively within your sites. In a previous post I already showed how to extend this by creating a token for the real media root folder (to be able to add additional -lower- folders after the token).

This is a really nice way of working, but... it only applies to sources that start with "query:".

SXA tokens in a Treelist datasource

I do have a multisite setup. With SXA. And some of my templates do have Treelist fields. You can use the query notation in the datasource and use tokens that way, but then you loose the flexibility of the other parameters - so I was looking for a solution to have both. I want parameters to define the possible templates and I also want to use tokens to define the parent. 

My research led me to two blogposts that inspired me. Diego Moretto wrote about dynamic datasources in Treelist fields but his solution was for non-SXA and with the SXA tokens we do already have so much flexibility. He based his solution on a post from Kamruz Jaman that explains a solution on how to embed queries within the datasource syntax.

While reading that (really good) post I thought I had my solution. By implementing this you actually do have a source that (can) start with "query:" so the resolveTokens pipeline will run and everything will work. I had a tweet ready to thank Kamruz, but then I stumbled upon a small issue: 

SXA cannot resolve the tokens outside an SXA site. This is very logical, but as this is the case in your standard values that resulted in errors when I used this in a feature template with standard values. I don't want errors and the solution was simple - just return a standard path instead of null when the result of the query is null.

As a recap, I'll add my version of the code here (but still - credits to Kamruz for the original):
public class TokenedTreelist : Sitecore.Shell.Applications.ContentEditor.TreeList
{
  public override string DataSource
  {
    get
    {
      var data = base.DataSource;
      if (!base.DataSource.StartsWith("query:", StringComparison.OrdinalIgnoreCase))
      {
        return data;
      }

      if (Sitecore.Context.ContentDatabase == null || ItemID == null)
      {
        return null;
      }

      var currentItem = Sitecore.Context.ContentDatabase.GetItem(ItemID);
      Item source = null;
      try
      {
        source = LookupSources.GetItems(currentItem, data).FirstOrDefault();
      }
      catch (Exception ex)
      {
        Log.Error("Treelist field failed to execute query.", ex, this);
      }

      return source == null ? "/sitecore/Content" : source.Paths.FullPath;
    }

    set => base.DataSource = value;
  }
}  
We are actually creating a new field here - fully using the TreeList as a base and overriding the Datasource property. A few minor changes were made to the code from Kamruz - but the most important one is the last line of the getter where we return "/sitecore/content" as default instead of null. 

Don't forget to register this field in the core database as this is a new field. Add an item at /sitecore/system/Field types/List Types and fill your assembly and class information.


Also add it to the field types list in the Sitecore config:
<sitecore>
  <fieldTypes>
    <fieldType patch:after="*[@name='Treelist']" name="Tokened Treelist" type="Sitecore.Data.Fields.MultilistField, Sitecore.Kernel" />
  </fieldTypes>
</sitecore>
You might want to add it to the index configuration as well - in our case that was not needed.

If we select "Tokened Treelist" as field type, we can now use all SXA tokens within our datasource. Such a datasource could look like:
datasource=query:$site/Data/Documents&allowmultipleselection=yes&includetemplatesfordisplay=Document,Document folder&includetemplatesforselection=Document

Conclusion

I don't think Kamruz realised in 2016 when he wrote that post that this would now enable me to use SXA tokens in a Treelist datasource. Sitecore community has proven this strength once more - so I had to share my findings as well to inform you about the extra options this can give us: multisite Treelists with SXA tokens! 

Tuesday, October 13, 2020

Cloning a SXA site (in another folder)

Cloning a Sitecore SXA site

Part 7 already in my series of blog posts related to a multisite project where we wanted to make it as easy as possible to create a new site - with the premise that all the sites can share almost all settings. As you might have read in the previous posts we are really doing this - and keeping it all flexible. It's not because we share (all) things, we cannot configure or overrule some parts if needed.

Anyway - we do have a setup with a shared site and a base "master" site within a tenant (as recommended by SXA). As I did not want these sites to be amongst all the other created sites, I placed those two in a Sites Folder within the tenant. We have (at least) one other Sites Folder where we want to place the actual sites - and we might even want to split those sites over different folders.

We use SXA site cloning to create a new site. This is a very easy and rapid process. The "Clone Site" Powershell (SPE) script that is used can be found by a right click on a site item in the Scripts section. It will ask the name of the new destination site and copy everything from the master site. Note that:
  • it will also copy media items within the sites media folder - really useful if you have a standard folder setup there
  • it will not copy any forms - a new empty folder is created though within Forms
  • it will change all links towards the newly created items

The script works fine and it is a very fast way to create a site. But.. it will create a site next to the master site. Of course, we can move all items after the creation - but it would be faster to just ask a preferred location and created the site there. So that's what I did...

The solution provided here is only tested with one level of site folders - it probably could work with a tree of those folders with a few more adjustments though. 

The Powershell scripts to clone a site

Show-CloneSiteDialog

First script to take care of is the dialog script. We want the user that clones a site to be able to choose the site folder where the destination site should be created. I created a copy of the ootb script "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Functions/Site cloning/Show-CloneSiteDialog" and adjusted that one (you could also opt to change the original one). The changes to the original script are not that big so I will not put the entire script here to keep focus.  

In the beginning of the script process the dialogParameters are defined. We add one more parameter there (siteLocation):
$dialogParameters.Add(@{ Name = "siteLocation"; 
  Title = [Sitecore.Globalization.Translate]::Text([Sitecore.XA.Foundation.Multisite.Texts]::Path); 
  Editor = "droplist"; Source="query:$($SourceSite.Parent.Paths.Path)/ancestor-or-self::*[@@templatename='Tenant']/*[@@templatename='Site Folder']"; 
  Tab = [Sitecore.Globalization.Translate]::Text([Sitecore.XA.Foundation.Multisite.Texts]::CloneSiteDialogGeneralTab); Mandatory=$true })  | Out-Null  
I am using a droplist here with a datasource query that finds my tenant and takes the children of type Site Folder. (note we are only supporting one level here - this customization would need to be changed to support a tree).
To keep it simple I used an existing text as label. The selection is marked as mandatory to make sure a choice is made.

The next (and final) change to this script is all the way at the bottom where the results are captured:
@{
    siteName              = $siteName
    siteDefinitionmapping = $mapping
    parent                = $siteLocation
}
We add a "parent" here that will contain the chosen location of the new site.

Clone-Site

Next script to adjust is the main one: "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Content Editor/Context Menu/Clone Site". This is the script that gets called from the context menu and we need to adjust it so we can use the parent from our dialog:
$ctx = Get-Item .
    
Import-Function Copy-Site
Import-Function Show-CloneSiteInParentDialog
    
$dialogResult = Show-CloneSiteInParentDialog $ctx
$cloneSiteName = $dialogResult.siteName
$mapping = $dialogResult.siteDefinitionmapping

#custom    
 $parent = $dialogResult.parent
 if (!$parent) {
     $parent = $ctx.Parent
  }
#endcustom

$destinationSite = Copy-Site $ctx $parent $cloneSiteName $mapping
$destinationSiteID = $destinationSite.ID.ToString()
$host.PrivateData.CloseMessages.Add("item:load(id=$destinationSiteID)")

Run-SiteManager
I am just showing the main part here (within the "try") to keep it clear. So we changed the call to Show-CloneSite to the one we created, we fetch the parent (with a fallback scenario although that is probably not needed) and pass it to the Copy-Site function.

And that's it. Well, that's what we thought so we tested this. The site was indeed created in the correct folder. So was the forms folder. But the media folder was still next to our master. 

Copy-Site

Let's take a look at the "/sitecore/system/Modules/PowerShell/Script Library/SXA/SXA - Multisite/Functions/Site cloning/Copy-Site" script. The site creation was fine with our new parent. We noticed that creating the Forms folder was done as it would be for a new site and that script uses the paths to determine where to create a new (empty) folder - this works fine in our version as well.

So we need to take a look at the copy process for the media folder. Somewhere in the middle of the Copy-Site script you'll find the media part and let's customize that a bit:
if ($Site.SiteMediaLibrary) {
  $SiteMediaLibrary = $Site.Database.GetItem($Site.SiteMediaLibrary) | Wrap-Item

#custom
  if ($SiteMediaLibrary.Parent.Name -ne $destinationSite.parent.Name) {
    $SiteMediaRoot = $ctx.Database.GetItem($SiteMediaLibrary.Parent.Paths.Path.Replace($($SiteMediaLibrary.Parent.Name),$($destinationSite.parent.Name)))
    if (!$SiteMediaRoot) {
      $SiteMediaRoot = $ctx.Database.GetItem($ctx.SiteMediaLibrary).Parent
    }
  }
  else {
    $SiteMediaRoot = $SiteMediaLibrary.Parent
  }
#end custom

  $NewSiteMediaLibrary = Copy-RootAndFixReference $SiteMediaLibrary $SiteMediaRoot $destinationSite.Name

  Set-NewLinkReference $Site $destinationSite $SiteMediaLibrary.Paths.Path $NewSiteMediaLibrary.Paths.Path
  $destinationSite.SiteMediaLibrary = $NewSiteMediaLibrary.ID
}
Instead of copying the media tree to the parent of the master media tree, we use string replacement and the paths to determine the location we want. Note that we do need a folder structure corresponding to the Site Folders structure, otherwise this part will fail. We could expand the script to create the necessary folders if needed.


Conclusion

With that last change we managed it. Our new site cloning script will ask us where to create the destination site - of course within the same tenant and in our one-level Site Folder structure. Combined with all the stuff from my previous posts - and the ootb features - creating a site is becoming really fast.




Tuesday, October 6, 2020

Overriding partial designs from SXA shared site

Sitecore SXA shared partial designs

In a SXA multisite setup it is best/common practice to have a Shared site. This shared site can contain page and/or partial designs that can be used within the other sites.

Currently I am working on a multisite project where the aim is to make creating a site as easy as possible, knowing that all the sites will share as much as possible. In my previous posts I have been explaining how we shared various settings on top of the out of the box possibilities.

Partial designs

One of these ootb options are the partial designs. As our sites will share their main layout, header and footer (they can differ by theme) we do want to share the partial designs (and the page designs).

In our other sites -created from a master site- we do not have any partial nor page designs and I would like to keep it that way unless there is no other option.

This went fine, all ootb.. until we bumped into the logo in our header. This is actually a Rich Text component with a datasource -in the Shared site- and we do want to be able to override it when needed. Partial designs can be created with base designs and such but I really want to avoid having designs in the non-shared sites. 

Next issue was within the metadata partial design as we do have a 3rd party GDPR solution there that requires some javascript to be plugged in - which is a bit different for each domain. Our current implementation uses a html snippet so we needed a solution for that as well.  

Overriding a Rich Text datasource from a shared site

To solve our issue with the Rich Text component, I though I could easily create a new component -based on the original Rich Text- that works like the reusable Rich Text and additionally:
  1. checks the current site if a suited datasource was provided
  2. automatically uses the fallback (original datasource) from the shared site if no data was found in the current site
The code:
public class RichTextSharedController : VariantsController
{
  protected override object GetModel()
  {
      var model = base.GetModel() as VariantsRenderingModel;
      if (model?.DataSourceItem == null)
      {
          return model;
      }

      if (PageMode.IsExperienceEditor || PageMode.IsExperienceEditorEditing || !Context.Item.HasSharedSite())
      {
        return model; // skip this if we are in a shared site
      }

      var path = model.DataSourceItem.Paths.FullPath;
      var newPath = path.Replace(MultisiteContext.GetDataItem(model.DataSourceItem).Paths.ContentPath,
                MultisiteContext.DataItem.Paths.ContentPath);
      var contentItem = model.DataSourceItem.Database.GetItem(newPath);
      if (contentItem != null && contentItem.Versions.Count > 0)
      {
        //model.DataSourceItem = contentItem;
        model.Item = contentItem;
      }

      return model;
  }
}
We create a new controller that inherits from the VariantsController. We don't need a model or view as we will use the SXA versions for that. We override GetModel:
  1. Get the base model
  2. If there is no datasource, return the model as we have nothing more to do in this case
  3. If we are in edit mode, return the model as we want to show the original datasource in that mode
  4. If the current item does not have a shared site we also do not need to continue
  5. The actual fallback: we get the path of the datasource item (which is in the shared site) and want to move this path into the current site - we do this by replacing the paths of the data items from both sites
  6. We check if the "local" content item exists and adjust the model (note that we need to change the Item property - the DatasourceItem is optional)
To check if we have a shared site present, we used an extension method:
public static bool IsItemFromSite(this Item item, Item site)
{
  return item.Paths.FullPath.StartsWith(StringUtil.EnsurePostfix('/', site.Paths.FullPath), StringComparison.OrdinalIgnoreCase);
}

public static bool HasSharedSite(this Item item)
{
  var sites = ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSites(item);
  return sites.Any() && !sites.Any(item.IsItemFromSite);
}
So now we have our controller. Next step is creating a new rendering in Sitecore. Easiest way to do this is to create a copy of the Rich Text (Reusable) rendering in your own folder and adjust the Controller field towards your implementation. As this is a new rendering, you probably also need to add it to the available renderings and placeholder settings. 

But that's it. We can now use this field in a partial design in the shared site - it will work just like a reusable rich text in all other sites with one big difference: if we create a Text datasource at the relative same location as the original datasource, this one will be used instead. We can now override datasources in Rich Text components in shared partial designs.  

Overriding a Html Snippet from a shared site

Next one is the html snippet. This is actually very similar to the Rich Text one. We created a controller based on the Sitecore.XA.Feature.GenericMetaRendering.Controllers.HtmlSnippetController and changed the GetModel just like in our Rich Text example. 

A few small differences though: for the snippet an extra property is present, called "Html". Changing the datasource is not sufficient - you also need to set the value for that property (again). In order to be able to do this, make sure the model is of type HtmlSnippetModel. Another difference is that our data is not in the data folder but in the settings folder so we have to use those when replacing the paths.
public class HtmlSnippetSharedController : HtmlSnippetController
{
  public HtmlSnippetSharedController(IHtmlSnippetRepository htmlSnippetRepository) : base(htmlSnippetRepository)
  {
  }

  protected override object GetModel()
  {
    var model = base.GetModel() as HtmlSnippetModel;
    if (model?.DataSourceItem == null)
    {
      return model;
    }

    if (PageMode.IsExperienceEditor || PageMode.IsExperienceEditorEditing || !Context.Item.HasSharedSite())
    {
      return model; // skip this if we are in a shared site
    }

    var path = model.DataSourceItem.Paths.FullPath;
    var newPath = path.Replace(MultisiteContext.GetSettingsItem(model.DataSourceItem).Paths.ContentPath,
                        MultisiteContext.SettingsItem.Paths.ContentPath);
    var contentItem = model.DataSourceItem.Database.GetItem(newPath);
    if (contentItem != null && contentItem.Versions.Count > 0)
    {
      model.DataSourceItem = contentItem;
      model.Html = model.DataSourceItem[Sitecore.XA.Feature.GenericMetaRendering.Templates.HtmlSnippet.Fields.Html];
    }

    return model;
  }
}  

To create your rendering, use a copy from the Html Snippet rendering (in the Generic Meta Rendering feature folder). Replace the Controller and in this case you also need to set the Rendering view path as that is not set here by default (it is for the Rich Text) - enter ~/Views/HtmlSnippet/HtmlSnippet.cshtml to use the ootb view.

Conclusion

We are now able to override datasources from (some) components in a partial design in a shared site, which makes our solution even more flexible. 

If you want those changes for the original components (instead of creating a new one), that is also possible. In that case you will need to create a new version of the repositories that create the model and put the same logic inside those repositories. Registering your version with the dependency injection will then do the trick.

As we are adding extra logic and item retrievals you might want to take extra care with caching on these components - but I guess that should always be the case...

This was a big step in our solution towards a very flexible site creation in minimal time. In a next post, we'll be diving into some Powershell.



Wednesday, September 30, 2020

Sharing settings in a multisite SXA setup

Settings in a shared site - multisite Sitecore SXA

As you might know if you read my previous blogs, I'm working on a setup with an undefined number of (mini-)sites within a tenant that should share as much as possible in order to make the setup of a new site as simple and fast as possible. We already managed to share a lot: placeholder settings, 404 settings and of course the out of the box stuff like page and partial designs, rendering variants and so on.

We have a setup that matches the best practices with a shared site and a master site to clone from. 

This is the 5th part already in the blog series that results from that project, and this time we'll take a look at the Settings item in each SXA site. There are some candidates that we could share - the error handling section was taken care of in a previous post but I'll go over some other ones here.
Let's work bottom up..

Robots

It would be possible to share the robots settings which generates the robots.txt content for the site. There is a pipeline called getRobotsContent where we can plugin an extra processor if needed. This pipeline has ootb 3 processors.
The first one -GetContentFromSettings- will get the content from the Settings item of the current site. The second one is a backup and will fetch a physical robots.txt file if no content was provided (note that this could be useful in some cases to use this fallback functionality). The last one will add the sitemap information. 

We could insert our own version of the GetContentFromSettings in the 2nd spot where we get the contents from the shared site if we have no local content yet. That would be fairly easy to do.. but as you might notice it's all conditional here (could/would/...). Indeed, we actually decided not to do this.

We have a robots.txt file as fallback for the whole environment. Also, we want our shared (and master) site to stay closed for all robots - ok, they shouldn't be able to access them anyway but still...  So, all things considered we've put robots content in the master site that disallows all robots to make sure a new site does not gets opened until the site admin actually wants it to open. Still, it was interesting to see how the robots feature in SXA works.

Favicon

The favicon for your site can be managed in the Settings item of each site. You can select an item from the media library and it will be set as the favicon in that site by the Favicon rendering which is usually present in the metadata partial design.

Most of our sites will use the same favicon however. So we would like to share it - and there are a few ways of doing that. We could select the favicon in the master site and all newly created sites would use that favicon as they copy that value. It could be overwritten if needed so that is a valid option. It is an ootb option as well. The only disadvantage is that if the icon has to be changed (we usually do create a new item in that case so we don't have caching issues) we would need to change it on all sites.

Another option to explore was a fallback to the shared site where we could define the favicon just once. It appeared to be rather easy - this is the code we need:
public class FaviconRepository : Sitecore.XA.Feature.SiteMetadata.Repositories.Favicon.FaviconRepository, IFaviconRepository
{
  public override string GetFaviconHref(Item contextItem)
  {
    var icon = base.GetFaviconHref(contextItem);
    if (!string.IsNullOrEmpty(icon))
    {
      return icon;
    }

    var field = GetSharedSettingsItem(contextItem)?.Fields[Sitecore.XA.Feature.SiteMetadata.Templates._Favicon.Fields.Favicon];
    return field != null ? field.GetImageUrl() : string.Empty;
  }

  private static Item GetSharedSettingsItem(Item contextItem)
  {
    var shared = ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSitesWithoutCurrent(contextItem).FirstOrDefault();
    return shared != null ? ServiceLocator.ServiceProvider.GetService<IMultisiteContext>().GetSettingsItem(shared) : null;
  }
}
We override the FaviconRepository which is used in the Favicon rendering. The new GetFaviconHref function first calls the base version to check if anything was filled on the current site. If that is not the case we locate the Settings item for the (first - we know we have no more than one) shared site. On that Settings item we read the Favicon field and return the image url (or nothing). 

Registering this version in the dependency injection will make sure this is used and that's all there is to it. 


Conclusion

A smaller post this time.. but all small things will bring us to what we want (a site in 15 minutes). 

Don't worry though, as there will be a part 6 as well in which I'll cover locally overwriting some datasources set in the partial designs from the shared site. 

Monday, September 28, 2020

Custom SXA media query token

SXA query tokens

The resolveTokens pipeline is used to resolve tokens that can be used in a Sitecore SXA site in queries at several places: a query rendering variant, a template field source..  

In the multisite project that I'm working on -which resulted in a bunch of posts already- we wanted to have the source of an Image field to point towards a folder inside the media library that exists for each site.

One of the ootb tokens that can be used is $siteMedia. The problem with this token is that it will be resolved into the virtual media root within the site - which means that adding extra folders (e.g. $siteMedia/pdf) in the query does not work - the resulting path does not exist because the resolved part is a virtual media folder.

We can create a custom resolver for our own token however. This has been described by Maciej Gontarz in his blog post - there are some particular bits into our custom token however so I wanted to share anyway.

Custom SXA media root token

The idea is simple: we want to have the actual root of the media items for the site (inside the Media Library) and not the virtual one (inside the site). This way we can add a subfolder to it and still have a valid path. 

Note: this can really help your editors to get into the correct folder immediately, but it's also a bit dangerous as you will get an error when opening your item in the content editor when the resulting folder does not exist.  

To resolve our token we need a resolver - a processor to add into the pipeline:
using Sitecore.XA.Foundation.Multisite;
using Sitecore.XA.Foundation.Multisite.Extensions;
using Sitecore.XA.Foundation.TokenResolution.Pipelines.ResolveTokens;

public class ResolveTokens : ResolveTokensProcessor
{
  private const string Token = "$mediaSiteRoot";
  private readonly IMultisiteContext multisiteContext;

  public ResolveTokens(IMultisiteContext multisiteContext)
  {
      this.multisiteContext = multisiteContext;
  }

  public override void Process(ResolveTokensArgs args)
  {
      args.Query = ReplaceTokenWithItemPath(args.Query, Token, () => GetMediaRoot(args.ContextItem), args.EscapeSpaces);
  }

  private Item GetMediaRoot(Item contextItem)
  {
    var mediaRoot = multisiteContext.GetSiteMediaItem(contextItem);
    if (mediaRoot == null)
    {
      return null;
    }

    var root = mediaRoot.GetVirtualChildren();
    return root.Any() ? root.First() : mediaRoot;
  }
}
We defined our token as "$mediaSiteRoot" but you can call it (almost) anything you want - just try to avoid picking something that starts with an already existing token.

We use the ReplaceTokenWithItemPath function (comes with the ResolveTokensProcessor) as this will handle everything for us if we pass the correct item. To find our root item we start by fetching the media root item for the site using the MultisiteContext. If this is found, we can use the extension method GetVirtualChildren to get all virtual children that are defined on that item. 

SXA will define 2 children on the virtual media root - by default it's the first one we are looking for.

Now we need to add this resolver to the resolveTokens pipeline:
<sitecore>
  <pipelines>
    <resolveTokens>
      <processor type="Feature.Multisite.Pipelines.ResolveTokens, Feature.Multisite" resolve="true" 
             patch:before="processor[@type='Sitecore.XA.Foundation.TokenResolution.Pipelines.ResolveTokens.EscapeQueryTokens, Sitecore.XA.Foundation.TokenResolution']" />
    </resolveTokens>
  </pipelines>
</sitecore>
As you can see we patch this in before the EscapeQueryTokens. This means our token will be after the standard ones -which is ok- but it has to be before the escape one because otherwise the query won't work as it will be escaped (with #) already before we do our magic.

Using the custom token

We are now ready to use the token. In the source of our Image field we can now put things like 
query:$mediaSiteRoot/Content/Hero


Conclusion

We can now guide our editors to the correct folders inside the media library. But be aware - as mentioned before those folders do need to exist in all sites where you use the template. In our case we start from a master site that gets cloned to create new sites. This also means that the (media) folders that are created in that master site will get created in all new sites, which is great of course as we can make sure that certain folders exist this way. SXA can be really great sometimes 😃

Wednesday, September 23, 2020

Shared 404 setting in Sitecore SXA

Sharing a 404 page across SXA sites 

A third post inspired by a project to create a number of (mini-)sites that should share as much as possible and be very easy to create. The first one covered enhancing the language selector,  the second one showed how you can share placeholder settings across sites within a tenant. In this episode we will see how we can make the site setup a bit easier by sharing the 404 error page.

As our main focus is to make creating a new site within the tenant as easy as possible, my idea is to have a shared 404 error page and no worries about any settings when creating a new site.

Delegated area

First thing to do is create an actual page to display in case of a 404 error. This page should be in a delegated area. We have this setup in the master site (the site which is used as a base site for all other sites to be cloned from). The actual item -with content- for the 404 page is in the shared site and can be edited there.

This is a pretty standard setup for content sharing in SXA - nothing fancy so far.

404 setting

In SXA you can/should define the 404 page on each site. In the Settings item of your site you will find a Error Handling section (don't forget to switch to the content tab, as the Settings item is a folder and by default will open the Folder tab with icons of the children).

On the Shared site, we insert our 404 page in the Page Not Found Link.

Normally we should do this on every site. Of course, I could do this on the master site and it would get cloned to all new sites. But maybe there is way to tell our sites to get the value from the Shared site if we don't have a local value...  not out of the box, but with a little bit of custom code we got this working.

The code

public class ErrorPageLinkProvider : Sitecore.XA.Feature.ErrorHandling.Services.ErrorPageLinkProvider, IErrorPageLinkProvider
{
  public ErrorPageLinkProvider(IContext context, IMultisiteContext multisiteContext, BaseLinkManager baseLinkManager, ILinkProviderService linkProviderService)
            : base(context, multisiteContext, baseLinkManager, linkProviderService)
  {
  }

  public override Item Get404ErrorPageItem()
  {
    var item = base.Get404ErrorPageItem();
    if (item != null)
    {
      return item;
    }

    var site = Sitecore.Context.Site;
    if (!site.IsSxaSite())
    {
      return null;
    }

    var home = Sitecore.Context.Database.GetItem(site.StartPath);
    if (home == null)
    {
      return null;
    }

    foreach (var shared in ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSitesWithoutCurrent(home))
    {
      item = Get404ErrorItem(shared);
      if (item != null)
      {
        return item;
      }
    }

    return null;
  }

  private Item Get404ErrorItem(Item startItem)
  {
    var settingsItem = MultisiteContext.GetSettingsItem(startItem);
    return settingsItem != null ? Context.Database.GetItem(settingsItem[Sitecore.XA.Feature.ErrorHandling.Templates._ErrorHandling.Fields.Error404Page]) : null;
  }
}

We are overriding the ErrorPageLinkProvider and in particular the Get404ErrorPageItem function. If the base call does not find an item as 404 page, we try to fetch it from the shared sites. 

Note that we use the SharedSitesContext to get the shared site info and the MultisiteContext to get the site related information (e.g. here the Settings item). When working with SXA and writing code to do stuff like this those contexts are very useful.

One small tip when working with this 404 solution: make sure to set RequestErrors.UseServerSideRedirect Sitecore setting to true to get a real 404 (instead of a 302 redirect to a 200-status page).

Conclusion

Once again we managed to tweak SXA into doing what we want with very little code. And in the meantime we are almost where we need to be with our multisite project. Almost.. meaning yet a few things to do and a few more blogpost to come - sharing settings, using tokens, overriding logo's...  

Thursday, September 17, 2020

Shared placeholder settings in SXA

Sitecore SXA placeholder settings

In Sitecore you can can set "placeholder settings" to manage/restrict the renderings that can be added in a placeholder by the editors. This is no different in SXA, although you will edit those restriction settings in another way as you can read on https://doc.sitecore.com/developers/sxa/93/sitecore-experience-accelerator/en/set-placeholder-restrictions.html.

In SXA you can set placeholder restrictions per site. These restrictions can be found in the Presentations folder of the SXA site and are applied on all pages in the site.




Sharing placeholder settings from a shared site

I was working on a setup for a multi-site solution where we have a tenant which includes many (mini-)sites that share almost everything except content (and sometimes the theme). We followed the SXA best practices for multi-site setups and have a shared site to manage all page designs, partial designs, themes, styles, variants and some content in delegated areas. We also have a master site to clone when creating new sites. 

When I was setting the placeholder settings for the mini-sites, I could have set them in the master site. But I know that all my site level placeholder settings will be the same for all those sites and it seemed an idea to set them in the shared site instead of copying them all over the sites. This was not possible ootb, but SXA is extensible and so.. with a little bit of custom code I made this work.

My code will first check if the current site has any placeholder settings defined. If no settings are found, we check the shared site(s) and get all settings from there. This works for us, but can be altered of course (e.g. to use a site setting to determine to use the shared restrictions or not).

public class LayoutsPageContext : Sitecore.XA.Foundation.PlaceholderSettings.Services.LayoutsPageContext, ILayoutsPageContext
{
  public override Item GetSxaPlaceholderItem(string placeholderKey, Item currentItem)
  {
    var result = base.GetSxaPlaceholderItem(placeholderKey, currentItem);
    if (result == null || !result.Children.Any())
    {
      foreach (var site in ServiceLocator.ServiceProvider.GetService<ISharedSitesContext>().GetSharedSitesWithoutCurrent(currentItem))
      {
        result = base.GetSxaPlaceholderItem(placeholderKey, site);
        if (result != null && result.Children.Any())
        {
           return result;
        }
      }
    }

    return result;
  }
}
We are implementing ILayoutsPageContext and inheriting from Sitecore.XA.Foundation.PlaceholderSettings.Services.LayoutsPageContext to keep the ootb functionality. In our case we want to extend the functionality that fetches the root item for placeholder settings - we need to override GetSxaPlaceholderItem

First thing to do is check the setting items in the current site which can be done by using the base (ootb) function. If that has no result we loop through the available shared sites (using Sitecore.XA.Foundation.Multisite) and call the base function for each site item. As the base function uses the item parameter to search for an item with the "Placeholder Settings Folder" template, this works fine and gets us what we want: the first folder with placeholder settings will be used.

Register this new context class with your dependency injection framework and your Sitecore solution will use it. And you are able to share placeholder restriction settings across sites - defining them in one place in a shared site.


Conclusion

This solution is not something you will want in all multi-site setups. But in our case we really benefit from having -next to all the other shared stuff- our placeholder settings in one place. For one tenant of course, not for the other tenants. If you also have a solution where your sites within a tenant could share the placeholder settings, this might be something for you. 

This post is the second one in a series inspired by our multi-mini-site setup. The first one handled the language selector. Stay tuned for more SXA knowledge sharing...


SXA language selector in a multisite/multilanguage environment

Sitecore SXA multi-language + multi-site

We have an environment with a full blown SXA site in place, available in 4 languages. Language fallback is setup on item level, including some search engine enhancements to avoid duplicate content issues.

Next to this company site, a bunch of (mini) sites is being created. These sites (should) have nothing in common with the main site (they are in another tenant) but they do share almost everything between them. As they do live in the same environment however, they are also available in all 4 languages - and with the item language fallback those versions will always exist.

But.. those sites do not need to exist in all those languages. They could be in any subset of the available (4) languages of the Sitecore environment.


SXA language selector

We are using the ootb language selector component from SXA to handle our language switch. This works fine on the sites, but of course it will show all the available languages and we would like to limit the number of languages per site. One option is creating our own language selector - that would be easy but I thought there would be a way to tell the ootb component which languages to use. And yes, there is.

First step: add site settings

On the site item in SXA (found in the "Settings/Site Grouping" folder in your site tree) you can add (custom) properties. Normally the formsRoot should be there already (set by the script that generated the site). We add a custom property to define the languages we want on the site: the name is "siteLanguages" and in the example here I've set the value (language list) to "nl|fr" to allow Dutch and French. 

Note that the name and the format for the value are custom - chosen in favor of the implementation.

Setting a default language is an ootb feature of SXA but note that if you do not have English in the list of allowed languages, you should (must) set a default language in the Language field in that same site definition item. 


Step two: a new LanguageSelectorRepository

The Language selector component gets its values from the Sitecore.XA.Feature.Context.Repositories.LanguageSelector.LanguageSelectorRepository which implements an interface ILanguageSelectorRepository. We need to create a new version of this repository and override the GetLangItems function:
public class LanguageSelectorRepository : Sitecore.XA.Feature.Context.Repositories.LanguageSelector.LanguageSelectorRepository, ILanguageSelectorRepository
{
  protected override IEnumerable<Item> GetLangItems(Item item)
  {
    var languageItems = base.GetLangItems(item);
    var languages = Sitecore.Context.Site.Properties["siteLanguages"];
    if (string.IsNullOrEmpty(languages))
    {
      return languageItems;
    }
    
    var siteLanguages = languages.Split(new[] { "|" }, StringSplitOptions.RemoveEmptyEntries);
    if (!siteLanguages.Any())
    {
      return languageItems;
    }

    return languageItems.Where(i => siteLanguages.Contains(i.Language.Name));
  }
}
As you can see we first use the existing (base) function to determine all the possible languages (the ootb implementation here is based on the existing language versions of the current item). We also fetch the siteLanguages property from the current Site. If we detect that this property is set we will filter the list of languages based on the set from the properties. 

It's a pretty simple example of extending this logic - you can do anything you want/need here of course.

Final step

As a final but easy step, use your dependency injection framework and register your LanguageSelectorRepository as the implementation of the ILanguageSelectorRepository interface.


Conclusion

Extending SXA is usually quite easy. That was not different in this case. We can still use the ootb component and only adapted the logic for fetching the data, which saved us time obviously.

However we did only adjust the data logic for the language selector component. We did not put anything in place to prevent viewing pages in the languages that we don't really want. That is out of scope for this blog post, but can be easily achieved with an extra resolver in the httpRequestBegin pipeline (an example can be seen in the EasyLingo module)
 

This is a first post in a series that will come from our multi-site project. Stay tuned for more SXA knowledge sharing...