Contentstack .NET Group Model for Page Metadata Fields

This blog post explains how I modeled a group of fields that appear in all content types that represent pages in the Contentstack SaaS headless content management system for a solution that uses ASP.NET razor pages. Specifically, this group contains metadata fields used for navigation, sitemap.xml and robots.txt, search results, to control the layout view template and partial view to render the entry, and otherwise.

This solution extends the ASP.NET Core razor pages prototype that I have been exploring recently.

This solution depends on techniques described in several other linked posts, the most important topic may be entry models, which are classes that represent

The following blog post contains some background information about collecting metadata for page entries.

Although websites are the most common use case, it is important to remember that a CMS can store data for any purpose, not just websites. Technology platforms and data uses should not necessarily or significantly influence complex data structures but can affect the fields available in entries. For example, the solution described in this blog post for ASP.NET razor pages includes fields that let the CMS user specify the layout and partial view for each entry, includes fields for web SEO, and includes a short title field for navigation links that appear on the site. None of these fields prevent using these entries for other purposes. The layout and partial view fields contain simple strings that any technology could use to control presentation.

All pages have some data elements in common. For example, every page has a URL and can have a title. To make these fields available in all Contentstack content types that define entries that represent pages, we can implement these fields as a global group, and all content types that represent these pages can use that group.

In general, links should use NavTitle of the entry when possible, its PageTitle otherwise, or its default title if neither NavTitle nor PageTitle is available. Everything else should use PageTitle if it is defined or the default entry title otherwise.

This approach in this solution uses the PageTitle and PageDescription fields for the OpenGraph title and description, but you could implement separate fields for these values.

This approach defines lists of available layouts and partial views for all pages in this one global group definition. If different content types can use only a subset of available layouts, then we can implement a custom field to limit the options, or simply another required layout select field with different options that overrides any value in the default layout field or find another strategy to let the user specify layout views, partial views, and other presentation components.

In Contentstack, I implemented a global group with the following fields to collect metadata common to all types of pages.

  • PageTitle (Single line Text): Entry title to appear in search results (HTML <title>).
  • NavTitle (Single line Text): Short text for links to entry.
  • Layout (Select): ASP.NET razor pages layout view to apply to the entry.
  • PartialView (Select): ASP.NET razor pages partial view to render the entry.
  • Url (Various): The URL of the entry.
  • PageDescription (Multiline Text): Description to appear in search results (HTML <meta> description).
  • SearchKeywords (Multiline Text): Keywords to influence search indexing (HTML <meta> keywords).
  • ExcludeFromSearch (Checkbox): Used by robots to exclude entries and descendants by URL from appearing in search results.
  • IncludeInSitemap (Checkbox): Whether to include the record in sitemap XML.
  • SearchChangeFrequency (Select): Used in sitemap.XML.
  • SearchPriority (Select): Used in sitemap XML.
  • OpenGraphImage: (Image): OpenGraph image.

I implemented a homepage content type that includes this global group.

One complication is that the File/Image field type contains multiple values, such as the alternate text for an image and the default URL of the image. The values of the OpenGraphImage field appear as if they were a nested group within the PageData group.

{
  "title": "HeadlessArchitect.net Home Page",
  "pagedata": {
    "pagetitle": "HeadlessArchitect.net Home Page",
    "navtitle": "HeadlessArchitect",
    "layout": "default",
    "partialview": "default",
    "url": "/",
    "pagedescription": "HeadlessArchitect.net describes the ASP.NET core razor pages solution used to build HeadlessArchitect.net with the Contentstack SaaS headless CMS.",
    "searchkeywords": "SaaS, CMS, headless CMS, dotnet, dotnet core, asp.net core, razor pages",
    "excludefromsearch": false,
    "includeinsitemap": false,
    "searchchangefrequency": "monthly",
    "searchpriority": "1",
    "opengraphimage": {
      "uid": "bltc30002573fcf738a",
      "created_at": "2021-05-17T01:15:29.756Z",
      "updated_at": "2021-05-17T01:15:29.756Z",
      "created_by": "bltf659c3e814f4dce6",
      "updated_by": "bltf659c3e814f4dce6",
      "content_type": "image/jpeg",
      "file_size": "14285",
      "tags": [],
      "filename": "HeadlessArchitectAvatar.jpg",
      "url": "https://images.contentstack.io/v3/assets/blt19bb56b18ed076dc/bltc30002573fcf738a/60a1c3b12474161960b8d9cf/HeadlessArchitectAvatar.jpg",
      "ACL": [],
      "is_dir": false,
      "parent_uid": "blt6151232cd4d5bcbd",
      "_version": 1,
      "title": "HeadlessArchitectAvatar.jpg",
      "dimension": {
        "height": 560,
        "width": 560
      },
      "publish_details": [
        {
          "environment": "blt7542a0a338b928ab",
          "locale": "en-us",
          "time": "2021-05-17T01:20:08.531Z",
          "user": "bltf659c3e814f4dce6",
          "version": 1
        }
      ]
    }
  },
  "tags": [],
  "locale": "en-us",
  "uid": "blt2e7b4f3feece86f4",
  "created_by": "bltf659c3e814f4dce6",
  "updated_by": "bltf659c3e814f4dce6",
  "created_at": "2021-05-16T17:41:17.635Z",
  "updated_at": "2021-05-17T01:15:47.113Z",
  "ACL": {},
  "_version": 2,
  "_in_progress": false,
  "publish_details": [
    {
      "environment": "blt7542a0a338b928ab",
      "locale": "en-us",
      "time": "2021-05-17T01:20:08.541Z",
      "user": "bltf659c3e814f4dce6",
      "version": 2
    }
  ]
}

To access the URL in this structure, we can model the file field type and its URL property, which is basically like modelling a group.

public class FileField
{
      public string Url { get; set; }
}

Then we can model the entire PageData group including a property of this FileField type to store the URL of the OpenGraphImage.

using Deliverystack.Core.Models.Fields;

public class PageData
{
    private string _navTitle;

    public string PageTitle { get; set; }

    public string NavTitle
    {
        set
        {
            _navTitle = value;
        }

        get
        {
            return _navTitle ?? PageTitle;
        }
    }

    public string Layout { get; set; }
    public string PartialView { get; set; }
    public string Url { get; set; }
    public string PageDescription { get; set; }
    public string SearchKeywords { get; set; }
    public bool ExcludeFromSearch { get; set; }
    public bool IncludeInSitemap { get; set; }
    public string SearchChangeFrequency { get; set; }
    public string SearchPriority { get; set; }
    public FileField OpenGraphImage { get; set; }
}        

I added the group model to the base class for page entry models.

using Deliverystack.Core.Models.Groups;

public abstract class PageEntryModelBase : EntryModelBase
{
    public PageData PageData { get; set; }
}

By implementing the PageData group containing the URL field, the URL of the entry moved from the entry to the PageData group. What was previously a flat list of my field identifiers and values now appears in a nested structure, similar to how the properties of the image appear in a nested structure (see the previous JSON sample). Again, this is not my best code, but because it only needs to retrieve entries for content types that define URLs, I updated that code as follows.

foreach (JsonElement contentType in page.GetProperty("content_types").EnumerateArray())
{
    bool include = !requireUrls;

    if (!include)
    {
        foreach(JsonElement field in contentType.GetProperty("schema").EnumerateArray())
        { 
            if (field.GetProperty("uid").ToString() != "pagedata")
            {
                continue;
            }

            include = true;
            break;

Also for the URL field location change, I updated the model that I use to map URLs to entries and the logic that checks whether a content type contains a URL to use the same PageData group model, and added some logic to get the navigation title.

using System;

using Deliverystack.Core.Models.Groups;

public struct PathApiEntryModel
{
    public string ContentType { get; set; }
    public string Uid { get; set; }
    public PageData PageData { get; set; }
    public string Title { get; set; }

    public string NavTitle
    {
        get
        {
            if (!String.IsNullOrEmpty(PageData.NavTitle))
            {
                return PageData.NavTitle;
            }
            else if (!string.IsNullOrEmpty(PageData.PageTitle))
            {
                return PageData.PageTitle;
            }
            else
            {
                return Title;
            }
        }
    }
}

I implemented a homepage entry model to represent the home page content type.

namespace HeadlessArchitect.Core.Models.ContentTypes
{
    using Deliverystack.Core.Attributes;
    using Deliverystack.Core.Models;

    [ContentTypeIdentifier("homepage")]
    public class HomePage : PageEntryModelBase
    {
    }
}

I updated the Configure() method in Startup.cs to load an assembly that contains classes attributed to handle Contentstack modular blocks so that reflection can find those classes before running code that references them.

Type loadTheAssemblyContainingThisTypeForFutureImplicitInterrogationAndDiscardTheResult =
    typeof(HomePage);

I implemented a partial view to render the PageData values.

@model Deliverystack.Core.Models.Groups.PageData

<title>@Model.PageTitle</title>
<meta name="description" content="@Model.PageDescription" />
<meta name="keywords" content="@Model.SearchKeywords" />
<meta property="og:title" content="@Model.PageTitle" />
<meta property="og:description" content="@Model.PageDescription" />
<meta property="og:image" content="@Model.OpenGraphImage.Url" />
<meta property="og:image:url" content="@Model.OpenGraphImage.Url" />
<meta property="twitter:image" content="@Model.OpenGraphImage.Url" />

I updated the layout to invoke this new partial.

@model Deliverystack.RazorPages.Models.PageModelBase

<!DOCTYPE html>
<html lang="en">
<head>
    @(await Html.PartialAsync("__pageData", Model.EntryModel.PageData))
...

There were probably a few other minor changes that I will eventually include in a more comprehensive update, but hopefully this gets the idea across.

I think that the output is approximately what I had intended. It took some work to get here, but much of the infrastructure is infinitely reusable because it can apply to all entries in all solutions that use razor pages. It also demonstrates many of the strengths of SaaS headless CMS with ASP.NET Core that increase developer productivity and product quality through code re-use.

<title>HeadlessArchitect.net Home Page</title>
<meta name="description" content="HeadlessArchitect.net describes the ASP.NET core razor pages solution used to build HeadlessArchitect.net with the Contentstack SaaS headless CMS." />
<meta name="keywords" content="SaaS, CMS, headless CMS, dotnet, dotnet core, asp.net core, razor pages" />
<meta property="og:title" content="HeadlessArchitect.net Home Page" />
<meta property="og:description" content="HeadlessArchitect.net describes the ASP.NET core razor pages solution used to build HeadlessArchitect.net with the Contentstack SaaS headless CMS." />
<meta property="og:image" content="https://images.contentstack.io/v3/assets/blt19bb56b18ed076dc/bltc30002573fcf738a/60a1c3b12474161960b8d9cf/HeadlessArchitectAvatar.jpg" />
<meta property="og:image:url" content="https://images.contentstack.io/v3/assets/blt19bb56b18ed076dc/bltc30002573fcf738a/60a1c3b12474161960b8d9cf/HeadlessArchitectAvatar.jpg" />
<meta property="twitter:image" content="https://images.contentstack.io/v3/assets/blt19bb56b18ed076dc/bltc30002573fcf738a/60a1c3b12474161960b8d9cf/HeadlessArchitectAvatar.jpg" />

2 thoughts on “Contentstack .NET Group Model for Page Metadata Fields

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: