Next.jsで作っていた個人ブログをGhost(Pro)に移行した

Next.jsで作っていた個人ブログをGhost(Pro)に移行した
Photo by Robert Linder / Unsplash

元々Next.jsで作っていた個人ブログをGhost Proに移行することにしました。

Next.jsのバージョンアップ速度が早く、自分でメンテし続けるのが辛くなってきていたところに、久々に記事を書いたらビルド出来なくなってしまったことで、このままでは良くないなという気持ちが芽生えて、色々調べてみたところGhostを公式でホスティングしてくれるサービスがあると知り、良さそうだったので使ってみようという感じです。

そういえば以前、GatsbyからNext.jsへの移行もビルドができなくなったのがきっかけでした。

Ghost(Pro)とは

Ghostとは

Ghostとは、NodeJSベースで作られているオープンソースのブログプラットフォームです。

Ghostの大きな特徴は、その高速なパフォーマンスと直感的なUI/UXです。専門的な出版ツールとして、SEO最適化、メンバーシップやサブスクリプションモデル、カスタマイズ可能なデザインなどを提供し、プロのブロガーや出版者にとって理想的なプラットフォームとなっています。

同じようなことができるWordPressとMediumと比較してみると、WordPressは多様なプラグインやテーマなどでECサイトを作ったりもできるカスタマイズ性の高さが魅力的な一方、複雑になりがちで、速度も遅めというのがあります。

MediumはシンプルなUIで記事を書くことに特化しています。一方でテーマ設定がないなど、オリジナリティを出すのが難しいです。

Ghostはその間を取っているバランスの良いプラットフォームだと感じました。

Ghost Proとは

Ghost Proは、そんなオープンソースのGhostをホスティングしてくれるサービスで、Ghostの開発母体が運営しています。

セルフホスティングだとアップデート等の対応を自分でやる必要がありますが、全て任せられるのが良いところです。

料金は9ドル/月〜で、比較的安めになってます。

Ghost ProのPros/Cons

既に少し特徴は書きましたが、改めてPros/Consをまとめて、詳しく見ていきます。

Pros

  1. 管理しなくて良い
  2. ブログに必要な機能は揃ってる
  3. 独自ドメインが使える
  4. 有料記事が書ける
  5. 最悪Ghostをセルフホスティングできる

Cons

  1. 料金体系がちょっと特殊
  2. 管理画面が全部英語
  3. 記事執筆画面のフォントが明朝体
  4. ググって出てくるGhostの記事が、Ghost本体のことを言っているのか、Ghost Proのことを言っているのかが分からない

管理しなくて良い

Ghost Proの話です。月額9ドル〜を支払う必要はありますが、ライブラリのバージョンアップ等をやらなくて済むのはNext.jsの管理が辛くなった自分としては非常にありがたいです。

ブログに必要な機能は揃ってる

記事をMarkdown形式で書けるのはもちろんのこと、YouTubeやTwitterの埋め込みが可能で、RSS、sitemap.xml等の出力も勝手にやってくれます。

ちなみに、シンタックスハイライト、Google Analyticsの導入等はヘッダー、フッターにコードを挿入できる機能があるので、それを使って自分でJSやStyleの埋め込む必要があります。

独自ドメインが使える

ブログプラットフォームで使えないところもありますが、Ghostはしっかりと使えます。

ちなみに、ルートドメイン(ネイキッドドメイン)は標準では使えず、Cloudflare等のDNSサービスを活用して対応することになります。

有料記事が書ける

ブログプラットフォームとしてはあまりないですが、Note等にある「ここから先は○○円の支払いが必要です。」みたいなこと(ペイウォール)が設定できます。

他にも定期購読者限定の記事を出せたりもします。

ちなみに、メルマガみたいなことも出来るようです。

最悪Ghostをセルフホスティングできる

もしどうしても変更したい、追加したい機能があって、それがGhost Proでは出来ない場合は、セルフホスティングするという方法が取れます。

管理しなくても良いというメリットを捨てることにはなりますが、それでも良いと判断したらそっくりそのまま移せるので移行の手間は他のサービスからの移行に比べたら遥かに簡単だと思われます。

料金体系がちょっと特殊

月額9ドル〜と書きましたが、料金体系は特殊だと思います。メンバーの数とスタッフの数、連携機能周りが料金に影響します。

特にメンバーの数というのが特殊だなと思っています。メンバーの数ってどういう意味だろうと思ったのですが、これはブログの購読者の数だそうです。

読んでいただいているこの記事の右下とか、一番最後に「Subscribe」と出てると思うのですが、そこから登録した数がメンバーの数ってことになります。

管理画面が全部英語

全部英語です。

ブログの管理を何かしらでしたことあれば英語に慣れてなくても、なんとなくいけるとは思います。

記事執筆画面のフォントが明朝体

これがちょっと慣れないんですが、明朝体(serif体)になります。

記事執筆画面の場合は明朝体で固定
書いた記事をプレビューするとゴシック体になる(これはゴシック体を選択しているから)

記事執筆画面のフォント設定ができず明朝体固定です。

記事を表示した場合のフォントはゴシック体か明朝体かは選択出来ます。私の場合はゴシック体を選択しています。

できれば同じ書体で書きたいところですが、記事執筆画面のフォント変更機能は提供する予定がないそうです。このフォーラムのやり取りの中でChrome拡張でフォントを変更する方法が示されてますが、これを使うとアイコンフォントが潰れるという問題もあるので、明朝体で慣れるしかなさそうです。

ググって出てくるGhostの記事が、Ghost本体のことを言っているのか、Ghost Proのことを言っているのかが分からない

そもそもGhostという名前でググラビリティが低いので、全く関係ない記事が出てくるんですが、それとは別にGhostのシステムに近いところを調べようとするとGhostのソースに手を加えるパターンで解決する記事がチラホラ出てきます。タイトルでは判断できないので記事を見て、これは使える、これは使えないっていう感じで精査が必要なのでその点若干手間です。

以上、Pros/Consを詳しく見ていきました。

次は、実際の移行についてです。

Next.jsからGhostへの移行

実際に移行する時に色々作業をしたので、まとめておきます。

  1. 記事データの移行と調整
  2. テーマ調整
  3. シンタックスハイライトへの対応
  4. Google Analyticsの設置
  5. リダイレクトの設定
  6. 独自ドメインの設定

記事データの移行と調整

記事データ移行

Import機能が一応あるのでそれを使います。Lab機能扱いですが使えました。

フォーマットがよく分からなかったんですが、以下のようなJSONを作ることで取り込まれました。

{
  "db": [
    {
      "meta": { "exported_on": 1700643180946, "version": "5.74.0" },
      "data": {
        "posts": [
          {
            "title": "テスト",
            "slug": "test",
            "mobiledoc": "{\"version\":\"0.3.1\",\"markups\":[],\"atoms\":[],\"cards\":[[\"markdown\",{\"cardName\":\"markdown\",\"markdown\":\"# hogehoge \\nfugafuga\"}]],\"sections\":[[10,0]]}",
            "status": "published",
            "published_at": 1595842200000,
          },
          ~~~~
        ]
      }
    }
  ]
}

一応。公式のドキュメントがあります。

Migrating to Ghost - Developer Guide
A detailed guide for migrating to the Ghost publishing platform from WordPress, Tumblr, Medium, and other CMS tools.

手で作るのは無理だと思ったので出力はJSでスクリプトを書いてやりました。

参考までに置いときます。ほぼChatGPTに生成してもらいました。

const fs = require('fs')
const path = require('path')
const yaml = require('js-yaml')
const { promisify } = require('util')
const readdir = promisify(fs.readdir)
const readFile = promisify(fs.readFile)
const stat = promisify(fs.stat)

async function processMDXFiles(directory) {
  let posts = []
  const items = await readdir(directory)

  for (const item of items) {
    const fullPath = path.join(directory, item)
    const itemStats = await stat(fullPath)

    if (itemStats.isDirectory()) {
      posts = posts.concat(await processMDXFiles(fullPath))
    } else if (item.endsWith('.mdx')) {
      const content = await readFile(fullPath, 'utf8')
      const { metadata, body } = parseMDX(content)

      const mobiledoc = createMobileDoc(body)
      posts.push({
        title: metadata.title,
        slug: path.basename(item, '.mdx'),
        mobiledoc: JSON.stringify(mobiledoc),
        status: 'published',
        published_at: new Date(metadata.publishedAt).getTime(),
        tags: metadata.tags || [],
      })
    }
  }

  return posts
}

function parseMDX(content) {
  const separator = '---'
  const parts = content.split(separator)
  const metadata = yaml.load(parts[1])
  const body = parts
    .slice(2)
    .join(separator)
    // RichLinkというOGPを読み取って埋め込めるコンポーネントを独自に作っていたので、それをただのURLをのみに変換
    .replace(/<RichLink\s+href="([^"]+)"[^>]*\/>/g, '$1')

  return { metadata, body }
}

function createMobileDoc(content) {
  return {
    version: '0.3.1',
    markups: [],
    atoms: [],
    cards: [['markdown', { cardName: 'markdown', markdown: content }]],
    sections: [[10, 0]],
  }
}

processMDXFiles('_posts')
  .then(posts => console.log(JSON.stringify(posts, null, 4)))
  .catch(error => console.error(error))

ちなみに、今回は元のブログで設定していたタグについては諦めました。頑張ればできるかもしれないですが、仕様が不明確なのと、手で直せる量だと判断しました。

画像についても同様で、手動でアップロードしています。

調整

前述の方法でインポートすると、画像のような形になります。

Markdownブロックの中にすべてが入っている形になる

これでも大半はOKなんですが、埋め込みがあるとこの状態だと対応してくれないので、Markdownブロックから出す必要があります。

とは言っても簡単で、Markdownブロックを開いてコピペするだけです。あとは埋め込み化したいものを1つずつ対応していくだけでいけました。

画像について

画像については、元記事のパスをそのまま引き継ぐんですが、それだと画像が存在しないので、1つずつ手動でアップロードし直しました。

タグについて

あとは元記事を参考にタグを設定しました。

タグについては、slugに日本語が使えないので、英語表記に変える必要があります。

slugに日本語が使えないのは記事やページのslugも同様なので、もし日本語を使ってる場合はリダイレクトで頑張るか、諦めるかになると思います。

OGP画像について

これはNext.jsに移行した時のまま、vercel/og-imageをベースにした画像生成サーバーを使っています。

Ghostの機能でアイキャッチ画像を設定出来るfeature imageっていう機能があるんですが、それを使うようにしました。

Unsplashとの連携機能を使って、移行した記事にもアイキャッチ画像を設定して、良い感じになったと思います。

テーマ調整

月額25ドル以上のプランにすれば自由にテーマを作れますが、まだそこまでではないかなということで、既存のテーマを使っています。

一部ヘッダーと色の調整とかができるのでその範囲での調整は行いました。フォントも明朝体からゴシック体に変更するのだけはやりました。

本当は日本語に合わせたフォントの指定を持ってきたかったんですが、細かいフォントの指定は出来ないです。

シンタックスハイライトへの対応

コードを書く記事がそこそこあるので、シンタックスハイライトにも対応。

ヘッダーとフッターにそれぞれ、以下を追加しています。

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism-themes/1.9.0/prism-night-owl.min.css" integrity="sha512-MXRiGOm1i9a1yooXJgIZhA+q5XHvP+iHXn0ardNqqdE3ixpQBvjR0NCkFTR0Jic6jmhhnpviB87P+tK3zuzuSg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js" integrity="sha512-9khQRAUBYEJDCDVP2yw3LRUQvjJ0Pjx0EShmaQjcHa6AXiOv6qHQu9lCAIR8O+/D8FtaCoJ2c0Tf9Xo7hYH01Q==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js" integrity="sha512-SkmBfuA2hqjzEVpmnMt/LINrjop3GKWqsuLSSB3e7iBmYK7JuWw4ldmmxwD9mdm2IRTTi0OxSAfEGvgEi0i2Kw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

Google Analytics(GTM)の設置

ヘッダーに追加

<!-- Google Tag Manager -->
<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-hogehoge');</script>
<!-- End Google Tag Manager -->

フッターに追加

<!-- Google Tag Manager (noscript) -->
<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-hogehoge"
height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>
<!-- End Google Tag Manager (noscript) -->

noscriptは本当はbodyタグの一番上に入れたいところですが、入れられないのでフッターに追加しました。

リダイレクトの設定

前述の通りタグや投稿のURIに日本語表記が許されていないので、元々日本語だったパスをGhostに登録したパスに合わせてリダイレクトしたいです。

リダイレクトの設定はLab機能で且つベータ機能なので、ちょっと不安

公式ドキュメントにはYAMLでのサンプルがあるが、執筆時点(2023年11月末)では使えず、JSON形式にする必要がありました。ご注意ください。

Implementing redirects in Ghost
Avoid broken links by redirecting old URLs to new ones. In this tutorial, learn everything you need to know about Ghost’s redirect process.
301:
  /tags/日本語/: /tags/japanese/

ドキュメントに沿ってYAMLのサンプルを参考にして作ったもの。これは使えない。

[
  {
    "from": "/tags/%E6%97%A5%E6%9C%AC%E8%AA%9E/",
    "to": "/tags/japanese/"
  },
]

JSON形式はこれ

サンプルは/tags/日本語//tags/japanese/にリダイレクトする設定です。

from, toで書くことも、URLエンコードしないといけないこともドキュメントにはなかったので苦労しましたがいけました。

Routesの設定

Routesもベータ機能ですが変更出来たのでやっておきました。

routes:

collections:
  /:
    permalink: /{slug}/
    template: index

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

デフォルトの状態

routes:

collections:
  /:
    permalink: /{slug}/
    template: index

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

taxonomiesを変更

既存のブログに合わせて/tag//tags/にしたかったのでそのように変更。リダイレクトしても良かったんですが変更できるならその方が楽なのでこのようにしています。

独自ドメインの設定

最後にドメインの設定です。

一度設定するとドメインの向き先が変わるので戻せないと考えて最終確認をします。

Ghost Proの画面からDomainを開いて設定

DNSに登録するレコード情報があるので、それをDNSに設定してActivateすればOK。

まとめ

結構長くなりましたが、Nest.jsからGhostへの移行についてでした。

これで気兼ねなくブログ記事が書けるようになりました。

メルマガ、サブスク周りについては全然触っていないので、追々使えたらなとは思います。

今後ともどうぞよろしくお願いします。