Migrating Rich Text Editor Macros to Blocks using uSync Migrations

Modern Umbraco allows Blocks to be added inline in the Rich Text Editor. Umbraco 14 removes support for macros. This code sample will map macros to blocks with the help of uSync Migrations.

, by Joe Glombek

I haven't yet worked out a way to contribute this back to the uSync Migrations project as it requires too much customisation. If anybody wants to take this and make it more generically compatible, please do.

The first step is to create element types equivalent to all your macros. Feel free to change names, as the next step is to map them.

Create a Migrator that inherits from RichTextBoxMigrator:

[SyncMigrator(UmbConstants.PropertyEditors.Aliases.TinyMce, typeof(RichTextConfiguration), IsDefaultAlias = true)]
[SyncMigrator("Umbraco.TinyMCEv3")]
[SyncMigratorVersion(7, 8)]
public class RichTextBoxMacrosToBlocksMigrator : RichTextBoxMigrator
{
    private IJsonSerializer _jsonSerializer;
    private ILogger<RichTextBoxMacrosToBlocksMigrator> _logger;

    public RichTextBoxMacrosToBlocksMigrator(ILogger<RichTextBoxMacrosToBlocksMigrator> logger, IJsonSerializer jsonSerializer)
    {
        _logger = logger;
        _jsonSerializer = jsonSerializer;
    }
}

Then create a mapping for all the macros you want to replace with blocks.

private class MacroMapping
{
    public required string MacroAlias { get; set; }
    public required string BlockTypeAlias { get; set; }
    public Dictionary<string, string>? PropertyMappings { get; set; }
}

private IEnumerable<MacroMapping> mappings = new List<MacroMapping>()
{
    new MacroMapping()
    {
        MacroAlias = "renderUmbracoForm",
        BlockTypeAlias = "RenderUmbracoFormEmbeddedBlock",
        PropertyMappings = new Dictionary<string, string>()
        {
            { "ExcludeScripts", "excludeScripts" },
            { "FormGuid", "form" },
            { "FormTheme", "theme" },
            { "RedirectToPageId", "redirectToPage" }
        }
    }
};

Then we can map these across by overriding the GetContentValue method:

public override string? GetContentValue(SyncMigrationContentProperty contentProperty, SyncMigrationContext context)
{
    var rawValue = base.GetContentValue(contentProperty, context) ?? string.Empty;

    var value = new RteValue();

    // Newer sites or RTEs within blocks will already have been converted to the new format, so attempt to parse the JSON
    if (RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rawValue, _jsonSerializer, _logger, out RichTextEditorValue? existingValue))
    {
        // Migrate existing blocks
        value.Blocks!.ContentData.AddRange(existingValue.Blocks?.ContentData.Select(x => new BlockListRowValue() { ContentTypeKey = x.ContentTypeKey, RawPropertyValues = x.RawPropertyValues, Udi = x.Udi?.ToString() }) ?? Enumerable.Empty<BlockListRowValue>());
        value.Blocks!.SettingsData.AddRange(existingValue.Blocks?.SettingsData.Select(x => new BlockListRowValue() { ContentTypeKey = x.ContentTypeKey, RawPropertyValues = x.RawPropertyValues, Udi = x.Udi?.ToString() }) ?? Enumerable.Empty<BlockListRowValue>());
        value.Blocks!.Layout!.BlockOrder.AddRange(existingValue.Blocks?.Layout?["Umbraco.TinyMCE"].Select(x => new BlockUdiValue() { ContentUdi = x["contentUdi"]?.Value<string>(), SettingsUdi = x["settingsUdi"]?.Value<string>() }) ?? Enumerable.Empty<BlockUdiValue>());
    }
    else
    {
        // Existing value is plain HTML markup, we need to update it to match the new format
        existingValue = new RichTextEditorValue() { Markup = rawValue, Blocks = new Umbraco.Cms.Core.Models.Blocks.BlockValue() };
    }

    value.Markup = Regex.Replace(existingValue.Markup, @"\<\?UMBRACO_MACRO macroAlias=""(?<macro>[^""]+)""(?:\s*(?<prop>[^=]*)=""(?<propval>[^""]*)"")*? \/>", match =>
    {
        var macroAlias = match.Groups["macro"].Value;
        var mapping = mappings.FirstOrDefault(x => x.MacroAlias.InvariantEquals(macroAlias));
        if (mapping != null)
        {
            var block = new BlockListRowValue()
            {
                ContentTypeKey = context.ContentTypes.GetKeyByAlias(mapping.BlockTypeAlias),
                Udi = $"umb://element/{Guid.NewGuid():N}"
            };

            // Add block config

            var propVals = match.Groups["propval"].Captures;
            var propNames = match.Groups["prop"].Captures;

            for (int i = 0; i < propVals.Count; i++)
            {
                var propVal = HttpUtility.HtmlDecode(propVals[i].Value);
                var propName = propNames[i].Value;
                propName = mapping.PropertyMappings?[propName] ?? propName;

                block.RawPropertyValues[propName] = propVal;
            }

            value.Blocks!.ContentData.Add(block);

            // Add settings if nescessary
            // value.Blocks.SettingsData.Add(settings);

            value.Blocks!.Layout!.BlockOrder.Add(new BlockUdiValue()
            {
                ContentUdi = block.Udi,
                //SettingsUdi = settings.Udi
            });

            return $"<umb-rte-block class=\"ng-scope ng-isolate-scope\" data-content-udi=\"{block.Udi}\"><!--Umbraco-Block--></umb-rte-block>";
        }
        return match.Value;
    }, RegexOptions.IgnoreCase | RegexOptions.Multiline);

    if (value.Blocks?.Layout?.BlockOrder?.Any() != true)
    {
        // Clear this out if we have no blocks
        value.Blocks = null;
    }

    return JsonConvert.SerializeObject(value, Formatting.Indented);
}

This code sample makes use of custom strongly-typed models for the RTE value. These are included in the full code sample at the end.

You'll then need to add this migrator to your custom Migration Plan:

using System;
using System.Collections.Generic;
using uSync.Migrations.Core;
using uSync.Migrations.Core.Composing;
using uSync.Migrations.Core.Configuration.Models;
using UmbConstants = Umbraco.Cms.Core.Constants;

public class MyMigrationPlan : ISyncMigrationPlan
{
    private readonly SyncMigrationHandlerCollection _migrationHandlers;

    public MyMigrationPlan(SyncMigrationHandlerCollection migrationHandlers)
    {
        _migrationHandlers = migrationHandlers;
    }

    public int Order => 250;
    public string Name => "My Migration Plan";
    public string Icon => "icon-brick color-green";
    public string Description => "";

    public MigrationOptions Options => new MigrationOptions
    {
        Group = "Convert",
        Source = "uSync/v9",
        Target = $"{uSyncMigrations.MigrationFolder}/{DateTime.Now:yyyyMMdd_HHmmss}",
        Handlers = _migrationHandlers.SelectGroup(8, string.Empty),
        SourceVersion = 8,

        // This adds our migrator
        PreferredMigrators = new Dictionary<string, string>
        {
            { UmbConstants.PropertyEditors.Aliases.TinyMce, "RichTextBoxMacrosToBlocksMigrator" },
        }
    };
}

Complete RichTextBoxMacrosToBlocksMigrator.cs file

using Newtonsoft.Json.Linq;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Extensions;
using UmbConstants = Umbraco.Cms.Core.Constants;
using uSync.Migrations.Core.Migrators;
using uSync.Migrations.Core.Migrators.Models;
using uSync.Migrations.Core.Context;
using System.Linq;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using System;
using System.Web;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Serialization;
using Microsoft.Extensions.Logging;

namespace uSync.Migrations.Migrators.Core;

[SyncMigrator(UmbConstants.PropertyEditors.Aliases.TinyMce, typeof(RichTextConfiguration), IsDefaultAlias = true)]
[SyncMigrator("Umbraco.TinyMCEv3")]
[SyncMigratorVersion(7, 8)]
public class RichTextBoxMacrosToBlocksMigrator : RichTextBoxMigrator
{
    private IJsonSerializer _jsonSerializer;
    private ILogger<RichTextBoxMacrosToBlocksMigrator> _logger;

    public RichTextBoxMacrosToBlocksMigrator(ILogger<RichTextBoxMacrosToBlocksMigrator> logger, IJsonSerializer jsonSerializer)
    {
        _logger = logger;
        _jsonSerializer = jsonSerializer;
    }


    // Map across each macro to an already created type and optionally map the properties
    private IEnumerable<MacroMapping> mappings = new List<MacroMapping>()
    {
        new MacroMapping()
        {
            MacroAlias = "renderUmbracoForm",
            BlockTypeAlias = "RenderUmbracoFormEmbeddedBlock",
            PropertyMappings = new Dictionary<string, string>()
            {
                { "ExcludeScripts", "excludeScripts" },
                { "FormGuid", "form" },
                { "FormTheme", "theme" },
                { "RedirectToPageId", "redirectToPage" }
            }
        }
    };

    public override string? GetContentValue(SyncMigrationContentProperty contentProperty, SyncMigrationContext context)
    {
        var rawValue = base.GetContentValue(contentProperty, context) ?? string.Empty;

        var value = new RteValue();

        // Newer sites or RTEs within blocks will already have been converted to the new format, so attempt to parse the JSON
        if (RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rawValue, _jsonSerializer, _logger, out RichTextEditorValue? existingValue))
        {
            // Migrate existing blocks
            value.Blocks!.ContentData.AddRange(existingValue.Blocks?.ContentData.Select(x=> new BlockListRowValue() { ContentTypeKey = x.ContentTypeKey, RawPropertyValues = x.RawPropertyValues, Udi = x.Udi?.ToString() }) ?? Enumerable.Empty<BlockListRowValue>());
            value.Blocks!.SettingsData.AddRange(existingValue.Blocks?.SettingsData.Select(x => new BlockListRowValue() { ContentTypeKey = x.ContentTypeKey, RawPropertyValues = x.RawPropertyValues, Udi = x.Udi?.ToString() }) ?? Enumerable.Empty<BlockListRowValue>());
            value.Blocks!.Layout!.BlockOrder.AddRange(existingValue.Blocks?.Layout?["Umbraco.TinyMCE"].Select(x => new BlockUdiValue() { ContentUdi = x["contentUdi"]?.Value<string>(), SettingsUdi = x["settingsUdi"]?.Value<string>() }) ?? Enumerable.Empty<BlockUdiValue>());
        }
        else
        {
            // Existing value is plain HTML markup, we need to update it to match the new format
            existingValue = new RichTextEditorValue() { Markup = rawValue, Blocks = new Umbraco.Cms.Core.Models.Blocks.BlockValue() };
        }

        value.Markup = Regex.Replace(existingValue.Markup, @"\<\?UMBRACO_MACRO macroAlias=""(?<macro>[^""]+)""(?:\s*(?<prop>[^=]*)=""(?<propval>[^""]*)"")*? \/>", match =>
        {
            var macroAlias = match.Groups["macro"].Value;
            var mapping = mappings.FirstOrDefault(x => x.MacroAlias.InvariantEquals(macroAlias));
            if (mapping != null)
            {
                var block = new BlockListRowValue()
                {
                    ContentTypeKey = context.ContentTypes.GetKeyByAlias(mapping.BlockTypeAlias),
                    Udi = $"umb://element/{Guid.NewGuid():N}"
                };

                // Add block config

                var propVals = match.Groups["propval"].Captures;
                var propNames = match.Groups["prop"].Captures;

                for (int i = 0; i < propVals.Count; i++)
                {
                    var propVal = HttpUtility.HtmlDecode(propVals[i].Value);
                    var propName = propNames[i].Value;
                    propName = mapping.PropertyMappings?[propName] ?? propName;

                    block.RawPropertyValues[propName] = propVal;
                }

                value.Blocks!.ContentData.Add(block);

                // Add settings if nescessary
                // value.Blocks.SettingsData.Add(settings);

                value.Blocks!.Layout!.BlockOrder.Add(new BlockUdiValue()
                {
                    ContentUdi = block.Udi,
                    //SettingsUdi = settings.Udi
                });

                return $"<umb-rte-block class=\"ng-scope ng-isolate-scope\" data-content-udi=\"{block.Udi}\"><!--Umbraco-Block--></umb-rte-block>";
            }
            return match.Value;
        }, RegexOptions.IgnoreCase | RegexOptions.Multiline);

        if (value.Blocks?.Layout?.BlockOrder?.Any() != true)
        {
            // Clear this out if we have no blocks
            value.Blocks = null;
        }

        return JsonConvert.SerializeObject(value, Formatting.Indented);
    }

    private class MacroMapping
    {
        public required string MacroAlias { get; set; }
        public required string BlockTypeAlias { get; set; }
        public Dictionary<string, string>? PropertyMappings { get; set; }
    }

    // Strongly typed versions of RichTextEditorValue
    private class RteValue
    {
        [JsonProperty("markup")]
        public string? Markup { get; set; }
        [JsonProperty("blocks")]
        public BlockListValue? Blocks { get; set; } = new BlockListValue();
    }
    private class BlockListValue
    {
        [JsonProperty("layout")]
        public BlockListLayoutValue? Layout { get; set; } = new BlockListLayoutValue();

        [JsonProperty("contentData")]
        public List<BlockListRowValue> ContentData { get; set; } = new List<BlockListRowValue>();

        [JsonProperty("settingsData")]
        public List<BlockListRowValue> SettingsData { get; set; } = new List<BlockListRowValue>();
    }
    private class BlockListLayoutValue
    {
        [JsonProperty("Umbraco.TinyMCE")]
        public List<BlockUdiValue> BlockOrder { get; set; } = new List<BlockUdiValue>();
    }
    private class BlockUdiValue
    {
        [JsonProperty("contentUdi")]
        public string? ContentUdi { get; set; }

        [JsonProperty("settingsUdi")]
        public string? SettingsUdi { get; set; }
    }
    private class BlockListRowValue
    {
        [JsonProperty("contentTypeKey")]
        public Guid ContentTypeKey { get; set; }

        [JsonProperty("udi")]
        public string? Udi { get; set; }

        [JsonExtensionData]
        public IDictionary<string, object?> RawPropertyValues { get; set; } = new Dictionary<string, object?>();
    }
}