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.
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:
There is a couple of adjustments to make so that MSBuild is helping up ease the bundling:
<Project>
<PropertyGroup>
<NoWarn>NU1507</NoWarn>
<RestorePackagesWithLockFile>True</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
<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>
<?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>
<?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>
<?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:
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 });
}
});
}
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:
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;
}
}
[HttpGet]
[ModuleRoute("Default", "Index")]
public IActionResult Index()
{
return View();
}
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.