Breaking Free: Portable Text Specification

Breaking Free: Portable Text Specification
Photo by Martin Adams / Unsplash

In previous posts, we mentioned that Portable Text specification can be helpful and making Rich Text distributed for other platforms.

The Portable Text Specification represents a novel and increasingly popular approach to handling Rich Text in structured content environments, particularly within the realm of content management systems (CMS) and beyond. It's designed to offer a more flexible and extensible way of representing Rich Text beyond the traditional HTML, specifically catering to the needs of decoupled (headless) content management systems. where content needs to be easily portable and render-able across different platforms and devices.

Key Features of Portable Text:

  • Rich Content as JSON: Portable Text stores rich text data as structured JSON, making it highly portable and easy to manipulate programmatically.
  • Block-based Structure: Content is broken down into blocks, which can be simple text blocks, embedded images, lists, or custom types defined by the developer, facilitating complex content structures beyond simple text formatting.
  • Extensible and Customizable: Developers can extend the specification with custom block types, allowing for the integration of complex data structures like interactive elements, embedded widgets, or third-party content, directly within the text.
  • Cross-platform Compatibility: Being JSON, Portable Text content can be easily rendered on any platform that can process JSON, from web browsers to native mobile apps, and even in server-side rendering scenarios.
GitHub - portabletext/portabletext: Portable Text is a JSON based rich text specification for modern content editing platforms.
Portable Text is a JSON based rich text specification for modern content editing platforms. - portabletext/portabletext

Rich Text into Portable Text

In the previous Rich Text example we have been working with:

  • H2 and H3
  • Blockquote
  • Bold and Italics
  • Paragraphs
  • Call to Action

Narrowing in on the first two blocks, let's look how the following markup would translate to Portable Text.

<h2>Example Heading 2</h2>
<blockquote><em>"Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit..."</em></blockquote>

The JSON object would be defined as follows for Portable Text:

[
  {
    "_type": "block",
    "_key": "2343E1C8",
    "markDefs": [],
    "style": "h2",
    "level": null,
    "listItem": null,
    "children": [
      {
        "_type": "span",
        "_key": "2097E87B",
        "marks": [],
        "text": "Example Heading 2"
      }
    ]
  },
  {
    "_type": "block",
    "_key": "EF0B714",
    "markDefs": [],
    "style": "blockquote",
    "level": null,
    "listItem": null,
    "children": [
      {
        "_type": "span",
        "_key": "1D6E4269",
        "marks": [
          "em"
        ],
        "text": "\"Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...\""
      }
    ]
  },

We can use @portabletext/react in React or @portabletext/react-native in our React Native app. When markup does not translate well from Web to a Mobile app, this is where Portable Text can be helpful.

Here is an example of using a PortableText component in React Native:

<PortableText
    value={pageItem.portabletext}
    components={myPortableTextComponents}
  />

And we can write a component to handle the em mark our Portable Text:

const myPortableTextComponents = {
  marks: {
    em: ({ value, children }) => (
      <Text style={
        {
          "fontStyle": "italic",
        }
      }
      >
        {value}
      </Text>
    ),
  }
};

Sitecore Rendering Contents Resolver

When it comes to XM Cloud, Rendering Contents Resolvers are a great option as Experience Edge caches the snapshot of the Layout Service output including Rendering Content Resolvers.

In our first post, we mentioned how being locked in with presentation to get data can work against portability of data. Additionally, Layout Service is not a good choice if we are focused on ease of data access.

Additionally, Sitecore also recommends not customizing your XM Cloud instance. Content Resolvers are not in this bucket so this is an acceptable solution, but works against us in other ways. What about GraphQL in XM Cloud? Anything we would want to do with GraphQL is not accessible with Experience Edge. We are limited here in the meantime until extensibility goes to the Experience Edge.

Below is the diagram that shows how Layout Service snapshot is cached in Experience Edge alone with Items and Media. GraphQL sits on the Edge so any extension is not possible.

For XM/XP, the same approach can be taken with Rendering Content Resolvers, however, it would be good to consider configuring some Output Caching for the JSON Rendering component. There is also more we can do here which will follow in a future post.

We are going to work with a Helix module we have established as Foundation.Text. Here we will implement our Content Resolver. We will need a library that helps us serialize markup to JSON under Portable Text Specification.

A great open-source project, NHI.PortableText, will work great for our needs.

GitHub - nhi/portable-text-dotnet: Convert HTML into PortableText in .NET (C#) https://www.nuget.org/packages/NHI.PortableText
Convert HTML into PortableText in .NET (C#) https://www.nuget.org/packages/NHI.PortableText - nhi/portable-text-dotnet

Once the NuGet package is installed in our project, we can implement our Content Resolver.

Let's start with creating it in Sitecore. Under /sitecore/system/Modules/Layout Service/Rendering Contents Resolvers/ we can create an item named Portable Text Context Item Resolver. In the Type field, we will enter the reference to our class we will create in the next step, Foundation.Text.ContentsResolvers.PortableTextContentsResolver, Foundation.Text.

We named it with Context as it is intended to reference the Content field on our Page Route.

In Visual Studio, we place all Content Resolvers under the folder, ContentsResolver. You will want to make sure the class is created under this folder if you want to follow the same namespace in which the class exists.

Here is the example of our Portable Text Contents Resolver:

namespace Foundation.Text.ContentsResolver
{
    public class PortableTextContentsResolver : Sitecore.LayoutService.ItemRendering.ContentsResolvers.IRenderingContentsResolver
    {
        public object ResolveContents(Rendering rendering, IRenderingConfiguration renderingConfig)
        {
            var item = base.GetContextItem(rendering, renderingConfig);
    
            JObject portableTextObject = new JObject()
            {
                ["PortableText"] = new JObject()
            };
            
            var converter = new BlockConverter();
    
            string richText = item?.Fields["Content"]?.Value;
    
            if(string.IsNullOrWhiteSpace(richText)) 
            {
                string portableText = converter.SerializeHtml(richText);
                portableTextObject["portableText"] = JObject.Parse(portableText);
            }
    
            return portableTextObject;
        }
    }
}

The method for ResolveContents is the method where we handle the JSON output of the rendering. To learn how to implement a Contents Resolver in Sitecore, refer to Customizing the Layout Service Rendering Output.

Next, we need to create the rendering that will use the Rendering Contents Resolver. Portable Text Rendering will be created as a JSON Rendering and will be configured with our new Portable Text Context Item Resolver.

JSON Rendering configuration for Contents Resolver

Now, we can bind the Portable Text Rendering to the presentation of our Page item.

Finally, we can test our Layout Service response for the Page item to see Portable Text is being rendered with our rendering:

"sitecore": {
  "context": {
    "pageEditing": "false",
    "site": {
      "name": "Site Name"
    },
    "pageState": "normal",
    "language": "en"
    ...
  },
  "route": {
    "fields": {
      "PageTitle": {
        "value": "Page Title Example"
      },
      "Image": {
        "src": "/-/media/site/page.jpg&rev=23eabea139834c6a8feae01d336727ba&hash=afaf6e21384d4266a7fc9d8f9d6d702b",
        "alt": "Page Image",
        "width": "1024",
        "height": "640"
      }
    }
    ...
    "placeholders": {
      "jss-header": [],
      "jss-main": [
        {
          "uid": "0ad0118f-675e-4577-8a67-2410ba334361",
          "componentName": "PortableTextRendering",
          "dataSource": "",
          "params": {},
          "fields": {
            "PortableText": [
              {
                "_type": "block",
                "_key": "2BDB3C84",
                "markDefs": [],
                "style": "h2",
                "level": null,
                "listItem": null,
                "children": [
                  {
                    "_type": "span",
                    "_key": "23C9AFCA",
                    "marks": [],
                    "text": "Example Heading 2"
                  }
                ]
              },
              {
                "_type": "block",
                "_key": "41297FC0",
                "markDefs": [],
                "style": "blockquote",
                "level": null,
                "listItem": null,
                "children": [
                  {
                    "_type": "span",
                    "_key": "2DA1ECDA",
                    "marks": [
                      "em"
                    ],
                    "text": "\"Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...\""
                  }
                ]
              },
              {
                "_type": "block",
                "_key": "32356354",
                "markDefs": [],
                "style": "normal",
                "level": null,
                "listItem": null,
                "children": [
                  {
                    "_type": "span",
                    "_key": "3A81C764",
                    "marks": [
                      "strong"
                    ],
                    "text": "Lorem ipsum"
                  },
                  {
                    "_type": "span",
                    "_key": "12040453",
                    "marks": [],
                    "text": " dolor sit amet, consectetur adipiscing elit. Curabitur eleifend massa ac scelerisque hendrerit. Curabitur a lacus at neque ornare laoreet quis non sapien. Vivamus sed vestibulum enim. Vivamus in aliquet nibh, a mollis turpis. Suspendisse varius, quam vitae varius accumsan, lectus felis faucibus turpis, at auctor quam magna quis nibh. Quisque id felis at enim pretium accumsan. Aenean aliquam elementum placerat. In venenatis augue et enim ultrices, ut egestas dui bibendum. Fusce in leo ac diam suscipit lacinia. Fusce ut placerat augue, ac gravida risus. Sed a justo purus. Mauris sapien orci, pretium vitae suscipit a, commodo ac augue. Integer mauris arcu, lobortis sed neque et, eleifend vestibulum felis. Nulla vel lorem vitae quam viverra semper quis ac ante. Vestibulum non ipsum velit. Quisque nec arcu posuere, consectetur elit sit amet, imperdiet dolor. Quisque enim ipsum, tincidunt suscipit libero at, finibus dignissim nunc. Nam ac nulla dui. Etiam auctor dictum risus nec maximus. Vivamus eu laoreet magna, a molestie ex. Nullam sed risus ut dolor vehicula condimentum tristique a sapien. Aliquam viverra, tellus pharetra accumsan aliquet, turpis ex iaculis turpis, ultrices varius velit turpis vel mauris. "
                  }
                ]
              },
              {
                "_type": "block",
                "_key": "3BF49CD5",
                "markDefs": [],
                "style": "h3",
                "level": null,
                "listItem": null,
                "children": [
                  {
                    "_type": "span",
                    "_key": "1FBA0B58",
                    "marks": [],
                    "text": "Example Heading 3"
                  }
                ]
              },
              {
                "_type": "block",
                "_key": "79BD8236",
                "markDefs": [],
                "style": "normal",
                "level": null,
                "listItem": null,
                "children": [
                  {
                    "_type": "span",
                    "_key": "662AA45E",
                    "marks": [],
                    "text": " Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur eleifend massa ac scelerisque hendrerit. Curabitur a lacus at neque ornare laoreet quis non sapien. Vivamus sed vestibulum enim. Vivamus in aliquet nibh, a mollis turpis. Suspendisse varius, quam vitae varius accumsan, lectus felis faucibus turpis, at auctor quam magna quis nibh. Quisque id felis at enim pretium accumsan. Aenean aliquam elementum placerat. In venenatis augue et enim ultrices, ut egestas dui bibendum. Fusce in leo ac diam suscipit lacinia. Fusce ut placerat augue, ac gravida risus. Sed a justo purus. Mauris sapien orci, pretium vitae suscipit a, commodo ac augue. Integer mauris arcu, lobortis sed neque et, eleifend vestibulum felis. Nulla vel lorem vitae quam viverra semper quis ac ante. Vestibulum non ipsum velit. Quisque nec arcu posuere, consectetur elit sit amet, imperdiet dolor. Quisque enim ipsum, tincidunt suscipit libero at, finibus dignissim nunc. Nam ac nulla dui. Etiam auctor dictum risus nec maximus. Vivamus eu laoreet magna, a molestie ex. Nullam sed risus ut dolor vehicula condimentum tristique a sapien. Aliquam viverra, tellus pharetra accumsan aliquet, turpis ex iaculis turpis, ultrices varius velit turpis vel mauris. "
                  }
                ]
              },
              {
                "_type": "block",
                "_key": "7BEB9BC0",
                "markDefs": [
                  {
                    "_key": "43B780B1",
                    "_type": "link",
                    "href": "#cta",
                    "target": ""
                  }
                ],
                "style": "normal",
                "level": null,
                "listItem": null,
                "children": [
                  {
                    "_type": "span",
                    "_key": "6580DDEB",
                    "marks": [
                      "43B780B1"
                    ],
                    "text": "Learn More"
                  },
                  {
                    "_type": "span",
                    "_key": "6C6F26E4",
                    "marks": [],
                    "text": " "
                  },
                  {
                    "_type": "span",
                    "_key": "5AE49AEB",
                    "marks": [],
                    "text": "\n"
                  },
                  {
                    "_type": "span",
                    "_key": "6D2F393B",
                    "marks": [],
                    "text": "%A0"
                  }
                ]
              }
            ]           
          }
        }
      ],
      "jss-footer": []
    }
  }
}

Takeaways

One drawback with handling Rich Text just with our new Portable Text Rendering is it does not support inline editing with tools like Sitecore Pages or Experience Editor. Adding this rendering does add bloat to the Layout Service when the rendering may only be used for Mobile apps. It would be helpful if extensibility could be added in XM Cloud as we can control the shape of our data getting back from Experience Edge.

The alternative option to build Portable Text into Sitecore is to build a Rich Text field that ends up storing the serialized markup into Sitecore. This could put the implementation into a grey area of whether we would cross the line in customization in XM Cloud. The big benefit of XM Cloud is no longer needing to upgrade it like XM/XP and if it breaks when Sitecore upgrades your instance then it requires managing that risk.

In a follow-up post, we will build this mechanism in GraphQL in XM/XP instances.