One project to rule them all: managing multiple sites within one single Visual Studio project

tagged with .NET Core Configuration Frontend Razor v10

How we consolidated multiple slightly different websites into one Visual Studio project by carefully designing our doctypes, using the BlockListEditor and by, among other things, implementing a themes-based view engine

Have you ever needed to reuse the same Umbraco project for multiple customers, building a product where all the CMS structure is the same, but the frontend and design are different? How did you manage it? We initially struggled with it and ended up forking the repository and effectively having different projects. But things became unmanageable, with multiple projects that needed to be updated and maintained and that needed bug fixing.

In this article, I will explain how we managed to go back to one single repository used as the source for all the projects, making management much more straightforward.

What differs between customers

In our scenario, we need to build many similar websites, all with the same content types (news, events, “content” pages, who’s who) but with different graphics, configurations and, to a certain extent, even different logic and structure of items listings, search results and homepage.

We managed to consolidate all these different implementations into one codebase by implementing the following “features”:

  • We adopted a Page Builder approach to allow different structures in the pages without the need to change properties in the document type.
  • We built a theme-based view engine to allow different graphics and layouts.
  • We customised the configuration builder of .NET to have overrides specific to a customer.
  • We deployed the infrastructure on Azure using ARM templates.
  • Finally, we used build events to include or exclude files that cannot be managed via code.

But enough with the introduction: let’s dive into the actual implementation.

Page Builder

Let’s start with how we designed our document types so that they could be easily reused, without any change, in multiple installations.

We adopted two main rules:

  • The strict separation between content and metadata.
  • Every page is customisable.

Separation between content and metadata

To give full flexibility to editors, all our documents are made of only one “content” property, implemented with the Block List Editor, which contains all the possible “components” editors might need to design their pages as they prefer. This property is all that is needed to render the document in the frontend. However, it is not suitable for referencing the page inside other pages, like in a list of news, in search results, or when the page is included as a highlighted element on the homepage.

For this reason, we also added a few properties, like title, abstract, date, cover image, taxonomies, and similar, which are used when the document is showing inside other pages, like the aforementioned search results page.

Every page is customisable

What we noticed in the first projects, was that every customer wanted something slightly different, even for pages that should be pretty standard, like the listing and search pages. And I’m not talking about the design, but also the logic of the search or listing of documents.

For example, one wanted just the list of news, another wanted some “highlighted” news on top before the list. One wanted the news as a simple list, another wanted to show them grouped by date, and another grouped by topic.

To avoid creating different page controllers per customer, which would have effectively killed the idea of a reusable base project, we implemented also these pages with a Block List Editor. 

This way editors could assemble the available components as they wanted and choose between different variations of the same components. We also implemented the settings screen for each component, to allow fine-tuning of the behaviour: for example, to set the page size in the search results or the number of items in the latest news box. And if the “variation” they wanted was not available, we only needed to add a new BLE component, with its related view component for the frontend, and later this would have been available for all future customers.

Themes

To allow different designs for each website, we devised a solution to allow a themes-based approach for the frontend views. With this approach, we can have a generic view reused in all sites that can be replaced by a view specific to an individual website.

This is achieved by creating a ViewLocationExpander and adding it to the current view engine via the RazorViewEngineOptions. The custom expander needs to be added as last on the list to be considered the most "specific" one.

The following code snippet shows the code for the custom ViewLocationExpander, which instructs the view engine to look for files in the /Views/Themes/<ThemeName>/ folder and subfolders instead of just in /Views/.


using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Razor;


public class ThemeViewLocationExpander : IViewLocationExpander
{

    private string _themeName;

    public ViewLocationExpander(string themeName)
    {
        _themeName = themeName;
    }

    public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context, IEnumerable<string> viewLocations)
    {

        string[] themedLocations = new string[]
        {
            "/Views/Themes/"+_themeName+"/{0}.cshtml",
            "/Views/Themes/"+_themeName+"/Shared/{0}.cshtml",
            "/Views/Themes/"+_themeName+"/Partials/{0}.cshtml",
            "/Views/Themes/"+_themeName+"/MacroPartials/{0}.cshtml",
        };

        viewLocations = themedLocations.Concat(viewLocations);

        return viewLocations;
    }

    // not a dynamic expander
    public void PopulateValues(ViewLocationExpanderContext context) { }
}

ThemeViewLocation Expander

The custom view location expander needs to be added to the current instance of the razor view engine, which is done via a class to configure the options of a service.


using System;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.Options;


public class ThemeViewEngineOptionsSetup : IConfigureOptions<RazorViewEngineOptions>
{
    private string _themeName;

    public ThemedViewEngineOptionsSetup(string themeName)
    {
        _themeName = themeName;
    }

    public void Configure(RazorViewEngineOptions options)
    {
        if (options == null)
        {
            throw new ArgumentNullException(nameof(options));
        }

        options.ViewLocationExpanders.Add(new ThemeViewLocationExpander(_themeName));
    }
}

ThemeViewEngineOptionsSetup

This would normally be enough to configure the new view location expander. Unfortunately for us, there is no way of accessing the current instance of the RazorViewEngine after it is set up because Umbraco wraps it inside a few other view engines used for profiling and for refreshing the Umbraco models.

The only solution would be to instantiate the Razor View Engine before Umbraco does it inside the services.AddUmbraco method. This approach has another drawback: it would add our view search paths as firsts in the list, which means that they would be considered as the most generic ones, so searched only if all the other paths didn’t return a file. Basically, the opposite of what we wanted.

I posted an issue on the Umbraco issue tracker, and thanks to a very helpful comment, I found the solution: we needed to create yet another view engine to wrap the Razor view engine, with the sole task of moving our view location expander to the bottom of the list when it gets initialised.

For brevity, the following snippet shows the relevant parts of the view engine (it's constructor). The rest is simply reimplementing all methods of a view engine, but calling the same method in the wrapped Razor View engine (the whole class is in the comment on Github).


public LocationExpanderMoverRazorViewEngine
(IRazorPageFactoryProvider pageFactory,
    IRazorPageActivator pageActivator,
    HtmlEncoder htmlEncoder,
    IOptions<RazorViewEngineOptions> optionsAccessor,
    ILoggerFactory loggerFactory,
    DiagnosticListener diagnosticListener)
{
    var razorViewEngineOptions = optionsAccessor.Value;

    // Our view location expanders will be the first item, this needs to be at the end to override the searched locations.
    var ownViewLocationExpander = razorViewEngineOptions.ViewLocationExpanders[0];
    razorViewEngineOptions.ViewLocationExpanders.RemoveAt(0);
    razorViewEngineOptions.ViewLocationExpanders.Add(ownViewLocationExpander);

    _razorViewEngine = new RazorViewEngine(pageFactory, pageActivator, htmlEncoder, optionsAccessor, loggerFactory, diagnosticListener);
}

Constructor of the LocationExpanderMoverRazorViewEngine class

To bind it all up together, we need an extension method so that the setup is as easy as calling services.AddThemes(_config); before calling the AddUmbraco method.



using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;


public static class ThemeExtensions
{
    public static IServiceCollection AddThemes(this IServiceCollection services, IConfiguration configuration)
    {
        // Workaround to setup custom view location expander (setting )
        var coreOptions = configuration.GetSection(CoreOptions.SectionName).Get<CoreOptions>();
        var themeConfig = new ThemeViewEngineOptionsSetup(coreOptions.Theme);
        services.ConfigureOptions(themeConfig);
        services.AddSingleton<IRazorViewEngine, LocationExpanderMoverRazorViewEngine>();
        return services;
    }
}


ThemeServiceExtension

To configure the theme name, we put in the appsettings.json file, and it is referenced in code in the CoreOptions class you can see in the previous snippet.


{
  "Core": {
    "Theme": "<ThemeName>"
  }
}

Snippet of our appsetting.json file

I know it looks a bit "hacky", but until Umbraco finds a way to expose current view engine instance, that's what is needed to make it work.

Configuration

Moving on from the themes, let's see how we implemented a similar feature for the configuration files. We also wanted more specific settings to override the ones defined in the more generic files. For example, license keys, client IDs and secrets for 3rd party APIs, tracking codes for analytics and so on.

Normally in .NET Core, the order of config files is:

  • appsettings.json
  • appsettings.<envName>.json

We wanted to have customer-specific configurations to override both the generic and the environment-specific files, effectively having the following order:

  • appsettings.json
  • appsettings.<envName>.json
  • appsettings.<themeName>.json
  • appsettings.<envName>.<themeName>.json

Luckly the implementation was much simpler than the one for the themes. It is done in the Program.cs file, calling the ConfigureAppConfiguration method inside the CreateHostBuilder.

 


.ConfigureAppConfiguration((ctx, builder) =>
{
    IHostEnvironment env = ctx.HostingEnvironment;
    var theme = Environment.GetEnvironmentVariable("24DAYS_Core__Theme");

    builder.AddJsonFile($"appsettings.{theme}.json", true, true);
    builder.AddJsonFile($"appsettings.{env.EnvironmentName}.{theme}.json", true, true);
})
.ConfigureAppConfiguration(config => { config.AddEnvironmentVariables(prefix: "24DAYS_"); })

The call to the ConfigureAppConfiguration

To identify which theme to apply, we use the same key used to switch the design in the themes, but since here we are before the configuration reader is even setup, we need to read the environment variable directly and then use the same to set the theme for the view search paths.

For infrastructure-related configurations, like connection strings or Azure storage keys and other similar configurations, we inject them directly into the appservice configuration section during the infrastructure deployment that we do via ARM templates. This topic needs an article on its own. And I wrote it already last year as part of a 3 article series on Skrift.io.

Inclusion or exclusion of files at build time

Unfortunately, not everything can be managed via clever coding solutions. For some types of files, we need to manipulate the publish directory at build time.

One example is the list of languages enabled for the sites, which are managed via uSync, and are different for each site. In this case, we need to put only the correct files in the publish folder. The same applies to license files, where we need to deploy only that one license specific to the site.

As an example, the following snippet shows the MSBuild target that copies the languages from an external folded to the right folder inside uSync structure.


	<Target Name="SetUsyncLanguages" BeforeTargets="BeforeBuild">

		<Message Text="Copying languages files" Importance="high" />

		<ItemGroup>
			<LanguagesPath Include="$(ProjectDir)$(Separator)..$(Separator)Configs$(Separator)Languages$(Separator)$(Theme)$(Separator)*.*" />
			<LanguagesTargetPath Include="$(ProjectDir)$(Separator)uSync$(Separator)v9$(Separator)Languages" />
		</ItemGroup>

		<Copy SourceFiles="@(LanguagesPath)" DestinationFolder="@(LanguagesTargetPath)" />
	
	</Target>

Lessons learned

One mistake we made, and we learned from is that we tried to generalise the implementation of this product already in the first customer, trying to envision what we might also need for the future. But we ended up forking the project for the second and third customers. And it’s only now, with the 4th and 5th projects, that we managed to consolidate all the requirements and merge everything into one. So the first lesson is: do not generalise prematurely.

Another issue that we faced was that we needed to implement all new features so that they were backwards compatible with the previous installations. This has forced us also to include in our CI/CD pipeline a better testing strategy so that we can verify that all new changes, including upgrades to new versions of Umbraco, do not break the older projects.

I hope you took some good hints and suggestions from our experience and do not hesitate in contacting me (or commenting down in the comment section) in case you have questions.


Tech Tip: JetBrains Rider

Check it out here