In today’s mobile-first world, delivering personalized experiences to visitors using mobile devices is crucial for maximizing engagement and conversions. Optimizely’s powerful experimentation and personalization platform allows you to define custom audience criteria to target mobile users effectively.
By leveraging Optimizely’s audience segmentation, you can create tailored experiences based on factors such as device type, operating system, screen size, and user behavior. Whether you want to optimize mobile UX, test different layouts, or personalize content for Android vs. iOS users, understanding how to define mobile-specific audience criteria can help you drive better results.
In this blog, we’ll explore how to set up simple custom audience criteria for mobile visitors in Optimizely, the key benefits of mobile targeting, and the best practices to enhance user experiences across devices. Let’s dive in!
This solution is based on Example – Create audience criteria, which you can find in the Optimizely documentation.
First, we need to create two classes in our solution:
Class VisitorDeviceTypeCriterionSettings
needs to inherit CriterionModelBase
class, and we need only one property (settings) to determine if the visitor is using a desktop or a mobile device.
public bool IsMobile { get; set; }
The abstract CriterionModelBase
class requires you to implement the Copy()
method. Because you are not using complex reference types, you can implement it by returning a shallow copy as shown (see Create custom audience criteria):
public override ICriterionModel Copy() { return base.ShallowCopy(); }
The entire class will look something like this:
using EPiServer.Data.Dynamic; using EPiServer.Personalization.VisitorGroups; namespace AlloyTest.Personalization.Criteria { [EPiServerDataStore(AutomaticallyRemapStore = true)] public class VisitorDeviceTypeCriterionSettings : CriterionModelBase { public bool IsMobile { get; set; } public override ICriterionModel Copy() { // if this class has reference types that require deep copying, then // that implementation belongs here. Otherwise, you can just rely on // shallow copy from the base class return base.ShallowCopy(); } } }
Now, we need to implement the criterion class VisitorDeviceTypeCriterion
and inherit the abstract CriterionBase
class with the settings class as the type parameter:
public class VisitorDeviceTypeCriterion : CriterionBase<VisitorDeviceTypeCriterionSettings>
Add a VisitorGroupCriterion
attribute to set the category, name, and description of the criterion (for more available VisitorGroupCriterion
properties, see Create custom audience criteria:
[VisitorGroupCriterion( Category = "MyCustom", DisplayName = "Device Type", Description = "Criterion that matches type of the user's device" )]
The abstract CriterionBase
class requires you to implement an IsMatch()
method that determines whether the current user matches this audience criterion. In this case, we need to determine from which device the visitor is accessing our site. Because Optimizely doesn’t provide this out of the box, we need to figure out that part.
One of the solutions is to use information from the request header, from the User-Agent
field and analyze it to determine the OS and device type. We can do that by writing our match method:
public virtual bool MatchBrowserType(string userAgent) { var os = new Regex( @"(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od|ad)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino", RegexOptions.IgnoreCase | RegexOptions.Multiline); var device = new Regex( @"1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-", RegexOptions.IgnoreCase | RegexOptions.Multiline); var deviceInfo = string.Empty; if (os.IsMatch(userAgent)) { deviceInfo = os.Match(userAgent).Groups[0].Value; } if (device.IsMatch(userAgent.Substring(0, 4))) { deviceInfo += device.Match(userAgent).Groups[0].Value; } if (!string.IsNullOrEmpty(deviceInfo)) { return true; } return false; }
Now, we can go back and implement the IsMatch()
method that is required by CriterionBase
abstract class.
public override bool IsMatch(IPrincipal principal, HttpContext httpContext) { return MatchBrowserType(httpContext.Request.Headers["User-Agent"].ToString()); }
In the CMS we need to create a new audience criterion. When you click on the ‘Add Criteria’ button, there will be ‘MyCustom’ criteria group with our criteria:
When you select the ‘Device Type’ criteria, you will see something like this:
We can easily add a label for the checkbox by using Optimizely’s translation functionality. Create a new XML file VisitorGroupCriterion.xml
and place it in your translations folder where your translation files are, like this:
Put this into the file that you created:
<?xml version="1.0" encoding="utf-8" standalone="yes"?> <languages> <language name="English" id="en-us"> <visitorgroups> <criteria> <ismobile> <key>Is Mobile Device (Use this setting to show content only on Mobile)</key> </ismobile> </criteria> </visitorgroups> </language> </languages>
There is one more thing to do. In VisitorDeviceTypeCriterionSettings.cs,
decorate the IsMobile
property with the translation definition. Add this attribute:
[CriterionPropertyEditor(LabelTranslationKey = "/visitorgroups/criteria/ismobile/key")]
It should look like this:
Now, in the editor view, we have a label for the checkbox.
Personalize the content by setting the content for this visitor group.
Desktop view:
Mobile view:
You can see that there is content that is only visible if you access the site with a mobile device.
And that’s it!
]]>Having additional navigation for your content is a must-have in any modern website.
One example of that kind of navigation is Anchor/Jump links navigation. Simple, but very effective. We are simply using anchor links to jump to a specific part of the page. But since we are using the Optimizely CMS we are gonna make it more dynamic and automate the creation of anchor tags and links.
The idea is to create navigation with all anchor links that are on the page. The simplest way is to have a page template with Content Area with blocks and then “just” render the anchor link for every block. To achieve that we are gonna make an interface IJumpLinkable so that every block that we want to have an anchor tag for the anchor navigation will inherit. In that interface we will have a string property that will represent the anchor navigation item label.
public interface IJumpLinkable : IContentData { string LinkText { get; set; } }
Now add this interface in every block that we want to use. We need to implement the property from the interface and to make it a fail-safe, we will tap in in the publishing event ( Opti Documentation ) and check if the link label is empty, then use the block’s name as a fallback value.
public class MyBlock : BaseBlock, IJumpLinkable { .... [Display( GroupName = GroupNames.Navigation, Name = "Anchor/Jump Link Label", Description = "", Order = 10)] public virtual string LinkText { get; set; } .... public void PublishingContent(object sender, ContentEventArgs e) { if (string.IsNullOrEmpty(LinkText)) { LinkText = (this as IContent).Name; } } }
In our PageViewModel we have to add a dictionary object that will have key -> link label and value -> block ID pairs.
public Dictionary<string, string> JumpLinks { get; set; }
In our page controller after we create a page view model, we need to populate that dictionary object with the content area items. We will scan the items list for the content that is of IJumpLinkable type and from that content (block) we will take the link label and content ID.
... public ActionResult Index(MyContentPage currentContent) { var viewModel = CreateModel(currentContent); if(currentContent.MainContentArea != null) { var jumpLinkItems = currentContent.MainContentArea.FilteredItems.GetContentItems<IJumpLinkable>(); var links = new Dictionary<string, string>(); foreach (var jumpLinkItem in jumpLinkItems.Where(jumpLinkItem => !string.IsNullOrWhiteSpace(jumpLinkItem.LinkText))) { links.Add(jumpLinkItem.LinkText, ((IContent)jumpLinkItem).ContentLink.ID.ToString()); } viewModel.JumpLinks = links; } return View("MyContentPage", viewModel); } ...
Now that we have a list of links, all we have to do now is to render that list in our page view. We need to loop twice through that list, first to render the navigation menu and second to render the anchor tags before rendering each block from the content area. We have to render the blocks from the content area in the loop because we need to put an anchor tag with the unique ID before every block. In this example the anchor ID is constructed with the prefix “content” plus the block’s ID. ( W3School Anchor example )
if (Model.JumpLinks != null && Model.JumpLinks.Count > 0) { <section> <div class="container"> @foreach (var link in Model.JumpLinks) { <div> <a href="#content-@link.Value"> @link.Key </a> </div> } </div> </section> }
Rendering the content area:
<section class="content"> <div @Html.EditAttributes(x => x.CurrentContent.MainContentArea)> @if (Model.CurrentContent.MainContentArea != null) { foreach (var item in Model.CurrentContent.MainContentArea.Items) { <a id="content-@item.ContentLink.ID"></a> @Html.DisplayFor(x => item.ContentLink) } } </div> </section>
And that’s it!
]]>Working with a headless website setup sometimes can be challenging.
For example, if you insert an image directly into the TinyMCE text editor sometimes (it depends on a lot of factors) you can see the relative path to the server storage where the image is saved. But because it’s a headless setup, the front end part is at one web address and the back end could be in a different address which can be seen if someone inspects the page source.
The solution for everything is always to write a custom XhtmlString render service because you will mostly use text properties as content working with a headless website setup.
The idea is to hijack the final page render and parse the page content.
First, let’s implement an XhtmlStringExtensions class where we will do the parsing of the output content. To do that we will scan the page content for the image tags and replace all image sources attribute with the absolute source path of the image.
public static class XhtmlStringExtensions { /// <summary> /// Parses the XHtml String and forces all relative images to be absolute /// </summary> public static string ForceAbsoluteImgUrls(this XhtmlString html) { return html == null ? string.Empty : html.ToHtmlString().ForceAbsoluteImgUrls(); } public static string ForceAbsoluteImgUrls(this string input) { var logger = LogManager.GetLogger(typeof(XhtmlStringExtensions)); var logPrefix = $"{nameof(XhtmlStringExtensions)}->ForceAbsoluteImgUrls:"; try { var doc = new HtmlDocument(); doc.LoadHtml(input); // find all image tags, if there are none just return out- otherwise we need to adjust them var imgs = doc.DocumentNode.SelectNodes("//img"); if (imgs == null) return input; var scheme = EPiServer.Web.SiteDefinition.Current.SiteUrl.Scheme; var serverUrl = new HostString(EPiServer.Web.SiteDefinition.Current.SiteUrl.Host); logger.Information($"{logPrefix} scheme={scheme} & serverUrl={serverUrl}"); // for each image, interrogate src accordingly foreach (var img in imgs) { var src = img.Attributes["src"]?.Value; logger.Information($"{logPrefix} img src found: {src}"); // try parsing uri and determine if it's absolute Uri uri = null; if (Uri.TryCreate(src, UriKind.Absolute, out uri)) { if (uri.IsAbsoluteUri) { logger.Information($"{logPrefix} img src is absolute, skipping ({src})"); continue; // if it's already absolute, just continue processing } } // must not be absolute, so go ahead and build an absolute url for it var newSrc = UriHelper.BuildAbsolute(scheme, serverUrl, src); logger.Information($"{logPrefix} img src is not absolute, fixing setting old {src} to {newSrc}"); img.SetAttributeValue("src", newSrc); } var outerHtml = doc.DocumentNode.OuterHtml; return outerHtml; // return out the new resulting HTML } catch (Exception ex) { logger.Error($"{logPrefix} Error encountered when trying to force absolute URLs in XhtmlStrings", ex); } return input; } }
Next, we need to implement our custom Xhtml render service from where we will call our Xhtml string extension method.
// hijack the base implementation and force it to use canonical URLs for all src properties [ServiceConfiguration(typeof(CustomXhtmlRenderService))] public class CustomXhtmlRenderService : XhtmlRenderService { private ILogger logger; public CustomXhtmlRenderService() : base(ServiceLocator.Current.GetInstance<ContentApiOptions>(), ServiceLocator.Current.GetInstance<IHtmlHelper>(), ServiceLocator.Current.GetInstance<ITempDataProvider>(), ServiceLocator.Current.GetInstance<ICompositeViewEngine>(), ServiceLocator.Current.GetInstance<IModelMetadataProvider>()) { logger = ServiceLocator.Current.GetInstance<ILogger<CustomXhtmlRenderService>>(); } public CustomXhtmlRenderService( ContentApiOptions options, IHtmlHelper htmlHelper, ITempDataProvider tempDataProvider, ICompositeViewEngine compositeViewEngine, IModelMetadataProvider metadataProvider, ILogger<CustomXhtmlRenderService> logger) : base(options, htmlHelper, tempDataProvider, compositeViewEngine, metadataProvider) { this.logger = logger; } public override string RenderXhtmlString(HttpContext context, XhtmlString xhtmlString) { var result = base.RenderXhtmlString(context, xhtmlString); try { result = result.ForceAbsoluteImgUrls(); } catch (Exception e) { logger.LogError($"Unable to correct Absolute Urls in XhtmlString requested from {context?.Request?.GetDisplayUrl()}", e); } return result; } }
And finally, we need to register our custom Xhtml render service in Startup.cs .
var serviceDescriptor = services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(XhtmlRenderService)); services.Remove(serviceDescriptor); services.AddTransient(typeof(XhtmlRenderService), typeof(CustomXhtmlRenderService));
And that’s it!
Additionally, if you want to use this custom Xhtml renderer service in the project that has a front end (regular website) and the headless mode enabled, you can use it simply by adding a template override for the XhtmlString property.
To do so, in your Views folder add this file \Shared\DisplayTemplates\XhtmlString.cshtml .
@using EPiServer.Core @model XhtmlString <!-- XhtmlString.cshtml Display Template Start --> @Html.Raw(Model.ForceAbsoluteImgUrls()) <!-- XhtmlString.cshtml Display Template End -->
Happy coding!
]]>A headless CMS is a back-end only content management system (CMS) built from the ground up as a content repository that makes content accessible via a RESTful API or GraphQL API for display on any device.
The term “headless” comes from the concept of chopping the “head” (the front end, i.e. the website) off the “body” (the back end, i.e. the content repository). A headless CMS remains with an interface to manage content and a RESTful or GraphQL API to deliver content wherever you need it. Due to this approach, a headless CMS does not care about how and where your content gets displayed. It only has one focus: storing and delivering structured content and allowing content editors to collaborate on new content.
With Optimizely you can implement your headless solution using the Content Delivery API.
To filter the API response and remove the fields that you don’t need and/or remove the null field to get the nicer and clearer output you need to use IContentApiModelFilter which is explained in the Optimizely documentation.
But, that didn’t work for me how I wanted.
Here is an example of the default API response in JSON format:
{ "contentLink": { "id": 17, "workId": 0, "guidValue": "85d21fcb-6b86-47e2-8ac7-40561746f6b8", "providerName": null, "url": null, "expanded": null }, "name": "Rich Text", "language": { "link": null, "displayName": "English", "name": "en" }, "existingLanguages": [ { "link": null, "displayName": "English", "name": "en" } ], "masterLanguage": { "link": null, "displayName": "English", "name": "en" }, "contentType": [ "Block", "RichTextBlock" ], "parentLink": { "id": 16, "workId": 0, "guidValue": "ff300114-39a2-469e-9304-3703317a4894", "providerName": null, "url": "http://perficient.local/contentassets/ff30011439a2469e93043703317a4894/", "expanded": null }, "routeSegment": null, "url": null, "changed": "2022-08-05T14:59:05Z", "created": "2022-08-05T14:59:05Z", "startPublish": "2022-08-05T14:59:05Z", "stopPublish": null, "saved": "2022-08-11T12:33:39Z", "status": "Published", "category": { "value": [], "propertyDataType": "PropertyCategory" }, "globalStyle": { "value": "", "propertyDataType": "PropertyLongString" }, "mainBody": { "value": "<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime mollitia, molestiae quas vel sint commodi repudiandae consequuntur voluptatum laborum numquam blanditiis harum quisquam eius sed odit fugiat iusto fuga praesentium optio, eaque rerum! Provident similique accusantium nemo autem. Veritatis obcaecati tenetur iure eius earum ut molestias architecto voluptate aliquam nihil, eveniet aliquid culpa officia aut! Impedit sit sunt quaerat, odit, tenetur error, harum nesciunt ipsum debitis quas aliquid. Reprehenderit, quia.</p>\n<p><img src=\"http://perficient.local/globalassets/crop.jpg\" alt=\"crop\" width=\"1280\" height=\"853\" /></p>\n<p> </p>\n<p><img src=\"http://perficient.local/contentassets/b20dbeb471b94ba18ba2f76cfdab5a05/caesars.png\" alt=\"caesars.png\" /></p>", "propertyDataType": "PropertyXhtmlString" } }
After adding the Custom Content API Model Filter how is explained in the documentation, you will get something like this:
{ "contentLink": { "id": 17, "workId": 0, "guidValue": "85d21fcb-6b86-47e2-8ac7-40561746f6b8", "providerName": null, "url": null, "expanded": null }, "name": "Rich Text", "language": { "link": null, "displayName": "English", "name": "en" }, "existingLanguages": [ { "link": null, "displayName": "English", "name": "en" } ], "masterLanguage": { "link": null, "displayName": "English", "name": "en" }, "contentType": [ "Block", "RichTextBlock" ], "parentLink": null, "routeSegment": null, "url": null, "changed": "2022-08-05T14:59:05Z", "created": null, "startPublish": null, "stopPublish": null, "saved": null, "status": null, "contentTypeGuid": "3c2ed8a86f1a41459d4498cfeb8a5652", "globalStyle": { "value": "", "propertyDataType": "PropertyLongString" }, "mainBody": { "value": "<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime mollitia, molestiae quas vel sint commodi repudiandae consequuntur voluptatum laborum numquam blanditiis harum quisquam eius sed odit fugiat iusto fuga praesentium optio, eaque rerum! Provident similique accusantium nemo autem. Veritatis obcaecati tenetur iure eius earum ut molestias architecto voluptate aliquam nihil, eveniet aliquid culpa officia aut! Impedit sit sunt quaerat, odit, tenetur error, harum nesciunt ipsum debitis quas aliquid. Reprehenderit, quia.</p>\n<p><img src=\"http://perficient.local/globalassets/crop.jpg\" alt=\"crop\" width=\"1280\" height=\"853\" /></p>\n<p> </p>\n<p><img src=\"http://perficient.local/contentassets/b20dbeb471b94ba18ba2f76cfdab5a05/caesars.png\" alt=\"caesars.png\" /></p>", "propertyDataType": "PropertyXhtmlString" } }
For this example, I put the null values for these fields:
contentApiModel.StartPublish = null; contentApiModel.StopPublish = null; contentApiModel.ParentLink = null; contentApiModel.RouteSegment = null; contentApiModel.Created = null; contentApiModel.Saved = null; contentApiModel.Status = null;
You can see in the previous JSON response that those fields are still there but with the null value.
To actually remove those nulled fields, you need to add one settings option in Startup.cs services under the Content Delivery section.
// remove null values from serialized data services.ConfigureContentDeliveryApiSerializer(settings => { settings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore; });
The only thing here is that the ConfigureContentDeliveryApiSerializer is in the Internal namespace but that won’t go anywhere any time soon.
Now we have this nice and clean JSON response:
{ "contentLink": { "id": 17, "workId": 0, "guidValue": "85d21fcb-6b86-47e2-8ac7-40561746f6b8" }, "name": "Rich Text", "language": { "displayName": "English", "name": "en" }, "existingLanguages": [ { "displayName": "English", "name": "en" } ], "masterLanguage": { "displayName": "English", "name": "en" }, "contentType": [ "Block", "RichTextBlock" ], "changed": "2022-08-05T14:59:05Z", "globalStyle": "", "mainBody": "<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime mollitia, molestiae quas vel sint commodi repudiandae consequuntur voluptatum laborum numquam blanditiis harum quisquam eius sed odit fugiat iusto fuga praesentium optio, eaque rerum! Provident similique accusantium nemo autem. Veritatis obcaecati tenetur iure eius earum ut molestias architecto voluptate aliquam nihil, eveniet aliquid culpa officia aut! Impedit sit sunt quaerat, odit, tenetur error, harum nesciunt ipsum debitis quas aliquid. Reprehenderit, quia.</p>\n<p><img src=\"http://perficient.local/globalassets/crop.jpg\" alt=\"crop\" width=\"1280\" height=\"853\"></p>\n<p> </p>\n<p><img src=\"http://perficient.local/contentassets/b20dbeb471b94ba18ba2f76cfdab5a05/caesars.png\" alt=\"caesars.png\"></p>" }
For example, for some reason I want to have a content type GUID as a field in the JSON response.
Just add this code at the end of your Custom Content API Model Filter.
contentApiModel.Properties[CONTENT_TYPE_GUID] = Guid.Empty.ToString("N"); if (contentApiModel.ContentLink.Id != null && contentApiModel.ContentLink.Id.HasValue) { var content = contentLoader.Get<IContent>(new ContentReference(contentApiModel.ContentLink.Id.Value)); var contentType = content.ContentTypeID; var type = contentTypeRepository.Load(contentType); if (type != null && type.GUID != Guid.Empty) { contentApiModel.Properties[CONTENT_TYPE_GUID] = type.GUID.ToString("N"); } }
Now the response will be like this:
{ "contentLink": { "id": 17, "workId": 0, "guidValue": "85d21fcb-6b86-47e2-8ac7-40561746f6b8" }, "name": "Rich Text", "language": { "displayName": "English", "name": "en" }, "existingLanguages": [ { "displayName": "English", "name": "en" } ], "masterLanguage": { "displayName": "English", "name": "en" }, "contentType": [ "Block", "RichTextBlock" ], "changed": "2022-08-05T14:59:05Z", "contentTypeGuid": "3c2ed8a86f1a41459d4498cfeb8a5652", "globalStyle": "", "mainBody": "<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime mollitia, molestiae quas vel sint commodi repudiandae consequuntur voluptatum laborum numquam blanditiis harum quisquam eius sed odit fugiat iusto fuga praesentium optio, eaque rerum! Provident similique accusantium nemo autem. Veritatis obcaecati tenetur iure eius earum ut molestias architecto voluptate aliquam nihil, eveniet aliquid culpa officia aut! Impedit sit sunt quaerat, odit, tenetur error, harum nesciunt ipsum debitis quas aliquid. Reprehenderit, quia.</p>\n<p><img src=\"http://perficient.local/globalassets/crop.jpg\" alt=\"crop\" width=\"1280\" height=\"853\"></p>\n<p> </p>\n<p><img src=\"http://perficient.local/contentassets/b20dbeb471b94ba18ba2f76cfdab5a05/caesars.png\" alt=\"caesars.png\"></p>" }
Here is our added field.
And that’s it!
CustomContentApiModelFilter.cs
using EPiServer; using EPiServer.ContentApi.Core.Serialization; using EPiServer.ContentApi.Core.Serialization.Internal; using EPiServer.ContentApi.Core.Serialization.Models; using EPiServer.Core; using EPiServer.DataAbstraction; using EPiServer.ServiceLocation; using System; namespace Perficient.Web.Middleware.ApiModelFilter { [ServiceConfiguration(typeof(IContentApiModelFilter), Lifecycle = ServiceInstanceScope.Singleton)] public class CustomContentApiModelFilter : ContentApiModelFilter<ContentApiModel> { private readonly IContentLoader contentLoader; private readonly IContentTypeRepository contentTypeRepository; private const string CONTENT_TYPE_GUID = "ContentTypeGuid"; public CustomContentApiModelFilter(IContentLoader contentLoader, IContentTypeRepository contentTypeRepository) { this.contentLoader = contentLoader; this.contentTypeRepository = contentTypeRepository; } public override void Filter(ContentApiModel contentApiModel, ConverterContext converterContext) { // To remove values from the output, set them to null. // thus the response output will not contain these "out of the box" fields contentApiModel.StartPublish = null; contentApiModel.StopPublish = null; contentApiModel.ParentLink = null; contentApiModel.RouteSegment = null; contentApiModel.Created = null; contentApiModel.Saved = null; contentApiModel.Status = null; // remove category as we don't need it at the API level contentApiModel.Properties.Remove("Category"); #region Add Content Type GUID to output // add a field called contentTypeGuid which has the ID of the content type in the output, this will be // useful for keying off of to understand what type of content is being delivered contentApiModel.Properties[CONTENT_TYPE_GUID] = Guid.Empty.ToString("N"); if (contentApiModel.ContentLink.Id != null && contentApiModel.ContentLink.Id.HasValue) { var content = contentLoader.Get<IContent>(new ContentReference(contentApiModel.ContentLink.Id.Value)); var contentType = content.ContentTypeID; var type = contentTypeRepository.Load(contentType); if (type != null && type.GUID != Guid.Empty) { contentApiModel.Properties[CONTENT_TYPE_GUID] = type.GUID.ToString("N"); } } #endregion } } }
]]>
Alongside Node.js, we need to have node-sass installed as well so that we can compile scss files.
Here is how you can easily do that in the Alloy Demo project.
First, we need to create these three files: webpack.config.js
, package.json
and babel.config.json
place it in /Static
the folder:
Create app.js
file and place it in /Static/js
folder:
Create two folders (empty for now) under the /Static
folder : dist
and scss
.
Edit and add these lines to the webpack.config.js
.
const path = require('path'); const fs = require("fs"); const webpack = require("webpack"); const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = { entry: './js/app.js', output: { path: path.resolve(__dirname, './dist'), filename: 'alloy.bundle.js' }, //watch: true, // this line will enable watcher for the js and scss changes (real time) module: { rules: [ { test: /\.css$/, use: ['style-loader', 'css-loader'] }, { test: /\.scss$/, use: ["style-loader", "css-loader", "sass-loader"] }, { test: /\.js$/, exclude: /(node_modules|bower_components)/, use: ["babel-loader"] }, { test: /\.(eot|gif|otf|png|svg|ttf|woff)(\?v=[0-9]\.[0-9]\.[0-9])?$/, use: ['file-loader'] } ] }, stats: { colors: true }, devtool: 'source-map', plugins: [ new CleanWebpackPlugin(), // jQuery globally new webpack.ProvidePlugin({ $: "jquery", jQuery: "jquery", "window.jQuery": "jquery" }) ] };
This is the standard webpack config settings:
$
in all the scripts that are defined and used via app.js
.
Edit and add these lines to the package.json.
{ "name": "static", "version": "1.0.0", "description": "", "main": "webpack.config.js", "dependencies": { "bootstrap": "^4.5.0", "bootstrap-slider": "^11.0.2", "bootstrap-validate": "^2.2.0", "jquery-match-height": "^0.7.2", "popper.js": "^1.16.1", "slick-carousel": "^1.8.1", "underscore": "^1.10.2" }, "devDependencies": { "@babel/core": "^7.10.3", "@babel/preset-env": "^7.10.3", "axios": "^0.19.2", "babel-loader": "^8.1.0", "clean-webpack-plugin": "^3.0.0", "css-loader": "^3.6.0", "expose-loader": "^1.0.0", "file-loader": "^6.0.0", "jquery": "^3.5.1", "sass": "^1.26.9", "sass-loader": "^8.0.2", "style-loader": "^1.2.1", "webpack": "^4.43.0", "webpack-cli": "^3.3.12" }, "scripts": { "dev": "webpack --mode development", "build": "webpack --mode production", "compile-scss": "node-sass ./scss/main.scss ./css/main.css --source-map true --source-map-contents sass --output-style compressed && node-sass ./scss/editor.scss ./css/editor.css --source-map true --source-map-contents sass --output-style compressed && node-sass ./scss/editmode.scss ./css/editmode.css --source-map true --source-map-contents sass --output-style compressed" }, "keywords": [], "author": "Perficient", "license": "ISC" }
All these packages will be installed when you run npm install
in the command line in /Static
folder. Under the ‘scripts’ section, we have three build/run options:
npm run dev
in the command linenpm run build
in the command linenode-sass
for compiling scss files with options/switches. In the Alloy project, we have three css files: main.css
, editor.css
and editmode.css
; use npm run compile-scss
in the command line
Edit and add these lines to the babel.config.json.
{ "presets": [ "@babel/preset-env" ] }
Edit and add these lines to the app.js
.
"use strict"; import 'bootstrap';
Alloy projects use bootstrap and jQuery only, so we don’t have any additional JS code here.
Now because the Alloy project already contains JS and CSS files that are bundled in the Initialization module ( BundleConfig.cs
) we need to make some changes so that we can use those files with webpack:
vendor
folder under the \Static\js
folder and move all js files from the Alloy project there.
scss
folder under the \Static
folder and move all files from css
folder to the scss
folder.
main.scss
, and editmode.scss
.editmode.css
to the editmodealloy.css
.main.scss
@import "bootstrap", "bootstrap-responsive", "media", "style";
editor.scss
@import "bootstrap", "style";
editmode.scss
@import "editmodealloy";
To check if we have done everything right, we can run these two commands in the command line in /Static
folder: npm run dev
and npm run compile-scss
. If everything is OK, in the solution explorer (VS) you will have this file structure
Now we need to update the main template view – _Root.cshtml
file.
<head> ... @*@Styles.Render("~/bundles/css") @Scripts.Render("~/bundles/js")*@ <link rel="stylesheet" href="~/Static/css/main.css"> @if (PageEditing.PageIsInEditMode) { <link rel="stylesheet" href="~/Static/css/editor.css"> } ... </head>
We need to comment (or delete) the bundle lines and add the link to the newly created css files. We added to check if the page is rendered in Edit mode so that we can load editor.css
only when we edit the page.
<body> ... <script type="text/javascript" src="~/Static/dist/alloy.bundle.js"></script> </body>
Add the link to the webpack generated js file.
And that’s it!
If you want to webpack build and compile js and scss files when you build the project in VS, just add these lines in .csproj
file.
<Target Name="CompileSass" AfterTargets="Build"> <Message Text="Compiling Front-End assets" /> <!-- always --> <Exec Command="npm run compile-scss" IgnoreExitCode="false" WorkingDirectory="$(SolutionDir)/AlloyDemo/Static" /> <!-- local dev --> <Exec Command="npm run dev" Condition="'$(Configuration)' == 'Debug'" IgnoreExitCode="false" WorkingDirectory="$(SolutionDir)/AlloyDemo/Static" /> <!-- release --> <Exec Command="npm run build" Condition="'$(Configuration)' == 'Release'" IgnoreExitCode="false" WorkingDirectory="$(SolutionDir)/AlloyDemo/Static" /> </Target>
Just replace AlloyDemo
with the name of your project.
Create a standard Episerver initialization module and implement Initialize()
function.
[InitializableModule] [ModuleDependency(typeof(EPiServer.Web.InitializationModule))] public class PageRestrictionInitialization : IInitializableModule { private IContentTypeRepository _contentTypeRepository; private IAvailableSettingsRepository _availableSettingsRepository; private ILogger _log; public void Initialize(InitializationEngine context) { } public void Uninitialize(InitializationEngine context) { } }
We are using two helper functions that are making the code cleaner and easier to read and maintain.
private void DisallowAll<T>() { var page = _contentTypeRepository.Load(typeof(T)); var setting = new AvailableSetting { Availability = Availability.None }; _availableSettingsRepository.RegisterSetting(page, setting); }
DisallowAll()
function will set a page type to not have any available pages to be inserted beneath of it. We are using this function to first set the availability to none and then set the availability how we want.private void SetPageRestriction<T>(IEnumerable<Type> pageTypes) { var page = _contentTypeRepository.Load(typeof(T)); var setting = new AvailableSetting { Availability = Availability.Specific }; foreach (var pageType in pageTypes) { var contentType = _contentTypeRepository.Load(pageType); setting.AllowedContentTypeNames.Add(contentType.Name); } _availableSettingsRepository.RegisterSetting(page, setting); _log.Log(Level.Debug, $"{page.Name} restriction added."); }
SetPageRestriction()
function is where all the restrictions are done. You can pass the list of page types as a function argument and that those page types will be set as allowed page types for the passing page type type <T>.
Now we can implement the Initialize()
function.
public void Initialize(InitializationEngine context) { _contentTypeRepository = ServiceLocator.Current.GetInstance<IContentTypeRepository>(); _availableSettingsRepository = ServiceLocator.Current.GetInstance<IAvailableSettingsRepository>(); _log = LogManager.GetLogger(); var sysRoot = _contentTypeRepository.Load("SysRoot") as PageType; var setting = new AvailableSetting { Availability = Availability.Specific, AllowedContentTypeNames = { nameof(StartPage), // can't use custom interfaces with IContentTypeRepository } }; _availableSettingsRepository.RegisterSetting(sysRoot, setting); // Disallow insertion for all product and article related pages DisallowAll<ProductPage>(); DisallowAll<ArticlePage>(); DisallowAll<LandingPage>(); DisallowAll<FindSearchPage>(); // Home Page SetPageRestriction<StartPage>(new List<Type> { typeof(ContainerPage), typeof(StandardPage), typeof(LandingPage), typeof(FindSearchPage), typeof(ProductPage) }); // Landing Page SetPageRestriction<LandingPage>(new List<Type> { typeof(ArticlePage), typeof(NewsPage) }); // Container Page SetPageRestriction<ContainerPage>(new List<Type> { typeof(ContactPage) }); // Product Page SetPageRestriction<ProductPage>(new List<Type> { typeof(ContainerPage), typeof(StandardPage) }); }
I am using the Episerver Alloy demo website for this example and all the page types are from the Alloy project.
First, we get the SysRoot node as a page type and use it as a first page/node. Then we allow only the StartPage to be inserted under the root page and save the settings.
Next, we disallow inserting any page under the ProductPage, ArticlePage, LandingPage and FindSearchPage. Now we need to allow insertion for every page type how we want it. For this example I did that for the StartPage, LandingPage, ContainerPage and the ProductPage.
And that’s it.
In this way, you have a clean model for all page types and everything in one place and it’s easy to maintain and extend later on. And this concept consolidates to the first SOLID principles – Single Responsibility.
]]>These days, every website has some video content. We wanted to make editing video easier for Epi content editors, so we created the YouTube Video block. This block is a feature of our new SCORE for Episerver platform, which launched in 2019.
To create the best editor experience with on-page editing functionality, we created the YouTube Video block – which actually consists of two blocks: a Dojo widget and two views. In this way, the changes are visible immediately on the page editing view and the block can be used as a stand-alone block or as a property of another block or page type. And of course, you’ll need a working YouTube Data API Key.
This is an example of a stand-alone YT Video block. After creation, you can see the placeholder image which is clickable (on-page edit).
YT Video editor is a Dojo widget where you can perform video searches in the same way you’d usually do on the YouTube website.
On editor dialog, you have a result list and pagination links.
Just click on the desired video and the video field will be updated with the selected video right away.