Optimizely Content Cloud CMS Apps (Add-ons) - Tips & Tricks

Posted on January 22, 2024

Developing an Optimizely CMS Add-on can be a bit tricky. There is a lot of advantages of doing it, but building it right can be a bit tedious. You have probably already seen the following documentation page about the subject itself, but it feels like a lot of details are missing to make it right. You will also quickly realize there is a lot of historical elements which makes the process a bit more complicated/confusing. In this blog, we will unravel everything so that you can successfully build yours!

I will be assuming that your project is currently using .NET 6.0, but the process is the same if the target framework changes. You can also add conditional dependencies based on the target framework, which then the command dotnet pack will automatically handle when bundling your library. I will also assume that you are already mastering how packing your library to .nupkg file(s) works.

1. Adjustements inside the project file

First of all, create a new solution with a library project using your preferred IDE, then edit the .csproj file. Your file should look like the following in the end:

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
  </PropertyGroup>

  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
  
  <ItemGroup>
		<!--    These are package example. Install those you need. -->
    <PackageReference Include="EPiServer.CMS.AspNetCore.Mvc" />
    <PackageReference Include="EPiServer.CMS.Core" />
    <PackageReference Include="EPiServer.CMS.UI.Core" />
  </ItemGroup>

  <ItemGroup>
    <Content Remove="module.config" />
    <None Include="module.config">
      <CopyToOutputDirectory>Never</CopyToOutputDirectory>
    </None>
    <Content Remove="packages.lock.json" />
    <None Include="packages.lock.json">
      <CopyToOutputDirectory>Never</CopyToOutputDirectory>
    </None>
  </ItemGroup>
</Project>

As you can also realize, I'm using CPM. This will greatly simplify the dependency management along the way.

Couple of things to highlight here:

  • Super important to change the SDK attribute on the "Project" node to "Microsoft.NET.Sdk.Razor".
  • Inside the first PropertyGroup node, add AddRazorSupportForMvc and set it "true".
  • Make sure to add a FrameworkReference node which will be including the Microsoft.AspNetCore.App reference. Necessary for having the ASP.NET Core web references, which normally the SDK Microsoft.NET.Sdk.Web includes by default.
  • The last, but not least, make sure to remove & ignore any files you want to exclude from your .nupkg file. In our example, and you will probably need it too, we want to prevent both "module.config" and "packages.lock.json" file to be inside the file package.

2. Custom MSBuild instructions

There is a couple of adjustments to make so that MSBuild is helping up ease the bundling:

  1. Assuming your projects are hierarchically structured in the following directory pattern: [root]/src/projectname/projectname.csproj, create a Directory.Build.props file in the "src" folder with the following content:
<Project>
  <PropertyGroup>
    <NoWarn>NU1507</NoWarn>
    <RestorePackagesWithLockFile>True</RestorePackagesWithLockFile>
  </PropertyGroup>
</Project>
  1. Assuming the same structure, create under the "src" folder the file Directory.Packages.props with the following content:
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
  <ItemGroup>
		<!--    These are package example. Install those you need. -->
    <PackageVersion Include="EPiServer.CMS.AspNetCore.Mvc" Version="[12.4.0, 13.0.0)" />
    <PackageVersion Include="EPiServer.CMS.Core" Version="[12.4.0, 13.0.0)" />
    <PackageVersion Include="EPiServer.CMS.UI.Core" Version="[12.4.0, 13.0.0)" />
  </ItemGroup>
</Project>
  1. Inside the project folder, where the .csproj file resides, create a new file entitled Directory.Build.props with the following content:
<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <ItemGroup>
        <ClientResources Include="$(ProjectDir)ClientResources\**\*"/>
    </ItemGroup>
    <PropertyGroup>
        <TmpOutDir>$([System.IO.Path]::Combine($(ProjectDir), 'tmp'))</TmpOutDir>
        <NoWarn>NU1507</NoWarn>
        <RestorePackagesWithLockFile>True</RestorePackagesWithLockFile>
    </PropertyGroup>
    <ItemGroup>
        <Content Include="$(MSBuildProjectName).zip">
            <Pack>true</Pack>
            <PackagePath>contentFiles\any\any\modules\_protected\$(MSBuildProjectName)</PackagePath>
            <BuildAction>None</BuildAction>
            <PackageCopyToOutput>true</PackageCopyToOutput>
        </Content>
        <Content Include="msbuild\CopyZipFiles.targets" >
            <Pack>true</Pack>
            <PackagePath>build\$(MSBuildProjectName).targets</PackagePath>
        </Content>
    </ItemGroup>
</Project>
  1. Still in the project folder, create the file Directory.Build.targets and add the following content:
<?xml version="1.0" encoding="utf-8" ?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
  <Target Name="CreateCmsAddOnZip" BeforeTargets="Build">
    <Copy SourceFiles="$(ProjectDir)module.config" DestinationFolder="$(TmpOutDir)\content"/>
    <Copy SourceFiles="@(ClientResources)" DestinationFiles="@(ClientResources -> '$(TmpOutDir)\content\$(PackageVersion)\ClientResources\%(RecursiveDir)%(Filename)%(Extension)')"/>

    <!-- Update the module config with the version information -->
    <XmlPoke XmlInputPath="$(TmpOutDir)\content\module.config" Query="/module/@clientResourceRelativePath" Value="$(PackageVersion)"/>
  </Target>
  <Target Name="ZipClientResources" BeforeTargets="Build" AfterTargets="CreateCmsAddOnZip" DependsOnTargets="CreateCmsAddOnZip">
    <ZipDirectory SourceDirectory="$(TmpOutDir)\content" DestinationFile="$(ProjectDir)$(MSBuildProjectName).zip" Overwrite="true"/>
  </Target>
  <Target Name="CleanupTmpOutDir" BeforeTargets="Build" AfterTargets="ZipClientResources" DependsOnTargets="ZipClientResources">
    <RemoveDir Directories="$(TmpOutDir)"/>
  </Target>
</Project>
  1. You will also have to add the file CopyZipFiles.targets to a new "msbuild" folder under the root of the project directory:
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
  <ItemGroup>
    <CmsAddOnZips Include="$(MSBuildThisFileDirectory)..\contentFiles\any\any\modules\_protected\**\*.zip"/>
  </ItemGroup>

  <Target Name="CopyCmsAddOnZip" BeforeTargets="Build">
    <Copy SourceFiles="@(CmsAddOnZips)" DestinationFolder="$(MSBuildProjectDirectory)\modules\_protected\%(RecursiveDir)"/>
  </Target>
</Project>

To summarize, these customizations will do the following to your project each time you will be compiling:

  1. Adds CPM, as previously mentioned.
  2. Adds NuGet lock files, super useful for your DevOps pipeline.
  3. Automatically compile views.
  4. Automatically generates module the zip file with all necessary elements in it that will be packaged within your .nupkg file.

3. Minimum required configuration

See the "module.config" file like the definition of your addon. Without it, Optimizely will use the default values from the class ShellModuleManifest, which unfortunately omit a very important detail; The assembly’s name of your addon. Without it, Optimizely won't be able to load yours at boot. Create it where the .csproj file resides with the following content:

<?xml version="1.0" encoding="utf-8"?>
<module loadFromBin="false" name="Your.Assembly.Name" viewEngine="Razor" clientResourceRelativePath="$version$" tags="EPiServerModulePackage">
  <assemblies>
		<!-- Change the assembly name with yours -->
    <add assembly="Your.Assembly.Name" />
  </assemblies>
  <clientModule>
    <moduleDependencies>
			<!-- Adjust accordingly -->
      <add dependency="CMS" type="RunAfter" />
    </moduleDependencies>
  </clientModule>
</module>

Add an extension method on the interface "IServiceCollection" and add at least the following piece of code:

public static IServiceCollection AddMyAddon(this IServiceCollection services)
    {
        // Add services here.
        return services
                // Super required, otherwise your addon won't load when the site loads.
            .Configure<ProtectedModuleOptions>(
            pm =>
            {
                if (!pm.Items.Any(i => i.Name.Equals(ModuleName, StringComparison.OrdinalIgnoreCase)))
                {
                    pm.Items.Add(new ModuleDetails { Name = ModuleName });
                }
            });
    }

4. Controllers & Views

Probably the most undocumented/unclear part for Optimizely CMS addons. The instructions around views doesn't exactly explain how they can be used or how the routing works within your library. If you're looking at existing addons, e.g., Geta.NotFoundHandler, the majority of developers are handling it slightly differently. This page explains how to structure your files in a manner that the module will automatically include them for you, but I was unable to make it work. Maybe because it should be exclusively a razor page and not a simple razor view dependent on a Controller. Fortunately, you can make it work with a controller, but with a little additional tweaking:

  1. Create a new Route Attribute. We will use it to customize the routes to our controllers. Here's an example:
using EPiServer.Shell;
using Microsoft.AspNetCore.Mvc.Routing;

namespace Playground.Mvc;

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)]
public class ModuleRoute : Attribute, IRouteTemplateProvider
{
    private readonly string _controllerName;
    private readonly string _actionName;

    public string Template => Paths.ToResource(typeof(ModuleRoute), $"{_controllerName}/{_actionName}");
    public int? Order { get; set; } = 0;
    public string Name { get; set; }

    public ModuleRoute(string controllerName, string actionName)
    {
        _controllerName = controllerName;
        _actionName = actionName;
    }
}
  1. Decorate your controller actions with your newly created route attribute. e.g.:
    [HttpGet]
    [ModuleRoute("Default", "Index")]
    public IActionResult Index()
    {
        return View();
    }
  1. Create a new menu provider class including all paths to your actions in your addons. e.g.:
using EPiServer.Framework.Localization;
using EPiServer.Shell;
using EPiServer.Shell.Navigation;
using Playground.Controllers;

namespace Playground.Optimizely;

[MenuProvider]
public class AddonMenuProvider : IMenuProvider
{
    private readonly LocalizationService _localizationService;

    public AddonMenuProvider(LocalizationService localizationService)
    {
        _localizationService = localizationService;
    }

    public IEnumerable<MenuItem> GetMenuItems()
    {
        yield return new UrlMenuItem(_localizationService.GetString("/myaddon/gadget/title", "My Addon"), "/global/cms/myaddon",
            Paths.ToResource(GetType(), $"Default/{nameof(DefaultController.Index)}"))
        {
            SortIndex = 0,
            Alignment = 0,
            IsAvailable = _ => true
        };

        yield return new UrlMenuItem(_localizationService.GetString("/myaddon/index/menu", "Home"), "/global/cms/myaddon/index",
            Paths.ToResource(GetType(), $"Default/{nameof(DefaultController.Index)}"))
        {
            SortIndex = 10,
            Alignment = 0,
            IsAvailable = _ => true
        };

        yield return new UrlMenuItem(_localizationService.GetString("/myaddon/secondaction/menu", "Second Action"), "/global/cms/myaddon/secondaction",
            Paths.ToResource(GetType(), $"Default/{nameof(DefaultController.SecondAction)}"))
        {
            SortIndex = 20,
            Alignment = 0,
            IsAvailable = _ => true
        };
    }
}

By doing so, you are generating routes which will point directly on your addon controller. Say by example you have the "DefaultController" class with the "Index" action, well then, the action under your browser should look as the following: ~/EPiServer/MyAddon/Default/Index. This also convenientely allow you to use razor helpers such as Html.BeginForm, since the MVC routing system knows these belongs to your addon with a custom template.

It is very important to respect the actions defined within your IMenuProvider implementation, otherwise the custom routing attribute won't be working just right. As you can see, a certain minimum level of structure has been established in this file, which makes actions from the controller to work correctly. The third parameter of the constructor of UrlMenuItem is exactly where the magic happens. The recommendation is indeed to keep using the helper, Paths.ToResource and add the additional segment manually. This consequently creates the same path as previously described in the last paragraph and will match it with the available controller action. A menu item is essentially an element that will appear under the Backoffice, when navigating under ~/EPiServer.

I hope this will be super helpful to people reading this blog! You can view a starter code example over there: https://github.com/ddprince17/Optimizely-CMS-Addon-Playground.