Migrating from Hugo to Ghost

Migrating from Hugo to Ghost

I’ve been trying to migrate this blog, currently built on Hugo, to Ghost. The goal is to migrate a bunch of markdown files located in content/codebase/*.md to the format accepted by Ghost, all the while preserving URLs, meta descriptions and images without manual work for each post.

Preserving URLs

Regarding URLs, Ghost has built-in support for redirects via a routes.yml file:

routes:

collections:
  /:
    permalink: /codebase/{year}/{month}/{day}/{slug}/
    template:
      - index

taxonomies:
  tag: /tag/{slug}/
  author: /author/{slug}/

Preserving images

I manually put images from Hugo in Ghost’s content/images folder with the same paths as in the Hugo version of the blog, to prevent images’ URL from changing during the migration. That’s enough for Ghost to pick up on them.

Transforming markdown to Ghost’s JSON format

Here’s the script I use to convert raw markdown to Ghost’s JSON format:

const { readdirSync, readFileSync } = require('fs')
const { basename } = require('path')

// I have my posts in the `content/codebase` directory
const listPosts = () => {
    return readdirSync(`${__dirname}/content/codebase`)
        .map(el => `${__dirname}/content/codebase/${el}`)
}

const createPost = (image, title, slug, publishedAt, markdown) => {
    return {
        "title": title,
        "slug": slug,
        "feature_image": image,
        "mobiledoc": JSON.stringify({
            version: '0.3.1',
            markups: [],
            atoms: [],
            cards: [['markdown', {cardName: 'markdown', markdown }]],
            sections: [[10, 0]]
        }),
        "status": "published",
        // These dates end up somewhat of an approximation, because
        // I would have to extract them from the .md files' metadata
        // which is more work and of little use in my case
        "published_at":  publishedAt,
        "created_at":  publishedAt,
        "updated_at":  publishedAt,
    }
}

// Extracts post data from the Markdown files used in Hugo
//
// The original Hugo files look somewhat like this:
//
// <example lang="hugo">
//   +++
//   title = "Hello world"
//   slug = "hello-world-url"
//   date = "2019-04-16"
//   +++
//   Post content is here...
// </example>
const createPostDataFromFileContent = (filename, fileContent) => {
    const contentRegexp = /^\+\+\+((.|\n)+)\+\+\+((.|\n)+)$/m
    const titleRegexp = /title = "(.+)"/
    const dateRegexp = /date = (.+)/
    const imageRegexp = /image = "(.+)"/

    const contentMatches = fileContent.match(contentRegexp)

    const header = contentMatches[1]
    const titleMatches = header.match(titleRegexp)
    const dateMatches = header.match(dateRegexp)
    const imageMatches = header.match(imageRegexp)

    const image = imageMatches[1]
    const title = titleMatches[1]
    const date = (new Date(dateMatches[1])).getTime()
    const markdown = contentMatches[3].trim()
    // In my case, the filenames are the same as the slug
    const slug = basename(filename.substring(0, filename.length - ('.md'.length)))

    return createPost(
        image,
        title,
        slug,
        date,
        markdown
    )
}

const postsData = listPosts()
    .map(filename => ({ filename, fileContent: readFileSync(filename).toString() }))
    .map(({ filename, fileContent }) => createPostDataFromFileContent(filename, fileContent))

// Prints the posts in JSON format for Ghost, which can be used to debug
// or create a .json file to import into Ghost, like so:
//
// <example lang="shell">
//   node hugo-to-ghost.js > ghost.json
// </example>
console.log(JSON.stringify(
        {
        "meta": {
            "exported_on":1408552443891,
            "version":"3.1.0",
        },
        "data": {
            "posts": postsData,
        },
    }
))