For my current client we’re building a component based platform as a common feature hub for serveral large EPiServer websites. For this, we have stripped away the default Razor based rendering that is normally used for the view and edit modes, and replaced it with React all the way through.
Having React render blocks through ContentArea properties for the view and on-page-edit modes may not be much hassle, it’s all about making EPiServer output the proper JSON; however, how do you change the rendering of a random block dropped into an XhtmlString property field if you at the same time want to keep partial block controllers at a minimum?
Custom markup for blocks dropped in EPiServer XhtmlString fields
By default you may find yourself getting a rather unuseful div-tag containing the block instance’s name in EPiServer when you drop it into a XhtmlString field.
<div class="epi-contentfragment">BlockName</div>
This is probably not what you’d want if you went through the effort of building something like a global link library of LinkBlock types supposed to be used in any field on the website.
So, let’s create an interface to help us.
IHasCustomXhtmlFieldRenderer.cs
public interface IHasCustomXhtmlFieldRenderer : IContentData { string ToXhtmlFieldString(); }
Sure, I could work on my naming. Anyhow, if we implement this interface on every type that is to have a custom rendering when dropped into an XhtmlString property, we would have the type itself being responsible for how it wants to be rendered.
LinkBlock.cs
[ContentType( DisplayName = "Library link", GUID = "534EF192-5784-42E9-9F96-B20BE1830DF0", Description = "Used to create global library links that you can use all over the website.")] [ThumbnailImage(Thumbnails.Link)] public class LinkBlock : BaseBlock, IHasCustomXhtmlFieldRenderer { private readonly Lazy<ILinkLibraryService> _service = new Lazy<ILinkLibraryService>(() => ServiceLocator.Current.GetInstance<ILinkLibraryService>()); [Display( Name = "Text", Description = "The link text that the visitor clicks on.", Order = 10)] public virtual string Text { get; set; } [Display( Name = "Target", Description = "The target of the link", Order = 20)] [ContentSerializerIgnore] public virtual Url Target { get; set; } [Ignore] [ContentSerializerInclude] public virtual string FriendlyTargetUrl => _service.Value.FriendlyUrlFor(this); public string ToXhtmlFieldString() { return $"<a href='{this.FriendlyTargetUrl}'>{this.Text}</a>"; } }
The important parts in the snippet above is the implementation of the interface. I’ve included the markup in the block type code to make it easier to understand, of course you could retrieve it from somewhere if you’re not a fan of having HTML code in your classes. The lazy loaded ILinkLibraryService service is just for getting the friendly version of the target URL, as well as some other stuff not vital to our goal.
As you probably know, the Ignore attribute makes EPiServer omit adding the property to the database. The ContentSerializerInclude and ContentSerializerIgnore attributes on the other hand comes from a third party serializer package which we use to avoid inventing the wheel again; JOS.ContentSerializer created by Josef Ottosson.
This serializer takes EPiServer content types and serializes them into JSON; which means, while it translates the types into something that is actually serializable, it would need to process our XhtmlString fields as well. Digging into the code, we find that Josef has left us with several highly useful interfaces to implement – for instance, the one responsible for handling XhtmlString properties IXhtmlStringPropertyHandler.
So, let’s make a custom implementation!
CustomXhtmlStringPropertyHandler.cs
public class CustomXhtmlStringPropertyHandler : IXhtmlStringPropertyHandler { private readonly IContentLoader _contentLoader; public CustomXhtmlStringPropertyHandler(IContentLoader contentLoader) { _contentLoader = contentLoader ?? throw new ArgumentNullException(nameof(contentLoader)); } public object GetValue(XhtmlString xhtmlString) { HandleFragmentsFor(ref xhtmlString); return (object)xhtmlString.ToHtmlString(); } private void HandleFragmentsFor(ref XhtmlString xhtmlString) { if (xhtmlString?.Fragments == null || !xhtmlString.Fragments.Any()) { return; } if (!HasBlocksOfType<IHasCustomXhtmlFieldRenderer>(xhtmlString.Fragments)) { return; } xhtmlString = xhtmlString.CreateWritableClone(); for (var i = 0; i < xhtmlString.Fragments.Count; i++) { var fragment = xhtmlString.Fragments[i] as ContentFragment; if (fragment == null) { continue; } var hasContent = _contentLoader.TryGet<IContent>(fragment.ContentLink, out IContent referencedContent); if (!hasContent) { continue; } if (!referencedContent.IsPublished() || ! referencedContent.IsAccessible()) { continue; } var content = referencedContent as IHasCustomXhtmlFieldRenderer; if (content == null) { continue; } var replacementFragment = new StaticFragment(content.ToXhtmlFieldString()); xhtmlString.Fragments[i] = replacementFragment; } } private bool HasBlocksOfType<T>(StringFragmentCollection fragments) where T : IContentData { var contentFragments = fragments.OfType<ContentFragment>(); return contentFragments.Any(f => _contentLoader.TryGet(f.ContentLink, out T content)); } }
The serializer’s default implementation really doesn’t do much other than what you’d expect it to be doing, so I used it as a template and added a method for handling XhtmlString fragments. First, all it does is checking whether or not it is necessary to actually process the field; the HasBlocksOfType method at the bottom checks if there are any ContentFragments of the proper type (in this case, our interface IHasCustomXhtmlFieldRenderer). If there are relevant content, we can go ahead and create our writable clone object (since we cannot change the fragment collection otherwise).
Looping through all the fragments, we look for occurences of our block. When we find them, we make sure that they are both published and accessible for the currently user (the IsPublished and IsAccessible extension methods are just using EPiServer’s own code internally).
Blocks of a type implementing our interface are then replaced by a StaticFragment containing what we get from our ToXhtmlFieldString method call, before being passed back into the JOS Serializer.
In order to make the JOS Serializer use our custom handler rather than it’s own, we need to registrer it with the container. This may be done by creating a class implementing EPiServer’s IConfigurableModule as below.
JOSPropertyHandlerInitialization.cs
[InitializableModule] [ModuleDependency(typeof(EPiServer.Web.InitializationModule))] public class JOSPropertyHandlerInitialization : IConfigurableModule { public void Initialize(InitializationEngine context) { } public void Uninitialize(InitializationEngine context) { } public void ConfigureContainer(ServiceConfigurationContext context) { // Force remove JOS default handler already registered, we don't need it. context.Services.RemoveAll<IXhtmlStringPropertyHandler>(); context.Services.AddSingleton<IXhtmlStringPropertyHandler, CustomXhtmlStringPropertyHandler>(); } }
As we don’t need the default handler anymore, we just remove all previous registrations for the interface, and then add our own. Now when a LinkBlock is dropped into an EPiServer XhtmlString field it will render an a-tag in the markup rather than a div.
Thank you for writing about my package, it’s really fun to see how people use it.
I like your implementation of the XhtmlStringProperty, it’s really tricky to have a “full featured” default propertyhandler for that kind of property since everybody uses it differently… :)
Thanks! Yeah very tricky, but those interfaces are very handy to shape it for custom needs :) Is there any special reason behind the default ContentArea handler using the Items property instead of FilteredItems by the way? Thinking of changing it to the filtered version for this particular website, but I’m not entirely sure it won’t cause effects that’s I’ve not considered.
Cheers!
Mathias
Nope, no reason at all!
I will release a major update in a week or so, that uses FilteredItems, plus a bunch of other nice features :)