Migrating your blog from Jekyll to Ghost

Laptop Blogging Setup with Coffee Mug and Journal Notebook in front of bright window

There are many tools out there that do this job, but I ended up having to customise one script heavily to fully migrate my markdown-based Jekyll blog to Ghost.

We will use a custom jekyll plugin linked on my Github to export a JSON file that can be imported into Ghost. I have used the Mobiledoc specification and Ghost import file format to write this tool. There are other articles such as this one, which mentions two different migration tools (outdated unfortunately). I found that doing the migration from a jekyll plugin was the easiest because the export script can access Jekylls API and load data from it. This is how I added excerpt and post images from YAML Front Matter.

The script migrates posts, users and tags into the new Ghost v2 format.

Additionally, I have added migration support for:

  • Links to other posts via relative URLs and jekyll slugs
  • Markdown content is inserted into Ghost markdown cards.
  • Images are replaced by Ghost’s image cards.
  • Image URLs are migrated from /assets/img to /content/images. You will need to copy and paste your Jekyll image folder (/assets/img) to the Ghost image folder /content/images.
  • Free standing Link tags are converted to bookmark cards
  • ability to mark posts as featured via YAML Front Matter
  • ability to add post image via YAML Front Matter

Those last two items are easier and quicker to do in your Jekyll files before migration to Ghost.

Assumptions:

  • Your posts are plain markdown
  • there are no in-line images
  • you used the post_url liquid tag to cross-link to other posts.

Migration Plugin

Place this script into your _plugins directory (create if it doesnt exist)

require 'json'
module Jekyll

end
class GhostPage < StaticFile

    def initialize(site, base, dir, name, contents)
        @site = site
        @base = base
        @dir  = dir
        @name = name
        @contents = contents
    end

    def write(dest)
        File.open(File.join(@site.config['destination'], 'ghost_export.json'), 'w') do |f|
            f.write(JSON.pretty_generate(@contents))
        end
        true
    end

end



class JsonFileGenerator < Generator


    safe true


    def initialize(site)
        super
        @tags = []
        @postTagMap = Hash.new
    end


    def generate(site)

        converter = site.find_converter_instance(Jekyll::Converters::Markdown)
        ex_posts = []
        id = 0

        site.posts.docs.each do |post|

            # timestamp =Time.now.to_i
            timestamp = post.date.to_i * 1000
            author_id = 1
                            if post.data['featured'] == nil
                                post.data['featured']  = 0
                            end

                            # "{\"version\":\"0.3.1\",\"atoms\":[],\"cards\":[[\"markdown\",{\"markdown\":\"Fds\"}]],\"markups\":[],\"sections\":[[10,0],[1,\"p\",[]]]}"
            mobiledoc = generate_mobileloc(post) ;
            # Jekyll.logger.info post.to_json
            ex_post = {
                "id" => id,
                "title" => post.data['title'],
                "slug" => post.data['slug'],
                # "markdown" => post.content,
                # "mobiledoc" => mobiledoc,
                "mobiledoc" => mobiledoc.to_json.to_s,
                # "html" => converter.convert(post.content),
                "feature_image" => processImageUrl(post.data['image']),
                "featured" => post.data['featured'] ,
                "page" => 0,
                "status" => "published",
                "published_at" => timestamp,
                "published_by" => 1,
                "meta_title" => post.data['title'],
                "meta_description" => post.data['excerpt'].to_s.slice!(0, 499),
                "author_id" => author_id,
                "created_at" => timestamp,
                "created_by" => 1,
                "updated_at" => timestamp,
                "updated_by" => 1
            }

            ex_posts.push(ex_post)

            self.process_tags(id, post.data['tags'], post.data['categories'])
            id += 1
        end

        export_object = {
            "meta" => {
                "exported_on" => Time.now.to_i * 1000,
                "version" => "2.14.0"
            },
            "data" => {
                "posts" => ex_posts,
                "tags" => self.tag_objects,
                "posts_tags" => self.posts_tag_objects,
                "users" => self.author_objects(site)
                # TODO: add roles_users
            }
        }

        site.static_files << GhostPage.new(site, site.source, site.config['destination'], 'ghost_export.json', export_object)

    end
    def generate_mobileloc(post) 
        Jekyll.logger.info(" ##############################################################################################################################")
        Jekyll.logger.info("  PROCESSING "  + post.data['title'])
        Jekyll.logger.info(" ##############################################################################################################################")
        cards = []
        content = post.content
        slug = post.data['slug']
        Jekyll.logger.info post.data['slug']

        # find all occurances of link {% post_url rails/2016-12-15-carrierwave-upload-to-gcp %}
        # re = "/{% post_url (.*) %}/mU"
        re = /(\{\%\s?post_url\s?(.*)\s?%\})/

        str = content
        Jekyll.logger.info "Generating Mobileloc"
        # Print the match result
        str.scan(re) do |match|
            Jekyll.logger.info match

            new_link = gen_new_link(match[1])
            Jekyll.logger.info "new link\t\t" + new_link + "\t\t" + match[0] 
            str = str.gsub(match[0], new_link)

        end


        # find all image tag references
        re = /(\!\[(.*?)\]\(\{\{(.*?)\}\}\))/

        str = str
        Jekyll.logger.info "Generating Mobileloc"
        # Print the match result
        last_one = str
        str.scan(re) do |match|
            Jekyll.logger.info "============================ Processing image " + match[0].gsub('\n','') + " ==============================="
            if last_one
            # Jekyll.logger.info "Previous last_one\t\t" + last_one.gsub('\n','')
            Jekyll.logger.info "------------------------------------------------------------------------------------------------------------"

            # split by image tag ![Caption]({{ "/assets/img/image.jpg" | relative_url }})

                split = last_one.split( match[0]) # [before, after]
                # Jekyll.logger.info split
                # Jekyll.logger.info "Split Before\t\t" + split[0].gsub('\n','')

                 add_markdown_card(cards, split[0])

                # Jekyll.logger.info "Split After\t\t" + split[1].to_s.gsub('\n','')
                cards << add_image_card(match[1], match[2])
                last_one = split[1]
            else
                Jekyll.logger.info "The image was the last bit of text."
            end
        end
        if last_one
        add_markdown_card(cards, last_one)
        end

        return {
            "version" => '0.3.1',
            "atoms" => [],
            # "cards" => [['html', { "html" => converter.convert(post.content)}]],
            "cards" => cards,
            "markups" => [],
            "sections" => generate_sections(cards)
    }
    end
    def generate_sections(cards)
        sec = []
        cards.each.with_index { |val,index| 
            sec << [10, index]

        }
        sec << [1, "p", []]
        return sec
    end
    def add_markdown_card(cards, md)
        re = /(\n\n?\[[^\/](.*?)\]\((.*?)\))/

        # Print the match result
        last_one = md
        md.scan(re) do |match|
            Jekyll.logger.info "============================ Processing Link " + match[0].gsub('\n','') + " ==============================="
            if last_one
            # Jekyll.logger.info "Previous last_one\t\t" + last_one.gsub('\n','')
            Jekyll.logger.info "------------------------------------------------------------------------------------------------------------"

            # split by image tag ![Caption]({{ "/assets/img/image.jpg" | relative_url }})

            Jekyll.logger.info "Split by " + match[0].to_s.gsub('\n','')
                split = last_one.split( match[0].to_s.gsub('\n','')) # [before, after]
                # Jekyll.logger.info "Split by " + split.to_s
                # Jekyll.logger.info "Split Before\t\t" + split[0].gsub('\n','')

                 add_markdown_card(cards, split[0])

                # Jekyll.logger.info "Split After\t\t" + split[1].to_s.gsub('\n','')
                cards << add_bookmark_card(match[2], match[1])
                last_one = split[1]
            else
                Jekyll.logger.info "The link was the last bit of text."
            end
        end

        cards << ['markdown', { "markdown" => last_one}]
    end
    def add_bookmark_card(url, caption)
        return ['bookmark', { "url" => url, caption =>  caption}]
    end

    def add_image_card(caption, url)
        Jekyll.logger.info url
        theUrl = processImageUrl(url)
        Jekyll.logger.info theUrl
        return ["image", {"src" => theUrl, "caption" => caption}]
    end


    def add_adsense_card()
        theUrl = processImageUrl(url)
        Jekyll.logger.info theUrl
        return ["image", {"src" => theUrl, "caption" => caption}]
    end

    def processImageUrl(url)
        Jekyll.logger.info "URL to be processed " + url.to_s
        if url.to_s.include?  "|"
            url = url.to_s.split("|")[0]
        end
        return url.to_s.gsub('"','').gsub('/assets/img', '/content/images').gsub('assets/img', '/content/images').strip

    end
    def gen_new_link(filename)

        # remove date
        filename.slice!(0, 11)
        return '/' + filename

    end

    def process_tags(postId, tags, categories)
        unique_tags = tags | categories
        unique_tags = unique_tags.map do |t|

            t = t.to_s.chomp(",")
            t = t.downcase
        end
        @tags = unique_tags | @tags

        @postTagMap[postId] = unique_tags
    end


    def tag_objects
        tag_array = []
        @tags.each do |tag|
            tag_array.push({
                "id" => tag_array.size,
                "name" => tag,
                "slug" => tag.downcase,
                "description" => ""
            })
        end
        return tag_array
    end

    def posts_tag_objects
        posts_tag_array = []
        @postTagMap.each do |post, tags|
            tags.each do |tag|
                posts_tag_array.push({
                                    "id" => (posts_tag_array.size + 1),
                                    "post_id" => post,
                                    "tag_id" => @tags.index(tag)
                                    })
            end
        end
        return posts_tag_array
    end

    def author_objects(site)
        author_array = []

        # check if the site has any author information
        is_author_set = false
        if site.data.key?('authors')
            is_author_set = true
        end

        if is_author_set then
            all_users = site.data['authors'].keys

            for u in all_users do
                author = site.data['authors'][u]

                ex_author = {
                    "id" => u,
                    "name" => author['name'],
                    "slug" => nil,
                    "email" => author['email'] ? author['email'] : nil,
                    "profile_image" => author['profile_image'] ? author['profile_image'] : nil,
                    "cover_image" => author['cover_image'] ? author['cover_image'] : nil,
                    "bio" => author['bio'] ? author['bio'] : nil,
                    "website" => author['website'] ? author['website'] : nil,
                    "location" => author['location'] ? author['location'] : nil,
                    "accessibility" => author['accessibility'] ? author['accessibility'] : nil,
                    "meta_title" => author['meta_title'] ? author['meta_title'] : nil,
                    "meta_description" => author['meta_description'] ? author['meta_description'] : nil,
                    "created_at" => Time.now.to_i * 1000,
                    "created_by" => 1,
                    "updated_at" => Time.now.to_i * 1000,
                    "updated_by" => 1
                }

                author_array.push(ex_author)

            end
        end

        # handle cases when there is no author information provided from the _data folder
        default_author = {
            "id" => 1,
            "name" => "default", # Change this to your name
            "slug" => nil, # Change this to your slug, or keep it nil. Ghost will automatically assign one if nil
            "email" => "example@test.com", # Change this to your email
            "profile_image" => nil, # Change this to your profile image url
            "cover_image" =>  nil, # Change this to your cover image url
            "bio" => nil, # Change this to your bio
            "website" => "https://ghost.org/", # Change this to your website
            "location" => nil, # Change this to your location
            "accessibility" => nil, # Change this to your accessibility
            "meta_title" => nil, # Change this to your meta title (optional)
            "meta_description" => nil, # Change this to your meta description (optional)
            "created_at" => Time.now.to_i * 1000,
            "created_by" => 1,
            "updated_at" => Time.now.to_i * 1000,
            "updated_by" => 1
        }

        author_array.push(default_author)

        return author_array
    end

end

Conclusion

I hope this migration plugin will help you to move your blog onto the Ghost blogging platform. Some other plugins I found simply dumped all markdown into Ghost, which is not the best solution. Let me know in the comments if you run into any issues.

Related posts

Carrierwave Uploads to Google Cloud Storage

How to integrate a HTML theme into your Ruby on Rails application

This website uses cookies to improve your experience. We'll assume you're ok with this, but you can opt-out if you wish. Read More