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 markdowncards.
- Images are replaced by Ghost’s imagecards.
- Image URLs are migrated from /assets/imgto/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 bookmarkcards
- 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_urlliquid 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 
                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 
            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
endConclusion
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.
 
  				 
  				 
	