ブログを Gatsby + Netlify + Notion に移行

続かないブログ

年1程度しか更新してないブログですが、とうとう2019年は1つも書くことなく知らぬ間に終えてしまっていたようです...

前回、Node-REDでWordpressからHexoへMigrationでブログを刷新しましたが、環境を変えた直後のモチベーションでブログを書くものの、3日も経たずに忘れてしまいます。

途中、Netlify CMSに移行したりもしたんですが、移行だけで力尽き、とうとう記事にさえしなかったようです。

つまり、これまで移行し続けた環境はいずれも執筆のモチベーションが上がる環境ではなかったようです(他責)

Notionを更新するとブログが更新されるようにする

今はNotionを使ってドキュメント書いてるので、わざわざブログを書きにブラウザでCMSにログインしたり、ソース開いてgithubにpushしたり面倒なことをしなくても、手元のNotionでドキュメント書いたらブログが更新されるようにすれば最高じゃない!

ということで調べてみるとVercal(旧ZEIT Now?名前変わった?)使った記事がたくさん。自分が使ってるNetlifyだと数記事しかない。GatsbyのプラグインでNotionをソースにできるようだ。でも情報が少なかったり古かったりで思ったより難航...

なので2020年6月現在でうまくいった(このブログがそう)方法を記しておきます。

Notion側の設定

まずは、Notionでソースにするドキュメントを作って公開します。

こんな感じで親ページを作ります。

次に作ったページの Share をクリックして Share to the web のスイッチをONにして、直下に表示される公開用URLをコピーします。後ほど設定で使いますのでメモしておきます。

ブラウザのシークレットウィンドウとかでコピーしたURLで作成したページが見れればOKです。

これで、作成したページの子ページをブログの記事としてアクセスすることができます。

記事の投稿日や概要はどうするのか?

これ、実は最後までハマってましたが以下のようにタイトル直下に入力するようです。description!! の後に改行しないで書くことに注意してください。

あ、あと、 draft は下書きかどうかです。 true のままだとページが生成されませんのでローカルで確認する場合なども false にする必要があります(ややこしい)

GatsbyでNotionをソースにしたブログ作成

gatsby-source-notionso-exampleを参考にしていますが、今回はこのブログのテンプレートで説明します。

まずは、以下を実行します。

yarn global add gatsby-cli
gatsby new my-site https://github.com/santosfrancisco/gatsby-starter-cv
cd my-site
gatsby develop

これで http://localhost:8000 にブログサイトが表示されればOKです。

gatbsy-source-notionso プラグインの導入

まず、 gatbsy-source-notionso プラグインを追加します。

yarn add gatbsy-source-notionso

次に、 gatsby-config.js プラグインの設定を追加します。

...
plugins: [
    {
      resolve: `gatsby-source-notionso`,
      options: {
        rootPageUrl: process.env.NOTION_URL,
        name: 'Blog'
      },
    },
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        path: `${__dirname}/content/blog`,
        name: `blog`,
      },
    },
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        path: `${__dirname}/content/assets`,
        name: `assets`,
      },
    },
...

Notionから記事を読み込んでページを作成する処理

gatsby-node.js に以下を追記することで Gatsby のビルド時に Notion から記事を読み込んで public ディレクトリにページを生成すると思われます。

exports.createPages = async ({ graphql, actions, reporter }, options) => {
  const { createPage } = actions;

  const pageTemplate = require.resolve('./src/templates/post.js');

  const result = await graphql(
    `
      query {
        allNotionPageBlog {
          edges {
            node {
              pageId
              slug
            }
          }
        }
      }
    `,
  );
  if (result.errors) {
    reporter.panic('error loading events', result.errors);
    return;
  }

  result.data.allNotionPageBlog.edges.forEach(({ node }) => {
    const path = `/blog/${node.slug}`;
    createPage({
      path,
      component: pageTemplate,
      context: {
        pathSlug: path,
        pageId: node.pageId,
      },
    });
  });
};

テンプレートによってディレクトリ構造が違いますので ${__dirname}/content/blog とか存在しなければ作成するか、パスを変更するなり便宜修正してください。

ちなみに、このブログのディレクトリ構成は以下のようになっています。

※テンプレートのデフォルトの状態から content ディレクトリを追加して、 assets を作成した content 配下に移動させ、さらに content 配下に blog ディレクトリを作成しましたが、空のディレクトリは git の管理下に置けないので blog ディレクトリに dummy という名前の空ファイルを作成しました

.
├── LICENSE
├── README.md
├── content
│   ├── assets
│   │   └──gatsby-icon.png
│   └── blog
│       └── dummy
├── data
│   └── siteConfig.js
├── gatsby-browser.js
├── gatsby-config.js
├── gatsby-node.js
├── gatsby-ssr.js
├── joeartsea.com_bak.zip
├── package.json
├── preview-desktop.gif
├── preview-mobile.gif
├── preview.png
├── renovate.json
├── src
│   ├── components
│   └── pages
├── static
├── yarn-error.log
└── yarn.lock

ディレクトリ構成を変えた場合は favicon の読み込み設定をしている ./data/siteConfig.js の最下部付近を以下のように変更します。ついでに Blog メニューも増やしておきます。

...
icon: 'content/assets/gatsby-icon.png',
  headerLinks: [
    {
      label: 'Home',
      url: '/',
    },
    {
      label: 'Portifolio',
      url: '/portifolio',
    },
    {
      label: 'Blog',
      url: '/blog',
    }
  ]
}

ブログ記事一覧ページの作成

基本的には一覧表示したいページのコンポーネントに Notion へアクセスする GraphQL 書いて props で受け取って処理すると思われます。以下は私のテンプレートの都合上、サイトのトップページである index.js とは別に、 blog.js というブログ用のトップページというか、記事一覧ページを作った例です。

/src/pages/blog.js

import React from 'react'
import styled, { css } from 'styled-components'
import { Cntainer, Row, Col } from 'react-awesome-styled-grid'
import siteConfig from '../../data/siteConfig'
import { withPrefix } from "gatsby"
import Layout from '../components/layout'
import Hero from '../components/hero'
import SEO from '../components/SEO'
import Wrapper from '../components/wrapper'

const Image = styled.img`
  max-height: 220px;
  max-width: 220px;
  object-fit: cover;
  object-position: center center;
  border-radius: 10px;
  box-shadow: 24px 47px 79px -21px rgba(0,0,0,0.51);
`

const PostCard = styled.a`
  text-decoration: none;
  color: inherit;
  ${({ href }) => href && css`
    &:hover ${Image}{
      transition: transform .5s;
      transform: translateY(-5px);
    }
  `}
`

const Blog = ({ data, className, location }) => {
  const title = "My Blog"
  const { keywords } = siteConfig
  return (
    <Layout location={location}>
      <SEO
        title={title}
        keywords={keywords}
      />

      <Hero
        heroImg={withPrefix('/images/pierre-chatel-innocenti-W5INoOK-5eI-unsplash.jpg')}
        title={title}
      />

      <Wrapper className={className}>
        <Container className="page-content" fluid>
          <Row>
            {data.allNotionPageBlog.edges.map(edge => (
              <Col
                key={edge.node.title}
                align="center"
              >
                <PostCard
                  as={edge.node.slug ? "a" : "div"}
                  href={`/blog/${edge.node.slug}`}
                >
                  <h3>{edge.node.title}</h3>
                  <p>{edge.node.excerpt}</p>
                  <div className="post-date">
                    Created: {new Date(edge.node.createdAt).toLocaleString()}
                  </div>
                  <hr />
                </PostCard>
              </Col>
            ))}
          </Row>
        </Container>
      </Wrapper>
    </Layout>
  )
}

export default styled(Blog)`
  .page-content {
    max-width: 100%;
    margin-bottom: 40px;
  }
  .post-date {
    color: #bbb;
    font-size: 14px;
    text-align: right;
  }
  hr {
    margin-top: 16px;
  }
`
export const query = graphql`
  query {
    allNotionPageBlog(
      filter: { isDraft: { eq: false } }
      sort: { fields: [indexPage], order: ASC }
    ) {
      edges {
        node {
          title
          slug
          excerpt
          pageIcon
          createdAt
        }
      }
    }
  }
`

記事ページのテンプレートを作成

最後にビルド時に利用されるテンプレートを gatsby-node.js で設定した ./src/templates/post.js に作成します。

import React from 'react'
import styled from 'styled-components'
import { Container } from 'react-awesome-styled-grid'
import siteConfig from '../../data/siteConfig'
import { withPrefix } from "gatsby"
import Layout from '../components/layout'
import Hero from '../components/hero'
import SEO from '../components/SEO'
import Wrapper from '../components/wrapper'
import notionRendererFactory from 'gatsby-source-notionso/lib/renderer'
import NotionBlockRenderer from '../components/notionBlockRenderer'

const Post = ({ data, className, location }) => {
  console.log(data)
  const title = "My Blog"
  const { keywords } = siteConfig
  const notionRenderer = notionRendererFactory({
    notionPage: data.notionPageBlog,
  });
  return (
    <Layout location={location}>
      <SEO
        title={title}
        keywords={keywords}
      />

      <Hero
        heroImg={withPrefix('/images/pierre-chatel-innocenti-W5INoOK-5eI-unsplash.jpg')}
        title={title}
      />

      <Wrapper className={className}>
        <Container className="page-content" fluid>
          <NotionBlockRenderer
            data={data}
            renderer={notionRenderer}
            debug={false}
          />
        </Container>
      </Wrapper>
      
    </Layout>
  )
}

export default styled(Post)`
  .page-content {
    max-width: 100%;
    margin-bottom: 40px;
  }
`

export const query = graphql`
  query($pageId: String!) {
    notionPageBlog(pageId: { eq: $pageId }) {
      blocks {
        blockId
        blockIds
        type
        attributes {
          att
          value
        }
        properties {
          propName
          value {
            text
            atts {
              att
              value
            }
          }
        }
      }
      imageNodes {
        imageUrl
        localFile {
          publicURL
        }
      }
      pageId
      slug
      title
      isDraft
      id
      indexPage
      createdAt
    }
  }
`

この中で使っている NotionBlockRenderer というコンポーネントが、Notionから取得する記事本文の装飾を行っていて肝だと思いますが、なんか複雑なんでまんま流用させてもらいます。

まずは、NotionBlockRenderer で使ってるモジュールを追加します。現時点で最新のバージョンもモジュールを追加するとエラーになるので以下のバージョン固定で使いします。

yarn remove gatsby-source-filesystem
yarn add gatsby-source-filesystem@2.3.10 highlight.js@9.17.1

続いて、NotionBlockRenderer コンポーネントを追加します。

/src/components/notionBlockRenderer.js

/* eslint-disable */

import React from 'react';
import hljs from 'highlight.js/lib/highlight';
import hljs_javascript from 'highlight.js/lib/languages/javascript';
import hljs_typescript from 'highlight.js/lib/languages/typescript';
import hljs_bash from 'highlight.js/lib/languages/bash';
import hljs_plaintext from 'highlight.js/lib/languages/plaintext';
import hljs_json from 'highlight.js/lib/languages/json';

import Title from './Title';
import './styles.css';
import 'highlight.js/styles/github.css';

// see: https://github.com/highlightjs/highlight.js
hljs.registerLanguage('javascript', hljs_javascript);
hljs.registerLanguage('typescript', hljs_typescript);
hljs.registerLanguage('bash', hljs_bash);
hljs.registerLanguage('plaintext', hljs_plaintext);
hljs.registerLanguage('json', hljs_json);

const notionLanguageToHljs = {
  'Plain Text': 'plaintext',
  JavaScript: 'javascript',
  Bash: 'bash',
  JSON: 'json',
};

function renderBlockImage(meta) {
  return (
    <div className="nso-image">
      <img
        src={meta.publicImageUrl}
        style={{ width: `${meta.width}px` }}
        alt=""
      />
    </div>
  );
}

function renderBlockCode(children, meta) {
  const hlslanguage = notionLanguageToHljs[meta.language] || 'plaintext';
  const highlightedCode = hljs.highlight(hlslanguage, meta.title).value;
  return <pre className="nso-pre-code" dangerouslySetInnerHTML={{ __html: highlightedCode }} />;
}

function renderBlockText(children) {
  return <p className="nso-para">{children}</p>;
}

function renderBlockHeader(children, level) {
  switch (level) {
    case 1:
      return <h1 className="nso-header-1 has-text-primary has-background-white-bis subtitle is-2">{children}</h1>;
    case 2:
      return <h2 className="nso-header-2 has-text-primary has-background-white-bis subtitle is-3">{children}</h2>;
    case 3:
    default:
      return <h3 className="nso-header-3 has-text-primary has-background-white-bis subtitle is-5">{children}</h3>;
  }
}

function renderBulletedList(children) {
  return (
    <div className="content">
      <ul>{children}</ul>
    </div>
  );
}

function renderNumberedList(children) {
  return (
    <div className="content">
      <ol>{children}</ol>
    </div>
  );
}

function renderListItem(children) {
  return <li>{children}</li>;
}

function renderPage(children) {
  return <div>{children}</div>;
}

function renderBlock(type, meta, children) {
  switch (type) {
    case 'page':
      return renderPage(children);

    case 'text':
      return renderBlockText(children);

    case 'code':
      return renderBlockCode(children, meta);

    case 'image':
      return renderBlockImage(meta);

    case 'header':
      return renderBlockHeader(children, 1);

    case 'sub_header':
      return renderBlockHeader(children, 2);

    case 'sub_sub_header':
      return renderBlockHeader(children, 3);

    case 'bulleted_list':
      return renderBulletedList(children);

    case 'numbered_list':
      return renderNumberedList(children);

    case 'numbered_list__item':
      return renderListItem(children);

    case 'bulleted_list__item':
      return renderListItem(children);

    case '__meta':
      // we don't parse this block - it contains the pahe meta information such as the slug
      return null;

    default:
      console.log('@@@ unknow type to render>renderBlock>', type);
      return null;
  }
}

const _attToClassName = {
  i: 'nso-italic',
  b: 'nso-bold',
  s: 'nso-stroke',
  c: 'nso-code',
};

function mkRenderFuncs(_notionPageBlog) {
  return {
    wrapText: text => {
      return <React.Fragment>{text}</React.Fragment>;
    },
    renderTextAtt: (children, att) => {
      const className = _attToClassName[att];
      if (!className) {
        console.log(`@@@ no text attribute for: ${att}`);
      }
      return <span className={className || ''}>{children}</span>;
    },
    renderLink: (children, ref) => {
      return <a href={ref}>{children}</a>;
    },
    renderBlock: (type, meta, children) => {
      return renderBlock(type, meta, children);
    },
  };
}

const NotionBlockRenderer = ({ data, renderer, debug }) => {
  const { notionPageBlog } = data;
  const renderFuncs = mkRenderFuncs(notionPageBlog);
  const child = renderer.render(renderFuncs);
  return (
    <div>
      <Title title={notionPageBlog.title} />
      <div className="post-date">
        Created: {new Date(notionPageBlog.createdAt).toLocaleString()}
      </div>
      <div>{child}</div>
      {debug && (
        <div>
          <h2>notionPageBlog:</h2>
          <pre>{JSON.stringify(notionPageBlog, null, '  ')}</pre>
        </div>
      )}
    </div>
  );
};

export default NotionBlockRenderer;

NotionBlockRenderer コンポーネントが依存する Title コンポーネントを追加します。

/src/components/Title.js

/* eslint-disable */

import React from 'react';

const Title = ({ title }) => {
  return (
    <div>
      <h1 className="title nso-title has-text-primary is-size-1">{title}</h1>
    </div>
  );
};

export default Title;

同じく、NotionBlockRenderer コンポーネントが依存する styles.css を追加します。

/src/components/styles.css

.nso-italic {
  font-style: italic;
}

.nso-bold {
  font-style: normal;
  font-weight: bold;
}

.nso-stroke {
  text-decoration: line-through
}

.nso-para {
  padding-bottom: 4px;
  padding-top: 4px;
}

.nso-code {
  font-family:'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
  line-height:normal;
  background:rgba(135,131,120,0.15);
  color:#EB5757;
  border-radius:3px;
  font-size:85%;
  padding:0.2em 0.4em
}

.nso-title {
  padding-bottom: 16px;
  border-bottom: 1px solid #486581;
}

.nso-header-2 {
  margin-top: 8px;
}

.nso-pre-code {
  margin-top: 8px;
  margin-bottom: 8px;
}

.post-date {
  color: #bbb;
  font-size: 14px;
  text-align: right;
}

img {
  margin: 4px;
  padding:4px;
  border:1px solid #486581;
  background-color:whitesmoke;
}

最終的なディレクトリ構成は以下のようになっています。

.
├── LICENSE
├── README.md
├── content
│   ├── assets
│   │   └── gatsby-icon.png
│   └── blog
│       └── dummy
├── data
│   └── siteConfig.js
├── gatsby-browser.js
├── gatsby-config.js
├── gatsby-node.js
├── gatsby-ssr.js
├── package.json
├── preview-desktop.gif
├── preview-mobile.gif
├── preview.png
├── renovate.json
├── src
│   ├── components
│   │   ├── SEO
│   │   ├── Title.js
│   │   ├── about
│   │   ├── footer
│   │   ├── header
│   │   ├── hero
│   │   ├── layout
│   │   ├── loader
│   │   ├── notionBlockRenderer.js
│   │   ├── repositories
│   │   ├── skills
│   │   ├── styles
│   │   ├── styles.css
│   │   ├── timeline
│   │   └── wrapper
│   ├── pages
│   │   ├── 404.js
│   │   ├── blog.js
│   │   ├── index.js
│   │   └── portifolio.js
│   └── templates
│       └── post.js
├── static
│   └── images
│       ├── 404.jpeg
│       ├── avatar.jpeg
│       ├── awesome-grid.png
│       ├── cover.jpeg
│       ├── gatsby-starter-cv.png
│       ├── pierre-chatel-innocenti-W5INoOK-5eI-unsplash.jpg
│       └── ufo-and-cow.svg
├── yarn-error.log
└── yarn.lock

最後に、 gatsby-config.js のプラグイン設定で rootPageUrl に指定した環境変数 NOTION_URL に、冒頭 Share to the web でメモしたURLを指定します。

export NOTION_URL=https://www.notion.so/<任意のID>

これで、再度ローカルでビルドして確認します。

gatsby develop

Netlify 側の設定

Netlify側は通常の静的サイトとしての登録と、NOTION_URL 環境変数を設定するだけです。GatsbyとNetlifyで簡単にブログを作成とかGatsbyJS + Netlifyで環境変数を利用するのに迷った話を参考にします。

リダイレクトの設定

1点、ブログの移行では新しいサイトでパスが変わってしまうなどという理由から、検索サイトにインデックスされてしまっているパーマリンクから、新しい記事のパスにリダイレクトする必要があったりします。

今回、私の移行は artsnet.jp から joeartsea.com へブログを引っ越しました。

artsnet.jp もGatsby + Netlifyで運用してましたので、Gatsby + Netlifyでリダイレクト設定をします。あくまで古い方のサイト(この場合 artsnet.jp 側)の話です。

まず、GatsbyのNetlifyプラグインを追加します。

yarn add gatsby-plugin-netlify

次に、 gatsby-config.js で追加したプラグインを指定します。

...
plugins: [
    'gatsby-plugin-netlify',
...

続いて、 gatsby-node.js でリダイレクトの設定を追記します。

/**
 * Implement Gatsby's Node APIs in this file.
 *
 * See: https://www.gatsbyjs.org/docs/node-apis/
 */

// You can delete this file if you're not using it

exports.createPages = ({
  actions
}) => {
  const { createRedirect } = actions;
    createRedirect({
      fromPath: '/archives/*',
      toPath: 'https://joeartsea.com/blog/:splat',
      isPermanent: true
    });
}

こんな感じで移行パスに規則性があれば、 * から :splat へは動的に代入され、ちゃんとリダイレクトされるようになりました。