Jekyll 에서 Ghost 로 블로그 이전 - 2. 기술 편

서론

Jekyll 에서 Ghost로 블로그를 이전하기 위해서는 Jekyll의 특징과 Ghost의 데이터 처리 특징을 알고 있어야 한다. Jekyll는 Database가 없이 static html 을 가지고 운영이되고 Ghost는 SQLite, MySQL 등과 같이 RDB를 사용하여 운영이 된다. Jekyll에서 Ghost로 이전하기 위해선 Jekyll의 Markdown 파일 기반 데이터를 분석해서 Ghost의 데이터베이스로 import를 시켜야하는 것이 핵심이다. 이 글에서는 이 Jekyll과 Ghost의 데이터 구조와 처리방법을 살펴보고 node-jekyll-to-ghost를 사용해서 export하고 import하는 방법을 소개한다.

Jekyll 동작 원리

Jekyll은 기본적으로 Markdown 파일을 참조해서 static 사이트를 만드는 컨셉을 가지고 있다. jekyll은 데이터베이스를 사용하지 않는다. _posts/ 디렉토리 안에 저장된 markdown 파일의 정보로 포스트의 메타정보를 만든다. 파일이름은 날짜와 slug 조합으로 저장하고 이 markdown 파일이름으로 URI가 생성된다. 포스트의 구체적인 메타정보는 markdown 파일 가장 상단에 정의한 Front MatterYAML으로 정의한다. Jekyll은 빌드하거나 시작할 때 이 메타정보를 참조해서 각 포스트마다 완벽한 웹 페이지 HTML 파일을 생성한다.

Jekyll 포스트 등록

Jekyll은 Markdown 파일을 단순히 _posts/ 디렉토리에 저장하는 것으로 포스트가 등록이 된다. 만약 아직 완료되지 않은 작성중인 포스트는 _drafts/ 디렉토리 안에 저장하면 된다. 각 포스트에 대한 메타정보는 Front Matter를 Markdown 파일 머리에 선언하여 저장한다. Front Matter의 예는 다음과 같다.

---
layout: post  
title:  "Welcome to Ionic!"  
date:   2017-01-17 16:16:01 -0600  
categories: ionic  
---

제목, 발행일

Jekyll은 데이터베이스를 사용하지 않는다. 그래서 제목을 저장하거나 발행일을 연산할 수 없다. Jekyll은 기본적으로 단순하게 Markdown의 파일 이름에서 제목과 발행일 정보를 획득한다. 예를 들어서 2017-01-17-ionic-edge.md 라는 파일 이름으로 저장하는 것이다. YEAR-MONTH-DAY-title.MARKUP 기본 패턴으로 만들어지는 파일이름에서 발행일제목(title)을 획득한다. 만약 사용자가 명시적으로 발행일과 제목을 정의하고 싶을 경우 Front Matter에 다음과 같이 정의하면 된다.

title:  "Welcome to Ionic!"  
date:   2017-01-17 16:16:01 -0900  

Slug

Jekyll은 파일 이름을 참조해서 Slug를 자동으로 만들어준다. 만약 자동으로 만들어지는 Slug 대신 post의 고유한 Slug를 만들고 싶을 경우 Jekyll의 전반적인 환경 정의하는 _config.yml 파일 또는 Front Matter에 permalink를 정의하면 된다. 예를 들어 /ionic/2017/01/17/ionic-edge.html 과 같은 Slug를 만들려면 다음과 같이 정의하면 된다. 여기 변수는 파일이름을 날짜 정보나 Front Matter의 date:, category:, title: 정보를 참조한다.

permalink: /:categories/:year/:month/:day/:title.html  

만약 permalink 정의를 하지 않았으면 Jekyll은 기본적으로 다음과 같이 만들어진다. 예) /ionic/ionic-edige.html

/:categories/:title.html

Custom 속성 정의

사실 Jekyll의 특징은 위에서 언급한 것이 전부이다. 블로그에서 흔히 보이는 tagcover image 같은 것을 보여주는 것을 Jekyll은 기본적인 기능이 아니다. 하지만 Jekyll은 Front Matter를 잘 활용하여 마치 데이터베이스 처럼 사용할 수 있다. 예를 들어 다음과 같이 글에 대한 tag와 cover image를 정의할 수 있다.

tags:  
  - ionic
  - angular
  - cordova
cover:  
  image: "http://asset.blog.hibrainapps.net/saltfactory/images/48133d68-b7e5-4434-b808-a81d59494aef"

이렇게 정의한 custom 속성은 Liquid Template Language 로 표현할 수 있다.

Ghost 데이터 Import format

Ghost는 Markdown 파일 자체를 자기고 포스트를 Import 할 수 있다. 그리고 Node.js 기반으로 만들어졌다. 그래서 Import format도 JSON 포멧을 가진다. 예를 들어 사용자에 관한 데이터를 임포트하기 위해서는 다음과 같은 속성을 가지고 import를 하면된다. https://github.com/TryGhost/Ghost/wiki/Import-format

"users": [{
   id: 1,
   name: "송성광",
   email: "saltfactory@gmail.com"
}]

Ghost의 Import format meta blockdata block을 가지고 있다.

Meta Block

Meta Block은 exported된 날짜와 version 정보를 가지고 있다.

"meta":{
  "exported_on":1408552443891,
  "version":"003"
}

Data Block

Data Block은 posts, tags, poststags, users, 그리고 rolesusers 정보가 있다.

{
  "data":{
    "posts":[],
    "tags":[],
    "posts_tags":[],
    "users":[],
    "roles_users":[]
  }
}

Ghost import data 예를 보면 다음과 같다.

{
    "meta":{
        // epoch time in milliseconds
        "exported_on":  1388805572000,
        // Data version, current is 003
        "version":      "003"

    },
    "data":{
        "posts": [
            {
                "id":5,
                "title":        "my blog post title",
                "slug":         "my-blog-post-title",
                "markdown":     "the *markdown* formatted post body",
                "html":         "the <i>html</i> formatted post body",
                "image":        null,
                "featured":     0, // boolean indicating featured status
                "page":         0, // boolean indicating if this is a page or post
                "status":       "published", // or draft
                "language":     "en_US",
                "meta_title":   null,
                "meta_description":null,
                "author_id":    1, // the first user created has an id of 1
                "created_at":   1283780649000, // epoch time in millis
                "created_by":   1, // the first user created has an id of 1
                "updated_at":   1286958624000, // epoch time in millis
                "updated_by":   1, // the first user created has an id of 1
                "published_at": 1283780649000, // epoch time in millis
                "published_by": 1 // the first user created has an id of 1
            }
        ],
        "tags": [
            {
                "id":           3,
                "name":         "Colorado Ho!",
                "slug":         "colorado-ho",
                "description":  ""
            },
            {
                "id":           4,
                "name":         "blue",
                "slug":         "blue",
                "description":  ""
            }
        ],
        "posts_tags": [
            {"tag_id":3, "post_id":5},
            {"tag_id":3, "post_id":2},
            {"tag_id":4, "post_id":24}
        ],
        "users": [
            {
                "id":           2,
                "name":         "user's name",
                "slug":         "users-name",
                "email":        "user@example.com",
                "image":        null,
                "cover":        null,
                "bio":          null,
                "website":      null,
                "location":     null,
                "accessibility": null,
                "status":       "active",
                "language":     "en_US",
                "meta_title":   null,
                "meta_description": null,
                "last_login":   null,
                "created_at":   1283780649000, // epoch time in millis
                "created_by":   1, // the first user created has an id of 1
                "updated_at":   1286958624000, // epoch time in millis
                "updated_by":   1 // the first user created has an id of 1
            }
        ],
        "roles_users": [
            {
                "user_id": 2,
                "role_id": 3   
            }
        ]
    }
}

Jekyll to Ghost

Ghost에 import되는 JSON 데이터의 포멧을 살펴봤다. 이제 Jekyll에서 Markdown을 읽어 Ghost에 임포트될 JSON을 만들면 된다. 위에서 살펴봤듯 Jekyll은 포스트의 모든 정보를 Markdown에 가지고 있다. 파일명이나 Front Matter를 읽어서 Ghost에 들어갈 JSON을 만들면 된다. 이미 Ghost에서 Jekyll to Ghost 하는 플러그인들이 존재한다. 그 중에서 Ruby로 만들어져 Jekyll의 _plugins/ 에 플러그인으로 저장하여 jekyll build 로하면 자동으로 ghost 임포트 파일로 만들어지는 jekyll-to-ghost 를 사용했는데 이미 Front Matter를 커스텀하게 정의하고 사용하고 있는 환경 때문에 몇몇 코드를 수정해야했다. 그래서 Node.js로 이와 비슷하게 동작하는 node-jekyll-to-ghost를 직접 만들었다.

node-jekyll-to-ghost

아직 global install을 지원하지 않지만 (이후에 라이브러리를 공식적으로 release할 때는 global 명령어로 사용할 수 있을 예정이다.) 다음과 같이 사용할 수 있다.

git clone https://github.com/saltfactory/node-jekyll-to-ghost.git  

이 라이브러리의 첫번째 인자는 Jekyll에 Markdown 파일이 존재하는 _post/ 경로를 넣고 두번째 인자는 Ghost Import Data 파일인 .json 파일이 생성될 경로를 지정하면 된다.

node node-jekyll-to-ghost/index.js _post/ ./jekyll-to-ghost.json  

Jekyll은 사용자마다 사용하는 패턴이 다르다. 대부분 Front Matter를 커스텀하게 정의해서 사용할텐데 이 부분의 데이터를 가져와서 Ghost Import Data로 매핑하기 위해서는 다음 코드를 수정해서 사용하면 된다. 예를 들어서 Font Matter에 커버이미지를 cover.images.title:로 사용하고 있었다면 Font Matter를 로드해서 가지고 있는 matter 오브젝트에서 matter.data.images.title로 가져오도록 하면 된다. 다음은 Ghost의 Post에 관련된 데이터를 만드는 함수는 createGhostPost() 함수이다.

function createGhostPost(index, matter, filename) {  
  let importUserId = 1;
  let post = {
    featured: 0,
    page: 0,
    status: 'published',
    language: 'ko_KR',
    meta_description: null,
    author_id: importUserId,
    created_by: importUserId,
    updated_by: importUserId,
    published_by: importUserId,
  };

  post = extend(post, {
    id: index + 1,
    uuid: uuid.v4(),
    title: matter.data.title,
    slug: createSlug(filename),
    markdown: matter.content,
    html: markdown.toHTML(matter.content),
    image: matter.data.images ? matter.data.images.title : null,
    meta_title: matter.data.title,
    created_at: createDate(filename),
    updated_at: createDate(filename),
    publised_at: createDate(filename)
  });

  return post;
}

이렇게 만들어진 jekyll-to-ghost.json 파일을 임포트하는 방법은 Ghost의 Dashboard를 이용하면 된다. Labs 메뉴에 들어가면 Import 가 보이는데, 앞에서 생성한 파일을 파일 첨부 형태로 임포트하면 된다.

Nginx URL Rewrite

Jekyll 에서 Ghost로 이전하면 몇가지 URL을 수정해야하는 문제가 있다. Jekyll에서 permalink를 사용하여 Ghost와 같은 Slug 패턴으로 사용하지 않는한 대부분 기본적으로 Jekyll에서 제공하는대로 사용했을 것이라 예상이되고 Jekyll의 기본적인 Slug 패턴은 /:categories/:title.html 이 될 것이다. 하지만 Ghost는 /:title/ 과 같은 Slug가 된다 예를 들어서 Jekyll에서 http://blog.saltfactory.net/ionic/ionic-edge.html 으로 사용하던 URL이 Ghost로 옮기면 http://blog.saltfactory.net/ionic-edge/ 과 같은 패턴으로 사용된다. 난 기존의 포스팅에서 사용하던 URL을 변경하고 싶은 마음이 없다. 이미 누군가에게 내 글이 참조되고 있을것이고, 검색엔진에 지금까지 작성한 포스팅의 링크가 그대로 등록되어 있을 것이다. 누군가 검색 엔진에서 검색된 글을 보려고 링크를 누르는 순간 Ghost의 Not found 에러를 보게 하고 싶지 않았다.

Nginx 웹 서버에서 내가 고민하는 부분을 해결해 줄 수 있었다. Nginx 뿐만 아니라 Apache 웹 서버에는 rewrite 모듈을 가지고 있다. 이것을 사용하여 이전 URL로 들어오는 요청을 새로운 URL로 변경하여 요청할 수 있다.

rewrite ^/(.*)\.html$ /$1/ last;  

이와 함께 변경되어야할 URL은 다음과 같다.

  • tag
  • page
  • sitemap
  • atom
  rewrite ^/tags/(.*)$ /tag/$1/ last;
  rewrite ^/(.*)/(.*)\.html$ /$2/ last;  
  rewrite ^/(.*)\.html$ /$1/ last;
  rewrite ^/page([0-9]+)/$ /page/$1/ last;
  rewrite ^/sitemap/$ /sitemap.xml last;
  rewrite ^/atom/$ /rss/ last;

Comments

Jekyll 과 Ghost는 자체적인 Comment가 없다. 그래서 Jekyll을 사용할 때 댓글 서비스를 위해 Disqus를 가장 많이 사용한다. Disqus는 최근 유행하는 각종 블로그 서비스에 바로 embed 할 수 있는 코드를 제공하고 있다. Jekyll은 Universal Code를 레이아웃 파일에 포함시켜서 추가할 수 있다. 아래는 이 블로그의 Disqus Universal Code 이다.

<div id="disqus_thread"></div>  
<script>

/**
*  RECOMMENDED CONFIGURATION VARIABLES: EDIT AND UNCOMMENT THE SECTION BELOW TO INSERT DYNAMIC VALUES FROM YOUR PLATFORM OR CMS.
*  LEARN WHY DEFINING THESE VARIABLES IS IMPORTANT: https://disqus.com/admin/universalcode/#configuration-variables*/
/*
var disqus_config = function () {  
this.page.url = PAGE_URL;  // Replace PAGE_URL with your page's canonical URL variable  
this.page.identifier = PAGE_IDENTIFIER; // Replace PAGE_IDENTIFIER with your page's unique identifier variable  
};
*/
(function() { // DON'T EDIT BELOW THIS LINE
var d = document, s = d.createElement('script');  
s.src = '//saltfactoryblog.disqus.com/embed.js';  
s.setAttribute('data-timestamp', +new Date());  
(d.head || d.body).appendChild(s);
})();
</script>  
<noscript>Please enable JavaScript to view the <a href="https://disqus.com/?ref_noscript">comments powered by Disqus.</a></noscript>  

이 코드를 자세히 살펴보면 Disqus는 this.pagge.url 또는 this.page.identifier 를 지정할 수 있는데 이것은 Disqus가 어느 페이지에 속한 것인지 구분자를 지정하는 것이다. 기본적으로 아무 설정을 하지 않으면 웹페이지가 열리는 URL을 그대로 구분자로 사용을 한다.

예를 들어, http://blog.saltfactory.net/edge/ionic-edge.html 웹 페이지를 열면 이 URL을 Disqus의 유일한 구분자로 적용이된다. Jekyll to Ghost 이후 기존의 URL이 변경되기 때문에 Disqus의 유일한 구분자 정보를 지정하지 않으면 URL이 달라 이전에 사용하던 comments를 가져오지 못하는 문제가 발생한다. Disqus는 URL이 변경되면 구분자를 변경하는 Migration Tools을 제공하고 있다.

Jekyll 에서 Ghost로 이전하게 되면 Jekyll의 기본 slug가 Ghost의 slug와 다르기 때문에 Disqus가 제대로 나오지 않는데 이 문제를 해결하기 위해서 Disqus Migration Tools 중에 URL Mapper 방법을 사용하면 된다.

Disqus Admin Dashboard에서 Migration Tools 메뉴를 선택한다.

Migrate Threads 페이지가 나오면 두번째 항목인 Upload a URL map 옆에 있는 Start URL mapper 버튼을 클릭한다. 페이지가 전환되면 파일첨부 화면이 나타나는데 여기에 새롭게 이전 URL과 새로운 URL 매핑정보를 comma로 정의한 파일을 업로드하면 된다.

우선 기존의 URL 정보를 알기 위해서 you can download a CSV file 링크를 클릭하면 기존의 Disqus가 달려있는 URL 리스트 저장된 파일이 다운로드 된다.

예를 들어 다음과 같다.

http://blog.saltfactory.net/ionic/ionic-edge.html  
http://blog.saltfactory.net/spring/introduce-resttemplate.html  
... 생략 ...

이 부분을 새로운 URL로 매핑하는 정보를 반영하기 위해서 이 파일을 다음과 같이 comma로 새로운 URL을 함께 저장해서 업로드하면 된다.

예를 들면 다음과 같다.

http://blog.saltfactory.net/ionic/ionic-edge.html, http://blog.saltfactory.net/ionic/ionic-edge/  
http://blog.saltfactory.net/spring/introduce-resttemplate.html, http://blog.saltfactory.net/spring/introduce-resttemplate/  
... 생략 ...

이미 많은 Disqus Comments가 등록되어 있다면 파일 내용에 수정해야할 URL이 꽤 많아지게 된다. 사람이 하나하나 수정 하기에는 너무 많은 시간이 걸린다. 그래서 이 변환을 좀 더 쉽고 단순하게 하기 위해서 node-jekyll-to-ghost-discus-migration 라이브러리를 만들었다. Disqus에서 다운로드한 URL이 들어 있는 CSV 파일을 지정하고 output 파일경로를 지정하고 시작한다. 사용법은 다음과 같다.

git clone https://github.com/saltfactory/node-jekyll-to-ghost-disqus-migrator.git  
node node_moduels/jekyll-to-ghost-disqus-migrator/index.js ./oldUrl.csv  ./output.csv  

매핑 작업을 마친 내용은 output.csv는 Disqus Dashboard에 매핑 파일을 첨부하는 곳에 파일 업로드로 첨부한다. 시간이 조금 지나면 새로바뀐 URL에서 이전에 남겨진 댓글을 볼 수 있게 된다.

결론

이제 Jekyll 에서 Ghost를 이전할 준비를 모두 마쳤다. 이 글에서는 Jekyll 의 특징을 분석해서 Ghost 의 Import 데이터 포맷에 맞는 JSON을 만들어서 데이터를 임포트하는 것을 node-jekyll-to-ghost 라이브러리를 만들어서 마이그레이션 하는 방법을 소개했다. Jekyll과 Ghost의 slug 패턴이 다른 이유로 Nginx에서 rewrite로 이 문제를 해결했고, Disqus의 Comment 의 URL 식별자가 달라지는 문제를 해결하기 위해서 node-jekyll-to-ghost-disqus-migrator 라이브러를 만들어서 마이그레이션 하는 것을 소개했다.

언젠가 Ghost에서 Jekyll로 또는 다른 서비스로 이전할지 모른다. 그때는 지금과 반대 프로세스로 처리하면 된다. Ghost에서는 Export 기능을 제공하고 이 것은 앞에서 소개한 Import Datat Format으로 JSON 파일로 만들어지기 때문에 이 파일을 기반으로 마이그레이션을 진행하면 될 것으로 예상된다.