heroku cms ghost

Heroku wants paying, so time to move on...

Heroku have decided to discontinue their free dyno offering and so this blog needs to find a new home...

Shaun Wilde
a fistful of burning money

For over ten years I have been using Heroku to host small servers using their free dyno offering, I have even hosted production servers on their platform because it was so easy to setup and manage. However, Since their announcement to cease their support of free dynos I have to find a new home for my blog, or more specifically the headless Ghost CMS portion.

Even though the end date isn't for another month or so, I find myself quite time poor and so I needed to move quickly. I realised there are a LOT of blog hosting offerings and the choice is just bewildering; I also considered Medium, as I read it a lot and have a subscription, but couldn't find a way to host legacy posts.

I needed a something simple, easy to migrate to, definitely easy to migrate from, and I that could host on my own domain. I already use Netlify to host my Gatsby generated blog and so I initially looked for solutions that would allow migration from one headless CMS to another but use the same site generator. I then realised that Netlify have their own CMS so I decided to give that a look.

A nice thing about Netlify CMS is that the blog content itself is hosted on your own Github account so you always have full control of the data. I also found that the local development experience was excellent in that I could tweak the articles as I migrated them across and instantly review them without having to go through a long build cycle and use up those precious Netlify build minutes.

Migration process

I don't intend to supply the scripts I used as they are a mess, I went fast and dirty here as I knew I would not need these scripts once the job is done. I will however talk about the tooling I used to migrate the data.

First up was to migrate the blog data itself. Ghost CMS has an api and so using the @tryghost/content-api package I was able to quickly grab the content.

import ghostContentAPI from '@tryghost/content-api';

const api = new ghostContentAPI({
  url: ghostURL,
  key: 'xxxxxxxxxxxxxxxxxxxxxxxxxx',
  version: 'v2',
});

const posts = await contentType.browse({
  include: 'tags,authors',
  limit: 'all',
});

The content provided however was HTML and the Netlify CMS, as do a few others, requires it in Markdown format instead.

a snippet of html downloaded from ghost cms

To convert this to Markdown, I turned to another package turndown to do the conversion. Off the bat, it did a reasonable job but it failed to correctly handle code snippets that were using prismjs. What was needed was to extend turndown using a new rule to recognise these snippets and convert them correctly.

import TurndownService from 'turndown';

...

var turndownService = new TurndownService();
turndownService.addRule('prismCode', {
  filter: function (node) {
    return (
      node.nodeName === 'PRE' &&
      node.childNodes.length === 1 &&
      node.childNodes[0].nodeName === 'CODE' &&
      /^language-/.test(node.childNodes[0].getAttribute('class'))
    );
  },
  replacement: function (content, node) {
    const matches = node.childNodes[0]
      .getAttribute('class')
      .match(/^language-(?<lang>.+)/);
    return (
      '```' +
      matches.groups['lang']
        .replace('cmd', 'bash')
        .replace(',line-numbers', '') +
      '\n' +
      content +
      '\n```'
    );
  },
});

...

const markdown = post.html
    ? turndownService.turndown(post.html)
    : '';

With the posts now converted to markdown it is now possible to generate the post markdown files and map the data to the format expected by the Netlify CMS; the required content for a blog post is customisable and I suggest you give this some thought before committing the next step.

const template = `
---
date: ${post.published_at.slice(0, 10)}
title: ${post.title}
description: ${post.custom_excerpt ? post.custom_excerpt : ''}
featuredImage:
  src: ${post.feature_image ? post.feature_image : '/static/img/not-found-image.jpg'}
... more fields go here
---
${markdown}
`;

Finally, I needed to move the images as they were hosted on Cloudinary but the account was tied to the Heroku account and application which I intended to delete. I used regex to identify the images hosted on Cloudinary and saved the files to disk, and then used regex again to rewrite the new locations in the markdown files.

import fs from 'fs-extra';
import fetch from 'node-fetch';

...

const downloadImage = async ({ url, outputPath }) => {
  const response = await fetch(url);
  const data = await response.buffer();

  await fs.outputFile(outputPath, data);
};

Site generation

I also decided to ditch Gatsby and give 11ty a try instead as it appears to be more lightweight; if it is a disaster then I can always try something else now that I have my content in a convenient format. I also decided to change my comment platform to utterences as it is another github based storage solution and so I have full control of the data there as well.

Site generation is currently < 30s whereas with the Gatsby+Ghost setup this was 3-4 mins; this is important as previews (editorial workflow) use a branch and need building+deploying before they can be seen.

Afterthought

There is still some work to do, tags, search etc..., and I am not 100% convinced the Netlify CMS is the final solution as it has not seen a lot of love of late however I have my data in a format that should be easy to migrate if needs be.

Your feedback, as always, is appreciated!