Injecting slugs in EPiServer URL for multi language websites

My current client has a need to inject region information at the start of the URL’s path in one of their EPiServer websites. For the Stockholm region, this may look like /Stockholm/path/to/page. However, the website is also in multiple languages causing EPiServer to inject it’s own slugs for language management: /so-SO/Stockholm/path/to/page (Somali). My collegue Svante Seleborg has been deeply involved in developing this functionality.

Edit: This breaks the simple URL functionality a bit, see this article for how to add support for injection in simple URLs as well.

Resolving URLs with injected data site wide

To have EPiServer resolve URLs with the injected region information around the website we need to create our own version of the UrlResolver class.

public class RegionalUrlResolver : UrlResolver
{
  private readonly UrlResolver _urlResolver;

  public RegionalUrlResolver(RouteCollection routes, IContentLoader contentLoader, ISiteDefinitionRepository siteDefinitionRepository, TemplateResolver templateResolver, IPermanentLinkMapper permanentLinkMapper, IContentLanguageSettingsHandler contentLanguageSettingsHandler)
  {
    _urlResolver = new DefaultUrlResolver(routes, contentLoader, siteDefinitionRepository, templateResolver, permanentLinkMapper, contentLanguageSettingsHandler);
  }

We will need an instance of EPiServer’s own UrlResolver as well, so create one in the constructor.

In our own RegionalUrlResolver we will need to override a number of methods. The first one is GetUrl. This method is used by EPiServer internally to do just that.

We use the instance of EPiServer’s own UrlResolver to resolve the original version of the URL. Then we attempt to extract the language part via the route values or getting it directly from the URL (the PossibleLanguageFrom method is included further down).

public override string GetUrl(UrlBuilder urlBuilderWithInternalUrl, VirtualPathArguments virtualPathArguments)
{
  string decodedQuery = HttpUtility.UrlDecode(urlBuilderWithInternalUrl.Query);
  decodedQuery = HttpUtility.HtmlDecode(decodedQuery);
  urlBuilderWithInternalUrl.Query = decodedQuery.StartsWith("?") ? decodedQuery.Substring(1) : decodedQuery;
  string url = _urlResolver.GetUrl(urlBuilderWithInternalUrl, virtualPathArguments);
  if (url == urlBuilderWithInternalUrl.ToString() || !url.StartsWith("/"))
  {
    return url;
  }
  var language = virtualPathArguments?.RequestContext?.RouteData .Values[RoutingConstants.LanguageKey] as string ?? 
      PossibleLanguageFrom(url) ?? 
      string.Empty;
  return $"/{new RegionRedirection().InsertRegionalSlugRelative(url, language)}";
}

So, we use the InsertRegionalSlugRelative method for injecting the region slug at the proper location with consideration to the language code (also included later on).

The next method that we need to override is the GetVirtualPath method. Here we also use EPiServer’s own UrlResolver implementation for getting the original VirtualPathData object, which is then altered via the same method used above.

public override VirtualPathData GetVirtualPath(ContentReference contentLink, string language, VirtualPathArguments virtualPathArguments)
{
  VirtualPathData vpd = _urlResolver.GetVirtualPath(contentLink, language, virtualPathArguments);
  if (vpd == null)
  {
    return null;
  }
  if (virtualPathArguments != null && virtualPathArguments.ContextMode.EditOrPreview())
  {
    return vpd;
  }

  language = language ?? PossibleLanguageFrom(vpd.VirtualPath);
  vpd.VirtualPath = $"{new RegionRedirection().InsertRegionalSlugRelative(vpd.VirtualPath, language ?? string.Empty)}";
  return vpd;
}

The third one is the GetVirtualPathForNonContent method. What we do here is basically the same.

public override VirtualPathData GetVirtualPathForNonContent(object partialRoutedObject, string language, VirtualPathArguments virtualPathArguments)
{
  VirtualPathData vpd = _urlResolver.GetVirtualPathForNonContent(partialRoutedObject, language, virtualPathArguments);
  language = language ?? PossibleLanguageFrom(vpd?.VirtualPath);
  vpd.VirtualPath = $"{new RegionRedirection().InsertRegionalSlugRelative(vpd.VirtualPath, language ?? string.Empty)}";
  return vpd;
}

Other methods that may be overriden are for instance Route and TryToPermanent:

public override IContent Route(UrlBuilder urlBuilder, ContextMode contextMode)
{
  IContent content = _urlResolver.Route(urlBuilder, contextMode);
  return content;
}

public override bool TryToPermanent(string url, out string permanentUrl)
{
  bool result = _urlResolver.TryToPermanent(url, out permanentUrl);
  return result;
}

The PossibleLanguageFrom method lets EPiServer resolve the proper language URL, and then attempts to extract the correct slug from there. It uses the list of enabled languages in EPiServer to determine the proper code.

private string PossibleLanguageFrom(string relativeUrl)
{
  string possibleLanguage = relativeUrl?.TrimStart('/').Split('/').FirstOrDefault();
  if (string.IsNullOrEmpty(possibleLanguage))
  {
    return null;
  }

  IList<LanguageBranch> enabledLanguages = ServiceLocator.Current.GetInstance<ILanguageBranchRepository>().ListEnabled();

  if (!enabledLanguages.Any(l => l.LanguageID.Equals(possibleLanguage, StringComparison.InvariantCultureIgnoreCase)))
  {
    return null;
  }

  return possibleLanguage;
}

To have EPiServer use our RegionalUrlResovler instead of it’s own original UrlResolver we will need to register it with StructureMap.

For(typeof(UrlResolver)).Use(typeof(RegionalUrlResolver));

Injecting the slug into the URL

The insertion of the region data into the URL while considering the language part is done in the RegionRedirection class. This class also manages the actual redirecting of the requests, but is outside of the scope of this article.

The InsertRegionalSlugRelative method takes the path and the language code. We ignore URLs to assets such as ContentAssets and GlobalAssets. Also no rewriting is done to URLs that are in EPiServer’s edit mode context (see IsEpiserverPathInEditMode method further down).

public class RegionRedirection
{
  // ...

  public string InsertRegionalSlugRelative(string path, string language)
  {
    if (path == null)
    {
      throw new ArgumentNullException(nameof(path));
    }
    if (language == null)
    {
      throw new ArgumentNullException(nameof(language));
    }

    if (path.StartsWith("/"))
    {
      path = path.Substring(1);
    }

    if (path.StartsWith(SystemContentRootNames.ContentAssets, StringComparison.OrdinalIgnoreCase))
    {
      return path;
    }
    if (path.StartsWith(SystemContentRootNames.GlobalAssets, StringComparison.OrdinalIgnoreCase))
    {
      return path;
    }
    IHttpContextWrapper contextWrapper = ServiceLocator.Current.GetInstance<IHttpContextWrapper>();
    if (!contextWrapper.IsRequest)
    {
      return path;
    }
    if (IsEpiserverPathInEditMode(path))
    {
      return path;
    }

    IRegionService service = ServiceLocator.Current.GetInstance<IRegionService>();
    string regionCode = service.GetCurrentRegionCode();
    if (regionCode.Length == 0)
    {
      return path;
    }

    IRegionRepository repository = ServiceLocator.Current.GetInstance<IRegionRepository>();
    Region region = repository.GetRegionByCountyCode(regionCode);

    path = InsertSlug(region.Slug, language, $"/{path}");

    return path.Substring(1);
  }

To get the region to inject we have a helper service that has various methods of determining the proper region code (like looking at cookies and whatnot). You may probably want to construct your own way of knowing which slug you want to insert. If we have no region code, the page is believed to be in a national context, hence no slug is inserted.

The region code is then translated into the proper slug part, before passed along with the language code to the InsertSlug method (further down) for injection.

The way we determine if the URL is for EPiServer’s edit mode is rather crude.

private static bool IsEpiserverPathInEditMode(string path)
{
  if (!path.StartsWith("episerver", StringComparison.OrdinalIgnoreCase))
  {
    return false;
  }
  var ctxModeResolver = ServiceLocator.Current.GetInstance<IContextModeResolver>();
  if (ctxModeResolver != null && ctxModeResolver.CurrentMode.EditOrPreview())
  {
    return true;
  }
  return false;
}

The slug insertion method InsertSlug does this using common string parsing. If there is no language code, the slug is just inserted directly, while if there is, we need to do a bit more work.

public static string InsertSlug(string slug, string language, string pathAndQuery)
{
  if (language.Length == 0)
  {
    return $"{(slug.Length > 0 ? "/" : string.Empty)}{slug}{pathAndQuery}";
  }
  string languagePathPrefix = $"/{language}/";
  if (!pathAndQuery.StartsWith(languagePathPrefix, StringComparison.OrdinalIgnoreCase))
  {
    return $"{(slug.Length > 0 ? "/" : string.Empty)}{slug}{pathAndQuery}";
  }
  return $"{pathAndQuery.Substring(0, languagePathPrefix.Length)}{slug}{(slug.Length > 0 ? "/" : string.Empty)}{pathAndQuery.Substring(languagePathPrefix.Length)}";
}