個人ブログのCMSをContentfulからNotionに移行した所感(後悔) Cover

個人ブログのCMSをContentfulからNotionに移行した所感(後悔)

目次

    今までは Contentful でこのブログの記事を管理していましたが Notion に移行しました。

    この記事では移行手順や所感についてお話します。

    先に所感を話すと、エディタの体験が改善し、従来より執筆モチベは出るようになりました。

    今まで、なんとなく個人ブログよりも(確実に人の目に触れるのもあって)Zennに書こうかな〜と思っていたのが、体験的には割と同じくらいに変わったので、こっちも放置せず更新しようと思います。「個人ブログをちゃんと更新する」という今年の抱負も決まってよかったです。今年もあと1ヶ月少しで終わりますが。

    また、記事の公開をボタンを押下するだけで行える(ように実装を改良した)ため、気軽にポストを更新できるようになり、これもモチベ向上に貢献しています。

    ただ Notion APIが色々足らなかったり仕様があやふやだったりで、バッドノウハウまみれになってしまい、まだまだ整えがいがある印象です。

    APIとそのエコシステムに関してはContentfulのほうが成熟していると思います。

    Contentful内の必要なデータを書き出す

    移行には 3rd-party のツールを使う方法もありますが、Contentful と Notion の API の素振りも兼ねて手作業で行いました。

    はじめにブログのコンテンツモデルを Contentful から書き出します。Docs に方法が記載されているので、これを参考に書き出しました。

    前準備として contentful-cli が必要なので適宜インストールします。

    インストールできた/していたら contentful login でログインして、contentful space use でスペースを指定します。

    あとは Docs に倣って contentful space export [...opts] を実行します。

    今回はブログの記事と画像のデータだけ欲しかったので、以下のオプションを指定しました。

    contentful space export --content-only --download-assets --use-verbose-renderer --export-dir ~/Downloads/contentful

    完了すると JSON 形式の Content データ(と画像)が書き出されます。

    てっきり各 Content ごとに1つのファイルで書き出されると思っていたら、以下のように 1つの JSON ファイルに全部の Content が詰め込まれてて驚きました。

    {
      // ~~~
      "entries": [
        {
          "metadata": {
            "tags": [
              {
                "sys": {
                  "type": "Link",
                  "linkType": "Tag",
                  "id": "others"
                }
              }
            ]
          },
          "sys": {
            "space": {
              "sys": {
                "type": "Link",
                "linkType": "Space",
                "id": "fwqvgdqoxfc7"
              }
            },
            "id": "5CB95Nkl2lPZIsZPAZC2II",
            "type": "Entry",
            "createdAt": "2021-03-24T13:37:40.223Z",
            "updatedAt": "2021-05-16T03:05:46.685Z"
            // ~~~
          },
          "fields": {
            "title": {
              "ja-JP": "ブログを始めた"
            },
            "body": {
              "ja-JP": "ブログを始めました。\n\n## きっかけ\n\n今までは..."
            },
            "slug": {
              "ja-JP": "first-post"
            }
          }
        },
    		{
    			// ~~~
    		},
    		{
    			// ~~~
    		}
      ]
    }

    とりあえずこの JSON を記事ごとに割ります。

    import path from 'node:path';
    import fsPromises from 'node:fs/promises';
    import YAML from 'yaml';
    import { unified } from 'unified';
    import remarkParse from 'remark-parse';
    import { fileURLToPath } from 'node:url';
    import { visit } from 'unist-util-visit';
    
    import contentsJson from './contents.json' assert { type: 'json' };
    
    const LANG = 'ja-JP';
    // @see https://stackoverflow.com/a/50052194
    const __dirname = path.dirname(fileURLToPath(import.meta.url));
    
    (async () => {
      const { entries } = contentsJson;
    
      for (const entry of entries) {
        const { metadata, sys, fields } = entry;
        const slug = fields.slug[LANG];
        const updated = sys.updatedAt;
        const created = sys.createdAt;
        const tags = metadata.tags.map((tag) => tag.sys.id);
        const title = fields.title[LANG];
        const head = {
          title,
          slug,
          tags,
          updated,
          created,
        };
        const body = fields.body[LANG];
    
        const destDirPath = path.resolve(__dirname, `exports/${slug}`);
        try {
          await fsPromises.access(destDirPath);
        } catch (_) {
          await fsPromises.mkdir(path.resolve(destDirPath, 'media'), {
            recursive: true,
          });
        }
    
        const ast = unified().use(remarkParse).parse(body);
        visit(ast, 'image', async (node) => {
          const normalizedUrl = node.url.replace(/^\/\//, '');
          const pathToImage = path.resolve(__dirname, normalizedUrl);
          const name = path.parse(pathToImage).base;
          const meta = {
            name,
            url: node.url,
            alt: node.alt,
          };
          const metaPath = path.resolve(destDirPath, `media/meta-${name}.json`);
          await fsPromises.writeFile(metaPath, JSON.stringify(meta, null, '  '));
          const imagePath = path.resolve(destDirPath, `media/${name}`);
          await fsPromises.copyFile(pathToImage, imagePath);
        });
    
        const content = `---
    ${YAML.stringify(head)}---
    
    ${body}`;
        const filePath = path.resolve(path.join(destDirPath, `content.md`));
        await fsPromises.writeFile(filePath, content);
      }
    })();

    出力は以下のような Markdown ファイルになります。

    ---
    title: タオルを煮る
    slug: boiling-towel
    tags:
      - personal_research
    updated: 2022-08-27T13:50:17.711Z
    created: 2022-08-27T04:45:58.789Z
    ---
    
    タオルを煮ました...

    YAML 形式の部分は後で Notion のプロパティに設定します。

    Markdown の書き出しと併せて、記事に含まれる画像も出力ファイルと同階層の media ディレクトリに移動しました。

    exports/
    └── first-post/
        ├── content.md
        └── media/
            ├── first-post.jpg
            └── meta-first-post.jpg.json # 画像のaltなどのデータ

    Notionにデータベースを作る

    Notion のデータベース機能で、以下のように記事を管理します。

    適宜必要なプロパティを作成することで、Contentful における Content Model を再現しました。

    記事の内容にあまり制約を加えないように、テンプレートには必要最低限の ToC ぐらいしかつけていません。

    あとはこのテンプレートからページを作成して、Contentful から取ってきた Markdown を import しよう!となるのですが…

    テンプレートから生成したページには Markdown を import できません。

    さらに、空のページに import したとしてもデータベースのデータ(ページ)として表示されず、サブページの扱いになります。ぐぬぬ…という感じです。

    (記事書き始めたのが今年の年明けとかなので、ここは改善されているかもしれないです)

    今回サブページは作りたくないので、とりあえず Markdown をページへコピペしました。

    プロパティは残念ながらコピペで入力できないため、手入力します。記事内の画像も手作業でページに貼り付けます。

    ここらへんはAPIに頼っていい場面ですが、筋肉で解決してしまいました。

    フロントエンドの更新・修正

    しばらく手つかずのコードだったので、依存の更新から行っていきました。Nextをv11からv13にあげ、ほかも可能な限り更新しました。

    なお、このブログでは App Router は使用していません。

    従来のSGで運用する上でそこまで不便はないのと、RSCの刺さりどころがイマイチ見当たらないので見送りました。ここらへんはまた機会があれば記事や登壇で小出しにするかもしれないです。

    あとはTailwindCSSを入れてUIをひたすら書き換えました。

    特筆すべき面白い点はないですが、詳細は気になる方は以下のcommitを見てください。

    https://github.com/shuta13/appearance-none/commit/2919bba198ab8f7fc324cd18f749c851b1a8ee5b

    https://github.com/shuta13/appearance-none/commit/2dfcba41542f6bafd39c8a6ac9743243bb9a2f47

    notion-sdk-jsで記事を取得

    更新の次は実装を修正し、記事の取得を行いました。

    ContentfulのSDKをuninstallし、Notionのもの(notion-sdk-js)を用いるようにしました。

    簡単にこのSDKの使い方を説明すると、以下のようにクライアントを生成し、これに対してメソッドチェーンでページやデータベースを取得するような書き方をします。

    import { Client } from '@notionhq/client';
    
    const client = new Client({
      auth: accessToken,
    });
    // ページのタグなどプロパティが入っている
    const pageProps = await client.pages.retrieve({
      /** ~~~ */
    }).properties;
    // ページ内のブロックなどが入っている
    const pageContents = await client.blocks.children.list({
      /** ~~~ */
    });

    詳しくは https://developers.notion.com/docs を見てください。fetcher等の実装は必要なく、かなり手軽にNotionからデータ取得できます。

    今回はClean Architecture風味の構成に寄せ、Notionのデータベースから記事の内容をビルド時に引いてこれるようにしました。

    以下の順でデータを取得します。

    repository → model(presenter) → usecase → UI

    repositoryでSDKを呼び出し、ページのプロパティやコンテンツを取得します。

    modelではUIが求める形にSDKのAPI Responseを整形します。

    usecaseでここまでのデータを取得し、getStaticProps内で組み立ててPagePropsに突っ込みます。

    これで UI に表示が可能になります。言葉で説明しても分かりづらいので、以上の実装を掲示します。

    例えば記事1件取得の実装だと以下のようになります。

    repository

    import ArticlesModel from '~/models/articles';
    
    interface ArticleRepository {
      getArticle(params: { id: string }): Promise<Articles[number]>;
    }
    
    const article: ArticleRepository = {
      async getArticle({ id }) {
        const pageProps = await client.pages.retrieve({
          page_id: id,
        }).properties;
        const pageContents = await client.blocks.children.list({
          page_id: id,
        });
    
        /** ~~~ */
    
        return {
          meta: { id },
          head: {
            title: pageProps.Name.title[0].plain_text,
          },
          body: ArticlesModel.transform(pageContents.results),
        };
      },
    };

    model

    import { Articles } from '~/repositories/article';
    
    // type(interface)とnamespaceのmergingを利用
    type ArticlesModel = Readonly<Articles[number]>;
    
    namespace ArticlesModel {
      export function transform(result: Articles[number]): ArticlesModel {
        return {
          meta: result.meta,
          head: result.head,
          body: normalizeList(result.body),
        };
      }
    
      export function normalizeList(
        body: (Articles[number]['body'][number] | null)[]
      ): ArticlesModel['body'] {
        /** UIでほしい形にbodyを整形 */
      }
    }

    usecase

    import repo from '~/repositories/article';
    
    export function getArticle() {
      return {
        async invoke(params: { id: string }) {
          const result = await repo.getArticle(params);
          return result;
        },
      };
    }

    getStaticProps → UI

    export const getStaticProps = async (context: GetServerSidePropsContext) => {
      const article = await getArticle().invoke({
        id: context.params?.slug || '',
      });
      return { props: { article } };
    };
    
    const Slug: NextPageWithLayout<
      InferGetStaticPropsType<typeof getStaticProps>
    > = (props) => {
      const { article } = props;
      return (
        <article>
          <div
            dangerouslySetInnerHTML={{
              __html: article.body.map((article) => article.htmlStr).join(''),
            }}
          />
        </article>
      );
    };

    実装の見通しが良くなる反面コード量は増える印象です。

    Notion側にデータ送信する必要が出た場合に真価を発揮するかもしれません(CMS側更新することなんかあるのか?という感じですが)。

    個人的にNext.jsはサーバーサイドフレームワークだと思ってるので、Stateをサーバーサイドに寄せてしまえばClean Architectureと相性良いような気がしました。

    Notion API Response → React Element

    Notion APIへの理解を深めるため、Notion API ResponseをReact Elementに変換するRendererを実装しました。

    正直根性実装です。Notion Blockの種類を判別し、それに応じたtagNameでElementを構成しています。

    例えば見出し1(h1)は以下のようにElementを構成します。

    import { Heading1BlockObjectResponse } from '@notionhq/client/build/src/api-endpoints';
    
    export function isHeading1(
      block: BlockObjectResponse
    ): block is Heading1BlockObjectResponse {
      return block.type === 'heading_1';
    }
    
    export type SwitcherReturn = {
      tagName: keyof JSX.IntrinsicElements;
    };
    export function switcher(block: BlockObjectResponse): SwitcherReturn {
      if (isHeading1(block)) {
        return {
          tagName: 'h1',
        };
      }
    }
    
    export function renderer(block: BlockObjectResponse) {
      const { tagName } = switcher(block);
      const Element = tagName;
    
      if (isHeading1(block)) {
        const content = block.heading_1.rich_text;
        return (
          <Element id={content}>
            <a
              href={`#${content}`}
              dangerouslySetInnerHTML={{
                __html: content,
              }}
            />
          </Element>
        );
      }
    }

    type guardでBlockに型をつけることで switcher や renderer の中で参照した際に、型推論が効くようにしています。

    同様に必要なtagNameに応じた処理を追加し、ulやcodeなど、よく使うElementは表示できるようにしました。

    詳しくは実装見てもらうほうが早いと思います。

    https://github.com/shuta13/appearance-none/blob/main/packages/utils/notion-block-renderer.tsx

    画像の保存

    有名な話かもしれないですが、Notionの画像は署名付きURLになっています。

    URLの有効期限は 1 時間なので、画像を表示したくば 1 時間毎にビルドする必要があります。普通に嫌です。

    https://developers.notion.com/reference/file-object#notion-hosted-files:~:text=The URL is valid for one hour. If the link expires%2C then you can send an API request to get an updated URL

    困ったのでインターネットを眺めていると、自前のStorageに持っていったり、base64にしてみたり、ダウンロードして全部ローカルで腹持ちにしたりする対応を取っている人がちらほら見られました。

    https://www.google.com/search?q=notion+画像+署名付きurl&sca_esv=579945731&ei=83JJZbfOGsGA1e8P85SkyAE&ved=0ahUKEwj3k4zyv7CCAxVBQPUHHXMKCRkQ4dUDCBA&uact=5&oq=notion+画像+署名付きurl&gs_lp=Egxnd3Mtd2l6LXNlcnAiHW5vdGlvbiDnlLvlg48g572y5ZCN5LuY44GNdXJsMgUQABiiBDIFEAAYogRIpAxQrAdY6QpwAXgBkAEAmAFsoAGDA6oBAzMuMbgBA8gBAPgBAcICChAAGEcY1gQYsAPiAwQYACBBiAYBkAYK&sclient=gws-wiz-serp#ip=1

    今回は1番最後の方法で、Actionsでビルド中に画像を /public に保存することにしました。

    こちらの方の実装を参考に、repositoryで取得した記事のBlockを総なめして、画像であればfsでフォルダに書き出しています。

    if (item.type === 'image' && item.image.type === 'file') {
      if (!isImageExist(item.id)) {
        const blob = await fetch(item.image.file.url).then((res) => res.blob());
        const binary = (await blob.arrayBuffer()) as ArrayBuffer;
        const buffer = Buffer.from(binary);
        fs.writeFileSync(path.join(imageBasePath, `/${item.id}.png`), buffer);
      }
      item.image.file.url = `${imagePathName}/${item.id}.png`;
      pageContents.results[pageContents.results.indexOf(item)] = item;
    }

    ホストに用いているサービスの容量の上限には気を配らないといけませんが、一旦これで解決しました。

    ちなみにこのブログはCloudflare Pagesでホストしてるので、画像等はなるべくR2やD1に持って行ったほうが良さそうです(確かPagesは1プロジェクト25MBが上限だった気がします)。

    GitHub Actionsに発射ボタンを生やす

    こんな感じで Run Workflow ボタンを押すとブログのデプロイが走ります。

    Cloudflare PagesはGitHub Reposと紐づけができますが、pushごとにビルド回るのが煩わしかったので、Actionsでwrangler使って任意のタイミングでビルドするようにしました。

    ちなみに以下の記述だけで Run Workflow ボタンを生やせます。

    on: [workflow_dispatch]

    詳しくは https://github.com/shuta13/appearance-none/tree/main/.github/workflows にあります。

    まとめ

    筋肉で解決したりゴリゴリ実装したり、ちょっと大変な引っ越し作業になってしまいました。

    Notion APIに触れた感想として、今のところNotionはUXに振り切ったソフトウェア感があります。

    ふと、昔のFigmaは開発者にあまり優しくなかったが、だんだん改善されていったように思ったので、時間が経てば同様にDX方面も充実するかもしれません。

    今流行っている点以外は特に共通点ないのと、そもそもNotionがHeadlessな仕組みをあまり作りたくなさそうではありますが。

    あとnext devするとかなり重いです。

    NotionのAPI Response整形する部分で、デカいデータ取り回してるあたりが原因だと思うので、もうちょっとテコ入れしたいなと思います。

    NotionをHeadless CMSとして使うことを勧める気持ちはないですが、何かの参考になれば幸いです。

    次ブログに関して書くときは、notion-sdk-jsを読んでみる回にでもしようかなと思ってます。

    ご精読ありがとうございました。

    出典・参考

  • カバー画像
  • この記事を共有する