Programmatic publishing to Contentful

This is note 5 of 5 in a series on publishing automation.

The world of headless content management systems (CMS) and Jamstack architecture is an exciting movement that puts content, not apps, back at the heart of the web. The programmer Daniel Janus delineates this app/content divide clearly:

These days, the WWW is mostly a Web of Applications. An application is a broader concept: it can display text or images, but also lets you interact not just with itself, but with the world at large. And that's all well and good, as long as you consciously intend these interactions to happen.

A document is safe. A book is safe: it will not explode in your hands, it will not magically alter its contents tomorrow, and if it happens to be illegal to possess, it will not call the authorities to denounce you. You can implicitly trust a document by virtue of it being one. An application, not so much.

I'm a fan of this historical view of the web as a place to view content, or documents, via static websites. With this view as an ideal, the problem that CMS' try to solve, despite often existing as web apps in and of themselves, is paramount: how to create, modify and organize these documents.

This last note in the series shows how we can keep content at the heart of our site's technical implementation and increase speed and convenience of delivery to the web. By leveraging a modern headless CMS like Contentful, we can develop a custom publishing automation pipeline to make the place we author and publish one and the same.

Programmatic Contentful

In the previous note in this series, we built and deployed the Lambda function from this site, parsed the text of the note passed from Bear, and prepared to send the structured content to Contentful's Content Management API. We then call our createBlogPost function which will create/update and publish our blog post to Contentful:

/**
 * Creates/updates and publishes a Contentful entry.
 * @param {Object} payload The raw note text to parse
 */
const createBlogPost = async payload => {
  try {
    const blogPost = format(payload);
    const { sys: { version } = {}} = await getEntry(blogPost);
    const blogPostEntry = await createEntry({
      payload: blogPost,
      version: version ? version: 1
    });
    await publishEntry(blogPostEntry);
    return {
      statusCode: 200,
      body: `Success, published version ${blogPostEntry.sys.version++} of "${blogPost.fields.title['en-US']}"`
    }
  } catch(e) {
    console.log(`Failed to create blog post. ${e}`);
    return {
      statusCode: 500,
      body: "Internal Server Error"
    }
  }
}

Breaking down this function:

But before looking deeper into each of the calls to the Content Management API, we really should talk about content modeling.

Content modeling can make or break you

When configuring any CMS for the first time, you'll need to engage with the ritual that is content modeling. In other words, in order to create that blog post piece of content, the CMS needs to know what pieces of information or fields make up said blog post. In our case, I defined our blog post fields to be as flat as possible, with the name and type of field:

|- title (short text)
|- slug (short text)
|- date (date & time)
|- short description (long text)
|- category (short text, list)
|- body (long text)

Simple enough. But this is a revised state—the original structure looked more like this:

|- title (short text)
|- slug (short text)
|- date (date & time)
|- short description (long text)
|- category (short text, list)
|- content (container)
   |- body (long text)

The key difference is the last item. If we define our blog post content type to have a container filled with different individual blocks of content (text, images, maybe video in the future, etc.), then programmatic publishing is much less straightforward. With this nested structure, you must explicitly keep track of links between content blocks. This means we have to first create the most nested blocks, such as a text block, then create the container and link the text block to the container. This was what the first version of the function above looked like as a result:

/**
 * Creates an individual entry in Contentful.
 * @param {Object} entry An individual entry
 * @param {string} entry.key The content type of the entry
 * @param {Object} entry.data The payload to send
 * @returns {Object} response The request response from the Content Management API.
 */
const createIndividualEntry = ({ key, data }) => fetch(`${base}/${createEntryPath}/${uuid()}`, {
  method: 'PUT',
  headers: headers(key),
  body: JSON.stringify(data)
})
  .then(res => res.json());

/**
 * Creates a series of individual entries in Contentful that are linked together.
 * Starts from the most nested entry and ends with the blog post itself.
 */
const createEntry = () => {

  // First, create a text block
  return createIndividualEntry(textEntry)
    .then(({ sys: { id, version }}) => {
      const blockText = { id, version };
      containerEntry.data.fields.content['en-US'][0].sys.id = id; // Required to link to container
      console.log('✅ Created a text block');

      // Then, create a container with content of text block
      return createIndividualEntry(containerEntry)
        .then(({ sys: { id, version }}) => {
          const container = { id, version };
          postEntry.data.fields.content['en-US'].sys.id = id; // Required to link to blog post
          console.log('✅ Created a container');

        // Then, create a blog post
        return createIndividualEntry(postEntry)
          .then(({ sys: { id, version }}) => {
            const blogPost = { id, version };
            console.log('✅ Created a blog post');

            // Finally, return entry reference containing id and version
            return [blockText, container, blogPost];
          });
        });
      });
    
}

To avoid this pain, I re-defined my blog post to be completely flat, enabling the function to make a single call to create and a single call to publish. If you're starting from scratch for a simple project, I highly recommend you keep your content model as flat as possible.

Get, create and publish

Back to our createBlogPost function above, let's look at each of the three smaller functions it calls.

First, getEntry to get the version of the blog post if it already exists:

/**
 * Gets an individual entry in Contentful.
 * @returns {Object} response from the Content Management API
 */
const getEntry = payload => {
  const id = payload.fields.slug['en-US']; // Use unique slug as id
  return fetch(`${base}/${path}/${id}`, {
    method: 'GET',
    headers
  })
    .then(res => {
      try {
        return res.json();
      } catch(e) {
        console.log(`Failed to parse get entry response.\n${e}`);
      }
    });
};

Then, createEntry using the version as a parameter:

/**
 * Creates an individual entry in Contentful.
 * @param {Object} payload blog post payload to send
 * @returns {Object} response from the Content Management API
 */
const createEntry = ({ payload, version }) => {
  const id = payload.fields.slug['en-US']; // Use unique slug as id
  return fetch(`${base}/${path}/${id}`, {
    method: 'PUT',
    headers: {headers,
      'X-Contentful-Version': version
    },
    body: JSON.stringify(payload)
  })
    .then(res => {
      try {
        return res.json();
      } catch(e) {
        console.log(`Failed to parse create entry response.\n${e}`);
      }
    })
    .then(data => {
      if (data.sys.type === 'Error') {
        console.log(`❌ Failed to create Contentul entry.\n${JSON.stringify(data)}`);
        return;
      };
      console.log(`✅ Created item ${id}`);
      return data;
    });
}

Finally, publish the newly created/updated entry:

/**
 * Publishes the blog post that was just created in Contentful.
 * @param {Object} entry entry object returned from Contentful
 * @param {Object} entry.sys system object returned from Contentful
 * @param {string} entry.sys.id id of the entry to publish
 * @param {string} entry.sys.version version of the entry to publish
 */
const publishEntry = ({ sys: { id, version }}) => {
  return fetch(`${base}/${path}/${id}/published`, {
    method: 'PUT',
    headers: {headers,
      'X-Contentful-Version': version
    }
  })
    .then(res => {
      try {
        return res.json();
      } catch(e) {
        console.log(`Failed to parse publish entry response.\n${e}`);
      }
    })
    .then(data => {
      if (data.sys.type === 'Error') {
        console.log(`❌ Failed to publish Contentful entry. ${data.message}`);
        return;
      };
      console.log(`✅ Published item ${id}`);
      return data;
  });
}

And once more, put all together in the createBlogPost function:

/**
 * Creates/updates and publishes a Contentful entry.
 * @param {Object} payload The raw note text to parse
 */
const createBlogPost = async payload => {
  try {
    const blogPost = format(payload);
    const { sys: { version } = {}} = await getEntry(blogPost);
    const blogPostEntry = await createEntry({
      payload: blogPost,
      version: version ? version: 1
    });
    await publishEntry(blogPostEntry);
    return {
      statusCode: 200,
      body: `Success, published version ${blogPostEntry.sys.version++} of "${blogPost.fields.title['en-US']}"`
    }
  } catch(e) {
    console.log(`Failed to create blog post. ${e}`);
    return {
      statusCode: 500,
      body: "Internal Server Error"
    }
  }
}

And there we have it! Zooming out to the entire automated publishing pipeline:

As a direct result of this pipeline, I can say that my focus has improved and rate of publishing has increased at least 3 fold. The ability to edit on the fly on my phone and publish updates instantly enables a continuous flow state while commuting and better writing overall. Suffice to say, I enjoy writing again.

If you've read through this entire series, thank you, it's been a pleasure. Do let me know if you implement something similar!

Cheers 🍻

Previous articles in this series:


Thanks for reading! Go home for more notes.