<?xml version="1.0" encoding="utf-8"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/"><channel><language>en</language><title>Blog posts by KennyG</title> <link>https://world.optimizely.com/blogs/kennyg/</link><description></description><ttl>60</ttl><generator>Optimizely World</generator><item> <title>Sending Email with Attachments with Optimizely Forms in 2025</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2025/8/sending-email-with-attachments-with-optimizely-forms-in-2025/</link>            <description>&lt;p&gt;&lt;em&gt;Most articles on adding attachments to Optimizely Forms emails are from around 2019 and don&amp;rsquo;t cover the most recent versions. This post walks through my 2025 implementation and the lessons learned. Your mileage may vary.&lt;/em&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;The Problem&lt;/h2&gt;
&lt;p&gt;Optimizely Forms&amp;rsquo; built-in &lt;strong&gt;SendEmailAfterSubmissionActor &lt;/strong&gt;sends clean, simple emails after a form is submitted. When you use the &lt;strong&gt;File Upload Form Element Block&lt;/strong&gt;, the actor includes links in the email to where files are stored in the CMS.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s fine for internal users with CMS access. But if your recipients are &lt;strong&gt;external&lt;/strong&gt; &amp;mdash; customers, partners, vendors &amp;mdash; those links won&amp;rsquo;t work for them.&lt;br /&gt;What I needed was:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Direct access to the &lt;strong&gt;MimeMessage &lt;/strong&gt;object.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Ability to attach uploaded files &lt;strong&gt;directly&lt;/strong&gt; to the outgoing email.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Why Subclassing Won&amp;rsquo;t Work&lt;/h2&gt;
&lt;p&gt;Optimizely&amp;rsquo;s &lt;strong&gt;SendEmailAfterSubmissionActor &lt;/strong&gt;only allows limited subclassing.&lt;br /&gt;Many of the key operations are &lt;strong&gt;private&lt;/strong&gt;, not &lt;strong&gt;protected&lt;/strong&gt;, and &lt;strong&gt;MimeMessage&lt;/strong&gt;&amp;nbsp;is never handed back for customization.&lt;/p&gt;
&lt;p&gt;That means no way to simply &amp;ldquo;hook in&amp;rdquo; and add attachments &amp;mdash; which is why I opted to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Decompile the original actor.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Copy the logic wholesale.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Insert my changes where needed.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;The Approach&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;original actor&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Sends plain text or HTML emails (&lt;strong&gt;TextPart&amp;nbsp;&lt;/strong&gt;only).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Has no concept of multipart MIME or file attachments.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;strong&gt;modified actor&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Builds a &lt;strong&gt;Multipart(&quot;mixed&quot;)&lt;/strong&gt; message.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Adds the original body as a &lt;strong&gt;TextPart&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Calls a new helper method &lt;strong&gt;AddFileAttachments(multipart)&lt;/strong&gt;,&amp;nbsp;to append uploaded files.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Resolves uploaded files from CMS blobs, determines their content type, and attaches them to the outgoing email.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Attachments Support&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Attachment handling includes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Detecting file upload fields from the form submission (&lt;strong&gt;FriendlyNameInfo &lt;/strong&gt;and field naming patterns like &lt;strong&gt;file&lt;/strong&gt;, &lt;strong&gt;upload&lt;/strong&gt;, &lt;strong&gt;attachment&lt;/strong&gt;).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Resolving blob references via &lt;strong&gt;IBlobFactory&lt;/strong&gt; and &lt;strong&gt;IUrlResolver&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Determining content type based on file extension.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Embedding attachments as &lt;strong&gt;MimePart &lt;/strong&gt;instances with base64 encoding.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Dependency Additions&lt;/h2&gt;
&lt;p&gt;To make this work, I added:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;IFormRepository&lt;/strong&gt; &amp;ndash; retrieve form field metadata.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;IBlobFactory &lt;/strong&gt;&amp;amp; &lt;strong&gt;IUrlResolver &lt;/strong&gt;&amp;ndash; fetch binary data for uploaded files.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A &lt;strong&gt;DetermineContentType &lt;/strong&gt;helper &amp;ndash; ensures attachments have correct MIME types.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Body Construction Changes&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Original&lt;/strong&gt;&lt;br /&gt;Used:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;message.Body = new TextPart(subtype) { Text = this.RewriteUrls(content) };&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Modified&lt;/strong&gt;&lt;br /&gt;Wraps the body in a &lt;strong&gt;Multipart(&quot;mixed&quot;)&lt;/strong&gt; so it can contain both the body and attachments:&lt;/p&gt;
&lt;div class=&quot;contain-inline-size rounded-2xl relative bg-token-sidebar-surface-primary&quot;&gt;
&lt;div class=&quot;overflow-y-auto p-4&quot;&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var multipart = new Multipart(&quot;mixed&quot;);
multipart.Add(new TextPart(subtype) { Text = this.RewriteUrls(content) });
this.AddFileAttachments(multipart);
message.Body = multipart;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr /&gt;
&lt;h2&gt;Key New Methods&lt;/h2&gt;
&lt;h3&gt;Attachment Processing&lt;/h3&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;protected virtual void AddFileAttachments(Multipart multipart)
{
    var friendlyNameInfos = _formRepository.Service.GetFriendlyNameInfos(this.FormIdentity);

    foreach (var submissionDataItem in this.SubmissionData.Data)
    {
        var friendlyNameInfo = friendlyNameInfos
            .FirstOrDefault(f =&amp;gt; string.Equals(f.ElementId, submissionDataItem.Key, StringComparison.OrdinalIgnoreCase));

        if (friendlyNameInfo != null &amp;amp;&amp;amp; IsFileUploadField(friendlyNameInfo, submissionDataItem.Value))
        {
            var fileUploads = submissionDataItem.Value.ToString().Split(&quot;|&quot;);

            foreach (var fileUpload in fileUploads)
            {
                AttachFileFromBlob(multipart, fileUpload, friendlyNameInfo.FriendlyName);
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If the form allows multiple uploads, they come through as a single string separated by &lt;strong&gt;|&lt;/strong&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;File Attachment Helper&lt;/h3&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;protected virtual void AttachFileFromBlob(Multipart multipart, string blobIdentifier, string friendlyName)
{
    Blob blob = null;
    IContent contentData = _urlResolver.Service.Route(new UrlBuilder(blobIdentifier));

    if (contentData is MediaData media)
    {
        blob = _blobFactory.Service.GetBlob(media.BinaryData.ID);
    }

    if (blob != null)
    {
        var memoryStream = new MemoryStream();
        blob.OpenRead().CopyTo(memoryStream);
        memoryStream.Position = 0;

        var fileName = blobIdentifier.Split(new[] { &quot;#@&quot; }, StringSplitOptions.None).Last();
        var contentType = DetermineContentType(blob, fileName);

        multipart.Add(new MimePart(contentType)
        {
            Content = new MimeContent(memoryStream, ContentEncoding.Default),
            ContentDisposition = new ContentDisposition(ContentDisposition.Attachment),
            ContentTransferEncoding = ContentEncoding.Base64,
            FileName = fileName
        });
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The original filename is appended to the identifier after &lt;strong&gt;#@&lt;/strong&gt; &amp;mdash; this extracts it for the email attachment.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;Determine Content Type Helper&lt;/h3&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    protected virtual string DetermineContentType(Blob blob, string fileName)
    {
        // Try to get content type from blob
        // This might be available in blob metadata depending on your implementation

        // Fall back to determining from file extension
        string extension = Path.GetExtension(fileName)?.ToLowerInvariant();

        return extension switch
        {
            &quot;.pdf&quot; =&amp;gt; &quot;application/pdf&quot;,
            &quot;.doc&quot; =&amp;gt; &quot;application/msword&quot;,
            &quot;.docx&quot; =&amp;gt; &quot;application/vnd.openxmlformats-officedocument.wordprocessingml.document&quot;,
            &quot;.xls&quot; =&amp;gt; &quot;application/vnd.ms-excel&quot;,
            &quot;.xlsx&quot; =&amp;gt; &quot;application/vnd.openxmlformats-officedocument.spreadsheetml.sheet&quot;,
            &quot;.jpg&quot; or &quot;.jpeg&quot; =&amp;gt; &quot;image/jpeg&quot;,
            &quot;.png&quot; =&amp;gt; &quot;image/png&quot;,
            &quot;.gif&quot; =&amp;gt; &quot;image/gif&quot;,
            &quot;.txt&quot; =&amp;gt; &quot;text/plain&quot;,
            &quot;.csv&quot; =&amp;gt; &quot;text/csv&quot;,
            &quot;.zip&quot; =&amp;gt; &quot;application/zip&quot;,
            &quot;.xml&quot; =&amp;gt; &quot;application/xml&quot;,
            &quot;.json&quot; =&amp;gt; &quot;application/json&quot;,
            _ =&amp;gt; &quot;application/octet-stream&quot;
        };
    }&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;SendMessage() Changes&lt;/h3&gt;
&lt;p&gt;Original:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;MimeMessage message = new MimeMessage();
            
message.Subject = str1;
string subtype = this._sendMessageInHTMLFormat ? &quot;html&quot; : &quot;plain&quot;;
message.Body = (MimeEntity) new TextPart(subtype, new object[1]
{
    (object) this.RewriteUrls(content)
});&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Modified:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;MimeMessage message = new MimeMessage();
message.Subject = str1;

// Create multipart message to support attachments
var multipart = new Multipart(&quot;mixed&quot;);

string subtype = this._sendMessageInHTMLFormat ? &quot;html&quot; : &quot;plain&quot;;
multipart.Add(new TextPart(subtype, new object[1]
{
       (object) this.RewriteUrls(content)
}));

// Add file attachments
this.AddFileAttachments(multipart);

message.Body = multipart;&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Wiring It Up&lt;/h2&gt;
&lt;p&gt;To replace the out-of-the-box actor, you&amp;rsquo;ll likely need to do it in your Dependency Injection setup.&lt;br /&gt;In my case, I didn&amp;rsquo;t replace it globally &amp;mdash; we already had a subclassed actor for applying different HTML templates, so I had that subclass inherit from the &lt;strong&gt;new replacement&lt;/strong&gt; instead of the original.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Takeaways&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Original actor:&lt;/strong&gt; Simple, reliable, zero attachment support.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Modified actor:&lt;/strong&gt; Same foundation, plus multipart MIME and attachment handling.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you need to extend built-in actors in Optimizely Forms beyond what the public API offers, sometimes you&amp;rsquo;ll have to work at the code level.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Caution:&lt;/strong&gt; After any update to the Forms package, check if SendEmailAfterSubmissionActor has changed &amp;mdash; you may need to re-sync your version or take advantage of new core features.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Have any thoughts or opinions? Let me know in the comments!&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2025/8/sending-email-with-attachments-with-optimizely-forms-in-2025/</guid>            <pubDate>Mon, 11 Aug 2025 18:22:37 GMT</pubDate>           <category>Blog post</category></item><item> <title>Follow-Up: Fixing JSON Casing in Optimizely CMS Export Data</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2025/5/a-targeted-fix-for-json-casing-on-exportdatagetexportstatus-in-optimizely-cms/</link>            <description>&lt;p&gt;&lt;!--StartFragment --&gt;&lt;/p&gt;
&lt;p&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;&lt;strong&gt;*Note: This issue seems to be resolved with&amp;nbsp;EPiServer.CMS Version 12.33.1.&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h2&gt;A Targeted Action Filter to Enforce CamelCase&lt;/h2&gt;
&lt;p&gt;In my &lt;a href=&quot;/link/4a41671b2d16476d832f19e7fdd13f78.aspx&quot;&gt;previous blog post&lt;/a&gt;, I discussed an issue we were having with JSON formatting in Optimizely CMS exports after updating NuGet packages. Specifically, the &lt;strong&gt;/EPiServer/EPiServer.Cms.UI.Admin/ExportData/GetExportStatus&lt;/strong&gt;&amp;nbsp;endpoint returned &lt;strong&gt;PascalCase&lt;/strong&gt; JSON property names, breaking other parts of our site where the frontend expected&amp;nbsp;&lt;strong&gt;camelCase&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That led to UI failures&amp;mdash;most notably, a never-ending loading spinner. To address this without affecting global JSON settings, I implemented an &lt;strong&gt;action filter&lt;/strong&gt; that ensures camelCase formatting for this specific route. Here&amp;rsquo;s how it works.&lt;/p&gt;
&lt;h3&gt;Recap: Why JSON Formatting Matters&lt;/h3&gt;
&lt;p&gt;Many JavaScript frameworks (like React and Angular) expect JSON property names in camelCase. When a response unexpectedly comes in PascalCase, bindings can break, leading to missing data or even complete UI failures.&lt;/p&gt;
&lt;p&gt;Instead of globally modifying JSON settings&amp;mdash;which might have unintended consequences across the CMS&amp;mdash;the best approach is a &lt;strong&gt;targeted fix&lt;/strong&gt; that applies &lt;strong&gt;only&lt;/strong&gt; to the problematic endpoint.&lt;/p&gt;
&lt;h3&gt;The Solution: CamelCase JSON for Export Status&lt;/h3&gt;
&lt;p&gt;This ASP.NET Core action filter ensures JSON responses from /EPiServer/EPiServer.Cms.UI.Admin/ExportData/GetExportStatus are always camelCase:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Formatters;
using System.Text.Json;
using System.Linq;
using System.Text.Json.Serialization.Metadata;

namespace Website.API.Filters
{
    /// &amp;lt;summary&amp;gt;
    /// Action filter to enforce camelCase JSON for /EPiServer/EPiServer.Cms.UI.Admin/ExportData/GetExportStatus responses.
    /// &amp;lt;/summary&amp;gt;
    public class CamelCaseJsonForExportStatusFilter : IActionFilter
    {
        public void OnActionExecuting(ActionExecutingContext context)
        {
            // No action needed before execution
        }

        public void OnActionExecuted(ActionExecutedContext context)
        {
            // Only apply to the specific route
            var path = context.HttpContext.Request.Path.Value;
            if (!string.Equals(path, &quot;/EPiServer/EPiServer.Cms.UI.Admin/ExportData/GetExportStatus&quot;,
                    StringComparison.OrdinalIgnoreCase))
                return;

            if (context.Result is ObjectResult objectResult)
            {
                // Find the SystemTextJsonOutputFormatter and update its options
                var jsonFormatter = objectResult.Formatters
                    .OfType&amp;lt;SystemTextJsonOutputFormatter&amp;gt;()
                    .FirstOrDefault();

                if (jsonFormatter != null)
                {
                    // Update the naming policy to camelCase
                    jsonFormatter.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
                }
                else
                {
                    objectResult.Formatters.Add(new SystemTextJsonOutputFormatter(
                        new JsonSerializerOptions
                        {
                            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
                            WriteIndented = false,
                            TypeInfoResolver = new DefaultJsonTypeInfoResolver()
                        }
                    ));
                }
            }
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;How to Plug It In&lt;/h3&gt;
&lt;p&gt;Add the following to Startup.cs to register the filter globally (since it self-filters by route):&lt;/p&gt;
&lt;div class=&quot;contain-inline-size rounded-md border-[0.5px] border-token-border-medium relative bg-token-sidebar-surface-primary&quot;&gt;
&lt;div class=&quot;overflow-y-auto p-4&quot;&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;services.AddControllersWithViews(options =&amp;gt;
{
    options.Filters.Add&amp;lt;CamelCaseJsonForExportStatusFilter&amp;gt;();
});
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;The filter only runs logic when the request is for&amp;nbsp;/ExportData/GetExportStatus, so it&amp;rsquo;s safe to register app-wide.&lt;/p&gt;
&lt;h3&gt;How It Works&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Intercepting the Response&lt;/strong&gt;&lt;/li&gt;
&lt;ul&gt;
&lt;li&gt;The filter implements &lt;strong&gt;IActionFilter, &lt;/strong&gt;modifying responses after an action executes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OnActionExecuting &lt;/strong&gt;remains empty since no changes are needed before execution.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OnActionExecuted &lt;/strong&gt;checks if the request path matches the export status endpoint.&lt;/li&gt;
&lt;/ul&gt;
&lt;li&gt;&lt;strong&gt;Applying JSON Formatting&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul&gt;
&lt;li&gt;If the response is an &lt;strong&gt;ObjectResult, &lt;/strong&gt;it searches for an existing &lt;strong&gt;SystemTextJsonOutputFormatter&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If found, it updates its &lt;strong&gt;SerializerOptions.PropertyNamingPolicy&lt;/strong&gt; to &lt;strong&gt;JsonNamingPolicy.CamelCase&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;If no formatter exists, it &lt;strong&gt;adds&lt;/strong&gt; a new one with the correct settings.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Why This Approach?&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scoped Fix&lt;/strong&gt; &amp;ndash; Only affects&lt;strong&gt; /ExportData/GetExportStatus&lt;/strong&gt;,&amp;nbsp;leaving other JSON responses untouched.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minimal Overhead&lt;/strong&gt; &amp;ndash; Modifies existing formatters when possible, optimizing performance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Future-Proofing&lt;/strong&gt; &amp;ndash; Ensures a formatter exists even if Optimizely CMS updates its behavior later.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;This targeted action filter prevents unexpected JSON formatting issues in Optimizely CMS. By enforcing camelCase &lt;strong&gt;only&lt;/strong&gt; for the export status response, it eliminates UI failures while avoiding unnecessary changes to global settings.&lt;/p&gt;
&lt;p&gt;Let me know if you have alternative approaches or thoughts&amp;mdash;drop them in the comments.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;!--EndFragment --&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2025/5/a-targeted-fix-for-json-casing-on-exportdatagetexportstatus-in-optimizely-cms/</guid>            <pubDate>Mon, 02 Jun 2025 13:17:32 GMT</pubDate>           <category>Blog post</category></item><item> <title>Fixing a Sneaky JSON Casing Bug when Exporting in Optimizely CMS</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2025/5/fixing-a-sneaky-json-casing-bug-when-exporting-in-optimizely-cms/</link>            <description>&lt;p&gt;&lt;span style=&quot;color: rgb(224, 62, 45);&quot;&gt;&lt;strong&gt;*Note: This issue seems to be resolved with&amp;nbsp;EPiServer.CMS Version 12.33.1.&lt;/strong&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;We recently encountered a pretty frustrating issue while working with Optimizely CMS: after attempting to upgrade to the latest NuGet packages, data exports would just hang&amp;mdash;no error, no download, just&amp;hellip; spinning forever.&lt;/p&gt;
&lt;p&gt;We contacted support, and they had us rule out a timeout problem by tweaking timeout settings in appsettings.json. No luck.&lt;br /&gt;Eventually, after providing Optimizely Support access to a lower environment, they discovered something weird in the export polling response: the JSON was being returned in PascalCase instead of camelCase. Here&amp;rsquo;s what the problematic JSON looked like:&lt;/p&gt;
&lt;pre class=&quot;language-javascript&quot;&gt;&lt;code&gt;{
  &quot;FileId&quot;: &quot;tmpOMsqJV.tmp&quot;,
  &quot;IsDone&quot;: true,
  &quot;IsAborting&quot;: false,
  &quot;IsTest&quot;: false,
  &quot;ErrorMessages&quot;: [],
  &quot;WarningMessages&quot;: [],
  &quot;InfoMessages&quot;: [&quot;Export completed without errors or warnings&quot;],
  &quot;PageCount&quot;: 44,
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client-side JavaScript was expecting camelCase (isDone, not IsDone), so even though the export finished, the UI didn&amp;rsquo;t recognize it. The result? An endless spinner.&lt;br /&gt;After some digging, we found this in our Startup.cs:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;services.AddControllers(
    options =&amp;gt; options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true)
    .AddJsonOptions(opts =&amp;gt; opts.JsonSerializerOptions.PropertyNamingPolicy = null);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting PropertyNamingPolicy to null meant the JSON output kept the C# property casing (PascalCase). Changing that to use JsonNamingPolicy.CamelCase solved the problem:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;services.AddControllers(
    options =&amp;gt; options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true)
    .AddJsonOptions(opts =&amp;gt; opts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once that was in place, the export started working correctly. Of course, we&#39;re planning more testing to make sure this doesn&#39;t cause side effects elsewhere, but if you run into mysterious export issues after a NuGet update or configuration change&amp;mdash;double-check your JSON naming settings. It might just save you hours of Googling.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2025/5/fixing-a-sneaky-json-casing-bug-when-exporting-in-optimizely-cms/</guid>            <pubDate>Tue, 27 May 2025 00:46:15 GMT</pubDate>           <category>Blog post</category></item><item> <title>Integrating IndexNow with Optimizely Publishing</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2024/10/integrating-indexnow-with-optimizely-publishing/</link>            <description>&lt;h2&gt;Overview of IndexNow&lt;/h2&gt;
&lt;p&gt;&lt;span&gt;&lt;a href=&quot;https://www.bing.com/indexnow/getstarted&quot;&gt;IndexNow &lt;/a&gt;is a standard for search engines that allows you to submit a URL (along with your API key) to inform the search engines that the page should be indexed. We wanted to automate this by hooking into the PublishedContent event.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;The payload follows this format:&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;Request
POST /IndexNow HTTP/1.1
Content-Type: application/json; charset=utf-8
Host: api.indexnow.org
{
  &quot;host&quot;: &quot;www.example.org&quot;,
  &quot;key&quot;: &quot;e50c2cc6036f4922945f1c62fab66f9e&quot;,
  &quot;keyLocation&quot;: &quot;https://www.example.org/e50c2cc6036f4922945f1c62fab66f9e.txt&quot;,
  &quot;urlList&quot;: [
      &quot;https://www.example.org/url1&quot;,
      &quot;https://www.example.org/folder/url2&quot;,
      &quot;https://www.example.org/url3&quot;
      ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Implementation&amp;nbsp;&lt;/h2&gt;
&lt;p&gt;First, we needed a way to host the API key which normally is stored in a plain text (txt) file hosted at the root of the website. I did not want to open up the website to serve up any text files that might happen to be part of the site so I created a new pagetype.&lt;/p&gt;
&lt;h3&gt;Txtpage&lt;/h3&gt;
&lt;p&gt;Txtpage.cs is a simple CMS pagetype with a single field for the content of the text file.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[Access(Roles = &quot;Administrators, WebAdmins, CmsAdmins&quot;)]
[ContentType(DisplayName = &quot;TxtPage&quot;, GUID = &quot;XXXXXXXX-XXX-XXXX-XXXX-XXXXXXXXXXX&quot;, Description = &quot;&quot;)]
public class TxtPage : PageData
{
        [Display(
            Name = &quot;Content&quot;,
            Description = &quot;Txt Content&quot;,
            GroupName = PropertyGroups.PageContent,
            Order = 1)]
        public virtual String? Content { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;The cshtml is about as simple as it ever gets, using Html.Raw() to output the content.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;@{
    Layout = null;
}
@model Century.Website.Features.Pages.TxtPage.TxtPage

@Html.Raw(Model.Content)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Updating the Name In Url field with the key and .txt extension allows this to mimic the path of an actual text file.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src=&quot;/link/bd3d4b04986b4cc68a9930659e2607e7.aspx&quot; /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;The content displays as so:&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;&lt;img src=&quot;/link/3dacb2c174b642f596ac282def432796.aspx&quot; /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3&gt;API Key&lt;/h3&gt;
&lt;p&gt;The API Key is stored in appsettings.json. I also added an environment-specific value for enabling/disabling the feature. (You probably don&#39;t want lower environment broadcasting themselves to search engines.)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/483e7d441a194ed9872962588115f496.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;These are pulled from the JSON by the Dependency-injected Configuration class.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var bingKey = _configuration.GetValue&amp;lt;string&amp;gt;(&quot;BingAPIKey&quot;);

var indexNowEnabled = _configuration.GetValue&amp;lt;bool&amp;gt;(&quot;BingIndexNowEnable&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Initialization Module to attach PublishedContent content event&amp;nbsp;&lt;/h3&gt;
&lt;p&gt;We make a few checks to ensure that the page being published is something we want to submit to search engines. After checking that the feature is enabled for the current environment we check the interface. Here we make sure that it is NOT a page type excluded from the sitemap (the IExcludeFromSitemap interface comes from the Geta 404Handler package) and ISEOAnalytic is our interface for pagetypes that have a SEO tab with noindex and nofollow settings. If noindex is active we do not want this page submitted.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;private void PublishedContent(object sender, ContentEventArgs e)
{
    try
    {
        var indexNowEnabled = _configuration.GetValue&amp;lt;bool&amp;gt;(&quot;BingIndexNowEnable&quot;);

        if (indexNowEnabled)
        {
            if (e.Content is ISEOAnalytic baseSeo)
            {
                //is set to noindex or is excluded by interface
                var markedNoIndex = e.Content is IExcludeFromSitemap || (baseSeo != null &amp;amp;&amp;amp; baseSeo.NoIndex);

                if (!markedNoIndex)
                {
                    _ = SubmitToUrl(e.ContentLink.GetExternalUrl());
                }
            }
        }
    }
    catch (Exception ex)
    {
        Logger.Error($&quot;ERROR in BINGIndexNow: {ex?.Message}, InnerException: {ex?.InnerException}&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If it passes all checks we call the SubmitToUrl method passing in the external URL.&lt;/p&gt;
&lt;p&gt;We have an IndexNowJson.cs POCO class for submitting as the payload.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public class IndexNowJson
{
    public string host { get; set; }
    public string key { get; set; }
    public string keyLocation { get; set; }
    public string[] urlList { get; set; }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is where the txt page created earlier and the apikey come together. (Obviously the key in appsettings and in the txtpage should be the same.)&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var indexNowData = new IndexNowJson
{
    host = $&quot;www.yourdomain.com&quot;,
    key = bingKey,
    keyLocation = $&quot;www.yourdomain.com/{bingKey}.txt&quot;,
    urlList = new[] { url }
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We take in the URL, create the payload, and POST it to the IndexNow endpoint.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;//fire and forget
private async Task SubmitToUrl(string url)
{
    var bingKey = _configuration.GetValue&amp;lt;string&amp;gt;(&quot;BingAPIKey&quot;);

    var indexNowData = new IndexNowJson
    {
        host = $&quot;www.yourdomain.com&quot;,
        key = bingKey,
        keyLocation = $&quot;www.yourdomain.com/{bingKey}.txt&quot;,
        urlList = new []{ url }
    };

    var jsonClass = JsonConvert.SerializeObject(indexNowData);

    var request = new HttpRequestMessage(HttpMethod.Post, &quot;https://api.indexnow.org/IndexNow&quot;);
    request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue(&quot;application/json&quot;));
    request.Content = new StringContent(jsonClass, Encoding.UTF8);
    request.Content.Headers.ContentType = new MediaTypeHeaderValue(&quot;application/json&quot;);


    var postResponse = await _httpClient.SendAsync(request);

    Logger.Log(Level.Information, $&quot;Status Code: {postResponse.StatusCode} Reason Phrase: {postResponse.ReasonPhrase}&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a fire-and-forget situation as we don&#39;t want the site to do anything differently if there is a problem submitting to IndexNow.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;It is a pretty straightforward process to submit to the IndexNow endpoint. I did find Bing&#39;s documentation to be inconsistent but was able to work through it. Thoughts, comments, concerns? Let me know below.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2024/10/integrating-indexnow-with-optimizely-publishing/</guid>            <pubDate>Wed, 23 Oct 2024 20:01:14 GMT</pubDate>           <category>Blog post</category></item><item> <title>Azure Function App for PDF Creation With Syncfusion .Net PDF Framework</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2024/8/azure-function-app-for-pdf-creation-with-syncfusion--net-pdf-framework/</link>            <description>&lt;p&gt;We have a couple of use cases that require internal and external users to print content in PDF format. We&#39;ve offloaded this functionality from the DXP Web App to an Azure Function App running in a Docker container. We make use of the Syncfusion .Net PDF Framework (note: subscription required). Our first use case allows you to provide a URL to the endpoint for printing. The second use case involves POSTing an array of XHTML content to the endpoint.&lt;/p&gt;
&lt;h2&gt;Create the Azure Function App&lt;/h2&gt;
&lt;p&gt;In Visual Studio create a new project and choose Azure Functions.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/fe654eb8f8bf402bae6ff400d1888624.aspx&quot; width=&quot;678&quot; height=&quot;452&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Give the project a name&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/a98c34bcfd8a4ec8b6c0352f45eec31b.aspx&quot; width=&quot;681&quot; height=&quot;454&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&#39;m taking the defaults here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt; .Net 8 Isolated&lt;/strong&gt; for the version&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Http trigger&lt;/strong&gt; for the function because we will be using GET and POST&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enable container support&lt;/strong&gt; because we will be utilizing Docker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Function &lt;/strong&gt;for the Authorization level so that it will require a Function Key. (When hosted in Azure at this authorization level it will require a Function Key to access the endpoint.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Click Create.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/20e5197f1aaa4da7a9bd257879969bf0.aspx&quot; width=&quot;943&quot; height=&quot;629&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This gives us the basic scaffolding.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/6f02ecb203a44839809a87eee3d66a6a.aspx&quot; /&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;

namespace TestPdfEndpoint
{
    public class Function1
    {
        private readonly ILogger&amp;lt;Function1&amp;gt; _logger;

        public Function1(ILogger&amp;lt;Function1&amp;gt; logger)
        {
            _logger = logger;
        }

        [Function(&quot;Function1&quot;)]
        public IActionResult Run([HttpTrigger(AuthorizationLevel.Function, &quot;get&quot;, &quot;post&quot;)] HttpRequest req)
        {
            _logger.LogInformation(&quot;C# HTTP trigger function processed a request.&quot;);
            return new OkObjectResult(&quot;Welcome to Azure Functions!&quot;);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Add the ConvertUrlToPdf Function&lt;/h2&gt;
&lt;p&gt;Rename Function1 to ConvertUrlToPdf.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public class ConvertUrlToPdf
    {
        private readonly ILogger&amp;lt;ConvertUrlToPdf&amp;gt; _logger;

        public ConvertUrlToPdf(ILogger&amp;lt;ConvertUrlToPdf&amp;gt; logger)
        {
            _logger = logger;
        }

        [Function(&quot;ConvertUrlToPdf&quot;)]
        public IActionResult Run([HttpTrigger(AuthorizationLevel.Function, &quot;get&quot;, &quot;post&quot;)] HttpRequest req)
        {
            _logger.LogInformation(&quot;C# HTTP trigger function processed a request.&quot;);
            return new OkObjectResult(&quot;Welcome to Azure Functions!&quot;);
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Change IActionResult to an async task and add a FunctionContext parameter.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;public async Task&amp;lt;IActionResult&amp;gt; Run([HttpTrigger(AuthorizationLevel.Function, &quot;get&quot;, &quot;post&quot;)] HttpRequest req, FunctionContext functionContext)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the Syncfusion.HtmlToPdfConverter.Net.Linux nuget package. Our Function App will be hosted on Linux. This package will do the heavy lifting of converting the URL to a PDF file. The nuget includes the Blink headless Chrome browser.&lt;/p&gt;
&lt;p&gt;We will pass a URL to the function as a querystring parameter so check the request for a &quot;URL&quot; parameter. Return an EmptyResult if not found.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;string urlString = req.Query[&quot;url&quot;];

 //return if no url
 if (string.IsNullOrEmpty(urlString))
{
     return new EmptyResult();
 }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Get the path to the Blink binaries. This will be needed later in a settings object:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var directory = Directory.GetCurrentDirectory();
var blinkBinariesPath = Path.Combine(directory, &quot;runtimes/linux/native&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;Initialize the HTML to PDF Converter:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;//Initialize the HTML to PDF converter with the Blink rendering engine.
var htmlConverter = new HtmlToPdfConverter(HtmlRenderingEngine.Blink)
 {
       ConverterSettings = null,
       ReuseBrowserProcess = false
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a settings object and set the values. These command line argument settings come from the Syncfusion documentation.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var settings = new BlinkConverterSettings();

 //Set command line arguments to run without sandbox.
settings.CommandLineArguments.Add(&quot;--no-sandbox&quot;);
settings.CommandLineArguments.Add(&quot;--disable-setuid-sandbox&quot;);

settings.BlinkPath = blinkBinariesPath;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Assign the settings object.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;//Assign WebKit settings to the HTML converter 
htmlConverter.ConverterSettings = settings;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pass the URL to the converter. Save to an output stream.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var newDocument = htmlConverter.Convert(urlString);

var ms = new MemoryStream();

newDocument.Save(ms);
newDocument.Close();

ms.Position = 0;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Return a FileStreamResult from the MemoryStream. Giving it a content type and a file name.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;return new FileStreamResult(ms, &quot;application/pdf&quot;)
{
      FileDownloadName = &quot;Test.pdf&quot;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At this point, your function should look like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public class ConvertUrlToPdf
    {
        private readonly ILogger&amp;lt;ConvertUrlToPdf&amp;gt; _logger;

        public ConvertUrlToPdf(ILogger&amp;lt;ConvertUrlToPdf&amp;gt; logger)
        {
            _logger = logger;
        }

        [Function(&quot;ConvertUrlToPdf&quot;)]
        public async Task&amp;lt;IActionResult&amp;gt; Run([HttpTrigger(AuthorizationLevel.Function, &quot;get&quot;, &quot;post&quot;)] HttpRequest req, FunctionContext functionContext)
        {
            string urlString = req.Query[&quot;url&quot;];

            //return if no url
            if (string.IsNullOrEmpty(urlString))
            {
                return new EmptyResult();
            }

            var directory = Directory.GetCurrentDirectory();

            var blinkBinariesPath = Path.Combine(directory, &quot;runtimes/linux/native&quot;);

            //Initialize the HTML to PDF converter with the Blink rendering engine.
            var htmlConverter = new HtmlToPdfConverter(HtmlRenderingEngine.Blink)
            {
                ConverterSettings = null,
                ReuseBrowserProcess = false
            };

            var settings = new BlinkConverterSettings();

            //Set command line arguments to run without sandbox.
            settings.CommandLineArguments.Add(&quot;--no-sandbox&quot;);
            settings.CommandLineArguments.Add(&quot;--disable-setuid-sandbox&quot;);

            settings.BlinkPath = blinkBinariesPath;

            //Assign WebKit settings to the HTML converter 
            htmlConverter.ConverterSettings = settings;

            var newDocument = htmlConverter.Convert(urlString);

            var ms = new MemoryStream();

            newDocument.Save(ms);
            newDocument.Close();

            ms.Position = 0;

            return new FileStreamResult(ms, &quot;application/pdf&quot;)
            {
                FileDownloadName = &quot;Test.pdf&quot;
            };

        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can add additional querystring parameters to control all aspects of the PDF output such as orientation, height, width, output file name, etc.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;string orientation = req.Query[&quot;orientation&quot;];
string widthString = req.Query[&quot;width&quot;];
string heightString = req.Query[&quot;height&quot;];
string fileName = req.Query[&quot;filename&quot;]; 

int.TryParse(widthString, out int width);
int.TryParse(heightString, out var height);

if (width == 0)
{
      width = 817;
}

if (!string.IsNullOrEmpty(orientation) &amp;amp;&amp;amp; orientation.ToLower().Equals(&quot;landscape&quot;))
       settings.Orientation = PdfPageOrientation.Landscape;

settings.BlinkPath = blinkBinariesPath;
settings.Margin.All = 0;
settings.ViewPortSize = new Size(width, height);&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Updating the Dockerfile&lt;/h2&gt;
&lt;p&gt;There are some underlying requirements that Syncfusion needs in place so you&#39;ll need to add the following to your Dockerfile (refer to Syncfusion documentation):&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;RUN apt-get update &amp;amp;&amp;amp; \
apt-get install -yq --no-install-recommends \ 
libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \ 
libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \ 
libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ 
libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \ 
libnss3 libgbm1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So my complete Dockerfile looks as follows:&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.

FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 AS base
RUN apt-get update &amp;amp;&amp;amp; \
apt-get install -yq --no-install-recommends \ 
libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 \ 
libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 \ 
libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \ 
libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 \ 
libnss3 libgbm1
WORKDIR /home/site/wwwroot
EXPOSE 8080

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY [&quot;TestPdfEndpoint.csproj&quot;, &quot;.&quot;]
RUN dotnet restore &quot;./TestPdfEndpoint.csproj&quot;
COPY . .
WORKDIR &quot;/src/.&quot;
RUN dotnet build &quot;./TestPdfEndpoint.csproj&quot; -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish &quot;./TestPdfEndpoint.csproj&quot; -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /home/site/wwwroot
COPY --from=publish /app/publish .
ENV AzureWebJobsScriptRoot=/home/site/wwwroot \
    AzureFunctionsJobHost__Logging__Console__IsEnabled=true&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Testing the PrintUrlToPdf Endpoint&lt;/h2&gt;
&lt;p&gt;Spin up the container using Docker Desktop. You can use your browser or Postman to test the PDF generation.&amp;nbsp; Append the URL of the site you want to print to the querystring.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/9d8b6ae4d7d242f79990f2b0db1f61a8.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Add the ConvertToPdf Function&lt;/h2&gt;
&lt;p&gt;The second function is mostly a copy/paste of the first with some tweaks.&lt;/p&gt;
&lt;p&gt;For this function, we&#39;ll look for an array of Xhtmlstrings that are POSTed.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;req.Form.TryGetValue(&quot;html&quot;, out var strings);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we&#39;ll create a stream for each one.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var streams = new Stream[strings.Count];

for (var i = 0; i &amp;lt; strings.Count; i++)
{
        streams[i] = GetMemoryStream(strings[i], htmlConverter);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GetMemoryStream looks like the following:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;private static MemoryStream GetMemoryStream(string url, HtmlToPdfConverter htmlConverter)
{
    try
    {
        PdfDocument newDocument = htmlConverter.Convert(url, string.Empty);

        var ms = new MemoryStream();

        newDocument.Save(ms);
        newDocument.Close();

        ms.Position = 0;

        return ms;
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
        throw;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a new PdfDocument that will hold the merged content.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var mergedDocument = new PdfDocument
{
    EnableMemoryOptimization = true
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the streams into the PdfDocument.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;PdfDocumentBase.Merge(mergedDocument, streams);

//Save the document into stream.
var stream = new MemoryStream();
mergedDocument.Save(stream);

//Close the document.
mergedDocument.Close(true);

//Disposes the streams.
foreach (var memStream in streams)
{
    await memStream.DisposeAsync();
}

stream.Position = 0;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, return the FileStreamResult.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;return new FileStreamResult(stream, &quot;application/pdf&quot;)
{
    FileDownloadName = &quot;TestMergedPdf.pdf&quot;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The final version of ConvertToPdf is as follows:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public class ConvertToPdf
    {
        private readonly ILogger&amp;lt;ConvertToPdf&amp;gt; _logger;

        public ConvertToPdf(ILogger&amp;lt;ConvertToPdf&amp;gt; logger)
        {
            _logger = logger;
        }

        [Function(&quot;ConvertToPdf&quot;)]
        public async Task&amp;lt;IActionResult&amp;gt; Run([HttpTrigger(AuthorizationLevel.Function, &quot;get&quot;, &quot;post&quot;)] HttpRequest req)
        {
            req.Form.TryGetValue(&quot;html&quot;, out var strings);
            
            var directory = Directory.GetCurrentDirectory();

            var blinkBinariesPath = Path.Combine(directory, &quot;runtimes/linux/native&quot;);

            //Initialize the HTML to PDF converter with the Blink rendering engine.
            var htmlConverter = new HtmlToPdfConverter(HtmlRenderingEngine.Blink)
            {
                ConverterSettings = null,
                ReuseBrowserProcess = false
            };

            var settings = new BlinkConverterSettings();

            //Set command line arguments to run without sandbox.
            settings.CommandLineArguments.Add(&quot;--no-sandbox&quot;);
            settings.CommandLineArguments.Add(&quot;--disable-setuid-sandbox&quot;);

            settings.BlinkPath = blinkBinariesPath;

            //Assign WebKit settings to the HTML converter 
            htmlConverter.ConverterSettings = settings;

            var streams = new Stream[strings.Count];

            for (var i = 0; i &amp;lt; strings.Count; i++)
            {
                streams[i] = GetMemoryStream(strings[i], htmlConverter);
            }


            var mergedDocument = new PdfDocument
            {
                EnableMemoryOptimization = true
            };

            try
            {
                PdfDocumentBase.Merge(mergedDocument, streams);

                //Save the document into stream.
                var stream = new MemoryStream();
                mergedDocument.Save(stream);

                //Close the document.
                mergedDocument.Close(true);

                //Disposes the streams.
                foreach (var memStream in streams)
                {
                    await memStream.DisposeAsync();
                }

                stream.Position = 0;

                return new FileStreamResult(stream, &quot;application/pdf&quot;)
                {
                    FileDownloadName = &quot;TestMergedPdf.pdf&quot;
                };
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
                throw;
            }
        }
        private static MemoryStream GetMemoryStream(string url, HtmlToPdfConverter htmlConverter)
        {
            try
            {
                PdfDocument newDocument = htmlConverter.Convert(url, string.Empty);

                var ms = new MemoryStream();

                newDocument.Save(ms);
                newDocument.Close();

                ms.Position = 0;

                return ms;
            }
            catch (Exception e)
            {
                Console.WriteLine(e);
                throw;
            }
        }
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Testing the ConvertToPdf Endpoint&lt;/h2&gt;
&lt;p&gt;Spin up the container using Docker Desktop. Use Postman to test. POST to the endpoint, providing multiple &quot;HTML&quot; Key-Value pairs. One per page of the PDF&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/5fc8d71f220245418673a885216b1025.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Tying it Back to your Optimizely Web App&lt;/h2&gt;
&lt;p&gt;Now that you have the endpoints, you can provide links to print specific URLs or POST content.&lt;/p&gt;
&lt;h2&gt;Notes&lt;/h2&gt;
&lt;p&gt;For production use, you will need to purchase a license and provide it to the app:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;Syncfusion.Licensing.SyncfusionLicenseProvider.RegisterLicense(&quot;XXX...&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Setting up the Function in Azure is outside of the scope of this article but I might cover it in a future post.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2024/8/azure-function-app-for-pdf-creation-with-syncfusion--net-pdf-framework/</guid>            <pubDate>Thu, 08 Aug 2024 16:21:38 GMT</pubDate>           <category>Blog post</category></item><item> <title>Rolling Restart Revolution!</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2024/8/rolling-restart-revolution/</link>            <description>&lt;p&gt;I&#39;m not sure if anyone has written about this yet or not. If so, I&#39;m sorry. But we discovered something this morning where the heavens opened up and the angels sang Hallelujah! A new setting in the PAAS self-service portal where you can kick off a &quot;rolling restart&quot;.&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;img src=&quot;/link/512349cb82ae43e09e6e61a7d32918ab.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&#39;m not sure about your team but this is something we&#39;ve been wishing for for a long while. Every once in a while we will push something and it doesn&#39;t work quite right until the sites have all been restarted. This will save us from having to open a support ticket and allow us to take care of it ourselves. Thank you Optimizely!&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2024/8/rolling-restart-revolution/</guid>            <pubDate>Thu, 01 Aug 2024 17:35:11 GMT</pubDate>           <category>Blog post</category></item><item> <title>Programmatically Exempt A PageType From Content Approval</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2023/8/programmatically-exempt-pagetype-from-content-approval/</link>            <description>&lt;p&gt;We&#39;ve got a use case that I think is a little advanced for what you get with Content Approval out of the box. We&#39;re a home builder and our commerce catalog is structured as Brand &amp;gt; State &amp;gt; City &amp;gt; Community &amp;gt; Lots (homes) and Plans.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/6b25c865aa2b4b18b5e6a187cbd52558.aspx&quot; width=&quot;354&quot; height=&quot;208&quot; /&gt;&lt;/p&gt;
&lt;p&gt;While we want to require content approval at the community level (which numbers in the hundreds) we might not want to require approval at the lot and plan level (which numbers in the thousands). So if you set up Content Approval at the State level it is going to inherit all of the way down. We want to break it at certain points programmatically.&lt;/p&gt;
&lt;p&gt;I&#39;ve tested this out in a fresh Alloy site. Enable Content Approval at the About Us node and it will apply to the entire branch.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/027aea6c4e3549c99436b19b4b16258d.aspx&quot; width=&quot;693&quot; height=&quot;415&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Let&#39;s exempt the NewsPage page type from Content Approval.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/2d73590082a24996825ccbf9c7292b57.aspx&quot; width=&quot;689&quot; height=&quot;352&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can see that it gets its definition from the parent node.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/c3dd83524b574e8ca8ce450397b8dd96.aspx&quot; width=&quot;222&quot; height=&quot;199&quot; /&gt;&lt;/p&gt;
&lt;p&gt;We create an Initialization Module so we can hook into the the SavingContent event.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;        public void Initialize(InitializationEngine context)
        {
            _contentEvents = context.Locate.Advanced.GetInstance&amp;lt;IContentEvents&amp;gt;();
            _approvalDefinitionRepository = context.Locate.Advanced.GetInstance&amp;lt;IApprovalDefinitionRepository&amp;gt;();
            _contentRepository = context.Locate.Advanced.GetInstance&amp;lt;IContentRepository&amp;gt;();

            _contentEvents.SavingContent += ContentEvents_SavingContent_DisableContentApproval;
        }

        private async void ContentEvents_SavingContent_DisableContentApproval(object sender, ContentEventArgs e)
        {
            if (e.Content is NewsPage)
            {
                // Gets the latest version of a definition by resolving a ContentReference.  
                var definitionResolveResult = await _approvalDefinitionRepository.ResolveAsync(e.ContentLink);

                // The Resolve-method returns a result with a definition and a flag specifying if the definition was found on an ancestor
                var definition = definitionResolveResult.Definition as ContentApprovalDefinition;
                var isInherited = definitionResolveResult.IsInherited;

                if (definition != null &amp;amp;&amp;amp; definition.IsEnabled)
                {
                    CreateOrUpdateDefinition(definition, e.ContentLink, false, isInherited);
                }
            }
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For any pagetype that is a NewsPage we use the ContentReference to get the definition (ApprovalDefinitionResolveResult) that applies to that specific node. ApprovalDefinitionResolveResult is an object that contains the Definition (ApprovalDefinition) and an IsInherited flag (bool) that signifies whether the definition comes from this node or is inherited from an ancestor.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public class ApprovalDefinitionResolveResult
    {
        //
        // Summary:
        //     The resolved approval definition or null is no is found
        public ApprovalDefinition Definition { get; set; }

        //
        // Summary:
        //     Specifies if the definition was inherited from a content ancestor
        public bool IsInherited { get; set; }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;We cast this definition as a ContentApprovalDefinition. We check to make sure the definition isn&#39;t null and that it is enabled (active). Then we pass it to a CreateOrUpdateDefinition method. When using this method if a definition does not exist it creates one, otherwise it updates it.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;        private void CreateOrUpdateDefinition(ContentApprovalDefinition definition, ContentReference contentLink, bool isEnabled, bool isInherited)
        {
            if (definition == null)
            {
                CreateContentDefinition(contentLink, isEnabled);
                return;
            }

            //first pass definition down to any children to break inheritance
            var children = _contentRepository.GetChildren&amp;lt;PageData&amp;gt;(contentLink);

            if (children != null &amp;amp;&amp;amp; children.Any())
            {

                foreach (var child in children)
                {
                    var definitionResolveResult = _approvalDefinitionRepository.ResolveAsync(child.ContentLink).Result;

                    if (definitionResolveResult.IsInherited)
                    {
                        UpdateDefinition(definitionResolveResult.Definition as ContentApprovalDefinition, child.ContentLink, definitionResolveResult.Definition.IsEnabled);
                    }
                }
            }

            //update page 
            UpdateDefinition(definition, contentLink, isEnabled);

        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We check to see if the current node has any children, if so (and they are of a non-exempt page type) we need to pass the definition down to each one to break the inheritance. We loop through the children and update the definition for each one. Now that each child is now the root of its own approval branch we can update the node itself and turn off inheritance for that node by using the UpdateDefinition method.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;        private void UpdateDefinition(ContentApprovalDefinition definition, ContentReference contentLink, bool isEnabled)
        {
            var newDefinition = definition.CreateWritableClone() as ContentApprovalDefinition;
            newDefinition.IsEnabled = isEnabled;
            newDefinition.ContentLink = contentLink;

            _approvalDefinitionRepository.SaveAsync(newDefinition).ConfigureAwait(false);
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We clone the definition that has been passed in (this definition comes from the ancestor), set it as enabled, give it the current node link, and save it. The method for creating a definition is very similar.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;        private void CreateContentDefinition(ContentReference contentLink, bool isEnabled)
        {
            var newDefinition = new ContentApprovalDefinition
            {
                ContentLink = contentLink,
                IsEnabled = isEnabled
            };

            _approvalDefinitionRepository.SaveAsync(newDefinition).ConfigureAwait(false);
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you may be saying to yourself, none of this comes into effect until the page is saved. That&#39;s what is nice about this approach, you don&#39;t have to loop through the entire tree to apply it. It kicks in for any node of the exempt type when an editor starts editing and applies during the first AutoSave.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Here is the entire Init Module:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Approvals.ContentApprovals;
using EPiServer.Approvals;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using Opti_Alloy.Models.Pages;

namespace Opti_Alloy.Business.Initialization
{
    [ModuleDependency(typeof(InitializationModule))]
    public class ExemptPageTypesInitialization : IInitializableModule
    {
        private IContentEvents _contentEvents;
        private IApprovalDefinitionRepository _approvalDefinitionRepository;
        private IContentRepository _contentRepository;

        public void Initialize(InitializationEngine context)
        {
            _contentEvents = context.Locate.Advanced.GetInstance&amp;lt;IContentEvents&amp;gt;();
            _approvalDefinitionRepository = context.Locate.Advanced.GetInstance&amp;lt;IApprovalDefinitionRepository&amp;gt;();
            _contentRepository = context.Locate.Advanced.GetInstance&amp;lt;IContentRepository&amp;gt;();

            _contentEvents.SavingContent += ContentEvents_SavingContent_DisableContentApproval;
        }

        private async void ContentEvents_SavingContent_DisableContentApproval(object sender, ContentEventArgs e)
        {
            if (e.Content is NewsPage)
            {
                // Gets the latest version of a definition by resolving a ContentReference.  
                var definitionResolveResult = await _approvalDefinitionRepository.ResolveAsync(e.ContentLink);

                // The Resolve-method returns a result with a definition and a flag specifying if the definition was found on an ancestor
                var definition = definitionResolveResult.Definition as ContentApprovalDefinition;
                var isInherited = definitionResolveResult.IsInherited;

                if (definition != null &amp;amp;&amp;amp; definition.IsEnabled)
                {
                    CreateOrUpdateDefinition(definition, e.ContentLink, false, isInherited);
                }
            }
        }

        private void CreateOrUpdateDefinition(ContentApprovalDefinition definition, ContentReference contentLink, bool isEnabled, bool isInherited)
        {
            if (definition == null)
            {
                CreateContentDefinition(contentLink, isEnabled);
                return;
            }

            //first pass definition down to any children to break inheritance
            var children = _contentRepository.GetChildren&amp;lt;PageData&amp;gt;(contentLink);

            if (children != null &amp;amp;&amp;amp; children.Any())
            {

                foreach (var child in children)
                {
                    var definitionResolveResult = _approvalDefinitionRepository.ResolveAsync(child.ContentLink).Result;

                    if (definitionResolveResult.IsInherited)
                    {
                        UpdateDefinition(definitionResolveResult.Definition as ContentApprovalDefinition, child.ContentLink, definitionResolveResult.Definition.IsEnabled);
                    }
                }
            }

            //update page 
            UpdateDefinition(definition, contentLink, isEnabled);

        }

        private void UpdateDefinition(ContentApprovalDefinition definition, ContentReference contentLink, bool isEnabled)
        {
            var newDefinition = definition.CreateWritableClone() as ContentApprovalDefinition;
            newDefinition.IsEnabled = isEnabled;
            newDefinition.ContentLink = contentLink;

            _approvalDefinitionRepository.SaveAsync(newDefinition).ConfigureAwait(false);
        }

        private void CreateContentDefinition(ContentReference contentLink, bool isEnabled)
        {
            var newDefinition = new ContentApprovalDefinition
            {
                ContentLink = contentLink,
                IsEnabled = isEnabled
            };

            _approvalDefinitionRepository.SaveAsync(newDefinition).ConfigureAwait(false);
        }

        public void Uninitialize(InitializationEngine context)
        {
            _contentEvents.SavingContent -= ContentEvents_SavingContent_DisableContentApproval;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Going back to our Alloy example, if we edit something on the Events page (NewsPage) we can see that it is ready for direct publish, because the definitions were updated as soon as it Autosaved.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/51d267caf08941f68c5f3fe7b44b7796.aspx&quot; width=&quot;816&quot; height=&quot;251&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Checking the Content Approval settings, we see that the process is disabled at this node.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/253f403dc8824fc485aa35ae3d5edf3f.aspx&quot; width=&quot;303&quot; height=&quot;265&quot; /&gt;&lt;/p&gt;
&lt;p&gt;And then checking that node&#39;s children, we see that each of them is now the root of a new approval sequence copied from the ancestor.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/8d8bf8b960454e7d81e996f120a903eb.aspx&quot; width=&quot;558&quot; height=&quot;439&quot; /&gt;&lt;/p&gt;
&lt;p&gt;There may be other approaches but I like that this one only kicks in when needed and can be done at any point in the branch. My initial attempt only worked for the end nodes (leaves) but this approach is much more flexible since it copies the definitions down to the children before updating the current node.&lt;/p&gt;
&lt;p&gt;Let me know what you think in the comments!&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2023/8/programmatically-exempt-pagetype-from-content-approval/</guid>            <pubDate>Sun, 13 Aug 2023 16:23:28 GMT</pubDate>           <category>Blog post</category></item><item> <title>New to the PAAS Portal - Environment Information (Outbound IPs)</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2023/3/new-to-the-paas-portal---environment-information-outbound-ips/</link>            <description>&lt;p&gt;So logging into the DXP PAAS portal this morning, I was treated to a surprise. (Maybe someone has already seen this and blogged about it but I haven&#39;t seen that.)&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/f77a88f572434f6eb7a283c2a848c8a0.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;If you are like us you probably have some integrations where you have to whitelist your web app (firewalls, FTP servers, etc.). Traditionally it has required a support ticket to find all the outbound IPs that your site might use but now Opti has given us the power to see for ourselves. It&#39;s still a pain when the IP pool changes but at least we have a self service way to see that now.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/b698a17fc2de4c1abb774dc2f6f868d2.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Thank you Optimizely for continuing to improve the PAAS portal!&amp;nbsp;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2023/3/new-to-the-paas-portal---environment-information-outbound-ips/</guid>            <pubDate>Fri, 03 Mar 2023 00:17:25 GMT</pubDate>           <category>Blog post</category></item><item> <title>Debugging Using The Output Panel? Filter Out That Noise!</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2022/10/debugging-using-the-output-panel-filter-out-that-noise/</link>            <description>&lt;p&gt;This is a quick tip!&lt;/p&gt;
&lt;p&gt;If you are making use of the Output window in Visual Studio but can&#39;t deal with all the Application Insight &quot;noise&quot;, try this!&lt;/p&gt;
&lt;p&gt;Say you want to monitor something during a huge import or similar situation so you output some values at a breakpoint but let the program continue running:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/e3a8bce567414e9db339593a90fd82e9.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;But you can&#39;t see your custom messages for all the Application Insights output:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/b3cc859eefba4947b543fce234cc0d47.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I found a handy setting that just affects Visual Studio (not your Azure App Insights instance).&lt;/p&gt;
&lt;p&gt;Add &lt;span&gt;&lt;strong&gt;TelemetryDebugWriter.IsTracingDisabled&lt;/strong&gt; to your &lt;strong&gt;Startup.cs&lt;/strong&gt; class and set it to true:&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/eb917472a8224fe1aba55304975e1a88.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This clears out a lot of the noise so you can find your own output messages!&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/c7635f1e10ec4c0bbecaeab088eda583.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Got other good VS debugging tips? Let me know in the comments.&lt;/p&gt;
&lt;p&gt;References:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/dotnet/api/microsoft.applicationinsights.extensibility.implementation.telemetrydebugwriter?view=azure-dotnet&quot;&gt;https://learn.microsoft.com/en-us/dotnet/api/microsoft.applicationinsights.extensibility.implementation.telemetrydebugwriter?view=azure-dotnet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/dotnet/api/microsoft.applicationinsights.extensibility.implementation.telemetrydebugwriter.istracingdisabled?view=azure-dotnet#microsoft-applicationinsights-extensibility-implementation-telemetrydebugwriter-istracingdisabled&quot;&gt;https://learn.microsoft.com/en-us/dotnet/api/microsoft.applicationinsights.extensibility.implementation.telemetrydebugwriter.istracingdisabled?view=azure-dotnet#microsoft-applicationinsights-extensibility-implementation-telemetrydebugwriter-istracingdisabled&lt;/a&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2022/10/debugging-using-the-output-panel-filter-out-that-noise/</guid>            <pubDate>Mon, 31 Oct 2022 17:56:33 GMT</pubDate>           <category>Blog post</category></item><item> <title>Need to do some troubleshooting? Go go gadget!</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2021/9/need-to-do-some-troubleshooting-go-go-gadget/</link>            <description>&lt;p&gt;We all know Version 12 of Optimizely is on the way with all its .Net Core goodness, but some of us will be maintaining the current version for a while. That being the case, it helps to have access to troubleshooting data.&lt;/p&gt;
&lt;p&gt;Here are a few dashboard gadgets I use to make sure that things have gotten properly transformed in the web.configs or that my user has the proper group memberships.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/aa1c5870d3884090b6fa90a6ec33fdd8.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Web.Config Viewer&lt;/h2&gt;
&lt;p&gt;This one is super simple. Just grab the web.config and output it to screen. Good for making sure that the proper Web.xxx.config entries were applied.&lt;/p&gt;
&lt;h3&gt;WebConfigViewerComponentController.cs&lt;/h3&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Security;
using EPiServer.Shell.ViewComposition;
using System.Linq;
using System.Web.Mvc;

namespace Website.Features.Components
{
    [IFrameComponent(Url = &quot;webconfigviewercomponent&quot;,
    Categories = &quot;dashboard&quot;,
    Title = &quot;Web Config Viewer&quot;, MinHeight = 400)]
    [Authorize(Roles = &quot;Administrators, WebAdmins, CmsAdmins&quot;)]
    public class WebConfigViewerComponentController : Controller
    {
        public ActionResult Index()
        {
            string file = &quot;~/web.config&quot;;
            string[] str = null;
            if (System.IO.File.Exists(Server.MapPath(file)))
            {
                str = System.IO.File.ReadAllLines(Server.MapPath(file));
            }

            return View(&quot;~/Features/Components/WebConfigViewerComponent.cshtml&quot;, str.ToList());
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;WebConfigViewerComponentController.cshtml&lt;/h3&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;@using Century.Website.Data
@using EPiServer
@using EPiServer.Framework.Web.Resources
@using EPiServer.ServiceLocation
@using System.Security.Claims

@model List&amp;lt;string&amp;gt;

@{
    Layout = null;
}

&amp;lt;!DOCTYPE html&amp;gt;

&amp;lt;html lang=&quot;en-us&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;title&amp;gt;Web Config&amp;lt;/title&amp;gt;
    &amp;lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=Edge&quot; /&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
        @if (Model != null &amp;amp;&amp;amp; Model.Any())
        {
            if (Model.Any() &amp;amp;&amp;amp; Model != null)
            {
                &amp;lt;pre&amp;gt;
                    @foreach (var line in Model)
                    {
                        @($&quot;{line}\n&quot;) 
                    }
                &amp;lt;/pre&amp;gt;
            }
        }
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;App Settings Viewer&lt;/h2&gt;
&lt;p&gt;Since there is configuration set on the Web App itself in DXP/Azure it sometimes helps to see what is going on. Especially in PREP and PROD where you don&#39;t have portal access.&lt;/p&gt;
&lt;h3&gt;AppSettingsComponentController.cs&lt;/h3&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Security;
using EPiServer.Shell.ViewComposition;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Web.Mvc;

namespace Website.Features.Components
{
    [IFrameComponent(Url = &quot;appsettingscomponent&quot;,
    Categories = &quot;dashboard&quot;,
    Title = &quot;App Settings&quot;, MinHeight = 400)]
    [Authorize(Roles = &quot;Administrators, WebAdmins, CmsAdmins&quot;)]
    public class AppSettingsComponentController : Controller
    {
        public ActionResult Index()
        {
            var settings = new Dictionary&amp;lt;string, string&amp;gt;();
               
            foreach (var key in ConfigurationManager.AppSettings.AllKeys.ToList())
            {
                settings.Add(key, ConfigurationManager.AppSettings[key]);
            }
            return View(&quot;~/Features/Components/AppSettingsComponent.cshtml&quot;, settings);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;AppSettingsComponent.cshtml&lt;/h3&gt;
&lt;p&gt;This one assumes you have some entry that states what environment you are in. In our case it is &quot;env&quot;&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;@using Century.Website.Data
@using EPiServer
@using EPiServer.Framework.Web.Resources
@using EPiServer.ServiceLocation
@using System.Security.Claims

@model Dictionary&amp;lt;string, string&amp;gt;

@{
    Layout = null;
    var env = !string.IsNullOrEmpty(Model[&quot;env&quot;]) ? Model[&quot;env&quot;] : string.Empty ;
}

&amp;lt;!DOCTYPE html&amp;gt;

&amp;lt;html lang=&quot;en-us&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;title&amp;gt;Security Groups and Roles&amp;lt;/title&amp;gt;
    &amp;lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=Edge&quot; /&amp;gt;
    &amp;lt;!-- Shell --&amp;gt;
    @Html.Raw(ClientResources.RenderResources(&quot;ShellCore&quot;))
    &amp;lt;!-- LightTheme --&amp;gt;
    @Html.Raw(ClientResources.RenderResources(&quot;ShellCoreLightTheme&quot;))

    &amp;lt;link href=&quot;/EPiServer/CMS/App_Themes/Default/Styles/system.css&quot; type=&quot;text/css&quot; rel=&quot;stylesheet&quot;&amp;gt;
    &amp;lt;link href=&quot;/EPiServer/CMS/App_Themes/Default/Styles/ToolButton.css&quot; type=&quot;text/css&quot; rel=&quot;stylesheet&quot;&amp;gt;

&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    @Html.Raw(Html.ShellInitializationScript())

    &amp;lt;div class=&quot;epi-contentContainer epi-padding&quot;&amp;gt;

        &amp;lt;script src=&quot;/Util/javascript/episerverscriptmanager.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/system.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/dialog.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/system.aspx&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;

        &amp;lt;input type=&quot;hidden&quot; id=&quot;doExport&quot; name=&quot;doExport&quot; value=&quot;False&quot;&amp;gt;
        &amp;lt;div class=&quot;epi-formArea&quot;&amp;gt;

        &amp;lt;/div&amp;gt;


        @if (Model != null &amp;amp;&amp;amp; Model.Any())
        {

            &amp;lt;div class=&quot;epi-contentArea epi-clear&quot;&amp;gt;
                &amp;lt;div&amp;gt;
                    @if (Model.Any() &amp;amp;&amp;amp; Model != null)
                    {
                        &amp;lt;table class=&quot;epi-default epi-default-legacy&quot;
                               cellspacing=&quot;0&quot;
                               id=&quot;FullRegion_MainRegion_ReportView&quot;
                               style=&quot;border-style: None; width: 100%; border-collapse: collapse;&quot;&amp;gt;
                            &amp;lt;tr&amp;gt;
                                &amp;lt;th scope=&quot;col&quot; colspan=&quot;2&quot;&amp;gt;@env App Settings&amp;lt;/th&amp;gt;
                            &amp;lt;/tr&amp;gt;
                            @foreach (var setting in Model)
                            {
                                &amp;lt;tr&amp;gt;
                                    &amp;lt;td&amp;gt;@setting.Key&amp;lt;/td&amp;gt;
                                    &amp;lt;td&amp;gt;@setting.Value&amp;lt;/td&amp;gt;
                                &amp;lt;/tr&amp;gt;
                            }
                        &amp;lt;/table&amp;gt;
                    }
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;

        }
        else
        {
            &amp;lt;div class=&quot;epi-floatLeft epi-marginVertical-small&quot;&amp;gt;No Information Found&amp;lt;/div&amp;gt;
        }
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Connection Strings Component&lt;/h2&gt;
&lt;p&gt;View which connection strings are set in DXP.&lt;/p&gt;
&lt;h3&gt;ConnectionStringsComponentController.cs&lt;/h3&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Shell.ViewComposition;
using System.Collections.Generic;
using System.Configuration;
using System.Linq;
using System.Web.Mvc;

namespace Website.Features.Components
{
    [IFrameComponent(Url = &quot;connectionstringscomponent&quot;,
    Categories = &quot;dashboard&quot;,
    Title = &quot;Connection Strings&quot;, MinHeight = 400)]
    [Authorize(Roles = &quot;Administrators, WebAdmins, CmsAdmins&quot;)]
    public class ConnectionStringsComponentController : Controller
    {
        public ActionResult Index()
        {
            var settings = new Dictionary&amp;lt;string, string&amp;gt;();
            var conStrings = ConfigurationManager.ConnectionStrings;


            foreach (var conn in ConfigurationManager.ConnectionStrings.Cast&amp;lt;ConnectionStringSettings&amp;gt;())
            {
                settings.Add(conn.Name, conn.ConnectionString);
            }
            return View(&quot;~/Features/Components/ConnectionStringsComponent.cshtml&quot;, settings);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;ConnectionStringsComponent.cshtml&lt;/h3&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;@using Century.Website.Data
@using EPiServer
@using EPiServer.Framework.Web.Resources
@using EPiServer.ServiceLocation
@using System.Security.Claims

@model Dictionary&amp;lt;string, string&amp;gt;

@{
    Layout = null;
}

&amp;lt;!DOCTYPE html&amp;gt;

&amp;lt;html lang=&quot;en-us&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;title&amp;gt;Security Groups and Roles&amp;lt;/title&amp;gt;
    &amp;lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=Edge&quot; /&amp;gt;
    &amp;lt;!-- Shell --&amp;gt;
    @Html.Raw(ClientResources.RenderResources(&quot;ShellCore&quot;))
    &amp;lt;!-- LightTheme --&amp;gt;
    @Html.Raw(ClientResources.RenderResources(&quot;ShellCoreLightTheme&quot;))

    &amp;lt;link href=&quot;/EPiServer/CMS/App_Themes/Default/Styles/system.css&quot; type=&quot;text/css&quot; rel=&quot;stylesheet&quot;&amp;gt;
    &amp;lt;link href=&quot;/EPiServer/CMS/App_Themes/Default/Styles/ToolButton.css&quot; type=&quot;text/css&quot; rel=&quot;stylesheet&quot;&amp;gt;

&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    @Html.Raw(Html.ShellInitializationScript())

    &amp;lt;div class=&quot;epi-contentContainer epi-padding&quot;&amp;gt;

        &amp;lt;script src=&quot;/Util/javascript/episerverscriptmanager.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/system.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/dialog.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/system.aspx&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;

        &amp;lt;input type=&quot;hidden&quot; id=&quot;doExport&quot; name=&quot;doExport&quot; value=&quot;False&quot;&amp;gt;
        &amp;lt;div class=&quot;epi-formArea&quot;&amp;gt;

        &amp;lt;/div&amp;gt;


        @if (Model != null &amp;amp;&amp;amp; Model.Any())
        {

            &amp;lt;div class=&quot;epi-contentArea epi-clear&quot;&amp;gt;
                &amp;lt;div&amp;gt;
                    @if (Model.Any() &amp;amp;&amp;amp; Model != null)
                    {
                        &amp;lt;table class=&quot;epi-default epi-default-legacy&quot;
                               cellspacing=&quot;0&quot;
                               id=&quot;FullRegion_MainRegion_ReportView&quot;
                               style=&quot;border-style: None; width: 100%; border-collapse: collapse;&quot;&amp;gt;
                            &amp;lt;tr&amp;gt;
                                &amp;lt;th scope=&quot;col&quot; colspan=&quot;2&quot;&amp;gt;Connection Strings&amp;lt;/th&amp;gt;
                            &amp;lt;/tr&amp;gt;
                            @foreach (var setting in Model)
                            {
                                &amp;lt;tr&amp;gt;
                                    &amp;lt;td&amp;gt;@setting.Key&amp;lt;/td&amp;gt;
                                    &amp;lt;td&amp;gt;@setting.Value&amp;lt;/td&amp;gt;
                                &amp;lt;/tr&amp;gt;
                            }
                        &amp;lt;/table&amp;gt;
                    }
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;

        }
        else
        {
            &amp;lt;div class=&quot;epi-floatLeft epi-marginVertical-small&quot;&amp;gt;No Information Found&amp;lt;/div&amp;gt;
        }
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Security Groups Component&lt;/h2&gt;
&lt;p&gt;We use Azure Active Directory for Single Sign On.&amp;nbsp; This component shows which groups and roles are being passed in for the logged in user. In this example we are looking for groups that start with &quot;SecGrp-Episerver&quot;&lt;/p&gt;
&lt;h3&gt;SecurityGroupsComponentController.cs&lt;/h3&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Security;
using EPiServer.Shell.ViewComposition;
using System.Linq;
using System.Security.Claims;
using System.Web.Mvc;

namespace Website.Features.Components
{
    [IFrameComponent(Url = &quot;securitygroupscomponent&quot;,
        Categories = &quot;dashboard&quot;,
        Title = &quot;Security Groups and Roles&quot;, MinHeight = 400)]
    public class SecurityGroupsComponentController : Controller
    {
        public ActionResult Index()
        {
            var claims = ClaimsPrincipal.Current.Identities.First().Claims.ToList();

            return View(&quot;~/Features/Components/SecurityGroupComponent.cshtml&quot;, claims); 
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;SecurityGroupComponent.cshtml&lt;/h3&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;@using Century.Website.Data
@using EPiServer
@using EPiServer.Framework.Web.Resources
@using EPiServer.ServiceLocation
@using System.Security.Claims

@model List&amp;lt;Claim&amp;gt;

@{
    Layout = null;

    var contentLoader = ServiceLocator.Current.GetInstance&amp;lt;IContentLoader&amp;gt;();
}

&amp;lt;!DOCTYPE html&amp;gt;

&amp;lt;html lang=&quot;en-us&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;title&amp;gt;Security Groups and Roles&amp;lt;/title&amp;gt;
    &amp;lt;meta http-equiv=&quot;X-UA-Compatible&quot; content=&quot;IE=Edge&quot; /&amp;gt;
    &amp;lt;!-- Shell --&amp;gt;
    @Html.Raw(ClientResources.RenderResources(&quot;ShellCore&quot;))
    &amp;lt;!-- LightTheme --&amp;gt;
    @Html.Raw(ClientResources.RenderResources(&quot;ShellCoreLightTheme&quot;))

    &amp;lt;link href=&quot;/EPiServer/CMS/App_Themes/Default/Styles/system.css&quot; type=&quot;text/css&quot; rel=&quot;stylesheet&quot;&amp;gt;
    &amp;lt;link href=&quot;/EPiServer/CMS/App_Themes/Default/Styles/ToolButton.css&quot; type=&quot;text/css&quot; rel=&quot;stylesheet&quot;&amp;gt;

&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    @Html.Raw(Html.ShellInitializationScript())

    &amp;lt;div class=&quot;epi-contentContainer epi-padding&quot;&amp;gt;

        &amp;lt;script src=&quot;/Util/javascript/episerverscriptmanager.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/system.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/dialog.js&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;
        &amp;lt;script src=&quot;/EPiServer/CMS/javascript/system.aspx&quot; type=&quot;text/javascript&quot;&amp;gt;&amp;lt;/script&amp;gt;

        &amp;lt;input type=&quot;hidden&quot; id=&quot;doExport&quot; name=&quot;doExport&quot; value=&quot;False&quot;&amp;gt;
        &amp;lt;div class=&quot;epi-formArea&quot;&amp;gt;

        &amp;lt;/div&amp;gt;


        @if (Model != null &amp;amp;&amp;amp; Model.Any())
        {

            var roleClaims = Model.Where(x =&amp;gt; x.Type.Contains(&quot;role&quot;) &amp;amp;&amp;amp; !x.Value.StartsWith(&quot;SecGrp-Episerver&quot;));
            var groupClaims = Model.Where(x =&amp;gt; x.Type.Contains(&quot;role&quot;) &amp;amp;&amp;amp; x.Value.StartsWith(&quot;SecGrp-Episerver&quot;));

            &amp;lt;div class=&quot;epi-contentArea epi-clear&quot;&amp;gt;
                &amp;lt;div&amp;gt;
                    @if (roleClaims.Any() &amp;amp;&amp;amp; roleClaims != null)
                    {
                        &amp;lt;table class=&quot;epi-default epi-default-legacy&quot;
                               cellspacing=&quot;0&quot;
                               id=&quot;FullRegion_MainRegion_ReportView&quot;
                               style=&quot;border-style: None; width: 100%; border-collapse: collapse;&quot;&amp;gt;
                            &amp;lt;tr&amp;gt;
                                &amp;lt;th scope=&quot;col&quot;&amp;gt;Roles&amp;lt;/th&amp;gt;
                            &amp;lt;/tr&amp;gt;
                            @foreach (var claim in roleClaims)
                            {
                                &amp;lt;tr&amp;gt;
                                    &amp;lt;td&amp;gt;@claim.Value&amp;lt;/td&amp;gt;
                                &amp;lt;/tr&amp;gt;
                            }
                        &amp;lt;/table&amp;gt;
                    }
                    &amp;lt;br /&amp;gt;&amp;lt;br /&amp;gt;
                    @if (groupClaims.Any() &amp;amp;&amp;amp; groupClaims != null)
                    {
                        &amp;lt;table class=&quot;epi-default epi-default-legacy&quot;
                               cellspacing=&quot;0&quot;
                               id=&quot;FullRegion_MainRegion_ReportView&quot;
                               style=&quot;border-style: None; width: 100%; border-collapse: collapse;&quot;&amp;gt;
                            &amp;lt;tr&amp;gt;
                                &amp;lt;th scope=&quot;col&quot;&amp;gt;Groups&amp;lt;/th&amp;gt;
                            &amp;lt;/tr&amp;gt;
                            @foreach (var claim in groupClaims)
                            {
                                &amp;lt;tr&amp;gt;
                                    &amp;lt;td&amp;gt;@claim.Value&amp;lt;/td&amp;gt;
                                &amp;lt;/tr&amp;gt;
                            }
                        &amp;lt;/table&amp;gt;
                    }
                    &amp;lt;br /&amp;gt;&amp;lt;br /&amp;gt;
                    @if (Model.Any() &amp;amp;&amp;amp; Model != null)
                    {
                        &amp;lt;table class=&quot;epi-default epi-default-legacy&quot;
                               cellspacing=&quot;0&quot;
                               id=&quot;FullRegion_MainRegion_ReportView&quot;
                               style=&quot;border-style: None; width: 100%; border-collapse: collapse;&quot;&amp;gt;
                            &amp;lt;tr&amp;gt;
                                &amp;lt;th scope=&quot;col&quot; colspan=&quot;2&quot;&amp;gt;Troubleshooting Info&amp;lt;/th&amp;gt;
                            &amp;lt;/tr&amp;gt;
                            @foreach (var claim in Model)
                            {
                                &amp;lt;tr&amp;gt;
                                    &amp;lt;td&amp;gt;@claim.Type&amp;lt;/td&amp;gt;
                                    &amp;lt;td&amp;gt;@claim.Value&amp;lt;/td&amp;gt;
                                &amp;lt;/tr&amp;gt;
                            }
                        &amp;lt;/table&amp;gt;
                    }
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;

        }
        else
        {
            &amp;lt;div class=&quot;epi-floatLeft epi-marginVertical-small&quot;&amp;gt;No Information Found&amp;lt;/div&amp;gt;
        }
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&#39;m not sure how these will transfer to the new version but I&#39;m sure something similar will be an option.&lt;/p&gt;
&lt;p&gt;I hope you found some of these useful and maybe it has given you some ideas for your own. Do you do anything similar? If so let me know in the comments.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2021/9/need-to-do-some-troubleshooting-go-go-gadget/</guid>            <pubDate>Mon, 27 Sep 2021 21:36:49 GMT</pubDate>           <category>Blog post</category></item><item> <title>Self-adjusting the SiteDefinition after automated Copydown</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2021/1/self-adjusting-sitedefinition-after-copydown/</link>            <description>&lt;p&gt;Needing to keep the data in your DXP environments in sync? Automate it!&lt;/p&gt;
&lt;p&gt;We have a PowerShell script that runs in our Azure DevOps instance that will copy the database and BLOBs down from PROD to the lower environments &lt;a href=&quot;/link/eb85a23d0c1843aaa36f121668e1059e.aspx&quot;&gt;using the Deployment API&lt;/a&gt;. This can be run on a regular schedule.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;$key = &quot;$(ClientKey)&quot;
$secret = &quot;$(ClientSecret)&quot;
$projectid = &quot;$(ProjectId)&quot;

if (-not (Get-Module -Name EpiCloud -ListAvailable)) {
    Install-Module EpiCloud -Scope CurrentUser -Force
}
Connect-EpiCloud -ClientKey $key -ClientSecret $secret  -ProjectId $projectid
Start-EpiDeployment -SourceEnvironment Production -TargetEnvironment Preproduction -IncludeBlob -IncludeDb -ShowProgress
Write-Host &quot;Content Sync Finished&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The problem was that we needed to go in and correct the site definition for the current environment once this had completed.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Luckily the &lt;span&gt;SiteDefinitionRepository gives us what we need.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;span&gt;First thing we store a setting for each environment in the proper web.config. These can be set using web.config transforms.&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;add key=&quot;env&quot; value=&quot;INTE&quot; xdt:Transform=&quot;SetAttributes&quot; xdt:Locator=&quot;Match(key)&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;span&gt;Then just to keep things tidy we store the environment URLs in a Constants file.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;    public class EnvironmentUrls
    {
        public const string DEV = &quot;http://localhost:xxx/&quot;;
        public const string INTE = &quot;https://integ.xxx.com/&quot;;
        public const string PREP = &quot;https://prep.xxx.com/&quot;;
        public const string PROD = &quot;https://www.xxx.com/&quot;;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, we add an initialization module that checks the environment app setting to determine which environment the code is running in and self-adjust.&lt;/p&gt;
&lt;p&gt;We get the current environment from the web.config.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;string environment = System.Web.Configuration.WebConfigurationManager.AppSettings[&quot;env&quot;];&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We use the SiteDefinitionRepository to find what is currently set.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;            var siteDefinitionRepository = ServiceLocator.Current.GetInstance&amp;lt;ISiteDefinitionRepository&amp;gt;();
            var currentDefinition = SiteDefinition.Current;
            string currentSiteUrl = currentDefinition.SiteUrl.ToString();&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If they don&#39;t match we create a writeable clone of the definition, loop through the Hosts setting the correct Url as Primary, reset all others to Undefined, and Save.&lt;/p&gt;
&lt;p&gt;(Site URLs for each environment are already present in the Manage Websites section in PROD so they are available here after the DB copydown.)&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;            if (!environmentUrl.Equals(currentSiteUrl))
            {
                var newDefinition = currentDefinition.CreateWritableClone();
                newDefinition.SiteUrl = new Uri(environmentUrl);
                foreach (var host in newDefinition.Hosts)
                {
                    if (host.Url != null &amp;amp;&amp;amp; environmentUrl.Equals(host.Url.ToString()))
                    {
                        host.Type = HostDefinitionType.Primary;
                    }
                    else if (host.Url != null &amp;amp;&amp;amp; host.Type == HostDefinitionType.Primary)
                    {
                        host.Type = HostDefinitionType.Undefined;
                    }
                }
                siteDefinitionRepository.Save(newDefinition);
            }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is the complete code.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using System;
using System.Linq;
using Website.Constants;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using EPiServer.Web;
namespace Website.Business.Initialization
{
    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class CopyDownInitialization : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            string environment = System.Web.Configuration.WebConfigurationManager.AppSettings[&quot;env&quot;];
            switch (environment)
            {
                case &quot;DEV&quot;:
                    ConfirmSiteConfig(EnvironmentUrls.DEV);
                    break;
                case &quot;INTE&quot;:
                    ConfirmSiteConfig(EnvironmentUrls.INTE);
                    break;
                case &quot;PREP&quot;:
                    ConfirmSiteConfig(EnvironmentUrls.PREP);
                    break;
                default:
                    break;
            }
        }
        private void ConfirmSiteConfig(string environmentUrl)
        {
            var siteDefinitionRepository = ServiceLocator.Current.GetInstance&amp;lt;ISiteDefinitionRepository&amp;gt;();
            var currentDefinition = SiteDefinition.Current;
            string currentSiteUrl = currentDefinition.SiteUrl.ToString();
            if (!environmentUrl.Equals(currentSiteUrl))
            {
                var newDefinition = currentDefinition.CreateWritableClone();
                newDefinition.SiteUrl = new Uri(environmentUrl);
                foreach (var host in newDefinition.Hosts)
                {
                    if (host.Url != null &amp;amp;&amp;amp; environmentUrl.Equals(host.Url.ToString()))
                    {
                        host.Type = HostDefinitionType.Primary;
                    }
                    else if (host.Url != null &amp;amp;&amp;amp; host.Type == HostDefinitionType.Primary)
                    {
                        host.Type = HostDefinitionType.Undefined;
                    }
                }
                siteDefinitionRepository.Save(newDefinition);
            }
        }
        public void Uninitialize(InitializationEngine context)
        {
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Next, I would love to find a way to automate flushing the Cloudflare cache after the copydown!&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2021/1/self-adjusting-sitedefinition-after-copydown/</guid>            <pubDate>Mon, 18 Jan 2021 16:43:19 GMT</pubDate>           <category>Blog post</category></item><item> <title>Using Custom Views for Editable Documentation</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2020/9/using-custom-views-for-editable-documentation/</link>            <description>&lt;p&gt;We&amp;rsquo;ve got some very structured hierarchical data that makes up a good portion of our site content.&lt;/p&gt;
&lt;p&gt;Think Corporate &amp;gt; State &amp;gt; Metro &amp;gt; City &amp;gt; Community etc.&lt;/p&gt;
&lt;p&gt;However, it can be very confusing for our site editors to know when a value should be set on the item itself, on its parent, grandparent, or worse still a related item. Sometimes you need to provide a little more info that what fits into the Description.&lt;/p&gt;
&lt;p&gt;This calls for some clear documentation. And the best documentation is nearby without you needing to leave the system to find it.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/783d2a313ec04a22ad2d69e30c40dea0.aspx&quot; /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/a3ddcacf69a544b28a1b79f0e30249ba.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;ve based this on the awesome Custom View samples provided by Glen Lalas (&lt;a href=&quot;https://github.com/barig224/Episerver-Custom-Views&quot;&gt;Github repo&lt;/a&gt;, &lt;a href=&quot;https://adagetechnologies.com/enhancing-edit-mode-custom-views-episerver/&quot;&gt;blog article&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;I started with a basic text page and container folder.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/6b4d9644d8854212ae88051b6192c76b.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Documentation Page&lt;/h2&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using System.ComponentModel.DataAnnotations;
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;

namespace Features.Pages.DocumentationPage
{
    [ContentType(DisplayName = &quot;Documentation Page&quot;, GUID = &quot;xxxxxxxxxxxxxxxxxxxxxxxx&quot;, Description = &quot;In Place Documentation Page&quot;)]
    public class DocumentationPage : PageData
    {

        [CultureSpecific]
        [Display(
            Name = &quot;Main body&quot;,
            Description = &quot;The main body will be shown in the main content area of the page, using the XHTML-editor you can insert for example text, images and tables.&quot;,
            GroupName = SystemTabNames.Content,
            Order = 10)]
        public virtual XhtmlString MainBody { get; set; }

    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Documentation Folder&lt;/h2&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using Century.Website.Constants;
using Century.Website.Features.Pages.DocumentationPage;
using EPiServer.Core;
using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;

namespace Website.Data
{
    [ContentType(
        DisplayName = &quot;Documentation Container&quot;, 
        GUID = &quot;xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&quot;,
        Description = &quot;Container for Documentation Pages&quot;,
        GroupName = PageGroups.DataPages)]
    [AvailableContentTypes(Include = new[] {
        typeof(DocumentationContainer), typeof(DocumentationPage)
    })]
    public class DocumentationContainer : PageData
    {
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are limited to a dark corner of the site via AllowedTypes on a parent node/page.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[AvailableContentTypes(Include = new[] {
    typeof(DocumentationContainer)
})]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are the basic pages to hold the documentation. Now we need to associate/map them to page types. (One editable page to many instances of the page type.) I stored these mappings on the homepage as a PropertyList.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/831fa33bd4114fe8aecabe7ae53d3926.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/1e3919be09554b6896e1b5c5165bd0a6.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Mappings PropertyList&lt;/h2&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;[CultureSpecific]
[Display(
    Name = &quot;Documentation Page Mappings&quot;,
    Description = &quot;Map PageType to Documentation Page&quot;,
    GroupName = SystemTabNames.Settings)]
[EditorDescriptor(EditorDescriptorType = typeof(CollectionEditorDescriptor&amp;lt;DocumentationPageMapping&amp;gt;))]
public virtual IList&amp;lt;DocumentationPageMapping&amp;gt; DocumentationPageMappings { get; set; }&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using System.ComponentModel.DataAnnotations;
using Website.Constants;
using Website.Features.Pages.DocumentationPage;
using EPiServer.Core;
using EPiServer.DataAnnotations;
using EPiServer.PlugIn;

namespace Features.CustomViews
{
    public class DocumentationPageMapping
    {
        [Display(Name = &quot;Type Of Page&quot;)]
        [UIHint(CmsUIHints.DocumentationTypes)]
        public string PageTypeName { get; set; }
        [Display(Name = &quot;Documentation Page&quot;)]
        [AllowedTypes(typeof(DocumentationPage))]
        public ContentReference pageReference { get; set; }

    }

    [PropertyDefinitionTypePlugIn]
    public class DocumentationMappings : PropertyList&amp;lt;DocumentationPageMapping&amp;gt;
    {
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Just to make life easier on everybody we&#39;re using a SelectionFactory to drive the PageType names for the mapping.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.Shell.ObjectEditing;
using System.Collections.Generic;

namespace Website.Business.SelectionFactory
{
    public class DocumentationTypeSelectionFactory : ISelectionFactory
    {
        public IEnumerable&amp;lt;ISelectItem&amp;gt; GetSelections(ExtendedMetadata metadata)
        {
            var selections = new List&amp;lt;SelectItem&amp;gt;();
            selections.Add(new SelectItem { Text = &quot;Community&quot;, Value = &quot;CommunityData&quot; });
            selections.Add(new SelectItem { Text = &quot;Metro&quot;, Value = &quot;MetroData&quot; });
            selections.Add(new SelectItem { Text = &quot;Lot&quot;, Value = &quot;LotData&quot; });
            selections.Add(new SelectItem { Text = &quot;Plan&quot;, Value = &quot;PlanData&quot; });
            return selections; 
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we need to display these pages in the Custom View tab. We register the route:&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;protected override void RegisterRoutes(RouteCollection routes)
{
    base.RegisterRoutes(routes);

    routes.MapRoute(&quot;AboutThisPageType&quot;, &quot;AboutThisPageType&quot;, new { controller = &quot;AboutThisPageType&quot;, action = &quot;Index&quot; });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We setup the page controller to get the documentation page mapped to the current pagetype.&lt;/p&gt;
&lt;h2&gt;AboutThisPageType Controller&lt;/h2&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using Website.Features.Pages.DocumentationPage;
using Website.Features.Pages.HomePage;
using EPiServer;
using EPiServer.Core;
using EPiServer.ServiceLocation;
using System.Linq;
using System.Web.Mvc;

namespace Website.Features.CustomViews
{
    public class AboutThisPageTypeController : Controller
    {
        public ActionResult Index()
        {
            var contentRepo = ServiceLocator.Current.GetInstance&amp;lt;IContentRepository&amp;gt;();
            // Since we&#39;re in an iFrame, need to do some manipulation to get the actual PageData object...
            var epiId = System.Web.HttpContext.Current.Request.QueryString[&quot;id&quot;];
            var currentPage = contentRepo.Get&amp;lt;PageData&amp;gt;(new ContentReference(epiId));
            var homePage = contentRepo.Get&amp;lt;HomePage&amp;gt;(ContentReference.StartPage);
            var mapping = homePage.DocumentationPageMappings?.Where(x =&amp;gt; x.PageTypeName == currentPage.PageTypeName)?.FirstOrDefault();
            var docPage = mapping != null ? contentRepo.Get&amp;lt;DocumentationPage&amp;gt;(mapping?.pageReference) : null ;
            
            return View(&quot;~/Features/CustomViews/AboutThisPageType.cshtml&quot;, docPage);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;AboutThisPageType.cshtml&lt;/h2&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;@using EPiServer.Core
@using EPiServer.Web.Mvc.Html
@using Website.Data;
@using Website.Features.Pages.DocumentationPage

@model DocumentationPage

@{ Layout = null; }
&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1, shrink-to-fit=no&quot;&amp;gt;
    &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;/dist/main.css&quot;&amp;gt;
    &amp;lt;title&amp;gt;Documentation&amp;lt;/title&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;

    &amp;lt;main role=&quot;main&quot; class=&quot;container&quot;&amp;gt;
        &amp;lt;br/&amp;gt;
        &amp;lt;br /&amp;gt;
        @if (Model != null)
        {
            @Html.PropertyFor(x =&amp;gt; x.MainBody, new { HasItemContainer = false }) 
        }
        else
        {
            &amp;lt;p&amp;gt;Documentation has not been provided for this item.&amp;lt;/p&amp;gt;
        }

    &amp;lt;/main&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We provide a simple message if a documentation page hasn&#39;t been mapped.&lt;/p&gt;
&lt;p&gt;We also need to tell Epi to add some view configurations to add the custom view for each of the page types we are working with.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using Website.Data;
using EPiServer.ServiceLocation;
using EPiServer.Shell;

namespace Website.Features.CustomViews
{

    [ServiceConfiguration(typeof(EPiServer.Shell.ViewConfiguration))]
    public class AboutCommunityPagesViewConfig : ViewConfiguration&amp;lt;CommunityData&amp;gt;
    {
        public AboutCommunityPagesViewConfig()
        {
            Key = &quot;aboutThisPage&quot;;
            Name = &quot;Community Documentation&quot;;
            Description = &quot;Info about Community pages&quot;;
            ControllerType = &quot;epi-cms/widget/IFrameController&quot;;
            ViewType = &quot;/AboutThisPageType/&quot;;
            IconClass = &quot;aboutThisPageType&quot;;
        }
    }

    [ServiceConfiguration(typeof(EPiServer.Shell.ViewConfiguration))]
    public class AboutMetroPagesViewConfig : ViewConfiguration&amp;lt;MetroData&amp;gt;
    {
        public AboutMetroPagesViewConfig()
        {
            Key = &quot;aboutThisPage&quot;;
            Name = &quot;Metro Documentation&quot;;
            Description = &quot;Info about Metro pages&quot;;
            ControllerType = &quot;epi-cms/widget/IFrameController&quot;;
            ViewType = &quot;/AboutThisPageType/&quot;;
            IconClass = &quot;aboutThisPageType&quot;;
        }
    }

    [ServiceConfiguration(typeof(EPiServer.Shell.ViewConfiguration))]
    public class AboutLotPagesViewConfig : ViewConfiguration&amp;lt;LotData&amp;gt;
    {
        public AboutLotPagesViewConfig()
        {
            Key = &quot;aboutThisPage&quot;;
            Name = &quot;Lot Documentation&quot;;
            Description = &quot;Info about Lot pages&quot;;
            ControllerType = &quot;epi-cms/widget/IFrameController&quot;;
            ViewType = &quot;/AboutThisPageType/&quot;;
            IconClass = &quot;aboutThisPageType&quot;;
        }
    }

    [ServiceConfiguration(typeof(EPiServer.Shell.ViewConfiguration))]
    public class AboutPlanPagesViewConfig : ViewConfiguration&amp;lt;PlanData&amp;gt;
    {
        public AboutPlanPagesViewConfig()
        {
            Key = &quot;aboutThisPage&quot;;
            Name = &quot;Plan Documentation&quot;;
            Description = &quot;Info about Plan pages&quot;;
            ControllerType = &quot;epi-cms/widget/IFrameController&quot;;
            ViewType = &quot;/AboutThisPageType/&quot;;
            IconClass = &quot;aboutThisPageType&quot;;
        }
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These all share the same icon and view type. We add the icon image and a little bit of css under ClientResources&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/ee786bf0d11741c0b7fb15b459856e41.aspx&quot; /&gt;&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;.Sleek .aboutThisPageType {
    background: url(&#39;images/icons8-about-24.png&#39;) no-repeat;
    height: 24px;
    width: 24px;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The css file is pulled in via the module.config file&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&amp;gt;
&amp;lt;module&amp;gt;
	&amp;lt;clientResources&amp;gt;
		&amp;lt;add name=&quot;epi-cms.widgets.base&quot; path=&quot;/ClientResources/epi-cms.css&quot; resourceType=&quot;Style&quot; /&amp;gt;
	&amp;lt;/clientResources&amp;gt;
&amp;lt;/module&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If I&#39;ve glossed over anything with the Custom View setup take a look at the Adage blog post that was my inspiration:&amp;nbsp;&lt;a href=&quot;https://adagetechnologies.com/enhancing-edit-mode-custom-views-episerver/&quot;&gt;https://adagetechnologies.com/enhancing-edit-mode-custom-views-episerver/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;So there you have it. It might seem like a little bit of smoke and mirros because this is just another page in the site that you are providing but it puts it pretty much in context of where the editors are working and maintenance is self-contained.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2020/9/using-custom-views-for-editable-documentation/</guid>            <pubDate>Thu, 01 Oct 2020 17:20:52 GMT</pubDate>           <category>Blog post</category></item><item> <title>Behold the Shadow Form!</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2020/8/behold-the-shadow-form/</link>            <description>&lt;p&gt;Ok, maybe that&amp;rsquo;s a little dramatic.&lt;/p&gt;
&lt;p&gt;Let me start off by saying that I&amp;rsquo;m sure that there is a better way to handle this (feel free to let me know in the comments) and this never moved past proof-of-concept, but this approach did seem to work.&lt;/p&gt;
&lt;p&gt;We had a situation where some forms were created as standard blocks (not using Episerver Forms) due to some interdependencies and very custom logic.&lt;/p&gt;
&lt;p&gt;It was decided that we needed to store the submissions in the CMS. I knew that was Out Of The Box with Epi Forms. If only we were using that. Thus was born the Shadow Form!&lt;/p&gt;
&lt;p&gt;The basic idea is to create an Epi Form that matches the content of the custom form but it won&#39;t be customer facing, it will live in the shadows. On submit you will map the values from the custom form into the Epi shadow form and then save so that you get all the benefits of the CMS (view, sort, export, etc.).&lt;/p&gt;
&lt;p&gt;First step is to create an Epi Form with a text(box) field for every item in the custom form. It doesn&#39;t matter what input fields are used on the front end, the values just need to pass through and will be stored as strings. Also, don&amp;rsquo;t worry about all the form configuration options as this form isn&amp;rsquo;t customer facing. Save and publish.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/9ea51e89ea3b4a2fa8ba334b49289369.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This is where things start to get just a little hacky. Add the new form &lt;em&gt;temporarily&lt;/em&gt; to the same page as the custom form and view it. Inspect the HTML to get the form GUID, page ID and the element names. These will be used in your code.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/580cef06bec947c58728284097207251.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Once you&amp;rsquo;ve got all the info, remove the form from the page (but don&amp;rsquo;t delete it!), this will serve as the container for the submissions. Save and publish.&lt;/p&gt;
&lt;p&gt;The magic happens by tapping into the POST event of the custom form.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;private void StoreFormSubmission(FormSubmissionInputModel model)
{
    var ddsSubmission = new DdsPermanentStorage();

    var formIdentity = new FormIdentity(Guid.Parse(&quot;3481166b-5750-4d6a-887c-5ae9764ae543&quot;), &quot;no&quot;);

    var submission = new Submission();

    submission.Id = string.Empty;
    submission.Data = new Dictionary&amp;lt;string, object&amp;gt;();

    //form elements...
    submission.Data.Add(&quot;__field_32827&quot;, model.FirstName);
    submission.Data.Add(&quot;__field_32828&quot;, model.LastName);
    submission.Data.Add(&quot;__field_32829&quot;, model.PostalCode);
    submission.Data.Add(&quot;__field_32830&quot;, model.StreetAddress);
    submission.Data.Add(&quot;__field_32831&quot;, model.HomePhoneNumber);
    submission.Data.Add(&quot;__field_32832&quot;, model.Email);

    //generic data fields
    submission.Data.Add(&quot;SYSTEMCOLUMN_SubmitTime&quot;, DateTime.Now.ToString());
    submission.Data.Add(&quot;SYSTEMCOLUMN_FinalizedSubmission&quot;, true);
    //language code
    submission.Data.Add(&quot;SYSTEMCOLUMN_Language&quot;, &quot;en&quot;);
    //the id of the form&#39;s container page
    submission.Data.Add(&quot;SYSTEMCOLUMN_HostedPage&quot;, &quot;1637&quot;);

    var subId = ddsSubmission.SaveToStorage(formIdentity, submission);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here is what is happening:&lt;/p&gt;
&lt;p&gt;ddsSubmission gives you access to the data store.&lt;/p&gt;
&lt;p&gt;formIdentity is a reference to the Episerver form.&lt;/p&gt;
&lt;p&gt;submission is a new Submission object where all the custom form fields are mapped to the Epi fields.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Pass the form reference and the submission object into ddsSubmission to save to storage.&lt;/p&gt;
&lt;p&gt;Upon submission of the original custom form, the data is processed as if it came from the Episerver Form.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/8e4194eae0b24643aca12f091ad44100.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;If you need to do further processing subId is the GUID of your submission and the submission can be retrieved like this:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;var result = ddsSubmission.LoadSubmissionFromStorage(formIdentity, new[] { subId.ToString() });&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;What do you think? Does the end justify the means? Let me know.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2020/8/behold-the-shadow-form/</guid>            <pubDate>Mon, 31 Aug 2020 15:42:23 GMT</pubDate>           <category>Blog post</category></item><item> <title>Episerver Forms – Making Dropdown Lists Multilingual</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2019/5/episerver-forms--making-dropdown-lists-multilingual/</link>            <description>&lt;p&gt;I recently ran into an issue with Episerver Forms and multi-language. Specifically, the Selection (Dropdown) Block that is part of the Basic Elements. Things like the label and the placeholder text can be translated but the actual &amp;lt;options&amp;gt; only pull from the base language of the block. This is a deeper dive into my &lt;a href=&quot;/link/1de5b04ba14442b7a07869b525001bbb.aspx&quot;&gt;forum discussion.&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;The problem&lt;/h2&gt;
&lt;p&gt;Say you&#39;ve got a new form and have added a dropdownlist (Selection Block).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/c164dec42e9142208f15a441838e1b15.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;If you switch to another language and edit that version of the block you aren&#39;t able to translate the items (&amp;lt;options&amp;gt;).&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/480adf5bc3e34112ab319f69f23eb9e7.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;So either language will only show the base language options.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/b10a72eb8ff44253a47a3c457523718f.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/2751139e6d4d4bed91f6c0df09a7b668.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Now the reasoning for this may be that you need the form&#39;s submitted value to always be the same regardless of the visitor&#39;s language.&lt;/p&gt;
&lt;h2&gt;A Solution&lt;/h2&gt;
&lt;p&gt;I decided to create a custom Culture Specific Selection block. This would allow me to use the regular Out-Of-The-Box Selection Element blocks for most cases and my custom one when I needed it.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;I inherited from SelectionElementBlock and overrode the Items:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;using EPiServer.DataAbstraction;
using EPiServer.DataAnnotations;
using EPiServer.Forms.EditView.Models.Internal;
using EPiServer.Forms.Implementation.Elements;
using System.Collections.Generic;

namespace SelectionBlockExample.Models.Blocks
{
    [ContentType(
        DisplayName = &quot;Culture Specific SelectionBlock&quot;, 
        GUID = &quot;xxxxx&quot;,
        Description = &quot;Use when you need the options in another language&quot;,
        GroupName = &quot;Custom Elements&quot;)]
    public class CultureSpecificSelectionBlock : SelectionElementBlock
    {
        [CultureSpecific]
        public override IEnumerable&amp;lt;OptionItem&amp;gt; Items { get; set; }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;I also needed to provide the view.&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;language-markup&quot;&gt;&lt;code&gt;&amp;lt;%--
    ====================================
    Version: 4.15.1. Modified: 20180726
    ====================================
--%&amp;gt;

&amp;lt;%@ import namespace=&quot;System.Web.Mvc&quot; %&amp;gt;
&amp;lt;%@ Import Namespace=&quot;EPiServer.Shell.Web.Mvc.Html&quot; %&amp;gt;
&amp;lt;%@ import namespace=&quot;EPiServer.Forms.Helpers.Internal&quot; %&amp;gt;
&amp;lt;%@ import namespace=&quot;EPiServer.Forms.Implementation.Elements&quot; %&amp;gt;
&amp;lt;%@ import namespace=&quot;SelectionBlockExample.Models.Blocks&quot; %&amp;gt;
&amp;lt;%@ control language=&quot;C#&quot; inherits=&quot;ViewUserControl&amp;lt;CultureSpecificSelectionBlock&amp;gt;
    &quot; %&amp;gt;

    &amp;lt;%
    var formElement = Model.FormElement;
    var labelText = Model.Label;
    var placeholderText = Model.PlaceHolder;
    var defaultOptionItemText = !string.IsNullOrWhiteSpace(placeholderText) ? placeholderText : 
        Html.Translate(string.Format(&quot;/episerver/forms/viewmode/selection/{0}&quot;, Model.AllowMultiSelect ? &quot;selectoptions&quot; : &quot;selectanoption&quot;));
    var defaultOptionSelected = !Model.AllowMultiSelect &amp;amp;&amp;amp; !Model.Items.Any(x =&amp;gt; x.Checked.HasValue &amp;amp;&amp;amp; x.Checked.Value) ? &quot;selected=\&quot;selected\&quot;&quot; : &quot;&quot;;
    var items = Model.GetItems();
    var defaultValue = Model.GetDefaultValue();
    %&amp;gt;

    &amp;lt;% using(Html.BeginElement(Model, new { @class=&quot;FormSelection&quot; + Model.GetValidationCssClasses(), data_f_type=&quot;selection&quot; })) { %&amp;gt;
    &amp;lt;label for=&quot;&amp;lt;%: formElement.Guid %&amp;gt;&quot; class=&quot;Form__Element__Caption&quot;&amp;gt;&amp;lt;%: labelText %&amp;gt;&amp;lt;/label&amp;gt;
    &amp;lt;select name=&quot;&amp;lt;%: formElement.ElementName %&amp;gt;&quot; id=&quot;&amp;lt;%: formElement.Guid %&amp;gt;&quot; &amp;lt;%: Model.AllowMultiSelect ? &quot;multiple&quot; : &quot;&quot; %&amp;gt;  &amp;lt;%= Model.AttributesString %&amp;gt; data-f-datainput&amp;gt;
        &amp;lt;option disabled=&quot;disabled&quot; &amp;lt;%= defaultOptionSelected %&amp;gt; value=&quot;&quot;&amp;gt;&amp;lt;%: defaultOptionItemText %&amp;gt;&amp;lt;/option&amp;gt;
        &amp;lt;%
        foreach (var item in items)
        {
            var defaultSelectedString = Model.GetDefaultSelectedString(item, defaultValue);
            var selectedString = string.IsNullOrEmpty(defaultSelectedString) ? string.Empty : &quot;selected&quot;;
        %&amp;gt;
        &amp;lt;option value=&quot;&amp;lt;%: item.Value %&amp;gt;&quot; &amp;lt;%= selectedString %&amp;gt; &amp;lt;%= defaultSelectedString %&amp;gt; data-f-datainput&amp;gt;&amp;lt;%: item.Caption %&amp;gt;&amp;lt;/option&amp;gt;
        &amp;lt;% } %&amp;gt;
    &amp;lt;/select&amp;gt;
    &amp;lt;%= Html.ValidationMessageFor(Model) %&amp;gt;
    &amp;lt;% } %&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you are new to custom FormElement blocks you need to add your view under Shared/Elementblocks.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/ecb5738654fb443183ae05432e3bffc6.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can copy and tweak the existing Selection block by getting the view from the EPiServer.Forms.zip file located in the modules folder.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/ff741ec673354c3fb4a1e0358cc2084a.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I just had to update the view to use my new class.&lt;/p&gt;
&lt;p&gt;Once you&#39;re ready the new Form Element should show up in the Custom Elements group.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/31e9c269db3f4a8dbb1ee2c08f45f95b.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Now use this instead of the original.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/ef1853f3ad7d458d9290ae4e5b6c1fa2.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/9cb52a439ac649c8989c531b4057a071.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Like I alluded to earlier, you may want the Choice to be in the selected language and the Value to always be in the base language so your submissions are consistent.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/0c254f979ef449a383b647382da06efb.aspx&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;Alternative&lt;/h2&gt;
&lt;p&gt;Another alternative suggested by &lt;a href=&quot;/link/5341f632537c4b0ab6b8fb651bd310f8.aspx?userid=c2dbd5e1-f46d-dd11-a1d1-0018717a8c82&quot;&gt;Paul Gruffydd&lt;/a&gt; was to use a initialization module to force all SelectionElementBlocks to be localizable but I like being able to give the editor the choice which element to use.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Have your own or better solution? Let me know!&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2019/5/episerver-forms--making-dropdown-lists-multilingual/</guid>            <pubDate>Fri, 31 May 2019 21:33:13 GMT</pubDate>           <category>Blog post</category></item><item> <title>Gotcha’s with reusing Episerver Forms ElementBlocks</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2019/4/gotchas-with-reusing-episerver-forms-elementblocks/</link>            <description>&lt;h2&gt;First a quick refresher, then the gotchas&lt;/h2&gt;
&lt;h3&gt;Copy/Move Form Elements&lt;/h3&gt;
&lt;p&gt;So, you may or may not be aware that you can reuse form elements. Say you&amp;rsquo;ve got a long list of locations that you want to use on more than one form.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/7fd27f09cb614b83b36243e726b2e330.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can copy/move an element from one form to another:&lt;/p&gt;
&lt;p&gt;With the first form open, switch from the Forms tab to the Blocks tab.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/60fc801b0ab44d339db20d0be4459acb.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Scroll down and select the &amp;ldquo;For this form&amp;rdquo; folder.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/cc58ef66bdde496aa8d8e6d4c47607c4.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;This contains all the Form Element Blocks.&amp;nbsp; From the context menu, choose copy/cut&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/640a71256451492fa749f14533a9b9e8.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Go to the other form, switch to the Blocks tab, and select the &amp;ldquo;For this form&amp;rdquo; folder.&lt;/p&gt;
&lt;p&gt;Here on the folder&amp;rsquo;s context menu, choose Paste.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/bc519089a29c4e979e94faa3afa7635a.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can now drag the copied element to the second form.&lt;/p&gt;
&lt;h3&gt;Share a single element between several forms&lt;/h3&gt;
&lt;p&gt;So, instead of making copies that will all need to be updated moving forward, say you wanted a shared element that you can use on multiple forms. Instead of pasting into the other form&amp;rsquo;s &amp;ldquo;For this form&amp;rdquo; folder paste them into a shared location. Something like a &amp;ldquo;Shared Form Elements&amp;rdquo; folder.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;img src=&quot;/link/9eeb89b57a754f158f1469fe24f4a646.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;From this folder you can drag the shared element onto multiple forms. Update the shared element and it affects all linked forms.&lt;/p&gt;
&lt;h2&gt;Now the Gotcha&amp;rsquo;s!&lt;/h2&gt;
&lt;p&gt;If you have the Salesforce connector installed and have the Form Element mapped to a Salesforce table, you lose access to the Extra Field Mappings tab. Here is an example of the original element:&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/d6898ce6b92f4d8cb52c0f5444f23eb4.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;And here it is after you&#39;ve moved it to a shared folder:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/fae1d246832e42d9b66bf10230704ace.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;I think this is because the element must look to its parent form for table mappings and this connection breaks when you move the element out of the &amp;ldquo;For this form&amp;rdquo; folder.&lt;/p&gt;
&lt;p&gt;A similar situation is the Dependencies tab:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/dde5c2f651e941a09aa6e852f5ee23f6.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;No error message, just a blank screen:&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;img src=&quot;/link/7b6f9c3e315349f1991edaa310deeac0.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;Any other gotcha&#39;s you&#39;ve run across, or ways to get around the issue? Let me know in the comments.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2019/4/gotchas-with-reusing-episerver-forms-elementblocks/</guid>            <pubDate>Mon, 01 Apr 2019 18:31:28 GMT</pubDate>           <category>Blog post</category></item><item> <title>PropertyList Changes Not Detected, Adding vs Updating</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2019/1/propertylist-changes-not-detected-adding-vs-updating/</link>            <description>&lt;p&gt;So here is a little nuance, that took me some time to figure out. I was programmatically updating a PropertyList but it never seemed to actually update.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s take the following propertylist as an example:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/e9edcc3875404a308d9da0f90939ec8d.aspx&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/link/a24818578b5a4955b8016e8287770f73.aspx&quot; /&gt;&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;namespace PropertyListDemo.Models
{
    public class Character
    {
        [Display(Name = &quot;Hero Name&quot;)]
        public string Name { get; set; }
        [Display(Name = &quot;Team Affiliation&quot;)]
        public string Affiliation { get; set; }
        [Display(Name = &quot;Secret Identity Name&quot;)]
        public string SecretIdentity { get; set; }
    }

    [PropertyDefinitionTypePlugIn]
    public class CharacterListProperty : PropertyListBase&amp;lt;Character&amp;gt;
    {
    }

    public class PropertyListBase&amp;lt;T&amp;gt; : PropertyList&amp;lt;T&amp;gt;
    {
        public PropertyListBase()
        {
            _objectSerializer = this._objectSerializerFactory.Service.GetSerializer(&quot;application/json&quot;);
        }
        private Injected&amp;lt;ObjectSerializerFactory&amp;gt; _objectSerializerFactory;

        private IObjectSerializer _objectSerializer;
        protected override T ParseItem(string value)
        {
            return _objectSerializer.Deserialize&amp;lt;T&amp;gt;(value);
        }

        public override PropertyData ParseToObject(string value)
        {
            ParseToSelf(value);
            return this;
        }
    }

    [EditorDescriptorRegistration(TargetType = typeof(IList&amp;lt;Character&amp;gt;))]
    public class CharacterCollectionEditorDescriptor : CollectionEditorDescriptor&amp;lt;Character&amp;gt;
    {
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Adding to the propertylist worked as expected:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;            var standardPage = currentPage.CreateWritableClone() as StandardPage;

            if (standardPage?.Heroes != null &amp;amp;&amp;amp; standardPage.Heroes.Any())
            {

                standardPage.Heroes.Add(new Character()
                {
                    Name = &quot;Hulk&quot;,
                    Affiliation = &quot;Avengers&quot;,
                    SecretIdentity = &quot;Bruce Banner&quot;
                });


                contentRepository.Save(standardPage, SaveAction.Publish, AccessLevel.NoAccess);
            }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;However, if instead of adding I was trying to update the propertylist:&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;            var standardPage = currentPage.CreateWritableClone() as StandardPage;

            if (standardPage?.Heroes != null &amp;amp;&amp;amp; standardPage.Heroes.Any())
            {
                var match = standardPage.Heroes.FirstOrDefault(x =&amp;gt; x.Name == &quot;Superman&quot;);
                if (match != null)
                {
                    match.Name = &quot;Iron Man&quot;;
                    match.Affiliation = &quot;Avengers&quot;;
                    match.SecretIdentity = &quot;Tony Stark&quot;;
                }


                contentRepository.Save(standardPage, SaveAction.Publish, AccessLevel.NoAccess);
            }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It would act like it had saved but the data wouldn&amp;rsquo;t be any different.&lt;/p&gt;
&lt;p&gt;After a lot of time Googling and debugging, I determined that there is a Boolean &amp;ldquo;IsModified&amp;rdquo; flag from the ContentData abstract class.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;standardPage.Property[&quot;Heroes&quot;].IsModified = true;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This needs to be true when saving the page. When adding to the propertylist it is true, but when updating it is false, so it has to be manually set. So, an update followed by an add would work but update by itself doesn&amp;rsquo;t work.&lt;/p&gt;
&lt;p&gt;So, it&amp;rsquo;s a good idea to go ahead and set the flag yourself before saving the page.&lt;/p&gt;
&lt;pre class=&quot;language-csharp&quot;&gt;&lt;code&gt;            var standardPage = currentPage.CreateWritableClone() as StandardPage;

            if (standardPage?.Heroes != null &amp;amp;&amp;amp; standardPage.Heroes.Any())
            {
                var match = standardPage.Heroes.FirstOrDefault(x =&amp;gt; x.Name == &quot;Superman&quot;);
                if (match != null)
                {
                    match.Name = &quot;Iron Man&quot;;
                    match.Affiliation = &quot;Avengers&quot;;
                    match.SecretIdentity = &quot;Tony Stark&quot;;
                }

                standardPage.Property[&quot;Heroes&quot;].IsModified = true;

                contentRepository.Save(standardPage, SaveAction.Publish, AccessLevel.NoAccess);
            }
&lt;/code&gt;&lt;/pre&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2019/1/propertylist-changes-not-detected-adding-vs-updating/</guid>            <pubDate>Fri, 25 Jan 2019 20:11:52 GMT</pubDate>           <category>Blog post</category></item><item> <title>Unexplained 302 redirect that adds trailing slash? Check your file/folder structure</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2019/1/unexplained-302-redirect-that-adds-trailing-slash-check-your-filefolder-structure/</link>            <description>&lt;div class=&quot;post&quot;&gt;
&lt;div class=&quot;body&quot;&gt;
&lt;div id=&quot;ff29b1de-2462-45c2-8c07-f0d8525461d0&quot; class=&quot;postBody&quot;&gt;
&lt;p&gt;Just a quick note for anyone experiencing something similar.&lt;/p&gt;
&lt;p&gt;We have a client who had a concern that /resources always redirected to /resources/. After a lot of wasted time checking the web.config for rewrite rules, writing rewrite rules that didn&#39;t work, disabling the 404handler, and testing init modules (routingOptions.UseTrailingSlash) Episerver&#39;s engineering team pointed us to the actual problem.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;The site actually had a folder named Resources that contains the XML language files. After renaming the folder and updating the&amp;nbsp;EPiServerFramework.config with the new path the URL worked without the redirect.&lt;/p&gt;
&lt;p&gt;So long story short, if you are having a problem with an alias check to make sure that you don&#39;t have a folder with the same name.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2019/1/unexplained-302-redirect-that-adds-trailing-slash-check-your-filefolder-structure/</guid>            <pubDate>Thu, 24 Jan 2019 23:50:35 GMT</pubDate>           <category>Blog post</category></item><item> <title>Staying under the document limit when using the Find developer index</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2017/12/staying-under-the-document-limit-when-using-the-find-developer-index/</link>            <description>&lt;p&gt;When working on a site migration with a LOT of content we often find ourselves running up against the 10,000 document limitation on a developer index.&lt;/p&gt; &lt;p&gt;I learned this trick from a coworker. You can use an Initialization Module to get some fine-grained control over what is getting added to the index.&lt;/p&gt;
&lt;pre&gt;using System;
using System.Diagnostics;
using System.Linq;
using AlloyInitModuleTest.Models.Media;
using AlloyInitModuleTest.Models.Pages;
using EPiServer.Core;
using EPiServer.Find.ClientConventions;
using EPiServer.Find.Cms;
using EPiServer.Find.Cms.Conventions;
using EPiServer.Find.Cms.Module;
using EPiServer.Find.Framework;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;


    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class SearchConventionInitializationModule : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            //Add initialization logic, this method is called once after CMS has been initialized
            this.SetTypesToIndex(ContentIndexer.Instance.Conventions);
    }

    [Conditional(&quot;DEBUG&quot;)]
    private void SetTypesToIndex(IContentIndexerConventions conventions)
    {
        // start from a clean slate
        conventions.ForInstancesOf&amp;lt;ContentData&amp;gt;&lt;contentdata&gt;().ShouldIndex(x =&amp;gt; false);

        //one by one turn on or off as you develop the site, remember to reindex 
        conventions.ForInstancesOf&amp;lt;ImageFile&amp;gt;&lt;imagefile&gt;().ShouldIndex(x =&amp;gt; false);
        conventions.ForInstancesOf&amp;lt;GenericMedia&amp;gt;&lt;genericmedia&gt;().ShouldIndex(x =&amp;gt; false);
        conventions.ForInstancesOf&amp;lt;LandingPage&amp;gt;&lt;landingpage&gt;().ShouldIndex(x =&amp;gt; true);
    }

    public void Uninitialize(InitializationEngine context)
        {
            //Add uninitialization logic
        }
    }



&lt;/landingpage&gt;&lt;/genericmedia&gt;&lt;/imagefile&gt;&lt;/contentdata&gt;&lt;/pre&gt;
&lt;p&gt;[Conditional(&quot;DEBUG&quot;)] allows us to limit this to debug builds and index everything in production.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2017/12/staying-under-the-document-limit-when-using-the-find-developer-index/</guid>            <pubDate>Wed, 20 Dec 2017 21:02:30 GMT</pubDate>           <category>Blog post</category></item><item> <title>Comparing draft content to published content</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2017/12/comparing-draft-content-to-published-content/</link>            <description>&lt;p&gt;We had a situation arise the other day where we needed to compare some values in a draft page against the currently published version. Based on some fields changing we knew we needed to reindex a list of related pages.&lt;p&gt;This all takes place in the IContentEvents PublishingContent event handler which has been wired up in an InitializationModule. &lt;p&gt;The trick here is to cast e.Content to get the current draft and to use e.ContentLink.ToReferenceWithoutVersion to get the published version so that you can compare. &lt;p&gt;Here is a code sample that checks for a change to the Name and logs it.&lt;p&gt;&lt;br /&gt;&lt;/p&gt;

&lt;pre&gt;using System;
using System.Linq;
using EPiServer;
using EPiServer.Core;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using EPiServer.Logging;


[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class PageDataUpdatedInitialization : IInitializableModule
{
    private static readonly ILogger Logger = LogManager.GetLogger();
    private Injected&lt;icontentloader&gt; contentLoader;


    public void Initialize(InitializationEngine context)
    {
        var events = context.Locate.ContentEvents();
        events.PublishingContent += this.PublishingContent;
    }

    private void PublishingContent(object sender, ContentEventArgs e)
    {
        var newPageData = e.Content as PageData;
        if (newPageData == null)
            return;

        var oldPageData = contentLoader.Service?.Get&lt;pagedata&gt;(e.ContentLink.ToReferenceWithoutVersion());
        if (oldPageData == null)
            return;

        // check to see whether any updates were made to the specific value
        if (!ValueChanged(oldPageData, newPageData))
            return;

        // if so, do something
        Logger.Debug(&quot;Value has changed from {0} to {1}&quot;, oldPageData.Name, newPageData.Name);
    }

    private static bool ValueChanged(PageData oldPageData, PageData newPageData)
    {
        var oldName = oldPageData.Name;
        var newName = newPageData.Name;

        if (newName != oldName)
        {
            return true;
        }

        return false;
    }

    public void Uninitialize(InitializationEngine context)
    {
        //Add uninitialization logic
    }
}

&lt;/pagedata&gt;&lt;/icontentloader&gt;&lt;/pre&gt;&lt;/p&gt;&lt;/p&gt;&lt;/p&gt;&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2017/12/comparing-draft-content-to-published-content/</guid>            <pubDate>Mon, 18 Dec 2017 16:28:42 GMT</pubDate>           <category>Blog post</category></item><item> <title>Make Episerver Forms a Little More Bootstrap Friendly</title>            <link>https://world.optimizely.com/blogs/kennyg/dates/2016/10/make-episerver-forms-a-little-more-bootstrap-friendly/</link>            <description>&lt;p&gt;You may have noticed that the way the Episerver Forms package renders form elements that it isn’t really bootstrap friendly. Well there is a way to fix that. &lt;/p&gt; &lt;p&gt;Dig down into \modules\_protected\EPiServer.Forms\EPiServer.Forms.zip\Views\ElementBlocks\ and copy those ascx files into \Views\Shared\ElementBlocks.&lt;/p&gt; &lt;p&gt;Then you can tweak each template.&lt;/p&gt; &lt;p&gt;A couple of examples:&lt;/p&gt; &lt;ul&gt; &lt;li&gt;On TextboxElementBlock.ascx add “form-group” to the class attribute on the outer DIV and add “form-control” to the class attribute for the input tag.&lt;/li&gt; &lt;li&gt;On SubmitButtonElementBlock.ascx add “btn” to the class attribute.&lt;/li&gt;&lt;/ul&gt; &lt;p&gt;Your form should now pickup bootstrap’s form styles.&lt;/p&gt;</description>            <guid>https://world.optimizely.com/blogs/kennyg/dates/2016/10/make-episerver-forms-a-little-more-bootstrap-friendly/</guid>            <pubDate>Wed, 19 Oct 2016 00:28:12 GMT</pubDate>           <category>Blog post</category></item></channel>
</rss>